feat(Inventory): 實作批號溯源完整功能與 UI 呈現,包含文字敘述卡片與更完整的關聯屬性
This commit is contained in:
@@ -185,6 +185,7 @@ class RoleController extends Controller
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'inventory_report' => '庫存報表',
|
||||
'inventory_traceability' => '批號溯源',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'purchase_returns' => '採購退回管理',
|
||||
|
||||
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Services\TraceabilityService;
|
||||
|
||||
class TraceabilityController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TraceabilityService $traceabilityService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 顯示批號溯源查詢的主頁面
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$batchNumber = $request->input('batch_number');
|
||||
$direction = $request->input('direction', 'backward'); // backward 或 forward
|
||||
|
||||
$result = null;
|
||||
|
||||
if ($batchNumber) {
|
||||
if ($direction === 'backward') {
|
||||
$result = $this->traceabilityService->traceBackward($batchNumber);
|
||||
} else {
|
||||
$result = $this->traceabilityService->traceForward($batchNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Inventory/Traceability/Index', [
|
||||
'search' => [
|
||||
'batch_number' => $batchNumber,
|
||||
'direction' => $direction,
|
||||
],
|
||||
'result' => $result
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
|
||||
});
|
||||
|
||||
// 批號溯源 (Lot Traceability)
|
||||
Route::middleware('permission:inventory_traceability.view')->group(function () {
|
||||
Route::get('/inventory/traceability', [\App\Modules\Inventory\Controllers\TraceabilityController::class, 'index'])->name('inventory.traceability.index');
|
||||
});
|
||||
|
||||
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
|
||||
@@ -122,8 +122,13 @@ class StoreRequisitionService
|
||||
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單
|
||||
|
||||
if (isset($data['items'])) {
|
||||
$requisition->load('items.product');
|
||||
$reqItemMap = $requisition->items->keyBy('id');
|
||||
|
||||
foreach ($data['items'] as $itemData) {
|
||||
$reqItemId = $itemData['id'];
|
||||
$reqItem = $reqItemMap->get($reqItemId);
|
||||
$productName = $reqItem?->product?->name ?? '未知商品';
|
||||
$totalApprovedQty = 0;
|
||||
$batches = $itemData['batches'] ?? [];
|
||||
|
||||
@@ -133,7 +138,22 @@ class StoreRequisitionService
|
||||
foreach ($batches as $batch) {
|
||||
$qty = (float)($batch['qty'] ?? 0);
|
||||
$bNum = $batch['batch_number'] ?? null;
|
||||
$invId = $batch['inventory_id'] ?? null;
|
||||
|
||||
if ($qty > 0) {
|
||||
if ($invId) {
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId);
|
||||
if ($inventory) {
|
||||
$available = max(0, $inventory->quantity - $inventory->reserved_quantity);
|
||||
if ($qty > $available) {
|
||||
$batchStr = $bNum ? "批號 {$bNum}" : "無批號";
|
||||
throw ValidationException::withMessages([
|
||||
'items' => "「{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$totalApprovedQty += $qty;
|
||||
$batchKey = $bNum ?? '';
|
||||
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
|
||||
@@ -151,6 +171,18 @@ class StoreRequisitionService
|
||||
// 無批號,傳統輸入
|
||||
$qty = (float)($itemData['approved_qty'] ?? 0);
|
||||
if ($qty > 0) {
|
||||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||||
->where('product_id', $reqItem->product_id)
|
||||
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||||
->value('available') ?? 0;
|
||||
|
||||
if ($qty > $totalAvailable) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => "「{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})",
|
||||
]);
|
||||
}
|
||||
|
||||
$totalApprovedQty += $qty;
|
||||
$processedItems[] = [
|
||||
'req_item_id' => $reqItemId,
|
||||
|
||||
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,21 @@ namespace App\Modules\Production\Contracts;
|
||||
interface ProductionServiceInterface
|
||||
{
|
||||
public function getPendingProductionCount(): int;
|
||||
|
||||
/**
|
||||
* 尋找產出特定批號的生產工單
|
||||
*
|
||||
* @param string $batchNumber
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 尋找使用了特定庫存批號的生產工單項目
|
||||
*
|
||||
* @param int $inventoryId
|
||||
* @param array $with
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection;
|
||||
}
|
||||
|
||||
@@ -26,4 +26,9 @@ class ProductionOrderItem extends Model
|
||||
{
|
||||
return $this->belongsTo(ProductionOrder::class);
|
||||
}
|
||||
|
||||
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Modules\Production\Services;
|
||||
|
||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||
use App\Modules\Production\Models\ProductionOrder;
|
||||
use App\Modules\Production\Models\ProductionOrderItem;
|
||||
|
||||
class ProductionService implements ProductionServiceInterface
|
||||
{
|
||||
@@ -11,4 +12,18 @@ class ProductionService implements ProductionServiceInterface
|
||||
{
|
||||
return ProductionOrder::where('status', 'pending')->count();
|
||||
}
|
||||
|
||||
public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection
|
||||
{
|
||||
return ProductionOrder::with(['items.inventory.product', 'items.inventory'])
|
||||
->where('output_batch_number', $batchNumber)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection
|
||||
{
|
||||
return ProductionOrderItem::with($with)
|
||||
->where('inventory_id', $inventoryId)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user