From 63e4f88a14d55b98b51dedadbe3d77e0ec5fd5a7 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 25 Feb 2026 17:32:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=84=AA=E5=8C=96=E9=96=80=E5=B8=82=E5=8F=AB?= =?UTF-8?q?=E8=B2=A8=E6=B5=81=E7=A8=8B=EF=BC=9A=E5=AF=A6=E4=BD=9C=E5=BA=AB?= =?UTF-8?q?=E5=AD=98=E9=A0=90=E6=89=A3=E6=A9=9F=E5=88=B6=E3=80=81=E9=8E=96?= =?UTF-8?q?=E5=AE=9A=E8=87=AA=E5=8B=95=E7=94=A2=E7=94=9F=E7=9A=84=E8=AA=BF?= =?UTF-8?q?=E6=92=A5=E5=96=AE=E6=98=8E=E7=B4=B0=E3=80=81=E4=BF=AE=E5=BE=A9?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E8=B2=A9=E8=B3=A3=E6=A9=9F=E8=B2=A8=E9=81=93?= =?UTF-8?q?=E6=95=B8=E9=87=8F=E9=80=A3=E5=8B=95=20Bug=20=E5=8F=8A=E7=8B=80?= =?UTF-8?q?=E6=85=8B=E5=90=8C=E6=AD=A5=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StoreRequisitionController.php | 72 +++- .../Controllers/TransferOrderController.php | 14 + app/Modules/Inventory/Models/Inventory.php | 29 ++ .../Services/StoreRequisitionService.php | 63 ++- .../Inventory/Services/TransferService.php | 50 ++- ...reserved_quantity_to_inventories_table.php | 28 ++ .../js/Pages/Inventory/Transfer/Show.tsx | 13 +- resources/js/Pages/StoreRequisition/Show.tsx | 361 +++++++++++------- 8 files changed, 469 insertions(+), 161 deletions(-) create mode 100644 database/migrations/tenant/2026_02_25_162728_add_reserved_quantity_to_inventories_table.php diff --git a/app/Modules/Inventory/Controllers/StoreRequisitionController.php b/app/Modules/Inventory/Controllers/StoreRequisitionController.php index 3ce7dc7..e601a7e 100644 --- a/app/Modules/Inventory/Controllers/StoreRequisitionController.php +++ b/app/Modules/Inventory/Controllers/StoreRequisitionController.php @@ -165,7 +165,10 @@ class StoreRequisitionController extends Controller */ public function show($id) { - $requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id); + $requisition = StoreRequisition::with([ + 'items.product.baseUnit', + 'transferOrder.items' // 載入產生的調撥單明細與批號 + ])->findOrFail($id); // 水和倉庫 $warehouses = Warehouse::select('id', 'name', 'type')->get(); @@ -198,8 +201,69 @@ class StoreRequisitionController extends Controller ->get() ->keyBy('product_id'); - $requisition->items->transform(function ($item) use ($inventories) { + // 取得供貨倉庫的可用庫存 + $supplyInventories = collect(); + $supplyBatchesMap = collect(); + if ($requisition->supply_warehouse_id) { + $supplyInventories = Inventory::where('warehouse_id', $requisition->supply_warehouse_id) + ->whereIn('product_id', $productIds) + ->select('product_id') + ->selectRaw('SUM(quantity) as total_qty') + ->selectRaw('SUM(reserved_quantity) as total_reserved') + ->groupBy('product_id') + ->get() + ->keyBy('product_id'); + + // 取得各商品的批號庫存 + $batches = Inventory::where('warehouse_id', $requisition->supply_warehouse_id) + ->whereIn('product_id', $productIds) + ->whereRaw('(quantity - reserved_quantity) > 0') // 僅撈出還有可用庫存的批號 + ->select('id', 'product_id', 'batch_number', 'expiry_date', 'location as position') + ->selectRaw('quantity - reserved_quantity as available_qty') + ->get(); + + $supplyBatchesMap = $batches->groupBy('product_id'); + } + + // 把調撥單明細 (核准的批號與數量) 整理成 map, key 為 product_id + $approvedBatchesMap = collect(); + if ($requisition->transferOrder) { + $approvedBatchesMap = $requisition->transferOrder->items->groupBy('product_id'); + } + + $requisition->items->transform(function ($item) use ($inventories, $supplyInventories, $supplyBatchesMap, $approvedBatchesMap) { $item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0; + + if ($supplyInventories->has($item->product_id)) { + $stock = $supplyInventories->get($item->product_id); + $item->supply_stock = max(0, $stock->total_qty - $stock->total_reserved); + + // 附加該商品的批號可用庫存 + $batches = $supplyBatchesMap->get($item->product_id) ?? collect(); + $item->supply_batches = $batches->map(function ($batch) { + return [ + 'inventory_id' => $batch->id, + 'batch_number' => $batch->batch_number, + 'position' => $batch->position, + 'available_qty' => $batch->available_qty, + 'expiry_date' => $batch->expiry_date ? $batch->expiry_date->format('Y-m-d') : null, + ]; + })->values()->toArray(); + } else { + $item->supply_stock = null; + $item->supply_batches = []; + } + + // 附加已核准的批號資訊 + $approvedBatches = $approvedBatchesMap->get($item->product_id) ?? collect(); + $item->approved_batches = $approvedBatches->map(function ($transferItem) { + // 如果是沒有批號管控的商品,batch_number 可能為 null + return [ + 'batch_number' => $transferItem->batch_number, + 'qty' => $transferItem->quantity, + ]; + })->values()->toArray(); + return $item; }); @@ -306,6 +370,10 @@ class StoreRequisitionController extends Controller 'items' => 'required|array', 'items.*.id' => 'required|exists:store_requisition_items,id', 'items.*.approved_qty' => 'required|numeric|min:0', + 'items.*.batches' => 'nullable|array', + 'items.*.batches.*.inventory_id' => 'nullable|integer', + 'items.*.batches.*.batch_number' => 'nullable|string', + 'items.*.batches.*.qty' => 'required_with:items.*.batches|numeric|min:0.01', ]); if (empty($requisition->supply_warehouse_id)) { diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index 2f6d7f5..8d1d5da 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -215,6 +215,9 @@ class TransferOrderController extends Controller // 2. 先更新資料 (如果請求中包含 items,則先執行儲存) $itemsChanged = false; if ($request->has('items')) { + if ($order->storeRequisition()->exists()) { + return redirect()->back()->with('error', '由叫貨單自動產生的調撥單無法修改明細'); + } $validated = $request->validate([ 'items' => 'array', 'items.*.product_id' => 'required|exists:products,id', @@ -263,6 +266,17 @@ class TransferOrderController extends Controller return redirect()->back()->with('error', '只能刪除草稿狀態的單據'); } + // 刪除前必須先釋放預留庫存 + 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); + } + } + $order->items()->delete(); $order->delete(); diff --git a/app/Modules/Inventory/Models/Inventory.php b/app/Modules/Inventory/Models/Inventory.php index c08dd6c..5f24bd7 100644 --- a/app/Modules/Inventory/Models/Inventory.php +++ b/app/Modules/Inventory/Models/Inventory.php @@ -17,6 +17,7 @@ class Inventory extends Model 'warehouse_id', 'product_id', 'quantity', + 'reserved_quantity', 'location', 'unit_cost', 'total_value', @@ -34,6 +35,8 @@ class Inventory extends Model protected $casts = [ 'arrival_date' => 'date:Y-m-d', 'expiry_date' => 'date:Y-m-d', + 'quantity' => 'decimal:4', + 'reserved_quantity' => 'decimal:4', 'unit_cost' => 'decimal:4', 'total_value' => 'decimal:4', ]; @@ -109,7 +112,33 @@ class Inventory extends Model }); } + /** + * 可用庫存(實體庫存 - 預留庫存) + */ + public function getAvailableQuantityAttribute() + { + return max(0, $this->quantity - $this->reserved_quantity); + } + /** + * 增加預留庫存(鎖定) + */ + public function reserveQuantity(float|int $amount) + { + if ($amount <= 0) return; + $this->reserved_quantity += $amount; + $this->save(); + } + + /** + * 釋放預留庫存(解鎖) + */ + public function releaseReservedQuantity(float|int $amount) + { + if ($amount <= 0) return; + $this->reserved_quantity = max(0, $this->reserved_quantity - $amount); + $this->save(); + } /** * 產生批號 diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index 3362773..6825108 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -118,17 +118,57 @@ class StoreRequisitionService } return DB::transaction(function () use ($requisition, $data, $userId) { - // 更新核准數量 + // 處理前端傳來的明細與批號資料 + $processedItems = []; // 暫存處理後的明細,用於轉入調撥單 + if (isset($data['items'])) { foreach ($data['items'] as $itemData) { - StoreRequisitionItem::where('id', $itemData['id']) + $reqItemId = $itemData['id']; + $totalApprovedQty = 0; + $batches = $itemData['batches'] ?? []; + + // 如果有批號,根據批號展開。若有多個無批號(null)的批次(例如來自不同貨道),則將其數量加總 + if (!empty($batches)) { + $batchGroups = []; + foreach ($batches as $batch) { + $qty = (float)($batch['qty'] ?? 0); + $bNum = $batch['batch_number'] ?? null; + if ($qty > 0) { + $totalApprovedQty += $qty; + $batchKey = $bNum ?? ''; + $batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty; + } + } + + foreach ($batchGroups as $bNumKey => $qty) { + $processedItems[] = [ + 'req_item_id' => $reqItemId, + 'batch_number' => $bNumKey === '' ? null : $bNumKey, + 'quantity' => $qty, + ]; + } + } else { + // 無批號,傳統輸入 + $qty = (float)($itemData['approved_qty'] ?? 0); + if ($qty > 0) { + $totalApprovedQty += $qty; + $processedItems[] = [ + 'req_item_id' => $reqItemId, + 'batch_number' => null, + 'quantity' => $qty, + ]; + } + } + + // 更新叫貨單明細的核准數量總和 + StoreRequisitionItem::where('id', $reqItemId) ->where('store_requisition_id', $requisition->id) - ->update(['approved_qty' => $itemData['approved_qty']]); + ->update(['approved_qty' => $totalApprovedQty]); } } // 優先使用傳入的供貨倉庫,若無則從單據中取得 - $supplyWarehouseId = $data['supply_warehouse_id'] ?? $requisition->supply_warehouse_id; + $supplyWarehouseId = $requisition->supply_warehouse_id; if (!$supplyWarehouseId) { throw ValidationException::withMessages([ @@ -152,12 +192,17 @@ class StoreRequisitionService // 將核准的明細寫入調撥單 $requisition->load('items'); $transferItems = []; - foreach ($requisition->items as $item) { - $qty = $item->approved_qty ?? $item->requested_qty; - if ($qty > 0) { + + // 建立 req_item_id 對應 product_id 的 lookup + $reqItemMap = $requisition->items->keyBy('id'); + + foreach ($processedItems as $pItem) { + $reqItem = $reqItemMap->get($pItem['req_item_id']); + if ($reqItem) { $transferItems[] = [ - 'product_id' => $item->product_id, - 'quantity' => $qty, + 'product_id' => $reqItem->product_id, + 'batch_number' => $pItem['batch_number'], + 'quantity' => $pItem['quantity'], ]; } } diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index c1631b2..8403c30 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -45,6 +45,17 @@ class TransferService 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' => [], @@ -67,6 +78,21 @@ class TransferService ]); $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'] || @@ -163,6 +189,9 @@ class TransferService $oldSourceQty = $sourceInventory->quantity; $newSourceQty = $oldSourceQty - $item->quantity; + // 釋放草稿階段預扣的庫存 + $sourceInventory->reserved_quantity = max(0, $sourceInventory->reserved_quantity - $item->quantity); + $item->update(['snapshot_quantity' => $oldSourceQty]); $sourceInventory->quantity = $newSourceQty; @@ -346,9 +375,22 @@ class TransferService if ($order->status !== 'draft') { throw new \Exception('只能作廢草稿狀態的單據'); } - $order->update([ - 'status' => 'voided', - 'updated_by' => $userId - ]); + + 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); + } + } + + $order->update([ + 'status' => 'voided', + 'updated_by' => $userId + ]); + }); } } diff --git a/database/migrations/tenant/2026_02_25_162728_add_reserved_quantity_to_inventories_table.php b/database/migrations/tenant/2026_02_25_162728_add_reserved_quantity_to_inventories_table.php new file mode 100644 index 0000000..587f93b --- /dev/null +++ b/database/migrations/tenant/2026_02_25_162728_add_reserved_quantity_to_inventories_table.php @@ -0,0 +1,28 @@ +decimal('reserved_quantity', 12, 4)->default(0)->after('quantity')->comment('預留/鎖定庫存數量'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventories', function (Blueprint $table) { + $table->dropColumn('reserved_quantity'); + }); + } +}; diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 3a476c7..526d158 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -269,6 +269,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr const canEdit = can('inventory_transfer.edit'); const isReadOnly = (order.status !== 'draft' || !canEdit); + const isItemsReadOnly = isReadOnly || !!order.requisition; const isVending = order.to_warehouse_type === 'vending'; // 狀態 Badge 渲染 @@ -567,7 +568,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr 請選擇要調撥的商品並輸入數量。所有商品將從「{order.from_warehouse_name}」轉出。

- {!isReadOnly && ( + {!isItemsReadOnly && (
@@ -402,8 +459,13 @@ export default function Show({ requisition, warehouses }: Props) { 商品編號 商品名稱 - 現有庫存 + 申請倉現有 + {requisition.supply_warehouse_id && ( + + 供貨倉可用 + + )} 需求數量 @@ -413,40 +475,145 @@ export default function Show({ requisition, warehouses }: Props) { 核准數量 )} + {isPending && canApprove && ( + + 核准數量 + + )} 備註 - {requisition.items.map((item, index) => ( - - - {index + 1} - - - {item.product_code} - - - {item.product_name} - - - {Number(item.current_stock).toLocaleString()} - - - {Number(item.requested_qty).toLocaleString()} - - {item.unit_name} - {["approved", "completed"].includes(requisition.status) && ( - - {item.approved_qty !== null - ? Number(item.approved_qty).toLocaleString() - : "-"} - - )} - - {item.remark || "-"} - - - ))} + {requisition.items.map((item, index) => { + const approvedItem = approvedItems.find((ai) => ai.id === item.id); + + // 判斷是否為「多批號」情境 (審核時使用) + const hasBatches = item.supply_batches && item.supply_batches.length > 0; + + // 判斷是否含有效已核准批號 (檢視時使用) + const hasApprovedBatches = item.approved_batches && item.approved_batches.some(b => b.batch_number !== null); + + // 計算目前填寫的核准總量 + const totalApprovedQty = approvedItem + ? approvedItem.batches.reduce((sum, b) => sum + (parseFloat(b.qty) || 0), 0) + : 0; + + const isOverTotalStock = item.supply_stock !== null && totalApprovedQty > item.supply_stock; + const rowClassName = (isPending && canApprove && isOverTotalStock) ? "bg-red-50/50" : ""; + + return ( + + + + {index + 1} + + + {item.product_code} + + + {item.product_name} + {hasBatches && isPending && canApprove && ( +
需分配批號出庫 ↑
+ )} +
+ + {Number(item.current_stock).toLocaleString()} + + {requisition.supply_warehouse_id && ( + + {item.supply_stock !== null ? Number(item.supply_stock).toLocaleString() : "-"} + + )} + + {Number(item.requested_qty).toLocaleString()} + + {item.unit_name} + {["approved", "completed"].includes(requisition.status) && ( + + {item.approved_qty !== null + ? Number(item.approved_qty).toLocaleString() + : "-"} + + )} + {isPending && canApprove && ( + + {!hasBatches ? ( + + updateApprovedBatchQty(item.id, null, e.target.value) + } + className={`h-8 text-right w-[100px] ${isOverTotalStock ? "border-red-400 text-red-600 focus-visible:ring-red-400" : ""}`} + /> + ) : ( +
+ 總計: {totalApprovedQty > 0 ? totalApprovedQty : "-"} +
+ )} +
+ )} + + {item.remark || "-"} + +
+ + {/* 展開批號/貨道輸入子行 */} + {hasBatches && isPending && canApprove && item.supply_batches!.map((batch) => { + const batchInput = approvedItem?.batches.find(b => b.inventory_id === batch.inventory_id); + const inputQty = parseFloat(batchInput?.qty || "0"); + const isBatchOverStock = inputQty > batch.available_qty; + + return ( + + + + {batch.batch_number ? ( + <>批號: {batch.batch_number}
+ ) : ( + <>無批號
+ )} + {batch.position && ( + <>貨道: {batch.position}
+ )} + (庫存: {Number(batch.available_qty)})
+ {batch.expiry_date && 效期: {batch.expiry_date}} +
+ + + updateApprovedBatchQty(item.id, batch.inventory_id, e.target.value)} + placeholder="輸入數量" + className={`h-7 text-right w-[100px] text-xs ${isBatchOverStock ? "border-red-400 text-red-600 focus-visible:ring-red-400 bg-red-50" : ""}`} + /> + + +
+ ); + })} + + {/* 展開已核准批號子行 (檢視用) */} + {["approved", "completed"].includes(requisition.status) && hasApprovedBatches && item.approved_batches!.filter(b => b.batch_number).map((batch, bIndex) => ( + + + + 核准批號: {batch.batch_number} + + + {Number(batch.qty).toLocaleString()} + + + + ))} +
+ ); + })}
@@ -476,93 +643,7 @@ export default function Show({ requisition, warehouses }: Props) { - {/* 核准對話框 */} - - - - 核准叫貨單 - 選擇供貨倉庫,並確認各商品的核准數量。 - -
-
-
- 供貨倉庫: - {requisition.supply_warehouse_name || "尚未選擇"} -
- {!requisition.supply_warehouse_id && ( - * 請先在基本資訊中選擇供貨倉庫 - )} -
-
- - - - 商品 - - 需求數量 - - 單位 - - 核准數量 - - - - - {requisition.items.map((item) => ( - - - - {item.product_code} - - {item.product_name} - - - {Number(item.requested_qty).toLocaleString()} - - - {item.unit_name} - - - ai.id === item.id) - ?.approved_qty || "" - } - onChange={(e) => - updateApprovedQty(item.id, e.target.value) - } - className="h-8 text-right" - /> - - - ))} - -
-
-
- - - - -
-
{/* 駁回對話框 */}