where('batch_number', $batchNumber) ->first(); // 定義根節點 $rootNode = [ 'id' => 'batch_' . $batchNumber, 'type' => 'target_batch', 'label' => '查詢批號: ' . $batchNumber, 'batch_number' => $batchNumber, 'product_name' => $baseInventory?->product?->name, 'spec' => $baseInventory?->product?->spec, 'warehouse_name' => $baseInventory?->warehouse?->name, 'children' => [] ]; // 1. 尋找這個批號是不是生產出來的成品 (Production Order Output) // 透過 ProductionService 獲取,以落實模組解耦 $productionOrders = $this->productionService->getProductionOrdersByOutputBatch($batchNumber); foreach ($productionOrders as $po) { $poNode = [ 'id' => 'po_' . $po->id, 'type' => 'production_order', 'label' => '生產工單: ' . $po->code, 'date' => $po->production_date instanceof \DateTimeInterface ? $po->production_date->format('Y-m-d') : $po->production_date, 'quantity' => $po->output_quantity, 'children' => [] ]; // 針對每一張工單,尋找它投料的原料批號 foreach ($po->items as $item) { if (isset($item->inventory)) { $materialNode = $this->buildMaterialBackwardNode($item->inventory, $item); $poNode['children'][] = $materialNode; } } $rootNode['children'][] = $poNode; } // 2. 如果這批號是直接採購進來的 (Goods Receipt) // 或者是為了補足直接查詢原料批號的場景 $inventories = Inventory::with(['product', 'warehouse']) ->where('batch_number', $batchNumber) ->get(); foreach ($inventories as $inv) { // 尋找進貨單 $grItems = GoodsReceiptItem::with(['goodsReceipt', 'product']) ->where('batch_number', $batchNumber) ->where('product_id', $inv->product_id) ->get(); foreach ($grItems as $grItem) { $gr = $grItem->goodsReceipt; if ($gr) { $grNode = [ 'id' => 'gr_' . $gr->id . '_' . $inv->id, 'type' => 'goods_receipt', 'label' => '進貨單: ' . $gr->code, 'date' => $gr->received_date instanceof \DateTimeInterface ? $gr->received_date->format('Y-m-d') : $gr->received_date, 'vendor_id' => $gr->vendor_id, 'quantity' => $grItem->quantity, 'product_name' => $grItem->product?->name, 'children' => [] ]; // 避免重複加入 $isDuplicate = false; foreach ($rootNode['children'] as $child) { if ($child['id'] === $grNode['id']) { $isDuplicate = true; break; } } if (!$isDuplicate) { $rootNode['children'][] = $grNode; } } } } // 補充廠商名稱 (跨模組) $this->hydrateVendorNames($rootNode); return $rootNode; } /** * 建立原料的逆向溯源節點 */ private function buildMaterialBackwardNode(Inventory $inventory, $poItem = null): array { $node = [ 'id' => 'inv_' . $inventory->id, 'type' => 'material_batch', 'label' => '原料批號: ' . $inventory->batch_number, 'product_name' => $inventory->product?->name, 'spec' => $inventory->product?->spec, 'batch_number' => $inventory->batch_number, 'quantity' => $poItem ? $poItem->quantity_used : null, 'warehouse_name' => $inventory->warehouse?->name, 'children' => [] ]; // 繼續往下追溯該原料是怎麼來的 (進貨單) if ($inventory->batch_number) { $grItems = GoodsReceiptItem::with(['goodsReceipt', 'product']) ->where('batch_number', $inventory->batch_number) ->where('product_id', $inventory->product_id) ->get(); foreach ($grItems as $grItem) { $gr = $grItem->goodsReceipt; if ($gr) { $node['children'][] = [ 'id' => 'gr_' . $gr->id, 'type' => 'goods_receipt', 'label' => '進貨單: ' . $gr->code, 'date' => $gr->received_date instanceof \DateTimeInterface ? $gr->received_date->format('Y-m-d') : $gr->received_date, 'vendor_id' => $gr->vendor_id, 'quantity' => $grItem->quantity, 'product_name' => $grItem->product?->name, 'children' => [] ]; } } } return $node; } /** * 順向追蹤:從原料批號往後追查被用在哪些成品及去向 * * @param string $batchNumber 原料批號 * @return array 樹狀結構資料 */ public function traceForward(string $batchNumber): array { $baseInventory = Inventory::with(['product', 'warehouse']) ->where('batch_number', $batchNumber) ->first(); $rootNode = [ 'id' => 'batch_' . $batchNumber, 'type' => 'source_batch', 'label' => '查詢批號: ' . $batchNumber, 'batch_number' => $batchNumber, 'product_name' => $baseInventory?->product?->name, 'spec' => $baseInventory?->product?->spec, 'warehouse_name' => $baseInventory?->warehouse?->name, 'children' => [] ]; // 1. 尋找這個批號被哪些工單使用了 $inventories = Inventory::with(['product', 'warehouse'])->where('batch_number', $batchNumber)->get(); foreach ($inventories as $inv) { // 透過 ProductionService 獲取,以落實模組解耦 $poItems = $this->productionService->getProductionOrderItemsByInventoryId($inv->id, ['productionOrder']); foreach ($poItems as $item) { $po = $item->productionOrder; if ($po) { $poNode = [ 'id' => 'po_' . $po->id, 'type' => 'production_order', 'label' => '投入工單: ' . $po->code, 'date' => $po->production_date instanceof \DateTimeInterface ? $po->production_date->format('Y-m-d') : $po->production_date, 'quantity' => $item->quantity_used, 'children' => [] ]; // 該工單產出的成品批號 if ($po->output_batch_number) { $outputInventory = Inventory::with(['product', 'warehouse']) ->where('batch_number', $po->output_batch_number) ->first(); $outputNode = [ 'id' => 'output_batch_' . $po->output_batch_number, 'type' => 'target_batch', 'label' => '產出成品: ' . $po->output_batch_number, 'batch_number' => $po->output_batch_number, 'quantity' => $po->output_quantity, 'product_name' => $outputInventory?->product?->name, 'spec' => $outputInventory?->product?->spec, 'warehouse_name' => $outputInventory?->warehouse?->name, 'children' => [] ]; // 追蹤成品的出庫紀錄 (銷貨、領料等) $outTransactions = InventoryTransaction::with(['reference', 'inventory.product']) ->whereHas('inventory', function ($q) use ($po) { $q->where('batch_number', $po->output_batch_number); }) ->where('quantity', '<', 0) // 出庫 ->get(); foreach ($outTransactions as $txn) { $refType = class_basename($txn->reference_type); $outputNode['children'][] = [ 'id' => 'txn_' . $txn->id, 'type' => 'outbound_transaction', 'label' => '出庫單據: ' . $refType . ' #' . $txn->reference_id, 'date' => $txn->actual_time, 'quantity' => abs($txn->quantity), 'product_name' => $txn->inventory?->product?->name, 'children' => [] ]; } $poNode['children'][] = $outputNode; } $rootNode['children'][] = $poNode; } } } // 2. 如果這個批號自己本身就有出庫紀錄 (不是被生產掉,而是直接被領走或賣掉) foreach ($inventories as $inv) { $outTransactions = InventoryTransaction::with(['reference', 'inventory.product']) ->where('inventory_id', $inv->id) ->where('quantity', '<', 0) ->get(); foreach ($outTransactions as $txn) { // 如果是生產工單領料,上面已經處理過,這裡濾掉 if ($txn->reference_type && str_contains($txn->reference_type, 'ProductionOrder')) { continue; } $refType = $txn->reference_type ? class_basename($txn->reference_type) : '未知'; $rootNode['children'][] = [ 'id' => 'txn_direct_' . $txn->id, 'type' => 'outbound_transaction', 'label' => '直接出庫: ' . $refType . ' #' . $txn->reference_id, 'date' => $txn->actual_time, 'quantity' => abs($txn->quantity), 'product_name' => $txn->inventory?->product?->name, 'children' => [] ]; } } return $rootNode; } /** * 水和廠商名稱 (跨模組) */ private function hydrateVendorNames(array &$node): void { $vendorIds = []; $this->collectVendorIds($node, $vendorIds); if (empty($vendorIds)) return; $vendors = $this->procurementService->getVendorsByIds(array_unique($vendorIds))->keyBy('id'); $this->applyVendorNames($node, $vendors); } private function collectVendorIds(array $node, array &$ids): void { if (isset($node['vendor_id'])) { $ids[] = $node['vendor_id']; } if (!empty($node['children'])) { foreach ($node['children'] as $child) { $this->collectVendorIds($child, $ids); } } } private function applyVendorNames(array &$node, Collection $vendors): void { if (isset($node['vendor_id']) && $vendors->has($node['vendor_id'])) { $vendor = $vendors->get($node['vendor_id']); $node['label'] .= ' (廠商: ' . $vendor->name . ')'; } if (!empty($node['children'])) { foreach ($node['children'] as &$child) { $this->applyVendorNames($child, $vendors); } } } }