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 && (