db = $db ?? \Config\Database::connect(); } /** * @return array{ * rows: list>, * barcodeCodes: array, * queried: bool * } */ public function build( int $lgIdx, string $refDate, int $leadDays, string $stockScope, string $salesScope, bool $queried ): array { $barcodeCodes = $this->loadBarcodeCodes($lgIdx); $products = $this->loadProducts($lgIdx); if ($products === [] || ! $queried) { return ['rows' => [], 'barcodeCodes' => $barcodeCodes, 'queried' => $queried]; } $inventory = $this->loadInventoryMap($lgIdx); $pendingIn = $this->loadPendingInbound($lgIdx); $lastOrders = $this->loadLastOrders($lgIdx); $monthlySales = $this->loadMonthlyAverageSales($lgIdx, $refDate, $salesScope, $barcodeCodes); $movementsSince = $this->loadMovementsSinceOrders($lgIdx, $lastOrders, $refDate); $rows = []; foreach ($products as $code => $name) { $isBarcode = isset($barcodeCodes[$code]); $rawStock = (int) ($inventory[$code] ?? 0); $currentStock = $this->scopedStock($rawStock, $stockScope, $isBarcode); $pendingQty = $this->scopedPending((int) ($pendingIn[$code] ?? 0), $stockScope, $isBarcode); $totalStock = $currentStock + $pendingQty; $monthlyFloat = (float) ($monthlySales[$code] ?? 0.0); $monthlyAvg = (int) round($monthlyFloat); $depletionDays = $this->calcDepletionDays($totalStock, $monthlyFloat); $scheduleDate = $this->calcScheduleDate($refDate, $depletionDays, $leadDays); $orderQty = $this->calcOrderQty($refDate, $scheduleDate, $depletionDays, $leadDays, $monthlyAvg, $totalStock); $last = $lastOrders[$code] ?? null; $orderDate = $last ? (string) ($last['order_date'] ?? '') : ''; $lastQty = $last ? (int) ($last['qty_sheet'] ?? 0) : 0; $stockAtOrder = 0; if ($orderDate !== '' && $lastQty > 0) { $mv = $movementsSince[$code] ?? ['sale' => 0, 'recv' => 0, 'issue' => 0]; $stockAtOrder = max(0, $rawStock + $mv['sale'] - $mv['recv'] - $mv['issue']); } $rows[] = [ 'bag_code' => $code, 'bag_name' => $name, 'is_barcode' => $isBarcode, 'last_order_date' => $orderDate, 'last_order_qty' => $lastQty, 'stock_at_order' => $stockAtOrder, 'current_stock' => $currentStock, 'pending_inbound' => $pendingQty, 'total_stock' => $totalStock, 'monthly_avg_sales' => $monthlyAvg, 'depletion_days' => $depletionDays, 'schedule_date' => $scheduleDate, 'schedule_overdue' => $scheduleDate !== '' && $scheduleDate <= $refDate && $depletionDays < 99999, 'order_qty' => $orderQty, ]; } usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['bag_code'], (string) $b['bag_code'])); return ['rows' => $rows, 'barcodeCodes' => $barcodeCodes, 'queried' => true]; } /** * @return array */ private function loadBarcodeCodes(int $lgIdx): array { if (! $this->db->tableExists('bag_receiving_pack_code')) { return []; } $set = []; foreach ($this->db->table('bag_receiving_pack_code') ->select('brpc_bag_code') ->distinct() ->where('brpc_lg_idx', $lgIdx) ->where('brpc_bag_code !=', '') ->get() ->getResultArray() as $row) { $code = trim((string) ($row['brpc_bag_code'] ?? '')); if ($code !== '') { $set[$code] = true; } } return $set; } /** * @return array code => name */ private function loadProducts(int $lgIdx): array { $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first(); $products = []; if ($kindO) { foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) { $code = trim((string) ($d->cd_code ?? '')); if ($code !== '') { $products[$code] = trim((string) ($d->cd_name ?? $code)); } } } foreach ($this->db->table('bag_inventory') ->select('bi_bag_code, bi_bag_name') ->where('bi_lg_idx', $lgIdx) ->get() ->getResultArray() as $row) { $code = trim((string) ($row['bi_bag_code'] ?? '')); if ($code === '') { continue; } if (! isset($products[$code])) { $products[$code] = trim((string) ($row['bi_bag_name'] ?? $code)); } } return $products; } /** * @return array */ private function loadInventoryMap(int $lgIdx): array { $map = []; foreach (model(\App\Models\BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll() as $inv) { $code = trim((string) ($inv->bi_bag_code ?? '')); if ($code !== '') { $map[$code] = (int) ($inv->bi_qty ?? 0); } } return $map; } /** * @return array */ private function loadPendingInbound(int $lgIdx): array { $map = []; $sql = " SELECT boi.boi_bag_code AS bag_code, SUM(GREATEST(0, CAST(boi.boi_qty_sheet AS SIGNED) - IFNULL(r.recv_qty, 0))) AS pending_qty FROM bag_order_item boi INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx LEFT JOIN ( SELECT br_bo_idx, br_bag_code, SUM(br_qty_sheet) AS recv_qty FROM bag_receiving GROUP BY br_bo_idx, br_bag_code ) r ON r.br_bo_idx = bo.bo_idx AND r.br_bag_code = boi.boi_bag_code WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' GROUP BY boi.boi_bag_code "; foreach ($this->db->query($sql, [$lgIdx])->getResult() as $row) { $qty = (int) ($row->pending_qty ?? 0); if ($qty > 0) { $map[(string) $row->bag_code] = $qty; } } return $map; } /** * @return array */ private function loadLastOrders(int $lgIdx): array { $map = []; $supportsWindow = $this->db->DBDriver === 'MySQLi'; if ($supportsWindow) { $sql = " SELECT bag_code, order_date, qty_sheet, bag_name FROM ( SELECT boi.boi_bag_code AS bag_code, bo.bo_order_date AS order_date, boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name, ROW_NUMBER() OVER ( PARTITION BY boi.boi_bag_code ORDER BY bo.bo_order_date DESC, bo.bo_idx DESC ) AS rn FROM bag_order_item boi INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' ) t WHERE t.rn = 1 "; foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) { $code = trim((string) ($row['bag_code'] ?? '')); if ($code === '') { continue; } $map[$code] = [ 'order_date' => (string) ($row['order_date'] ?? ''), 'qty_sheet' => (int) ($row['qty_sheet'] ?? 0), 'bag_name' => (string) ($row['bag_name'] ?? ''), ]; } return $map; } $sql = " SELECT boi.boi_bag_code AS bag_code, MAX(bo.bo_order_date) AS order_date FROM bag_order_item boi INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' GROUP BY boi.boi_bag_code "; foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) { $code = trim((string) ($row['bag_code'] ?? '')); $orderDate = (string) ($row['order_date'] ?? ''); if ($code === '' || $orderDate === '') { continue; } $item = $this->db->query(" SELECT boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name FROM bag_order_item boi INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' AND boi.boi_bag_code = ? AND bo.bo_order_date = ? ORDER BY bo.bo_idx DESC LIMIT 1 ", [$lgIdx, $code, $orderDate])->getRowArray(); if ($item) { $map[$code] = [ 'order_date' => $orderDate, 'qty_sheet' => (int) ($item['qty_sheet'] ?? 0), 'bag_name' => (string) ($item['bag_name'] ?? ''), ]; } } return $map; } /** * @param array $lastOrders * @return array */ private function loadMovementsSinceOrders(int $lgIdx, array $lastOrders, string $refDate): array { $byCode = []; $minDate = $refDate; foreach ($lastOrders as $code => $info) { $d = (string) ($info['order_date'] ?? ''); if ($d === '') { continue; } $byCode[$code] = $d; if ($d < $minDate) { $minDate = $d; } } if ($byCode === []) { return []; } $codes = array_keys($byCode); $placeholders = implode(',', array_fill(0, count($codes), '?')); $params = array_merge([$lgIdx], $codes, [$minDate, $refDate]); $out = []; foreach ($codes as $code) { $out[$code] = ['sale' => 0, 'recv' => 0, 'issue' => 0]; } $sql = " SELECT bs_bag_code AS bag_code, bs_sale_date AS mv_date, SUM(CASE WHEN bs_type IN ('sale','cancel') THEN ABS(bs_qty) ELSE 0 END) AS sale_qty FROM bag_sale WHERE bs_lg_idx = ? AND bs_bag_code IN ({$placeholders}) AND bs_sale_date >= ? AND bs_sale_date <= ? GROUP BY bs_bag_code, bs_sale_date "; foreach ($this->db->query($sql, $params)->getResult() as $row) { $code = (string) $row->bag_code; $orderDate = $byCode[$code] ?? ''; if ($orderDate === '' || (string) $row->mv_date < $orderDate) { continue; } $out[$code]['sale'] += (int) $row->sale_qty; } $sql = " SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS recv_qty FROM bag_receiving WHERE br_lg_idx = ? AND br_bag_code IN ({$placeholders}) AND br_receive_date >= ? AND br_receive_date <= ? GROUP BY br_bag_code, br_receive_date "; foreach ($this->db->query($sql, $params)->getResult() as $row) { $code = (string) $row->bag_code; $orderDate = $byCode[$code] ?? ''; if ($orderDate === '' || (string) $row->mv_date < $orderDate) { continue; } $out[$code]['recv'] += (int) $row->recv_qty; } $sql = " SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, SUM(bi2_qty) AS issue_qty FROM bag_issue WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$placeholders}) AND bi2_issue_date >= ? AND bi2_issue_date <= ? GROUP BY bi2_bag_code, bi2_issue_date "; foreach ($this->db->query($sql, $params)->getResult() as $row) { $code = (string) $row->bag_code; $orderDate = $byCode[$code] ?? ''; if ($orderDate === '' || (string) $row->mv_date < $orderDate) { continue; } $out[$code]['issue'] += (int) $row->issue_qty; } return $out; } /** * @param array $barcodeCodes * @return array */ private function loadMonthlyAverageSales( int $lgIdx, string $refDate, string $salesScope, array $barcodeCodes ): array { $fromDate = date('Y-m-d', strtotime($refDate . ' -' . self::AVG_SALES_MONTHS . ' months')); $legacyNet = []; $barcodeNet = []; foreach ($this->db->query(" SELECT bs_bag_code AS bag_code, SUM(CASE WHEN bs_type = 'sale' THEN ABS(bs_qty) WHEN bs_type IN ('return','cancel') THEN -ABS(bs_qty) ELSE 0 END) AS net_qty FROM bag_sale WHERE bs_lg_idx = ? AND bs_sale_date > ? AND bs_sale_date <= ? GROUP BY bs_bag_code ", [$lgIdx, $fromDate, $refDate])->getResult() as $row) { $code = (string) $row->bag_code; $legacyNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS; } if ($this->db->tableExists('bag_sale_scan_code')) { foreach ($this->db->query(" SELECT bssc_bag_code AS bag_code, SUM(bssc_qty) AS net_qty FROM bag_sale_scan_code WHERE bssc_lg_idx = ? AND bssc_state = 'sold' AND DATE(bssc_regdate) > ? AND DATE(bssc_regdate) <= ? GROUP BY bssc_bag_code ", [$lgIdx, $fromDate, $refDate])->getResult() as $row) { $code = (string) $row->bag_code; $barcodeNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS; } } $merged = []; $allCodes = array_unique(array_merge(array_keys($legacyNet), array_keys($barcodeNet))); foreach ($allCodes as $code) { $isBarcode = isset($barcodeCodes[$code]); $legacy = $legacyNet[$code] ?? 0.0; $scan = $barcodeNet[$code] ?? 0.0; $merged[$code] = match ($salesScope) { 'legacy' => $isBarcode ? 0.0 : $legacy, 'barcode' => $isBarcode ? ($scan > 0 ? $scan : $legacy) : 0.0, default => $isBarcode && $scan > 0 ? $scan : $legacy, }; } return $merged; } private function scopedStock(int $qty, string $scope, bool $isBarcode): int { return match ($scope) { 'legacy' => $isBarcode ? 0 : $qty, 'barcode' => $isBarcode ? $qty : 0, default => $qty, }; } private function scopedPending(int $qty, string $scope, bool $isBarcode): int { return $this->scopedStock($qty, $scope, $isBarcode); } private function calcDepletionDays(int $totalStock, float $monthlyAvg): int { if ($monthlyAvg <= 0.0) { return 0; } return (int) round(($totalStock / $monthlyAvg) * 30); } private function calcScheduleDate(string $refDate, int $depletionDays, int $leadDays): string { if ($depletionDays <= 0) { return ''; } if ($depletionDays >= 99999) { return ''; } $offset = $depletionDays - $leadDays; if ($offset < 0) { return $refDate; } $base = \DateTimeImmutable::createFromFormat('Y-m-d', $refDate); if ($base === false) { return ''; } try { $scheduled = $base->modify('+' . $offset . ' days'); } catch (\Exception) { return ''; } $year = (int) $scheduled->format('Y'); $refYear = (int) $base->format('Y'); if ($year < $refYear - 1 || $year > $refYear + 120) { return ''; } return $scheduled->format('Y-m-d'); } private function calcOrderQty( string $refDate, string $scheduleDate, int $depletionDays, int $leadDays, int $monthlyAvg, int $totalStock ): int { if ($monthlyAvg <= 0) { return 0; } $urgent = $scheduleDate !== '' && $scheduleDate <= $refDate; $lowStock = $depletionDays > 0 && $depletionDays <= $leadDays && $scheduleDate !== ''; if (! $urgent && ! $lowStock) { return 0; } $target = (int) round($monthlyAvg * ($urgent ? self::URGENT_REPLENISH_MONTHS : max(2, (int) ceil($leadDays / 30.0)))); return max(0, $target - $totalStock); } }