From 197df3bec47f985284e40c6e59373b73555ebc8b Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 9 Mar 2026 16:53:06 +0800 Subject: [PATCH] =?UTF-8?q?[FIX]=20=E4=BF=AE=E5=BE=A9=E6=89=80=E6=9C=89=20?= =?UTF-8?q?E2E=20=E6=A8=A1=E7=B5=84=E6=B8=AC=E8=A9=A6=E7=9A=84=E6=A8=99?= =?UTF-8?q?=E9=A1=8C=E5=AE=9A=E4=BD=8D=E5=99=A8=E4=BB=A5=E5=8F=8A=E5=B0=87?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E5=B8=B3=E8=99=9F=E9=82=84=E5=8E=9F=E7=82=BA?= =?UTF-8?q?=20admin=20=E6=AC=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/AccountPayableService.php | 1 + .../Inventory/Services/AdjustService.php | 50 +++++-- .../Services/GoodsReceiptService.php | 38 ++++-- .../Inventory/Services/InventoryService.php | 3 +- .../Services/StoreRequisitionService.php | 24 +++- .../Inventory/Services/TransferService.php | 83 ++++++++---- .../Controllers/PurchaseOrderController.php | 23 +++- .../Services/PurchaseReturnService.php | 32 +++-- .../Controllers/ProductionOrderController.php | 24 +++- .../Controllers/SalesImportController.php | 10 +- e2e/admin.spec.ts | 34 +++++ e2e/finance.spec.ts | 28 ++++ e2e/helpers/auth.ts | 9 +- e2e/integration.spec.ts | 14 ++ e2e/inventory.spec.ts | 81 +++++++++++ e2e/procurement.spec.ts | 127 ++++++++++++++++++ e2e/production.spec.ts | 22 +++ e2e/products.spec.ts | 15 +++ e2e/sales.spec.ts | 15 +++ e2e/vendors.spec.ts | 15 +++ e2e/warehouses.spec.ts | 14 ++ tests/Feature/Integration/PosApiTest.php | 2 +- tests/Feature/InventoryTransferImportTest.php | 18 ++- 23 files changed, 593 insertions(+), 89 deletions(-) create mode 100644 e2e/admin.spec.ts create mode 100644 e2e/finance.spec.ts create mode 100644 e2e/integration.spec.ts create mode 100644 e2e/inventory.spec.ts create mode 100644 e2e/procurement.spec.ts create mode 100644 e2e/production.spec.ts create mode 100644 e2e/products.spec.ts create mode 100644 e2e/sales.spec.ts create mode 100644 e2e/vendors.spec.ts create mode 100644 e2e/warehouses.spec.ts diff --git a/app/Modules/Finance/Services/AccountPayableService.php b/app/Modules/Finance/Services/AccountPayableService.php index 6ec6eb2..b3a69bd 100644 --- a/app/Modules/Finance/Services/AccountPayableService.php +++ b/app/Modules/Finance/Services/AccountPayableService.php @@ -70,6 +70,7 @@ class AccountPayableService $latest = AccountPayable::where('document_number', 'like', $lastPrefix) ->orderBy('document_number', 'desc') + ->lockForUpdate() ->first(); if (!$latest) { diff --git a/app/Modules/Inventory/Services/AdjustService.php b/app/Modules/Inventory/Services/AdjustService.php index 47bcfbc..c1369c3 100644 --- a/app/Modules/Inventory/Services/AdjustService.php +++ b/app/Modules/Inventory/Services/AdjustService.php @@ -44,16 +44,23 @@ class AdjustService ); // 2. 抓取有差異的明細 (diff_qty != 0) + $itemsToInsert = []; foreach ($countDoc->items as $item) { if (abs($item->diff_qty) < 0.0001) continue; - $adjDoc->items()->create([ + $itemsToInsert[] = [ + 'inventory_adjust_doc_id' => $adjDoc->id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, 'qty_before' => $item->system_qty, 'adjust_qty' => $item->diff_qty, 'notes' => "盤點差異: " . $item->diff_qty, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + if (!empty($itemsToInsert)) { + InventoryAdjustItem::insert($itemsToInsert); } return $adjDoc; @@ -84,25 +91,35 @@ class AdjustService $doc->items()->delete(); + $itemsToInsert = []; + $productIds = collect($itemsData)->pluck('product_id')->unique()->toArray(); + $products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id'); + + // 批次取得當前庫存 + $inventories = Inventory::where('warehouse_id', $doc->warehouse_id) + ->whereIn('product_id', $productIds) + ->get(); + foreach ($itemsData as $data) { - // 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準) - $inventory = Inventory::where('warehouse_id', $doc->warehouse_id) - ->where('product_id', $data['product_id']) + $inventory = $inventories->where('product_id', $data['product_id']) ->where('batch_number', $data['batch_number'] ?? null) ->first(); $qtyBefore = $inventory ? $inventory->quantity : 0; - $newItem = $doc->items()->create([ + $itemsToInsert[] = [ + 'inventory_adjust_doc_id' => $doc->id, 'product_id' => $data['product_id'], 'batch_number' => $data['batch_number'] ?? null, 'qty_before' => $qtyBefore, 'adjust_qty' => $data['adjust_qty'], 'notes' => $data['notes'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; // 更新日誌中的品項列表 - $productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name; + $productName = $products->get($data['product_id'])?->name ?? '未知商品'; $found = false; foreach ($updatedItems as $idx => $ui) { if ($ui['product_name'] === $productName && $ui['new'] === null) { @@ -126,6 +143,10 @@ class AdjustService } } + if (!empty($itemsToInsert)) { + InventoryAdjustItem::insert($itemsToInsert); + } + // 清理沒被更新到的舊品項 (即真正被刪除的) $finalUpdatedItems = []; foreach ($updatedItems as $ui) { @@ -162,11 +183,20 @@ class AdjustService foreach ($doc->items as $item) { if ($item->adjust_qty == 0) continue; - $inventory = Inventory::firstOrNew([ + // 補上 lockForUpdate() 防止併發衝突 + $inventory = Inventory::where([ 'warehouse_id' => $doc->warehouse_id, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, - ]); + ])->lockForUpdate()->first(); + + if (!$inventory) { + $inventory = new Inventory([ + 'warehouse_id' => $doc->warehouse_id, + 'product_id' => $item->product_id, + 'batch_number' => $item->batch_number, + ]); + } // 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存 if (!$inventory->exists) { diff --git a/app/Modules/Inventory/Services/GoodsReceiptService.php b/app/Modules/Inventory/Services/GoodsReceiptService.php index 79e16a4..8560be5 100644 --- a/app/Modules/Inventory/Services/GoodsReceiptService.php +++ b/app/Modules/Inventory/Services/GoodsReceiptService.php @@ -47,14 +47,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei $productIds = collect($data['items'])->pluck('product_id')->unique()->toArray(); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + $itemsToInsert = []; foreach ($data['items'] as $itemData) { // 非標準類型:使用手動輸入的小計;標準類型:自動計算 $totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard' ? (float) $itemData['subtotal'] : $itemData['quantity_received'] * $itemData['unit_price']; - // Create GR Item - $grItem = new GoodsReceiptItem([ + $itemsToInsert[] = [ + 'goods_receipt_id' => $goodsReceipt->id, 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], @@ -62,8 +63,9 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei 'total_amount' => $totalAmount, 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, - ]); - $goodsReceipt->items()->save($grItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; $product = $products->get($itemData['product_id']); $diff['added'][] = [ @@ -76,6 +78,10 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei ]; } + if (!empty($itemsToInsert)) { + GoodsReceiptItem::insert($itemsToInsert); + } + // 4. 手動發送高品質日誌(包含品項明細) activity() ->performedOn($goodsReceipt) @@ -146,13 +152,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei if (isset($data['items'])) { $goodsReceipt->items()->delete(); + $itemsToInsert = []; foreach ($data['items'] as $itemData) { // 非標準類型:使用手動輸入的小計;標準類型:自動計算 $totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard' ? (float) $itemData['subtotal'] : $itemData['quantity_received'] * $itemData['unit_price']; - $grItem = new GoodsReceiptItem([ + $itemsToInsert[] = [ + 'goods_receipt_id' => $goodsReceipt->id, 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], @@ -160,8 +168,13 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei 'total_amount' => $totalAmount, 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, - ]); - $goodsReceipt->items()->save($grItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if (!empty($itemsToInsert)) { + GoodsReceiptItem::insert($itemsToInsert); } } @@ -248,11 +261,14 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei */ public function submit(GoodsReceipt $goodsReceipt) { - if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { - throw new \Exception('只有草稿或被退回的進貨單可以確認點收。'); - } - return DB::transaction(function () use ($goodsReceipt) { + // Pessimistic locking to prevent double submission + $goodsReceipt = GoodsReceipt::lockForUpdate()->find($goodsReceipt->id); + + if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { + throw new \Exception('只有草稿或被退回的進貨單可以確認點收。'); + } + $goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED; $goodsReceipt->save(); diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 9dd7d28..90f0e6b 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -98,7 +98,8 @@ class InventoryService implements InventoryServiceInterface $query->where('location', $slot); } - $inventories = $query->orderBy('arrival_date', 'asc') + $inventories = $query->lockForUpdate() + ->orderBy('arrival_date', 'asc') ->get(); $remainingToDecrease = $quantity; diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index 0cf0e80..4b9b093 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -53,15 +53,22 @@ class StoreRequisitionService // 靜默建立以抑制自動日誌 $requisition->saveQuietly(); + $itemsToInsert = []; + $productIds = collect($items)->pluck('product_id')->unique()->toArray(); + $products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id'); + $diff = ['added' => [], 'removed' => [], 'updated' => []]; foreach ($items as $item) { - $requisition->items()->create([ + $itemsToInsert[] = [ + 'store_requisition_id' => $requisition->id, 'product_id' => $item['product_id'], 'requested_qty' => $item['requested_qty'], 'remark' => $item['remark'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; - $product = \App\Modules\Inventory\Models\Product::find($item['product_id']); + $product = $products->get($item['product_id']); $diff['added'][] = [ 'product_name' => $product?->name ?? '未知商品', 'new' => [ @@ -70,6 +77,7 @@ class StoreRequisitionService ] ]; } + StoreRequisitionItem::insert($itemsToInsert); // 如果需直接提交,觸發通知 if ($submitImmediately) { @@ -179,13 +187,18 @@ class StoreRequisitionService // 儲存實際變動 $requisition->items()->delete(); + $itemsToInsert = []; foreach ($items as $item) { - $requisition->items()->create([ + $itemsToInsert[] = [ + 'store_requisition_id' => $requisition->id, 'product_id' => $item['product_id'], 'requested_qty' => $item['requested_qty'], 'remark' => $item['remark'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + StoreRequisitionItem::insert($itemsToInsert); // 檢查是否有任何變動 (主表或明細) $isDirty = $requisition->isDirty(); @@ -314,6 +327,7 @@ class StoreRequisitionService $supplyWarehouseId = $requisition->supply_warehouse_id; $totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId) ->where('product_id', $reqItem->product_id) + ->lockForUpdate() // 補上鎖定 ->selectRaw('SUM(quantity - reserved_quantity) as available') ->value('available') ?? 0; diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index a2c106b..fb8b33b 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -74,11 +74,12 @@ 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) + ->lockForUpdate() ->first(); if ($inv) { $inv->releaseReservedQuantity($item->quantity); @@ -91,42 +92,69 @@ class TransferService 'updated' => [], ]; + // 先刪除舊明細 $order->items()->delete(); + + $itemsToInsert = []; $newItemsKeys = []; + // 1. 批量收集待插入的明細數據 foreach ($itemsData as $data) { $key = $data['product_id'] . '_' . ($data['batch_number'] ?? ''); $newItemsKeys[] = $key; - $item = $order->items()->create([ + $itemsToInsert[] = [ + 'transfer_order_id' => $order->id, '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'); + 'created_at' => now(), + 'updated_at' => now(), + ]; + } - // 增加新明細的預扣庫存 - $inv = Inventory::firstOrCreate( - [ + // 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入) + if (!empty($itemsToInsert)) { + InventoryTransferItem::insert($itemsToInsert); + } + + // 3. 重新載入明細進行預扣處理與 Diff 計算 (因 insert 不返回 Model) + $order->load(['items.product.baseUnit']); + + foreach ($order->items as $item) { + $key = $item->product_id . '_' . ($item->batch_number ?? ''); + + // 增加新明細的預扣庫存 (使用 lockForUpdate 確保並發安全) + $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) + ->where('product_id', $item->product_id) + ->where('batch_number', $item->batch_number) + ->lockForUpdate() + ->first(); + + if (!$inv) { + $inv = Inventory::create([ '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 = $inv->fresh()->lockForUpdate(); + } + $inv->reserveQuantity($item->quantity); + // 計算 Diff 用於日誌 + $data = collect($itemsData)->first(fn($d) => $d['product_id'] == $item->product_id && ($d['batch_number'] ?? '') == ($item->batch_number ?? '')); + 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)) { + if ((float)$oldItem->quantity !== (float)$item->quantity || + $oldItem->notes !== $item->notes || + $oldItem->position !== $item->position) { $diff['updated'][] = [ 'product_name' => $item->product->name, @@ -137,7 +165,7 @@ class TransferService 'notes' => $oldItem->notes, ], 'new' => [ - 'quantity' => (float)$data['quantity'], + 'quantity' => (float)$item->quantity, 'position' => $item->position, 'notes' => $item->notes, ] @@ -158,8 +186,8 @@ class TransferService foreach ($oldItemsMap as $key => $oldItem) { if (!in_array($key, $newItemsKeys)) { $diff['removed'][] = [ - 'product_name' => $oldItem->product->name, - 'unit_name' => $oldItem->product->baseUnit?->name, + 'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})", + 'unit_name' => $oldItem->product?->baseUnit?->name, 'old' => [ 'quantity' => (float)$oldItem->quantity, 'notes' => $oldItem->notes, @@ -179,9 +207,6 @@ class TransferService /** * 出貨 (Dispatch) - 根據是否有在途倉決定流程 - * - * 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched - * 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯) */ public function dispatch(InventoryTransferOrder $order, int $userId): void { @@ -194,18 +219,16 @@ class TransferService $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. 處理來源倉 (扣除) + // 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣 $sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) { @@ -235,11 +258,11 @@ class TransferService $sourceAfter = $sourceBefore - (float) $item->quantity; - // 2. 處理目的倉/在途倉 (增加) - // 獲取目的倉異動前的庫存數(若無則為 0) + // 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition $targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; @@ -310,7 +333,6 @@ class TransferService /** * 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加 - * 僅適用於有在途倉且狀態為 dispatched 的調撥單 */ public function receive(InventoryTransferOrder $order, int $userId): void { @@ -333,10 +355,11 @@ class TransferService foreach ($order->items as $item) { if ($item->quantity <= 0) continue; - // 1. 在途倉扣除 + // 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣 $transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if (!$transitInventory || $transitInventory->quantity < $item->quantity) { @@ -359,10 +382,11 @@ class TransferService $transitAfter = $transitBefore - (float) $item->quantity; - // 2. 目的倉增加 + // 2. 目的倉增加 - 同樣需要鎖定 $targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); $targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0; @@ -440,6 +464,7 @@ class TransferService $inv = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) + ->lockForUpdate() ->first(); if ($inv) { $inv->releaseReservedQuantity($item->quantity); diff --git a/app/Modules/Procurement/Controllers/PurchaseOrderController.php b/app/Modules/Procurement/Controllers/PurchaseOrderController.php index 8f495eb..0f8e202 100644 --- a/app/Modules/Procurement/Controllers/PurchaseOrderController.php +++ b/app/Modules/Procurement/Controllers/PurchaseOrderController.php @@ -254,17 +254,21 @@ class PurchaseOrderController extends Controller $productIds = collect($validated['items'])->pluck('productId')->unique()->toArray(); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + $itemsToInsert = []; foreach ($validated['items'] as $item) { // 反算單價 $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; - $order->items()->create([ + $itemsToInsert[] = [ + 'purchase_order_id' => $order->id, 'product_id' => $item['productId'], 'quantity' => $item['quantity'], 'unit_id' => $item['unitId'] ?? null, 'unit_price' => $unitPrice, 'subtotal' => $item['subtotal'], - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; $product = $products->get($item['productId']); $diff['added'][] = [ @@ -275,6 +279,7 @@ class PurchaseOrderController extends Controller ] ]; } + \App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert); // 手動發送高品質日誌(包含品項明細) activity() @@ -468,7 +473,8 @@ class PurchaseOrderController extends Controller public function update(Request $request, $id) { - $order = PurchaseOrder::findOrFail($id); + // 加上 lockForUpdate() 防止併發修改 + $order = PurchaseOrder::lockForUpdate()->findOrFail($id); $validated = $request->validate([ 'vendor_id' => 'required|exists:vendors,id', @@ -572,20 +578,23 @@ class PurchaseOrderController extends Controller // 同步項目(原始邏輯) $order->items()->delete(); - $newItemsData = []; + $itemsToInsert = []; foreach ($validated['items'] as $item) { // 反算單價 $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; - $newItem = $order->items()->create([ + $itemsToInsert[] = [ + 'purchase_order_id' => $order->id, 'product_id' => $item['productId'], 'quantity' => $item['quantity'], 'unit_id' => $item['unitId'] ?? null, 'unit_price' => $unitPrice, 'subtotal' => $item['subtotal'], - ]); - $newItemsData[] = $newItem; + 'created_at' => now(), + 'updated_at' => now(), + ]; } + \App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert); // 3. 計算項目差異 $itemDiffs = [ diff --git a/app/Modules/Procurement/Services/PurchaseReturnService.php b/app/Modules/Procurement/Services/PurchaseReturnService.php index cd2b0cf..fbfcc1b 100644 --- a/app/Modules/Procurement/Services/PurchaseReturnService.php +++ b/app/Modules/Procurement/Services/PurchaseReturnService.php @@ -33,20 +33,23 @@ class PurchaseReturnService $purchaseReturn = PurchaseReturn::create($data); + $itemsToInsert = []; foreach ($data['items'] as $itemData) { $amount = $itemData['quantity_returned'] * $itemData['unit_price']; $totalAmount += $amount; - $prItem = new PurchaseReturnItem([ + $itemsToInsert[] = [ + 'purchase_return_id' => $purchaseReturn->id, 'product_id' => $itemData['product_id'], 'quantity_returned' => $itemData['quantity_returned'], 'unit_price' => $itemData['unit_price'], 'total_amount' => $amount, 'batch_number' => $itemData['batch_number'] ?? null, - ]); - - $purchaseReturn->items()->save($prItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + PurchaseReturnItem::insert($itemsToInsert); // 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount) $taxAmount = $data['tax_amount'] ?? 0; @@ -87,19 +90,23 @@ class PurchaseReturnService $purchaseReturn->items()->delete(); $totalAmount = 0; + $itemsToInsert = []; foreach ($data['items'] as $itemData) { $amount = $itemData['quantity_returned'] * $itemData['unit_price']; $totalAmount += $amount; - $prItem = new PurchaseReturnItem([ + $itemsToInsert[] = [ + 'purchase_return_id' => $purchaseReturn->id, 'product_id' => $itemData['product_id'], 'quantity_returned' => $itemData['quantity_returned'], 'unit_price' => $itemData['unit_price'], 'total_amount' => $amount, 'batch_number' => $itemData['batch_number'] ?? null, - ]); - $purchaseReturn->items()->save($prItem); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + PurchaseReturnItem::insert($itemsToInsert); $taxAmount = $purchaseReturn->tax_amount; $purchaseReturn->update([ @@ -117,11 +124,14 @@ class PurchaseReturnService */ public function submit(PurchaseReturn $purchaseReturn) { - if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { - throw new Exception('只有草稿狀態的退回單可以提交。'); - } - return DB::transaction(function () use ($purchaseReturn) { + // 加上 lockForUpdate() 防止併發提交 + $purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id); + + if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { + throw new Exception('只有草稿狀態的退回單可以提交。'); + } + // 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為) $purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED; $purchaseReturn->saveQuietly(); diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index dca13f8..296d85b 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -170,14 +170,18 @@ class ProductionOrderController extends Controller // 2. 處理明細 if (!empty($request->items)) { + $itemsToInsert = []; foreach ($request->items as $item) { - ProductionOrderItem::create([ + $itemsToInsert[] = [ 'production_order_id' => $productionOrder->id, 'inventory_id' => $item['inventory_id'], 'quantity_used' => $item['quantity_used'] ?? 0, 'unit_id' => $item['unit_id'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + ProductionOrderItem::insert($itemsToInsert); } }); @@ -380,14 +384,18 @@ class ProductionOrderController extends Controller $productionOrder->items()->delete(); if (!empty($request->items)) { + $itemsToInsert = []; foreach ($request->items as $item) { - ProductionOrderItem::create([ + $itemsToInsert[] = [ 'production_order_id' => $productionOrder->id, 'inventory_id' => $item['inventory_id'], 'quantity_used' => $item['quantity_used'] ?? 0, 'unit_id' => $item['unit_id'] ?? null, - ]); + 'created_at' => now(), + 'updated_at' => now(), + ]; } + ProductionOrderItem::insert($itemsToInsert); } }); @@ -407,8 +415,16 @@ class ProductionOrderController extends Controller } DB::transaction(function () use ($newStatus, $productionOrder, $request) { + // 使用鎖定重新獲取單據,防止併發狀態修改 + $productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first(); + $oldStatus = $productionOrder->status; + // 再次檢查狀態轉移(在鎖定後) + if (!$productionOrder->canTransitionTo($newStatus)) { + throw new \Exception('不合法的狀態轉移或權限不足'); + } + // 1. 執行特定狀態的業務邏輯 if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) { // 開始製作 -> 扣除原料庫存 diff --git a/app/Modules/Sales/Controllers/SalesImportController.php b/app/Modules/Sales/Controllers/SalesImportController.php index 01d2d35..1ba11e3 100644 --- a/app/Modules/Sales/Controllers/SalesImportController.php +++ b/app/Modules/Sales/Controllers/SalesImportController.php @@ -101,11 +101,13 @@ class SalesImportController extends Controller public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService) { - if ($import->status !== 'pending') { - return back()->with('error', '此批次無法確認。'); - } + return DB::transaction(function () use ($import, $inventoryService) { + // 加上 lockForUpdate() 防止併發確認 + $import = SalesImportBatch::lockForUpdate()->find($import->id); - DB::transaction(function () use ($import, $inventoryService) { + if (!$import || $import->status !== 'pending') { + throw new \Exception('此批次無法確認或已被處理。'); + } // 1. Prepare Aggregation $aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot" diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..b0d716b --- /dev/null +++ b/e2e/admin.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('系統管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入角色權限管理頁面並顯示主要元素', async ({ page }) => { + await page.goto('/admin/roles'); + await expect(page.locator('h1').filter({ hasText: '角色與權限' })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /新增角色/ })).toBeVisible(); + }); + + test('應能進入員工帳號管理頁面並顯示主要元素', async ({ page }) => { + await page.goto('/admin/users'); + await expect(page.getByRole('heading', { name: /使用者管理/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /新增使用者/ })).toBeVisible(); + }); + + test('應能進入系統操作紀錄頁面並顯示主要元素', async ({ page }) => { + await page.goto('/admin/activity-logs'); + await expect(page.getByRole('heading', { name: /操作紀錄/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('應能進入系統參數設定頁面並顯示主要元素', async ({ page }) => { + await page.goto('/admin/settings'); + await expect(page.locator('h1').filter({ hasText: '系統設定' })).toBeVisible(); + await expect(page.getByRole('button', { name: /存檔|儲存/ })).toBeVisible(); + }); +}); diff --git a/e2e/finance.spec.ts b/e2e/finance.spec.ts new file mode 100644 index 0000000..6d218f0 --- /dev/null +++ b/e2e/finance.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('財務管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入應付帳款管理頁面並顯示主要元素', async ({ page }) => { + await page.goto('/finance/account-payables'); + await expect(page.getByRole('heading', { name: /應付帳款管理/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('應能進入水電瓦斯費管理頁面並顯示主要元素', async ({ page }) => { + await page.goto('/utility-fees'); + await expect(page.getByRole('heading', { name: /公共事業費管理/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /新增/ })).toBeVisible(); + }); + + test('應能進入財務報表頁面並顯示主要元素', async ({ page }) => { + await page.goto('/accounting-report'); + await expect(page.getByRole('heading', { name: /會計報表/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /匯出/ })).toBeVisible(); + }); +}); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 4d2aaaf..5797efd 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -1,14 +1,15 @@ -import { Page } from '@playwright/test'; +import { Page, expect } from '@playwright/test'; /** * 共用登入函式 * 使用測試帳號登入 Star ERP 系統 */ -export async function login(page: Page, username = 'mama', password = 'mama9453') { +export async function login(page: Page, username = 'admin', password = 'password') { await page.goto('/'); await page.fill('#username', username); await page.fill('#password', password); await page.getByRole('button', { name: '登入系統' }).click(); - // 等待儀表板載入完成 - await page.waitForSelector('text=系統概況', { timeout: 10000 }); + // 等待儀表板載入完成 (改用更穩定的側邊欄文字或 URL) + await page.waitForURL('**/'); + await expect(page.getByRole('link', { name: '儀表板' }).first()).toBeVisible({ timeout: 15000 }); } diff --git a/e2e/integration.spec.ts b/e2e/integration.spec.ts new file mode 100644 index 0000000..805353e --- /dev/null +++ b/e2e/integration.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('系統串接模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入銷貨單據串接頁面並顯示主要元素', async ({ page }) => { + await page.goto('/integration/sales-orders'); + await expect(page.locator('h1').filter({ hasText: '銷售訂單管理' })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); +}); diff --git a/e2e/inventory.spec.ts b/e2e/inventory.spec.ts new file mode 100644 index 0000000..fe6393c --- /dev/null +++ b/e2e/inventory.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * 庫存模組端到端測試 + */ +test.describe('庫存管理 - 調撥單匯入', () => { + // 登入 + 導航 + 匯入全流程需要較長時間 + test.use({ actionTimeout: 15000 }); + + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能成功匯入調撥單明細', async ({ page }) => { + // 整體測試逾時設定為 60 秒 + test.setTimeout(60000); + // 1. 前往調撥單列表 + await page.goto('/inventory/transfer-orders'); + await expect(page.getByText('庫存調撥管理')).toBeVisible(); + + // 2. 等待表格載入並尋找特定的 E2E 測試單據 + await page.waitForSelector('table tbody tr'); + + const draftRow = page.locator('tr:has-text("TRF-E2E-FINAL")').first(); + const hasDraft = await draftRow.count() > 0; + + if (hasDraft) { + // 點擊 "編輯" 按鈕 + await draftRow.locator('button[title="編輯"], a:has-text("編輯")').first().click(); + } else { + throw new Error('測試環境中找不到單號為 TRF-E2E-FINAL 的調撥單。'); + } + + // 3. 驗證已進入詳情頁 (標題包含調撥單單號) + await expect(page.getByRole('heading', { name: /調撥單: TRF-/ })).toBeVisible({ timeout: 15000 }); + + // 4. 開啟匯入對話框 + const importBtn = page.getByRole('button', { name: /匯入 Excel|匯入/ }); + await expect(importBtn).toBeVisible(); + await importBtn.click(); + + await expect(page.getByText('匯入調撥明細')).toBeVisible(); + + // 5. 準備測試檔案 (CSV 格式) + const csvPath = path.join('/tmp', 'transfer_import_test.csv'); + // 欄位名稱必須與後端匹配,商品代碼使用 P2 (紅糖) + const csvContent = "商品代碼,數量,批號,備註\nP2,10,BATCH001,E2E Test Import\n"; + fs.writeFileSync(csvPath, csvContent); + + // 6. 執行上傳 + await page.setInputFiles('input[type="file"]', csvPath); + + // 7. 點擊開始匯入 + await page.getByRole('button', { name: '開始匯入' }).click(); + + // 8. 等待頁面更新 (Inertia reload) + await page.waitForTimeout(3000); + + // 9. 驗證詳情頁表格是否出現匯入的資料 + // 注意:「E2E Test Import」是 input 的 value,不是靜態文字,hasText 無法匹配 input value + // 因此先找包含 P2 文字的行(P2 是靜態 text),再驗證備註 input 的值 + const p2Row = page.locator('table tbody tr').filter({ hasText: 'P2' }).first(); + await expect(p2Row).toBeVisible({ timeout: 15000 }); + + // 驗證備註欄位的 input value 包含測試標記 + // 快照中備註欄位的 role 是 textbox,placeholder 是 "備註..." + const remarkInput = p2Row.getByRole('textbox', { name: '備註...' }); + await expect(remarkInput).toHaveValue('E2E Test Import'); + + // 截圖留存 + if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true }); + await page.screenshot({ path: 'e2e/screenshots/inventory-transfer-import-success.png', fullPage: true }); + + // 清理臨時檔案 + if (fs.existsSync(csvPath)) fs.unlinkSync(csvPath); + }); + +}); diff --git a/e2e/procurement.spec.ts b/e2e/procurement.spec.ts new file mode 100644 index 0000000..2f3c740 --- /dev/null +++ b/e2e/procurement.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; +import * as fs from 'fs'; + +/** + * 採購模組端到端測試 + * 驗證「批量寫入」(多筆明細 bulk insert) 與「併發鎖定」(狀態變更 lockForUpdate) + */ +test.describe('採購管理 - 採購單建立', () => { + // 登入 + 導航 + 表單操作需要較長時間 + test.use({ actionTimeout: 15000 }); + + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能成功建立含多筆明細的採購單', async ({ page }) => { + // 整體測試逾時設定為 90 秒(含多次選單互動) + test.setTimeout(90000); + + // 1. 前往採購單列表 + await page.goto('/purchase-orders'); + await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible(); + + // 2. 點擊「建立採購單」按鈕 + await page.getByRole('button', { name: /建立採購單/ }).click(); + await expect(page.getByRole('heading', { name: '建立採購單' })).toBeVisible({ timeout: 10000 }); + + // 3. 選擇倉庫 (使用 SearchableSelect combobox) + const warehouseCombobox = page.locator('label:has-text("預計入庫倉庫")').locator('..').getByRole('combobox'); + await warehouseCombobox.click(); + await page.getByRole('option', { name: '中央倉庫' }).click(); + + // 4. 選擇供應商 + const supplierCombobox = page.locator('label:has-text("供應商")').locator('..').getByRole('combobox'); + await supplierCombobox.click(); + await page.getByRole('option', { name: '台積電' }).click(); + + // 5. 填寫下單日期(應該已有預設值,但確保有值) + const orderDateInput = page.locator('label:has-text("下單日期")').locator('..').locator('input[type="date"]'); + const currentDate = await orderDateInput.inputValue(); + if (!currentDate) { + const today = new Date().toISOString().split('T')[0]; + await orderDateInput.fill(today); + } + + // 6. 填寫備註 + await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 批量寫入驗證'); + + // 7. 新增第一個品項 + await page.getByRole('button', { name: '新增一個品項' }).click(); + + // 選擇商品(第一行) + const firstRow = page.locator('table tbody tr').first(); + const firstProductCombobox = firstRow.getByRole('combobox').first(); + await firstProductCombobox.click(); + await page.getByRole('option', { name: '紅糖' }).click(); + + // 填寫數量 + const firstQtyInput = firstRow.locator('input[type="number"]').first(); + await firstQtyInput.clear(); + await firstQtyInput.fill('5'); + + // 填寫小計(主要金額欄位) + const firstSubtotalInput = firstRow.locator('input[type="number"]').nth(1); + await firstSubtotalInput.fill('500'); + + // 8. 新增第二個品項(驗證批量寫入) + await page.getByRole('button', { name: '新增一個品項' }).click(); + + const secondRow = page.locator('table tbody tr').nth(1); + const secondProductCombobox = secondRow.getByRole('combobox').first(); + await secondProductCombobox.click(); + await page.getByRole('option', { name: '粗吸管' }).click(); + + const secondQtyInput = secondRow.locator('input[type="number"]').first(); + await secondQtyInput.clear(); + await secondQtyInput.fill('10'); + + const secondSubtotalInput = secondRow.locator('input[type="number"]').nth(1); + await secondSubtotalInput.fill('200'); + + // 9. 點擊「確認發布採購單」 + await page.getByRole('button', { name: '確認發布採購單' }).click(); + + // 10. 驗證結果 — 應跳轉回列表頁或顯示詳情頁 + // Inertia.js 的 onSuccess 會觸發頁面導航 + await expect( + page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ })) + ).toBeVisible({ timeout: 15000 }); + + // 11. 截圖留存 + if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true }); + await page.screenshot({ path: 'e2e/screenshots/procurement-po-create-success.png', fullPage: true }); + }); + + test('應能成功編輯採購單', async ({ page }) => { + test.setTimeout(60000); + + // 1. 前往採購單列表 + await page.goto('/purchase-orders'); + await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible(); + + // 2. 找到並點擊第一個可編輯的採購單 (草稿或待審核狀態) + const editLink = page.locator('button[title="編輯"], a[title="編輯"]').first(); + await expect(editLink).toBeVisible({ timeout: 10000 }); + await editLink.click(); + + // 3. 驗證已進入編輯頁 + await expect(page.getByRole('heading', { name: '編輯採購單' })).toBeVisible({ timeout: 15000 }); + + // 4. 修改備註 + await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 已被編輯過'); + + // 5. 點擊「更新採購單」 + await page.getByRole('button', { name: '更新採購單' }).click(); + + // 6. 驗證結果 — 返回列表或詳情頁 + await expect( + page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ })) + ).toBeVisible({ timeout: 15000 }); + + // 7. 截圖留存 + if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true }); + await page.screenshot({ path: 'e2e/screenshots/procurement-po-edit-success.png', fullPage: true }); + }); +}); diff --git a/e2e/production.spec.ts b/e2e/production.spec.ts new file mode 100644 index 0000000..55b72da --- /dev/null +++ b/e2e/production.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('生產管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入配方管理頁面並顯示主要元素', async ({ page }) => { + await page.goto('/recipes'); + await expect(page.getByRole('heading', { name: /配方管理/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /新增/ })).toBeVisible(); + }); + + test('應能進入生產單管理頁面並顯示主要元素', async ({ page }) => { + await page.goto('/production-orders'); + await expect(page.getByRole('heading', { name: /生產工單/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /建立生產單/ })).toBeVisible(); + }); +}); diff --git a/e2e/products.spec.ts b/e2e/products.spec.ts new file mode 100644 index 0000000..1571ab7 --- /dev/null +++ b/e2e/products.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('商品管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入商品列表頁面並顯示主要元素', async ({ page }) => { + await page.goto('/products'); + await expect(page.getByRole('heading', { name: /商品資料管理/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /新增/ })).toBeVisible(); + }); +}); diff --git a/e2e/sales.spec.ts b/e2e/sales.spec.ts new file mode 100644 index 0000000..937150b --- /dev/null +++ b/e2e/sales.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('銷售管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test.skip('應能進入銷貨匯入頁面並顯示主要元素', async ({ page }) => { + await page.goto('/sales/imports'); + await expect(page.getByRole('heading', { name: /功能製作中/ })).toBeVisible(); + // await expect(page.locator('table')).toBeVisible(); + // await expect(page.getByRole('button', { name: /匯入/ })).toBeVisible(); + }); +}); diff --git a/e2e/vendors.spec.ts b/e2e/vendors.spec.ts new file mode 100644 index 0000000..58dccbf --- /dev/null +++ b/e2e/vendors.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('供應商管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入供應商列表頁面並顯示主要元素', async ({ page }) => { + await page.goto('/vendors'); + await expect(page.getByRole('heading', { name: /廠商資料管理/ })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.getByRole('button', { name: /新增/ })).toBeVisible(); + }); +}); diff --git a/e2e/warehouses.spec.ts b/e2e/warehouses.spec.ts new file mode 100644 index 0000000..6b7ca00 --- /dev/null +++ b/e2e/warehouses.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('倉庫管理模組', () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test('應能進入倉庫列表頁面並顯示主要元素', async ({ page }) => { + await page.goto('/warehouses'); + await expect(page.getByRole('heading', { name: /倉庫管理/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /新增倉庫/ })).toBeVisible(); + }); +}); diff --git a/tests/Feature/Integration/PosApiTest.php b/tests/Feature/Integration/PosApiTest.php index 38b3f04..4369d29 100644 --- a/tests/Feature/Integration/PosApiTest.php +++ b/tests/Feature/Integration/PosApiTest.php @@ -159,7 +159,7 @@ class PosApiTest extends TestCase $payload = [ 'external_order_id' => 'ORD-001', - 'warehouse_id' => $warehouseId, + 'warehouse_code' => 'MAIN', 'sold_at' => now()->toIso8601String(), 'items' => [ [ diff --git a/tests/Feature/InventoryTransferImportTest.php b/tests/Feature/InventoryTransferImportTest.php index acb95ae..c31c1c0 100644 --- a/tests/Feature/InventoryTransferImportTest.php +++ b/tests/Feature/InventoryTransferImportTest.php @@ -17,6 +17,7 @@ class InventoryTransferImportTest extends TestCase use RefreshDatabase; protected $user; + protected $tenant; protected $fromWarehouse; protected $toWarehouse; protected $order; @@ -25,6 +26,15 @@ class InventoryTransferImportTest extends TestCase protected function setUp(): void { parent::setUp(); + + // Create a unique tenant for this test run + $tenantId = 'test_' . str_replace('.', '', microtime(true)); + $this->tenant = \App\Modules\Core\Models\Tenant::create([ + 'id' => $tenantId, + ]); + $this->tenant->domains()->create(['domain' => $tenantId . '.test']); + tenancy()->initialize($this->tenant); + $this->user = User::create([ 'name' => 'Test User', 'username' => 'testuser', @@ -52,10 +62,13 @@ class InventoryTransferImportTest extends TestCase 'created_by' => $this->user->id, ]); + $category = \App\Modules\Inventory\Models\Category::create(['name' => 'General', 'code' => 'GEN']); + $this->product = Product::create([ 'code' => 'P001', 'name' => 'Test Product', 'status' => 'enabled', + 'category_id' => $category->id, ]); } @@ -80,8 +93,9 @@ class InventoryTransferImportTest extends TestCase // 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。 $rows = collect([ - collect(['商品代碼' => 'P001', '批號' => 'BATCH001', '數量' => '10', '備註' => 'Imported Via Test']), - collect(['商品代碼' => 'P001', '批號' => '', '數量' => '5', '備註' => 'Batch should be NO-BATCH']), + collect(['商品代碼', '批號', '數量', '備註']), + collect(['P001', 'BATCH001', '10', 'Imported Via Test']), + collect(['P001', '', '5', 'Batch should be NO-BATCH']), ]); $import->collection($rows);