Files
star-erp/app/Modules/Inventory/Services/TraceabilityService.php

327 lines
13 KiB
PHP

<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\GoodsReceiptItem;
use App\Modules\Inventory\Models\GoodsReceipt;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Collection;
class TraceabilityService
{
public function __construct(
protected ProductionServiceInterface $productionService,
protected ProcurementServiceInterface $procurementService
) {}
/**
* 逆向溯源:從成品批號往前追溯用到的所有原料與廠商
*
* @param string $batchNumber 成品批號
* @return array 樹狀結構資料
*/
public function traceBackward(string $batchNumber): array
{
// 取得基本庫存資訊以作為根節點參考
$baseInventory = Inventory::with(['product', 'warehouse'])
->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);
}
}
}
}