inventoryService = $inventoryService; } /** * 建立調撥單草稿 */ public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder { // 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定) if (is_null($transitWarehouseId)) { $fromWarehouse = Warehouse::find($fromWarehouseId); if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) { $transitWarehouseId = $fromWarehouse->default_transit_warehouse_id; } } $order = new InventoryTransferOrder([ 'from_warehouse_id' => $fromWarehouseId, 'to_warehouse_id' => $toWarehouseId, 'transit_warehouse_id' => $transitWarehouseId, 'status' => 'draft', 'remarks' => $remarks, 'created_by' => $userId, ]); // 手動觸發單號產生邏輯,因為 saveQuietly 繞過了 Model Events if (empty($order->doc_no)) { $today = date('Ymd'); $prefix = 'TRF-' . $today . '-'; $lastDoc = InventoryTransferOrder::where('doc_no', 'like', $prefix . '%') ->orderBy('doc_no', 'desc') ->first(); if ($lastDoc) { $lastNumber = substr($lastDoc->doc_no, -2); $nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT); } else { $nextNumber = '01'; } $order->doc_no = $prefix . $nextNumber; } $order->saveQuietly(); return $order; } /** * 更新調撥單明細 (支援精確 Diff 與自動日誌整合) */ public function updateItems(InventoryTransferOrder $order, array $itemsData): bool { return DB::transaction(function () use ($order, $itemsData) { $oldItemsMap = $order->items->mapWithKeys(function ($item) { $key = $item->product_id . '_' . ($item->batch_number ?? ''); return [$key => $item]; }); // 釋放舊明細的預扣庫存 foreach ($order->items as $item) { $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) ->first(); if ($inv) { $inv->releaseReservedQuantity($item->quantity); } } $diff = [ 'added' => [], 'removed' => [], 'updated' => [], ]; $order->items()->delete(); $newItemsKeys = []; foreach ($itemsData as $data) { $key = $data['product_id'] . '_' . ($data['batch_number'] ?? ''); $newItemsKeys[] = $key; $item = $order->items()->create([ 'product_id' => $data['product_id'], 'batch_number' => $data['batch_number'] ?? null, 'quantity' => $data['quantity'], 'position' => $data['position'] ?? null, 'notes' => $data['notes'] ?? null, ]); $item->load('product'); // 增加新明細的預扣庫存 $inv = Inventory::firstOrCreate( [ 'warehouse_id' => $order->from_warehouse_id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, ], [ 'quantity' => 0, 'unit_cost' => 0, 'total_value' => 0, ] ); $inv->reserveQuantity($item->quantity); if ($oldItemsMap->has($key)) { $oldItem = $oldItemsMap->get($key); if ((float)$oldItem->quantity !== (float)$data['quantity'] || $oldItem->notes !== ($data['notes'] ?? null) || $oldItem->position !== ($data['position'] ?? null)) { $diff['updated'][] = [ 'product_name' => $item->product->name, 'unit_name' => $item->product->baseUnit?->name, 'old' => [ 'quantity' => (float)$oldItem->quantity, 'position' => $oldItem->position, 'notes' => $oldItem->notes, ], 'new' => [ 'quantity' => (float)$data['quantity'], 'position' => $item->position, 'notes' => $item->notes, ] ]; } } else { $diff['added'][] = [ 'product_name' => $item->product->name, 'unit_name' => $item->product->baseUnit?->name, 'new' => [ 'quantity' => (float)$item->quantity, 'notes' => $item->notes, ] ]; } } foreach ($oldItemsMap as $key => $oldItem) { if (!in_array($key, $newItemsKeys)) { $diff['removed'][] = [ 'product_name' => $oldItem->product->name, 'unit_name' => $oldItem->product->baseUnit?->name, 'old' => [ 'quantity' => (float)$oldItem->quantity, 'notes' => $oldItem->notes, ] ]; } } $hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']); if ($hasChanged) { $order->activityProperties['items_diff'] = $diff; } return $hasChanged; }); } /** * 出貨 (Dispatch) - 根據是否有在途倉決定流程 * * 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched * 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯) */ public function dispatch(InventoryTransferOrder $order, int $userId): void { $order->load('items.product'); DB::transaction(function () use ($order, $userId) { $fromWarehouse = $order->fromWarehouse; $hasTransit = !empty($order->transit_warehouse_id); $targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id; $targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse; $outType = '調撥出庫'; $inType = $hasTransit ? '在途入庫' : '調撥入庫'; $itemsDiff = []; foreach ($order->items as $item) { if ($item->quantity <= 0) continue; // 1. 處理來源倉 (扣除) $sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) ->first(); if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { $availableQty = $sourceInventory->quantity ?? 0; $shortageQty = $item->quantity - $availableQty; throw ValidationException::withMessages([ 'items' => ["商品 {$item->product->name} (批號: {$item->batch_number}) 在來源倉庫存不足。現有庫存:{$availableQty},尚欠:{$shortageQty}。"], ]); } $sourceBefore = (float) $sourceInventory->quantity; // 釋放草稿階段預扣的庫存 $sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity); $sourceInventory->saveQuietly(); $item->update(['snapshot_quantity' => $sourceBefore]); // 委託 InventoryService 處理扣庫與 Transaction $this->inventoryService->decreaseInventoryQuantity( $sourceInventory->id, $item->quantity, "調撥單 {$order->doc_no} 至 {$targetWarehouse->name}", InventoryTransferOrder::class, $order->id ); $sourceAfter = $sourceBefore - (float) $item->quantity; // 2. 處理目的倉/在途倉 (增加) // 獲取目的倉異動前的庫存數(若無則為 0) $targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) ->first(); $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $targetWarehouseId, 'product_id' => $item->product_id, 'quantity' => $item->quantity, 'unit_cost' => $sourceInventory->unit_cost, 'batch_number' => $item->batch_number, 'expiry_date' => $sourceInventory->expiry_date, 'reason' => "調撥單 {$order->doc_no} 來自 {$fromWarehouse->name}", 'reference_type' => InventoryTransferOrder::class, 'reference_id' => $order->id, 'location' => $hasTransit ? null : ($item->position ?? null), 'origin_country' => $sourceInventory->origin_country, 'quality_status' => $sourceInventory->quality_status, ]); $targetAfter = $targetBefore + (float) $item->quantity; // 記錄異動明細供整合日誌使用 $itemsDiff[] = [ 'product_name' => $item->product->name, 'batch_number' => $item->batch_number, 'quantity' => (float)$item->quantity, 'source_warehouse' => $fromWarehouse->name, 'source_before' => $sourceBefore, 'source_after' => $sourceAfter, 'target_warehouse' => $targetWarehouse->name, 'target_before' => $targetBefore, 'target_after' => $targetAfter, ]; } $oldStatus = $order->status; if ($hasTransit) { $order->status = 'dispatched'; $order->dispatched_at = now(); $order->dispatched_by = $userId; } else { $order->status = 'completed'; $order->posted_at = now(); $order->posted_by = $userId; } $order->saveQuietly(); // 手動觸發單一合併日誌 activity() ->performedOn($order) ->causedBy(auth()->user()) ->event('updated') ->withProperties([ 'items_diff' => $itemsDiff, 'attributes' => [ 'status' => $order->status, 'dispatched_at' => $order->dispatched_at ? $order->dispatched_at->format('Y-m-d H:i:s') : null, 'posted_at' => $order->posted_at ? $order->posted_at->format('Y-m-d H:i:s') : null, 'dispatched_by' => $order->dispatched_by, 'posted_by' => $order->posted_by, ], 'old' => [ 'status' => $oldStatus, ] ]) ->log($order->status == 'completed' ? 'posted' : 'dispatched'); }); } /** * 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加 * 僅適用於有在途倉且狀態為 dispatched 的調撥單 */ public function receive(InventoryTransferOrder $order, int $userId): void { if ($order->status !== 'dispatched') { throw new \Exception('僅能對已出貨的調撥單進行收貨確認'); } if (empty($order->transit_warehouse_id)) { throw new \Exception('此調撥單未設定在途倉庫'); } $order->load('items.product'); DB::transaction(function () use ($order, $userId) { $transitWarehouse = $order->transitWarehouse; $toWarehouse = $order->toWarehouse; $itemsDiff = []; foreach ($order->items as $item) { if ($item->quantity <= 0) continue; // 1. 在途倉扣除 $transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) ->first(); if (!$transitInventory || $transitInventory->quantity < $item->quantity) { $availableQty = $transitInventory->quantity ?? 0; throw ValidationException::withMessages([ 'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"], ]); } $transitBefore = (float) $transitInventory->quantity; // 委託 InventoryService 處理扣庫與 Transaction $this->inventoryService->decreaseInventoryQuantity( $transitInventory->id, $item->quantity, "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}", InventoryTransferOrder::class, $order->id ); $transitAfter = $transitBefore - (float) $item->quantity; // 2. 目的倉增加 $targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) ->first(); $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $order->to_warehouse_id, 'product_id' => $item->product_id, 'quantity' => $item->quantity, 'unit_cost' => $transitInventory->unit_cost, 'batch_number' => $item->batch_number, 'expiry_date' => $transitInventory->expiry_date, 'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}", 'reference_type' => InventoryTransferOrder::class, 'reference_id' => $order->id, 'location' => $item->position, 'origin_country' => $transitInventory->origin_country, 'quality_status' => $transitInventory->quality_status, ]); $targetAfter = $targetBefore + (float) $item->quantity; $itemsDiff[] = [ 'product_name' => $item->product->name, 'batch_number' => $item->batch_number, 'quantity' => (float)$item->quantity, 'source_warehouse' => $transitWarehouse->name, 'source_before' => $transitBefore, 'source_after' => $transitAfter, 'target_warehouse' => $toWarehouse->name, 'target_before' => $targetBefore, 'target_after' => $targetAfter, ]; } $oldStatus = $order->status; $order->status = 'completed'; $order->posted_at = now(); $order->posted_by = $userId; $order->received_at = now(); $order->received_by = $userId; $order->saveQuietly(); // 手動觸發單一合併日誌 activity() ->performedOn($order) ->causedBy(auth()->user()) ->event('updated') ->withProperties([ 'items_diff' => $itemsDiff, 'attributes' => [ 'status' => 'completed', 'posted_at' => $order->posted_at->format('Y-m-d H:i:s'), 'received_at' => $order->received_at->format('Y-m-d H:i:s'), 'posted_by' => $order->posted_by, 'received_by' => $order->received_by, ], 'old' => [ 'status' => $oldStatus, ] ]) ->log('received'); }); } /** * 作廢 (Void) - 僅限草稿狀態 */ public function void(InventoryTransferOrder $order, int $userId): void { if ($order->status !== 'draft') { throw new \Exception('只能作廢草稿狀態的單據'); } DB::transaction(function () use ($order, $userId) { foreach ($order->items as $item) { $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) ->first(); if ($inv) { $inv->releaseReservedQuantity($item->quantity); } } $oldStatus = $order->status; $order->status = 'voided'; $order->updated_by = $userId; $order->saveQuietly(); activity() ->performedOn($order) ->causedBy(auth()->user()) ->event('updated') ->withProperties([ 'attributes' => [ 'status' => 'voided', ], 'old' => [ 'status' => $oldStatus, ] ]) ->log('voided'); }); } }