[FIX] 修復所有 E2E 模組測試的標題定位器以及將測試帳號還原為 admin 權限
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
This commit is contained in:
@@ -70,6 +70,7 @@ class AccountPayableService
|
||||
|
||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||
->orderBy('document_number', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$latest) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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('只有草稿或被退回的進貨單可以確認點收。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($goodsReceipt) {
|
||||
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
||||
$goodsReceipt->save();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
return DB::transaction(function () use ($purchaseReturn) {
|
||||
// 加上 lockForUpdate() 防止併發提交
|
||||
$purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id);
|
||||
|
||||
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
||||
throw new Exception('只有草稿狀態的退回單可以提交。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($purchaseReturn) {
|
||||
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
|
||||
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
|
||||
$purchaseReturn->saveQuietly();
|
||||
|
||||
@@ -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) {
|
||||
// 開始製作 -> 扣除原料庫存
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
34
e2e/admin.spec.ts
Normal file
34
e2e/admin.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
28
e2e/finance.spec.ts
Normal file
28
e2e/finance.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
14
e2e/integration.spec.ts
Normal file
14
e2e/integration.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
81
e2e/inventory.spec.ts
Normal file
81
e2e/inventory.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
127
e2e/procurement.spec.ts
Normal file
127
e2e/procurement.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
22
e2e/production.spec.ts
Normal file
22
e2e/production.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
15
e2e/products.spec.ts
Normal file
15
e2e/products.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
15
e2e/sales.spec.ts
Normal file
15
e2e/sales.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
15
e2e/vendors.spec.ts
Normal file
15
e2e/vendors.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
14
e2e/warehouses.spec.ts
Normal file
14
e2e/warehouses.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' => [
|
||||
[
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user