From 1d134c9ad8ae66c66b19e75e4835451b6f91083c Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 22 Jan 2026 15:39:35 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E7=94=A2=E5=B7=A5=E5=96=AEBOM?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E6=89=B9=E8=99=9F=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/DashboardController.php | 16 +- app/Http/Controllers/InventoryController.php | 393 +++++++--- .../Controllers/ProductionOrderController.php | 261 ++++++- .../Controllers/SafetyStockController.php | 71 +- .../Controllers/TransferOrderController.php | 1 - app/Http/Controllers/WarehouseController.php | 3 - app/Models/Inventory.php | 6 +- app/Models/WarehouseProductSafetyStock.php | 41 + ...ehouse_product_unique_from_inventories.php | 36 + ...ase_inventory_transactions_type_length.php | 28 + ...00_add_deleted_at_to_inventories_table.php | 28 + ..._warehouse_product_safety_stocks_table.php | 32 + ...migrate_safety_stock_data_to_new_table.php | 55 ++ ...house_id_nullable_in_production_orders.php | 30 + package-lock.json | 40 +- package.json | 1 + .../ActivityLog/ActivityDetailDialog.tsx | 23 +- .../Inventory/BatchAdjustmentModal.tsx | 170 +++++ .../Warehouse/Inventory/InventoryTable.tsx | 487 ++++++------ .../Warehouse/Inventory/TransactionTable.tsx | 11 +- resources/js/Pages/Production/Create.tsx | 560 +++++++++----- resources/js/Pages/Production/Edit.tsx | 710 ++++++++++++++++++ resources/js/Pages/Production/Index.tsx | 24 +- resources/js/Pages/Production/Show.tsx | 46 +- resources/js/Pages/Warehouse/AddInventory.tsx | 206 ++++- .../js/Pages/Warehouse/EditInventory.tsx | 7 +- resources/js/Pages/Warehouse/Inventory.tsx | 44 +- .../js/Pages/Warehouse/InventoryHistory.tsx | 10 +- resources/js/types/warehouse.ts | 17 +- resources/js/utils/inventory.ts | 6 +- routes/web.php | 15 +- 31 files changed, 2684 insertions(+), 694 deletions(-) create mode 100644 app/Models/WarehouseProductSafetyStock.php create mode 100644 database/migrations/tenant/2026_01_22_083603_remove_warehouse_product_unique_from_inventories.php create mode 100644 database/migrations/tenant/2026_01_22_094700_increase_inventory_transactions_type_length.php create mode 100644 database/migrations/tenant/2026_01_22_095600_add_deleted_at_to_inventories_table.php create mode 100644 database/migrations/tenant/2026_01_22_103032_create_warehouse_product_safety_stocks_table.php create mode 100644 database/migrations/tenant/2026_01_22_103054_migrate_safety_stock_data_to_new_table.php create mode 100644 database/migrations/tenant/2026_01_22_141800_make_warehouse_id_nullable_in_production_orders.php create mode 100644 resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx create mode 100644 resources/js/Pages/Production/Edit.tsx diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 9e8df65..9a21288 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -7,8 +7,10 @@ use App\Models\Vendor; use App\Models\PurchaseOrder; use App\Models\Warehouse; use App\Models\Inventory; +use App\Models\WarehouseProductSafetyStock; use Inertia\Inertia; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class DashboardController extends Controller { @@ -21,15 +23,25 @@ class DashboardController extends Controller return redirect()->route('landlord.dashboard'); } + // 計算低庫存數量:各商品在各倉庫的總量 < 安全庫存 + $lowStockCount = DB::table('warehouse_product_safety_stocks as ss') + ->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'), + function ($join) { + $join->on('ss.warehouse_id', '=', 'inv.warehouse_id') + ->on('ss.product_id', '=', 'inv.product_id'); + }) + ->whereRaw('inv.total_qty <= ss.safety_stock') + ->count(); + $stats = [ 'productsCount' => Product::count(), 'vendorsCount' => Vendor::count(), 'purchaseOrdersCount' => PurchaseOrder::count(), 'warehousesCount' => Warehouse::count(), 'totalInventoryValue' => Inventory::join('products', 'inventories.product_id', '=', 'products.id') - ->sum('inventories.quantity'), // Simplified, maybe just sum quantities for now + ->sum('inventories.quantity'), 'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(), - 'lowStockCount' => Inventory::whereColumn('quantity', '<=', 'safety_stock')->count(), + 'lowStockCount' => $lowStockCount, ]; return Inertia::render('Dashboard', [ diff --git a/app/Http/Controllers/InventoryController.php b/app/Http/Controllers/InventoryController.php index b4ced82..61c1381 100644 --- a/app/Http/Controllers/InventoryController.php +++ b/app/Http/Controllers/InventoryController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; +use App\Models\WarehouseProductSafetyStock; class InventoryController extends Controller { @@ -19,49 +20,82 @@ class InventoryController extends Controller // 1. 準備 availableProducts $availableProducts = $allProducts->map(function ($product) { return [ - 'id' => (string) $product->id, // Frontend expects string + 'id' => (string) $product->id, 'name' => $product->name, - 'type' => $product->category?->name ?? '其他', // 暫時用 Category Name 當 Type + 'type' => $product->category?->name ?? '其他', ]; }); - // 2. 準備 inventories (模擬批號) - // 2. 準備 inventories - // 資料庫結構為 (warehouse_id, product_id) 唯一,故為扁平列表 - $inventories = $warehouse->inventories->map(function ($inv) { - return [ - 'id' => (string) $inv->id, - 'warehouseId' => (string) $inv->warehouse_id, - 'productId' => (string) $inv->product_id, - 'productName' => $inv->product?->name ?? '未知商品', - 'productCode' => $inv->product?->code ?? 'N/A', - 'unit' => $inv->product?->baseUnit?->name ?? '個', - 'quantity' => (float) $inv->quantity, - 'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null, - 'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態 - 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, // 優先使用 DB 批號,若無則 fallback - 'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, - 'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null, - 'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null, - ]; - }); + // 2. 從新表格讀取安全庫存設定 (商品-倉庫層級) + $safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id) + ->pluck('safety_stock', 'product_id') + ->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]); + + // 3. 準備 inventories (批號分組) + $items = $warehouse->inventories() + ->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction']) + ->get(); + + $inventories = $items->groupBy('product_id')->map(function ($batchItems) use ($safetyStockMap) { + $firstItem = $batchItems->first(); + $product = $firstItem->product; + $totalQuantity = $batchItems->sum('quantity'); + // 從獨立表格讀取安全庫存 + $safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null; + + // 計算狀態 + $status = '正常'; + if (!is_null($safetyStock)) { + if ($totalQuantity < $safetyStock) { + $status = '低於'; + } + } - // 3. 準備 safetyStockSettings - $safetyStockSettings = $warehouse->inventories->filter(function($inv) { - return !is_null($inv->safety_stock); - })->map(function ($inv) { return [ - 'id' => 'ss-' . $inv->id, - 'warehouseId' => (string) $inv->warehouse_id, - 'productId' => (string) $inv->product_id, - 'productName' => $inv->product?->name ?? '未知商品', - 'productType' => $inv->product?->category?->name ?? '其他', - 'safetyStock' => (float) $inv->safety_stock, - 'createdAt' => $inv->created_at->toIso8601String(), - 'updatedAt' => $inv->updated_at->toIso8601String(), + 'productId' => (string) $firstItem->product_id, + 'productName' => $product?->name ?? '未知商品', + 'productCode' => $product?->code ?? 'N/A', + 'baseUnit' => $product?->baseUnit?->name ?? '個', + 'totalQuantity' => (float) $totalQuantity, + 'safetyStock' => $safetyStock, + 'status' => $status, + 'batches' => $batchItems->map(function ($inv) { + return [ + 'id' => (string) $inv->id, + 'warehouseId' => (string) $inv->warehouse_id, + 'productId' => (string) $inv->product_id, + 'productName' => $inv->product?->name ?? '未知商品', + 'productCode' => $inv->product?->code ?? 'N/A', + 'unit' => $inv->product?->baseUnit?->name ?? '個', + 'quantity' => (float) $inv->quantity, + 'safetyStock' => null, // 批號層級不再有安全庫存 + 'status' => '正常', + 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, + 'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, + 'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null, + 'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null, + ]; + })->values(), ]; })->values(); + // 4. 準備 safetyStockSettings (從新表格讀取) + $safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id) + ->with(['product.category']) + ->get() + ->map(function ($setting) { + return [ + 'id' => (string) $setting->id, + 'warehouseId' => (string) $setting->warehouse_id, + 'productId' => (string) $setting->product_id, + 'productName' => $setting->product?->name ?? '未知商品', + 'productType' => $setting->product?->category?->name ?? '其他', + 'safetyStock' => (float) $setting->safety_stock, + 'createdAt' => $setting->created_at->toIso8601String(), + 'updatedAt' => $setting->updated_at->toIso8601String(), + ]; + }); + return \Inertia\Inertia::render('Warehouse/Inventory', [ 'warehouse' => $warehouse, 'inventories' => $inventories, @@ -73,10 +107,14 @@ class InventoryController extends Controller public function create(\App\Models\Warehouse $warehouse) { // 取得所有商品供前端選單使用 - $products = \App\Models\Product::with(['baseUnit', 'largeUnit'])->select('id', 'name', 'base_unit_id', 'large_unit_id', 'conversion_rate')->get()->map(function ($product) { + $products = \App\Models\Product::with(['baseUnit', 'largeUnit']) + ->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate') + ->get() + ->map(function ($product) { return [ 'id' => (string) $product->id, 'name' => $product->name, + 'code' => $product->code, 'baseUnit' => $product->baseUnit?->name ?? '個', 'largeUnit' => $product->largeUnit?->name, // 可能為 null 'conversionRate' => (float) $product->conversion_rate, @@ -98,45 +136,55 @@ class InventoryController extends Controller 'items' => 'required|array|min:1', 'items.*.productId' => 'required|exists:products,id', 'items.*.quantity' => 'required|numeric|min:0.01', - 'items.*.batchNumber' => 'nullable|string', + 'items.*.batchMode' => 'required|in:existing,new', + 'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id', + 'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2', 'items.*.expiryDate' => 'nullable|date', ]); return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) { foreach ($validated['items'] as $item) { - $batchNumber = $item['batchNumber'] ?? null; - // 如果未提供批號,且系統設定需要批號,則自動產生 (這裡先保留彈性,若無則為 null 或預設) - if (empty($batchNumber)) { - // 嘗試自動產生:需要 product_code, country, date + $inventory = null; + + if ($item['batchMode'] === 'existing') { + // 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加) + $inventory = \App\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']); + if ($inventory->trashed()) { + $inventory->restore(); + } + } else { + // 模式 B:建立新批號 + $originCountry = $item['originCountry'] ?? 'TW'; $product = \App\Models\Product::find($item['productId']); - if ($product) { - $batchNumber = \App\Models\Inventory::generateBatchNumber( - $product->code ?? 'UNK', - 'TW', // 預設來源 - $validated['inboundDate'] - ); + + $batchNumber = \App\Models\Inventory::generateBatchNumber( + $product->code ?? 'UNK', + $originCountry, + $validated['inboundDate'] + ); + + // 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的) + $inventory = $warehouse->inventories()->withTrashed()->firstOrNew( + [ + 'product_id' => $item['productId'], + 'batch_number' => $batchNumber + ], + [ + 'quantity' => 0, + 'arrival_date' => $validated['inboundDate'], + 'expiry_date' => $item['expiryDate'] ?? null, + 'origin_country' => $originCountry, + ] + ); + + if ($inventory->trashed()) { + $inventory->restore(); } } - // 取得或建立庫存紀錄 (加入批號判斷) - $inventory = $warehouse->inventories()->firstOrNew( - [ - 'product_id' => $item['productId'], - 'batch_number' => $batchNumber - ], - [ - 'quantity' => 0, - 'safety_stock' => null, - 'arrival_date' => $validated['inboundDate'], - 'expiry_date' => $item['expiryDate'] ?? null, - 'origin_country' => 'TW', // 預設 - ] - ); - $currentQty = $inventory->quantity; $newQty = $currentQty + $item['quantity']; - // 更新庫存並儲存 (新紀錄: Created, 舊紀錄: Updated) $inventory->quantity = $newQty; $inventory->save(); @@ -157,7 +205,46 @@ class InventoryController extends Controller }); } - public function edit(\App\Models\Warehouse $warehouse, $inventoryId) + /** + * API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號 + */ + public function getBatches(\App\Models\Warehouse $warehouse, $productId, \Illuminate\Http\Request $request) + { + $originCountry = $request->query('originCountry', 'TW'); + $arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d')); + + $batches = \App\Models\Inventory::where('warehouse_id', $warehouse->id) + ->where('product_id', $productId) + ->get() + ->map(function ($inventory) { + return [ + 'inventoryId' => (string) $inventory->id, + 'batchNumber' => $inventory->batch_number, + 'originCountry' => $inventory->origin_country, + 'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null, + 'quantity' => (float) $inventory->quantity, + ]; + }); + + // 計算下一個流水號 + $product = \App\Models\Product::find($productId); + $nextSequence = '01'; + if ($product) { + $batchNumber = \App\Models\Inventory::generateBatchNumber( + $product->code ?? 'UNK', + $originCountry, + $arrivalDate + ); + $nextSequence = substr($batchNumber, -2); + } + + return response()->json([ + 'batches' => $batches, + 'nextSequence' => $nextSequence + ]); + } + + public function edit(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId) { // 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人) // 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理 @@ -176,8 +263,8 @@ class InventoryController extends Controller 'productId' => (string) $inventory->product_id, 'productName' => $inventory->product?->name ?? '未知商品', 'quantity' => (float) $inventory->quantity, - 'batchNumber' => 'BATCH-' . $inventory->id, // Mock - 'expiryDate' => '2099-12-31', // Mock + 'batchNumber' => $inventory->batch_number ?? '-', + 'expiryDate' => $inventory->expiry_date ?? null, 'lastInboundDate' => $inventory->updated_at->format('Y-m-d'), 'lastOutboundDate' => null, ]; @@ -234,19 +321,21 @@ class InventoryController extends Controller ]); return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) { - $currentQty = $inventory->quantity; - $newQty = $validated['quantity']; + $currentQty = (float) $inventory->quantity; + $newQty = (float) $validated['quantity']; - // 判斷操作模式 - if (isset($validated['operation'])) { - $changeQty = 0; + // 判斷是否來自調整彈窗 (包含 operation 參數) + $isAdjustment = isset($validated['operation']); + $changeQty = 0; + + if ($isAdjustment) { switch ($validated['operation']) { case 'add': - $changeQty = $validated['quantity']; + $changeQty = (float) $validated['quantity']; $newQty = $currentQty + $changeQty; break; case 'subtract': - $changeQty = -$validated['quantity']; + $changeQty = -(float) $validated['quantity']; $newQty = $currentQty + $changeQty; break; case 'set': @@ -262,8 +351,9 @@ class InventoryController extends Controller $inventory->update(['quantity' => $newQty]); // 異動類型映射 - $type = $validated['type'] ?? 'adjustment'; + $type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment'); $typeMapping = [ + 'manual_adjustment' => '手動調整庫存', 'adjustment' => '盤點調整', 'purchase_in' => '採購進貨', 'sales_out' => '銷售出庫', @@ -274,22 +364,26 @@ class InventoryController extends Controller ]; $chineseType = $typeMapping[$type] ?? $type; - // 如果是編輯頁面來的,可能沒有 type,預設為 "盤點調整" 或 "手動編輯" - if (!isset($validated['type'])) { + // 如果是編輯頁面來的,且沒傳 type,設為手動編輯 + if (!$isAdjustment && !isset($validated['type'])) { $chineseType = '手動編輯'; } + // 整理原因 + $reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新'); + if (isset($validated['notes'])) { + $reason .= ' - ' . $validated['notes']; + } + // 寫入異動紀錄 - // 如果數量沒變,是否要寫紀錄?通常編輯頁面按儲存可能只改了其他欄位(如果有) - // 但因為我們目前只存 quantity,如果 quantity 沒變,可以不寫異動,或者寫一筆 0 的異動代表更新屬性 if (abs($changeQty) > 0.0001) { $inventory->transactions()->create([ 'type' => $chineseType, 'quantity' => $changeQty, 'balance_before' => $currentQty, 'balance_after' => $newQty, - 'reason' => ($validated['reason'] ?? '編輯頁面更新') . ($validated['notes'] ?? ''), - 'actual_time' => now(), // 手動調整設定為當下 + 'reason' => $reason, + 'actual_time' => now(), 'user_id' => auth()->id(), ]); } @@ -303,8 +397,13 @@ class InventoryController extends Controller { $inventory = \App\Models\Inventory::findOrFail($inventoryId); - // 歸零異動 + // 庫存 > 0 不允許刪除 (哪怕是軟刪除) if ($inventory->quantity > 0) { + return redirect()->back()->with('error', '庫存數量大於 0,無法刪除。請先進行出庫或調整。'); + } + + // 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零) + if (abs($inventory->quantity) > 0.0001) { $inventory->transactions()->create([ 'type' => '手動編輯', 'quantity' => -$inventory->quantity, @@ -322,33 +421,117 @@ class InventoryController extends Controller ->with('success', '庫存品項已刪除'); } - public function history(\App\Models\Warehouse $warehouse, $inventoryId) + public function history(Request $request, \App\Models\Warehouse $warehouse) { - $inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) { - $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); - }, 'transactions.user'])->findOrFail($inventoryId); + $inventoryId = $request->query('inventoryId'); + $productId = $request->query('productId'); - $transactions = $inventory->transactions->map(function ($tx) { - return [ - 'id' => (string) $tx->id, - 'type' => $tx->type, - 'quantity' => (float) $tx->quantity, - 'balanceAfter' => (float) $tx->balance_after, - 'reason' => $tx->reason, - 'userName' => $tx->user ? $tx->user->name : '系統', - 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), - ]; - }); + if ($productId) { + // 商品層級查詢 + $inventories = \App\Models\Inventory::where('warehouse_id', $warehouse->id) + ->where('product_id', $productId) + ->with(['product', 'transactions' => function($query) { + $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); + }, 'transactions.user']) + ->get(); - return \Inertia\Inertia::render('Warehouse/InventoryHistory', [ - 'warehouse' => $warehouse, - 'inventory' => [ - 'id' => (string) $inventory->id, - 'productName' => $inventory->product?->name ?? '未知商品', - 'productCode' => $inventory->product?->code ?? 'N/A', - 'quantity' => (float) $inventory->quantity, - ], - 'transactions' => $transactions - ]); + if ($inventories->isEmpty()) { + return redirect()->back()->with('error', '找不到該商品的庫存紀錄'); + } + + $firstInventory = $inventories->first(); + $productName = $firstInventory->product?->name ?? '未知商品'; + $productCode = $firstInventory->product?->code ?? 'N/A'; + $currentTotalQuantity = $inventories->sum('quantity'); + + // 合併所有批號的交易紀錄 + $allTransactions = collect(); + foreach ($inventories as $inv) { + foreach ($inv->transactions as $tx) { + $allTransactions->push([ + 'raw_tx' => $tx, + 'batchNumber' => $inv->batch_number ?? '-', + 'sort_time' => $tx->actual_time ?? $tx->created_at, + ]); + } + } + + // 依時間倒序排序 (最新的在前面) + $sortedTransactions = $allTransactions->sort(function ($a, $b) { + // 先比時間 (Desc) + if ($a['sort_time'] != $b['sort_time']) { + return $a['sort_time'] > $b['sort_time'] ? -1 : 1; + } + // 再比 ID (Desc) + return $a['raw_tx']->id > $b['raw_tx']->id ? -1 : 1; + }); + + // 回推計算結餘 + $runningBalance = $currentTotalQuantity; + $transactions = $sortedTransactions->map(function ($item) use (&$runningBalance) { + $tx = $item['raw_tx']; + + // 本次異動後的結餘 = 當前推算的結餘 + $balanceAfter = $runningBalance; + + // 推算前一次的結餘 (減去本次的異動量:如果是入庫+10,前一次就是-10) + $runningBalance = $runningBalance - $tx->quantity; + + return [ + 'id' => (string) $tx->id, + 'type' => $tx->type, + 'quantity' => (float) $tx->quantity, + 'balanceAfter' => (float) $balanceAfter, // 使用即時計算的商品總結餘 + 'reason' => $tx->reason, + 'userName' => $tx->user ? $tx->user->name : '系統', + 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), + 'batchNumber' => $item['batchNumber'], + ]; + })->values(); + + return \Inertia\Inertia::render('Warehouse/InventoryHistory', [ + 'warehouse' => $warehouse, + 'inventory' => [ + 'id' => 'product-' . $productId, + 'productName' => $productName, + 'productCode' => $productCode, + 'quantity' => (float) $currentTotalQuantity, + ], + 'transactions' => $transactions + ]); + } + + if ($inventoryId) { + // 單一批號查詢 + $inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) { + $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); + }, 'transactions.user'])->findOrFail($inventoryId); + + $transactions = $inventory->transactions->map(function ($tx) { + return [ + 'id' => (string) $tx->id, + 'type' => $tx->type, + 'quantity' => (float) $tx->quantity, + 'balanceAfter' => (float) $tx->balance_after, + 'reason' => $tx->reason, + 'userName' => $tx->user ? $tx->user->name : '系統', + 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), + ]; + }); + + return \Inertia\Inertia::render('Warehouse/InventoryHistory', [ + 'warehouse' => $warehouse, + 'inventory' => [ + 'id' => (string) $inventory->id, + 'productName' => $inventory->product?->name ?? '未知商品', + 'productCode' => $inventory->product?->code ?? 'N/A', + 'batchNumber' => $inventory->batch_number ?? '-', + 'quantity' => (float) $inventory->quantity, + ], + 'transactions' => $transactions + ]); + } + + return redirect()->back()->with('error', '未提供查詢參數'); } } diff --git a/app/Http/Controllers/ProductionOrderController.php b/app/Http/Controllers/ProductionOrderController.php index f487ea2..887dac4 100644 --- a/app/Http/Controllers/ProductionOrderController.php +++ b/app/Http/Controllers/ProductionOrderController.php @@ -73,11 +73,19 @@ class ProductionOrderController extends Controller */ public function store(Request $request) { - $validated = $request->validate([ + $status = $request->input('status', 'draft'); // 預設為草稿 + + // 共用驗證規則 + $baseRules = [ 'product_id' => 'required|exists:products,id', + 'output_batch_number' => 'required|string|max:50', + 'status' => 'nullable|in:draft,completed', + ]; + + // 完成模式需要完整驗證 + $completedRules = [ 'warehouse_id' => 'required|exists:warehouses,id', 'output_quantity' => 'required|numeric|min:0.01', - 'output_batch_number' => 'required|string|max:50', 'output_box_count' => 'nullable|string|max:10', 'production_date' => 'required|date', 'expiry_date' => 'nullable|date|after_or_equal:production_date', @@ -86,64 +94,96 @@ class ProductionOrderController extends Controller 'items.*.inventory_id' => 'required|exists:inventories,id', 'items.*.quantity_used' => 'required|numeric|min:0.0001', 'items.*.unit_id' => 'nullable|exists:units,id', - ], [ + ]; + + // 草稿模式的寬鬆規則 + $draftRules = [ + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'output_quantity' => 'nullable|numeric|min:0', + 'output_box_count' => 'nullable|string|max:10', + 'production_date' => 'nullable|date', + 'expiry_date' => 'nullable|date', + 'remark' => 'nullable|string', + 'items' => 'nullable|array', + 'items.*.inventory_id' => 'nullable|exists:inventories,id', + 'items.*.quantity_used' => 'nullable|numeric|min:0', + 'items.*.unit_id' => 'nullable|exists:units,id', + ]; + + $rules = $status === 'completed' + ? array_merge($baseRules, $completedRules) + : array_merge($baseRules, $draftRules); + + $validated = $request->validate($rules, [ 'product_id.required' => '請選擇成品商品', + 'output_batch_number.required' => '請輸入成品批號', 'warehouse_id.required' => '請選擇入庫倉庫', 'output_quantity.required' => '請輸入生產數量', - 'output_batch_number.required' => '請輸入成品批號', 'production_date.required' => '請選擇生產日期', 'items.required' => '請至少新增一項原物料', 'items.min' => '請至少新增一項原物料', ]); - DB::transaction(function () use ($validated, $request) { + DB::transaction(function () use ($validated, $request, $status) { // 1. 建立生產工單 $productionOrder = ProductionOrder::create([ 'code' => ProductionOrder::generateCode(), 'product_id' => $validated['product_id'], - 'warehouse_id' => $validated['warehouse_id'], - 'output_quantity' => $validated['output_quantity'], + 'warehouse_id' => $validated['warehouse_id'] ?? null, + 'output_quantity' => $validated['output_quantity'] ?? 0, 'output_batch_number' => $validated['output_batch_number'], 'output_box_count' => $validated['output_box_count'] ?? null, - 'production_date' => $validated['production_date'], + 'production_date' => $validated['production_date'] ?? now()->toDateString(), 'expiry_date' => $validated['expiry_date'] ?? null, 'user_id' => auth()->id(), - 'status' => 'completed', + 'status' => $status, 'remark' => $validated['remark'] ?? null, ]); - // 2. 建立明細並扣減原物料庫存 - foreach ($validated['items'] as $item) { - // 建立明細 - ProductionOrderItem::create([ - 'production_order_id' => $productionOrder->id, - 'inventory_id' => $item['inventory_id'], - 'quantity_used' => $item['quantity_used'], - 'unit_id' => $item['unit_id'] ?? null, - ]); + // 2. 建立明細 (草稿與完成模式皆需儲存) + if (!empty($validated['items'])) { + foreach ($validated['items'] as $item) { + if (empty($item['inventory_id'])) continue; - // 扣減原物料庫存 - $inventory = Inventory::findOrFail($item['inventory_id']); - $inventory->decrement('quantity', $item['quantity_used']); + // 建立明細 + ProductionOrderItem::create([ + 'production_order_id' => $productionOrder->id, + 'inventory_id' => $item['inventory_id'], + 'quantity_used' => $item['quantity_used'] ?? 0, + 'unit_id' => $item['unit_id'] ?? null, + ]); + + // 若為完成模式,則扣減原物料庫存 + if ($status === 'completed') { + $inventory = Inventory::findOrFail($item['inventory_id']); + $inventory->decrement('quantity', $item['quantity_used']); + } + } } - // 3. 成品入庫:在目標倉庫建立新的庫存紀錄 - $product = Product::findOrFail($validated['product_id']); - Inventory::create([ - 'warehouse_id' => $validated['warehouse_id'], - 'product_id' => $validated['product_id'], - 'quantity' => $validated['output_quantity'], - 'batch_number' => $validated['output_batch_number'], - 'box_number' => $validated['output_box_count'], - 'origin_country' => 'TW', // 生產預設為本地 - 'arrival_date' => $validated['production_date'], - 'expiry_date' => $validated['expiry_date'] ?? null, - 'quality_status' => 'normal', - ]); + // 3. 若為完成模式,執行成品入庫 + if ($status === 'completed') { + $product = Product::findOrFail($validated['product_id']); + Inventory::create([ + 'warehouse_id' => $validated['warehouse_id'], + 'product_id' => $validated['product_id'], + 'quantity' => $validated['output_quantity'], + 'batch_number' => $validated['output_batch_number'], + 'box_number' => $validated['output_box_count'], + 'origin_country' => 'TW', // 生產預設為本地 + 'arrival_date' => $validated['production_date'], + 'expiry_date' => $validated['expiry_date'] ?? null, + 'quality_status' => 'normal', + ]); + } }); + $message = $status === 'completed' + ? '生產單已建立,原物料已扣減,成品已入庫' + : '生產單草稿已儲存'; + return redirect()->route('production-orders.index') - ->with('success', '生產單已建立,原物料已扣減,成品已入庫'); + ->with('success', $message); } /** @@ -170,7 +210,7 @@ class ProductionOrderController extends Controller */ public function getWarehouseInventories(Warehouse $warehouse) { - $inventories = Inventory::with(['product.baseUnit']) + $inventories = Inventory::with(['product.baseUnit', 'product.largeUnit']) ->where('warehouse_id', $warehouse->id) ->where('quantity', '>', 0) ->where('quality_status', 'normal') @@ -188,9 +228,158 @@ class ProductionOrderController extends Controller 'arrival_date' => $inv->arrival_date?->format('Y-m-d'), 'expiry_date' => $inv->expiry_date?->format('Y-m-d'), 'unit_name' => $inv->product->baseUnit?->name, + 'base_unit_id' => $inv->product->base_unit_id, + 'base_unit_name' => $inv->product->baseUnit?->name, + 'large_unit_id' => $inv->product->large_unit_id, + 'large_unit_name' => $inv->product->largeUnit?->name, + 'conversion_rate' => $inv->product->conversion_rate, ]; }); return response()->json($inventories); } + + /** + * 編輯生產單(僅限草稿狀態) + */ + public function edit(ProductionOrder $productionOrder): Response + { + // 只有草稿可以編輯 + if ($productionOrder->status !== 'draft') { + return redirect()->route('production-orders.show', $productionOrder->id) + ->with('error', '只有草稿狀態的生產單可以編輯'); + } + + $productionOrder->load(['product', 'warehouse', 'items.inventory.product', 'items.unit']); + + return Inertia::render('Production/Edit', [ + 'productionOrder' => $productionOrder, + 'products' => Product::with(['baseUnit'])->get(), + 'warehouses' => Warehouse::all(), + 'units' => Unit::all(), + ]); + } + + /** + * 更新生產單 + */ + public function update(Request $request, ProductionOrder $productionOrder) + { + // 只有草稿可以編輯 + if ($productionOrder->status !== 'draft') { + return redirect()->route('production-orders.show', $productionOrder->id) + ->with('error', '只有草稿狀態的生產單可以編輯'); + } + + $status = $request->input('status', 'draft'); + + // 共用驗證規則 + $baseRules = [ + 'product_id' => 'required|exists:products,id', + 'output_batch_number' => 'required|string|max:50', + 'status' => 'nullable|in:draft,completed', + ]; + + // 完成模式需要完整驗證 + $completedRules = [ + 'warehouse_id' => 'required|exists:warehouses,id', + 'output_quantity' => 'required|numeric|min:0.01', + 'output_box_count' => 'nullable|string|max:10', + 'production_date' => 'required|date', + 'expiry_date' => 'nullable|date|after_or_equal:production_date', + 'remark' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.inventory_id' => 'required|exists:inventories,id', + 'items.*.quantity_used' => 'required|numeric|min:0.0001', + 'items.*.unit_id' => 'nullable|exists:units,id', + ]; + + // 草稿模式的寬鬆規則 + $draftRules = [ + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'output_quantity' => 'nullable|numeric|min:0', + 'output_box_count' => 'nullable|string|max:10', + 'production_date' => 'nullable|date', + 'expiry_date' => 'nullable|date', + 'remark' => 'nullable|string', + 'items' => 'nullable|array', + 'items.*.inventory_id' => 'nullable|exists:inventories,id', + 'items.*.quantity_used' => 'nullable|numeric|min:0', + 'items.*.unit_id' => 'nullable|exists:units,id', + ]; + + $rules = $status === 'completed' + ? array_merge($baseRules, $completedRules) + : array_merge($baseRules, $draftRules); + + $validated = $request->validate($rules, [ + 'product_id.required' => '請選擇成品商品', + 'output_batch_number.required' => '請輸入成品批號', + 'warehouse_id.required' => '請選擇入庫倉庫', + 'output_quantity.required' => '請輸入生產數量', + 'production_date.required' => '請選擇生產日期', + 'items.required' => '請至少新增一項原物料', + 'items.min' => '請至少新增一項原物料', + ]); + + DB::transaction(function () use ($validated, $status, $productionOrder) { + // 更新生產工單基本資料 + $productionOrder->update([ + 'product_id' => $validated['product_id'], + 'warehouse_id' => $validated['warehouse_id'] ?? null, + 'output_quantity' => $validated['output_quantity'] ?? 0, + 'output_batch_number' => $validated['output_batch_number'], + 'output_box_count' => $validated['output_box_count'] ?? null, + 'production_date' => $validated['production_date'] ?? now()->toDateString(), + 'expiry_date' => $validated['expiry_date'] ?? null, + 'status' => $status, + 'remark' => $validated['remark'] ?? null, + ]); + + // 刪除舊的明細 + $productionOrder->items()->delete(); + + // 重新建立明細 (草稿與完成模式皆需儲存) + if (!empty($validated['items'])) { + foreach ($validated['items'] as $item) { + if (empty($item['inventory_id'])) continue; + + ProductionOrderItem::create([ + 'production_order_id' => $productionOrder->id, + 'inventory_id' => $item['inventory_id'], + 'quantity_used' => $item['quantity_used'] ?? 0, + 'unit_id' => $item['unit_id'] ?? null, + ]); + + // 若為完成模式,則扣減原物料庫存 + if ($status === 'completed') { + $inventory = Inventory::findOrFail($item['inventory_id']); + $inventory->decrement('quantity', $item['quantity_used']); + } + } + } + + // 若為完成模式,執行成品入庫 + if ($status === 'completed') { + Inventory::create([ + 'warehouse_id' => $validated['warehouse_id'], + 'product_id' => $validated['product_id'], + 'quantity' => $validated['output_quantity'], + 'batch_number' => $validated['output_batch_number'], + 'box_number' => $validated['output_box_count'], + 'origin_country' => 'TW', + 'arrival_date' => $validated['production_date'], + 'expiry_date' => $validated['expiry_date'] ?? null, + 'quality_status' => 'normal', + ]); + } + }); + + $message = $status === 'completed' + ? '生產單已完成,原物料已扣減,成品已入庫' + : '生產單草稿已更新'; + + return redirect()->route('production-orders.index') + ->with('success', $message); + } } diff --git a/app/Http/Controllers/SafetyStockController.php b/app/Http/Controllers/SafetyStockController.php index d120a65..b0449a7 100644 --- a/app/Http/Controllers/SafetyStockController.php +++ b/app/Http/Controllers/SafetyStockController.php @@ -3,8 +3,9 @@ namespace App\Http\Controllers; use App\Models\Warehouse; -use App\Models\Inventory; +use App\Models\WarehouseProductSafetyStock; use App\Models\Product; +use App\Models\Inventory; use Illuminate\Http\Request; use Inertia\Inertia; use Illuminate\Support\Facades\DB; @@ -16,8 +17,6 @@ class SafetyStockController extends Controller */ public function index(Warehouse $warehouse) { - $warehouse->load(['inventories.product.category']); - $allProducts = Product::with(['category', 'baseUnit'])->get(); // 準備可選商品列表 @@ -30,32 +29,34 @@ class SafetyStockController extends Controller ]; }); - // 準備現有庫存列表 (用於狀態計算) - $inventories = $warehouse->inventories->map(function ($inv) { - return [ - 'id' => (string) $inv->id, - 'productId' => (string) $inv->product_id, - 'quantity' => (float) $inv->quantity, - 'safetyStock' => (float) $inv->safety_stock, - ]; - }); - - // 準備安全庫存設定列表 - $safetyStockSettings = $warehouse->inventories->filter(function($inv) { - return !is_null($inv->safety_stock); - })->map(function ($inv) { - return [ - 'id' => (string) $inv->id, - 'warehouseId' => (string) $inv->warehouse_id, - 'productId' => (string) $inv->product_id, - 'productName' => $inv->product->name, - 'productType' => $inv->product->category ? $inv->product->category->name : '其他', - 'safetyStock' => (float) $inv->safety_stock, - 'unit' => $inv->product->baseUnit?->name ?? '個', - 'updatedAt' => $inv->updated_at->toIso8601String(), - ]; - })->values(); + // 準備現有庫存列表 (用於庫存量對比) + $inventories = Inventory::where('warehouse_id', $warehouse->id) + ->select('product_id', DB::raw('SUM(quantity) as total_quantity')) + ->groupBy('product_id') + ->get() + ->map(function ($inv) { + return [ + 'productId' => (string) $inv->product_id, + 'quantity' => (float) $inv->total_quantity, + ]; + }); + // 準備安全庫存設定列表 (從新表格讀取) + $safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id) + ->with(['product.category', 'product.baseUnit']) + ->get() + ->map(function ($setting) { + return [ + 'id' => (string) $setting->id, + 'warehouseId' => (string) $setting->warehouse_id, + 'productId' => (string) $setting->product_id, + 'productName' => $setting->product->name, + 'productType' => $setting->product->category ? $setting->product->category->name : '其他', + 'safetyStock' => (float) $setting->safety_stock, + 'unit' => $setting->product->baseUnit?->name ?? '個', + 'updatedAt' => $setting->updated_at->toIso8601String(), + ]; + }); return Inertia::render('Warehouse/SafetyStockSettings', [ 'warehouse' => $warehouse, @@ -78,7 +79,7 @@ class SafetyStockController extends Controller DB::transaction(function () use ($validated, $warehouse) { foreach ($validated['settings'] as $item) { - Inventory::updateOrCreate( + WarehouseProductSafetyStock::updateOrCreate( [ 'warehouse_id' => $warehouse->id, 'product_id' => $item['productId'], @@ -96,13 +97,13 @@ class SafetyStockController extends Controller /** * 更新單筆安全庫存設定 */ - public function update(Request $request, Warehouse $warehouse, Inventory $inventory) + public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock) { $validated = $request->validate([ 'safetyStock' => 'required|numeric|min:0', ]); - $inventory->update([ + $safetyStock->update([ 'safety_stock' => $validated['safetyStock'], ]); @@ -110,13 +111,11 @@ class SafetyStockController extends Controller } /** - * 刪除 (歸零) 安全庫存設定 + * 刪除安全庫存設定 */ - public function destroy(Warehouse $warehouse, Inventory $inventory) + public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock) { - $inventory->update([ - 'safety_stock' => null, - ]); + $safetyStock->delete(); return redirect()->back()->with('success', '安全庫存設定已移除'); } diff --git a/app/Http/Controllers/TransferOrderController.php b/app/Http/Controllers/TransferOrderController.php index c6b7e8e..9af0715 100644 --- a/app/Http/Controllers/TransferOrderController.php +++ b/app/Http/Controllers/TransferOrderController.php @@ -46,7 +46,6 @@ class TransferOrderController extends Controller ], [ 'quantity' => 0, - 'safety_stock' => null, // 預設為 null (未設定),而非 0 ] ); diff --git a/app/Http/Controllers/WarehouseController.php b/app/Http/Controllers/WarehouseController.php index 420f975..955b77c 100644 --- a/app/Http/Controllers/WarehouseController.php +++ b/app/Http/Controllers/WarehouseController.php @@ -23,9 +23,6 @@ class WarehouseController extends Controller } $warehouses = $query->withSum('inventories as total_quantity', 'quantity') - ->withCount(['inventories as low_stock_count' => function ($query) { - $query->whereColumn('quantity', '<', 'safety_stock'); - }]) ->orderBy('created_at', 'desc') ->paginate(10) ->withQueryString(); diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index 618d7d9..4868459 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -9,13 +9,13 @@ class Inventory extends Model { /** @use HasFactory<\Database\Factories\InventoryFactory> */ use HasFactory; + use \Illuminate\Database\Eloquent\SoftDeletes; use \Spatie\Activitylog\Traits\LogsActivity; protected $fillable = [ 'warehouse_id', 'product_id', 'quantity', - 'safety_stock', 'location', // 批號追溯欄位 'batch_number', @@ -121,7 +121,9 @@ class Inventory extends Model $dateFormatted = date('Ymd', strtotime($arrivalDate)); $prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-"; - $lastBatch = static::where('batch_number', 'like', "{$prefix}%") + // 加入 withTrashed() 確保流水號不會撞到已刪除的紀錄 + $lastBatch = static::withTrashed() + ->where('batch_number', 'like', "{$prefix}%") ->orderByDesc('batch_number') ->first(); diff --git a/app/Models/WarehouseProductSafetyStock.php b/app/Models/WarehouseProductSafetyStock.php new file mode 100644 index 0000000..b967cfc --- /dev/null +++ b/app/Models/WarehouseProductSafetyStock.php @@ -0,0 +1,41 @@ + 'decimal:2', + ]; + + /** + * 所屬倉庫 + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * 所屬商品 + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/database/migrations/tenant/2026_01_22_083603_remove_warehouse_product_unique_from_inventories.php b/database/migrations/tenant/2026_01_22_083603_remove_warehouse_product_unique_from_inventories.php new file mode 100644 index 0000000..21640da --- /dev/null +++ b/database/migrations/tenant/2026_01_22_083603_remove_warehouse_product_unique_from_inventories.php @@ -0,0 +1,36 @@ +unique(['warehouse_id', 'product_id', 'batch_number'], 'warehouse_product_batch_unique'); + + // 然後移除舊的唯一約束 (倉庫 + 商品) + $table->dropUnique('warehouse_product_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventories', function (Blueprint $table) { + // 恢復時也要注意順序,先加回舊的(如果資料允許),再刪除新的 + // 但如果資料已經有多批號,加回舊的會失敗。這裡只盡力而為。 + $table->unique(['warehouse_id', 'product_id'], 'warehouse_product_unique'); + $table->dropUnique('warehouse_product_batch_unique'); + }); + } +}; diff --git a/database/migrations/tenant/2026_01_22_094700_increase_inventory_transactions_type_length.php b/database/migrations/tenant/2026_01_22_094700_increase_inventory_transactions_type_length.php new file mode 100644 index 0000000..cf2718f --- /dev/null +++ b/database/migrations/tenant/2026_01_22_094700_increase_inventory_transactions_type_length.php @@ -0,0 +1,28 @@ +string('type', 50)->comment('異動類型: 採購進貨, 銷售出庫, 盤點調整, 撥補入庫, 撥補出庫, 手動入庫, 手動調整庫存')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_transactions', function (Blueprint $table) { + $table->string('type', 20)->comment('異動類型: 採購進貨, 銷售出庫, 盤點調整, 撥補入庫, 撥補出庫, 手動入庫')->change(); + }); + } +}; diff --git a/database/migrations/tenant/2026_01_22_095600_add_deleted_at_to_inventories_table.php b/database/migrations/tenant/2026_01_22_095600_add_deleted_at_to_inventories_table.php new file mode 100644 index 0000000..ead9000 --- /dev/null +++ b/database/migrations/tenant/2026_01_22_095600_add_deleted_at_to_inventories_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventories', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/tenant/2026_01_22_103032_create_warehouse_product_safety_stocks_table.php b/database/migrations/tenant/2026_01_22_103032_create_warehouse_product_safety_stocks_table.php new file mode 100644 index 0000000..77157fb --- /dev/null +++ b/database/migrations/tenant/2026_01_22_103032_create_warehouse_product_safety_stocks_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->decimal('safety_stock', 10, 2)->default(0); + $table->timestamps(); + + $table->unique(['warehouse_id', 'product_id'], 'wh_product_safety_stock_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('warehouse_product_safety_stocks'); + } +}; diff --git a/database/migrations/tenant/2026_01_22_103054_migrate_safety_stock_data_to_new_table.php b/database/migrations/tenant/2026_01_22_103054_migrate_safety_stock_data_to_new_table.php new file mode 100644 index 0000000..7c6e3e3 --- /dev/null +++ b/database/migrations/tenant/2026_01_22_103054_migrate_safety_stock_data_to_new_table.php @@ -0,0 +1,55 @@ + 0 + GROUP BY warehouse_id, product_id + ON DUPLICATE KEY UPDATE safety_stock = VALUES(safety_stock), updated_at = NOW() + "); + + // 2. 移除 inventories 表的 safety_stock 欄位 + Schema::table('inventories', function (Blueprint $table) { + $table->dropColumn('safety_stock'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 1. 在 inventories 表重新加入 safety_stock 欄位 + Schema::table('inventories', function (Blueprint $table) { + $table->decimal('safety_stock', 10, 2)->nullable()->after('quantity'); + }); + + // 2. 將資料還原回 inventories (更新同商品所有批號) + DB::statement(" + UPDATE inventories i + INNER JOIN warehouse_product_safety_stocks ss + ON i.warehouse_id = ss.warehouse_id AND i.product_id = ss.product_id + SET i.safety_stock = ss.safety_stock + "); + } +}; diff --git a/database/migrations/tenant/2026_01_22_141800_make_warehouse_id_nullable_in_production_orders.php b/database/migrations/tenant/2026_01_22_141800_make_warehouse_id_nullable_in_production_orders.php new file mode 100644 index 0000000..33103c6 --- /dev/null +++ b/database/migrations/tenant/2026_01_22_141800_make_warehouse_id_nullable_in_production_orders.php @@ -0,0 +1,30 @@ +foreignId('warehouse_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('production_orders', function (Blueprint $table) { + $table->foreignId('warehouse_id')->nullable(false)->change(); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index b23c7e1..29748af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "lucide-react": "^0.562.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hot-toast": "^2.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, @@ -74,6 +75,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2537,6 +2539,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2547,6 +2550,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2557,6 +2561,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2664,6 +2669,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2876,8 +2882,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/date-fns": { "version": "4.1.0", @@ -3208,6 +3214,15 @@ "node": ">= 0.4" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3747,6 +3762,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3808,6 +3824,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3820,6 +3837,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3828,6 +3846,23 @@ "react": "^18.3.1" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4295,6 +4330,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index f2803e0..02c53e8 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "lucide-react": "^0.562.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hot-toast": "^2.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" } diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 7b3ad51..36d71e5 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -86,6 +86,15 @@ const fieldLabels: Record = { quantity: '數量', safety_stock: '安全庫存', location: '儲位', + // Inventory fields + batch_number: '批號', + box_number: '箱號', + origin_country: '來源國家', + arrival_date: '入庫日期', + expiry_date: '有效期限', + source_purchase_order_id: '來源採購單', + quality_status: '品質狀態', + quality_remark: '品質備註', // Purchase Order fields po_number: '採購單號', vendor_id: '廠商', @@ -118,6 +127,13 @@ const statusMap: Record = { completed: '已完成', }; +// Inventory Quality Status Map +const qualityStatusMap: Record = { + normal: '正常', + frozen: '凍結', + rejected: '瑕疵/拒收', +}; + export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) { if (!activity) return null; @@ -193,8 +209,13 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return statusMap[value]; } + // Handle Inventory Quality Status + if (key === 'quality_status' && typeof value === 'string' && qualityStatusMap[value]) { + return qualityStatusMap[value]; + } + // Handle Date Fields (YYYY-MM-DD) - if ((key === 'expected_delivery_date' || key === 'invoice_date') && typeof value === 'string') { + if ((key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') { // Take only the date part (YYYY-MM-DD) return value.split('T')[0].split(' ')[0]; } diff --git a/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx b/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx new file mode 100644 index 0000000..dd9500a --- /dev/null +++ b/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx @@ -0,0 +1,170 @@ + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/Components/ui/select"; +import { AlertCircle } from "lucide-react"; + +interface BatchAdjustmentModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (data: { + operation: "add" | "subtract" | "set"; + quantity: number; + reason: string; + }) => void; + batch?: { + id: string; + batchNumber: string; + currentQuantity: number; + productName: string; + }; + processing?: boolean; +} + +export default function BatchAdjustmentModal({ + isOpen, + onClose, + onConfirm, + batch, + processing = false, +}: BatchAdjustmentModalProps) { + const [operation, setOperation] = useState<"add" | "subtract" | "set">("add"); + const [quantity, setQuantity] = useState(""); + const [reason, setReason] = useState("手動調整庫存"); + + // 當開啟時重置 + useEffect(() => { + if (isOpen) { + setOperation("add"); + setQuantity(""); + setReason("手動調整庫存"); + } + }, [isOpen]); + + const handleConfirm = () => { + const numQty = parseFloat(quantity); + if (isNaN(numQty) || numQty <= 0 && operation !== "set") { + return; + } + + onConfirm({ + operation, + quantity: numQty, + reason, + }); + }; + + const previewQuantity = () => { + if (!batch) return 0; + const numQty = parseFloat(quantity) || 0; + if (operation === "add") return batch.currentQuantity + numQty; + if (operation === "subtract") return Math.max(0, batch.currentQuantity - numQty); + if (operation === "set") return numQty; + return batch.currentQuantity; + }; + + if (!batch) return null; + + return ( + !open && onClose()}> + + + + 庫存調整 - {batch.productName} + + + +
+
+
+ 批號: + {batch.batchNumber || "-"} +
+
+ 當前數量: + {batch.currentQuantity} +
+
+ +
+ + +
+ +
+ + setQuantity(e.target.value)} + placeholder="請輸入數量" + /> +
+ +
+ + setReason(e.target.value)} + placeholder="例:手動盤點修正" + /> +
+ +
+ + + 調整後預計數量: + + {previewQuantity()} + + +
+
+ + + + + +
+
+ ); +} diff --git a/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx b/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx index 9914719..8934427 100644 --- a/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx +++ b/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx @@ -1,19 +1,10 @@ /** - * 庫存表格元件 (扁平化列表版) - * 顯示庫存項目列表,不進行折疊分組 + * 庫存表格元件 (Warehouse 版本) + * 顯示庫存項目列表(依商品分組並支援折疊) */ -import { useState, useMemo } from "react"; -import { - AlertTriangle, - Trash2, - Eye, - CheckCircle, - Package, - ArrowUpDown, - ArrowUp, - ArrowDown -} from "lucide-react"; +import { useState } from "react"; +import { AlertTriangle, Edit, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react"; import { Table, TableBody, @@ -24,149 +15,87 @@ import { } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; import { Badge } from "@/Components/ui/badge"; -import { WarehouseInventory } from "@/types/warehouse"; -import { getSafetyStockStatus } from "@/utils/inventory"; +import { + Collapsible, + CollapsibleContent, +} from "@/Components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/Components/ui/tooltip"; +import { GroupedInventory } from "@/types/warehouse"; import { formatDate } from "@/utils/format"; import { Can } from "@/Components/Permission/Can"; +import BatchAdjustmentModal from "./BatchAdjustmentModal"; interface InventoryTableProps { - inventories: WarehouseInventory[]; + inventories: GroupedInventory[]; onView: (id: string) => void; onDelete: (id: string) => void; + onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void; + onViewProduct?: (productId: string) => void; } -type SortField = "productName" | "quantity" | "lastInboundDate" | "lastOutboundDate" | "safetyStock" | "status"; -type SortDirection = "asc" | "desc" | null; - export default function InventoryTable({ inventories, onView, onDelete, + onAdjust, + onViewProduct, }: InventoryTableProps) { - const [sortField, setSortField] = useState("status"); - const [sortDirection, setSortDirection] = useState("asc"); // "asc" for status means Priority High (Low Stock) first + // 每個商品的展開/折疊狀態 + const [expandedProducts, setExpandedProducts] = useState>(new Set()); - // 處理排序 - const handleSort = (field: SortField) => { - if (sortField === field) { - if (sortDirection === "asc") { - setSortDirection("desc"); - } else if (sortDirection === "desc") { - setSortDirection(null); - setSortField(null); - } else { - setSortDirection("asc"); - } - } else { - setSortField(field); - setSortDirection("asc"); - } - }; - - // 排序後的列表 - const sortedInventories = useMemo(() => { - if (!sortField || !sortDirection) { - return inventories; - } - - return [...inventories].sort((a, b) => { - let aValue: string | number; - let bValue: string | number; - - // Status Priority map for sorting: Low > Near > Normal - const statusPriority: Record = { - "低於": 1, - "接近": 2, - "正常": 3 - }; - - switch (sortField) { - case "productName": - aValue = a.productName; - bValue = b.productName; - break; - case "quantity": - aValue = a.quantity; - bValue = b.quantity; - break; - case "lastInboundDate": - aValue = a.lastInboundDate || ""; - bValue = b.lastInboundDate || ""; - break; - case "lastOutboundDate": - aValue = a.lastOutboundDate || ""; - bValue = b.lastOutboundDate || ""; - break; - case "safetyStock": - aValue = a.safetyStock ?? -1; // null as -1 or Infinity depending on desired order - bValue = b.safetyStock ?? -1; - break; - case "status": - const aStatus = (a.safetyStock !== null && a.safetyStock !== undefined) ? getSafetyStockStatus(a.quantity, a.safetyStock) : "正常"; - const bStatus = (b.safetyStock !== null && b.safetyStock !== undefined) ? getSafetyStockStatus(b.quantity, b.safetyStock) : "正常"; - aValue = statusPriority[aStatus] || 3; - bValue = statusPriority[bStatus] || 3; - break; - default: - return 0; - } - - if (typeof aValue === "string" && typeof bValue === "string") { - return sortDirection === "asc" - ? aValue.localeCompare(bValue, "zh-TW") - : bValue.localeCompare(aValue, "zh-TW"); - } else { - return sortDirection === "asc" - ? (aValue as number) - (bValue as number) - : (bValue as number) - (aValue as number); - } - }); - }, [inventories, sortField, sortDirection]); - - const SortIcon = ({ field }: { field: SortField }) => { - if (sortField !== field) { - return ; - } - if (sortDirection === "asc") { - return ; - } - if (sortDirection === "desc") { - return ; - } - return ; - }; + // 調整彈窗狀態 + const [adjustmentTarget, setAdjustmentTarget] = useState<{ + id: string; + batchNumber: string; + currentQuantity: number; + productName: string; + } | null>(null); if (inventories.length === 0) { return (
-

無符合條件的品項

請調整搜尋或篩選條件

); } + // 按商品名稱排序 + const sortedInventories = [...inventories].sort((a, b) => + a.productName.localeCompare(b.productName, "zh-TW") + ); + + const toggleProduct = (productId: string) => { + setExpandedProducts((prev) => { + const newSet = new Set(prev); + if (newSet.has(productId)) { + newSet.delete(productId); + } else { + newSet.add(productId); + } + return newSet; + }); + }; + // 獲取狀態徽章 - const getStatusBadge = (quantity: number, safetyStock: number) => { - const status = getSafetyStockStatus(quantity, safetyStock); + const getStatusBadge = (status: string) => { switch (status) { case "正常": return ( - + 正常 ); - case "接近": // 數量 <= 安全庫存 * 1.2 + + case "低於": return ( - - - 接近 - - ); - case "低於": // 數量 < 安全庫存 - return ( - + 低於 @@ -177,127 +106,201 @@ export default function InventoryTable({ }; return ( -
- - - - # - - - - -
- -
-
- - - - - - - -
- -
-
- -
- -
-
- 操作 -
-
- - {sortedInventories.map((item, index) => ( - - - {index + 1} - - {/* 商品資訊 */} - -
-
{item.productName}
-
{item.productCode}
+ +
+ {sortedInventories.map((group) => { + const totalQuantity = group.totalQuantity; + + // 使用後端提供的狀態 + const status = group.status; + + const isLowStock = status === "低於"; + const isExpanded = expandedProducts.has(group.productId); + const hasInventory = group.batches.length > 0; + + return ( + toggleProduct(group.productId)} + > +
+ {/* 商品標題 - 可點擊折疊 */} +
toggleProduct(group.productId)} + className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${isLowStock ? "bg-red-50" : "bg-gray-50" + }`} + > +
+
+ {/* 折疊圖示 */} + {isExpanded ? ( + + ) : ( + + )} +

{group.productName}

+ + {hasInventory ? `${group.batches.length} 個批號` : '無庫存'} + +
+
+
+ + 總庫存:{totalQuantity} 個 + +
+ {group.safetyStock !== null ? ( + <> +
+ + 安全庫存:{group.safetyStock} 個 + +
+
+ {status && getStatusBadge(status)} +
+ + ) : ( + + 未設定 + + )} + {onViewProduct && ( + + )} +
+
- - {/* 庫存數量 */} - - {item.quantity} - {item.unit} - + {/* 商品表格 - 可折疊內容 */} + + {hasInventory ? ( +
+
+ + + # + 批號 + 庫存數量 + 進貨編號 + 保存期限 + 最新入庫 + 最新出庫 + 操作 + + + + {group.batches.map((batch, index) => { + return ( + + {index + 1} + {batch.batchNumber || "-"} + + {batch.quantity} + + {batch.batchNumber || "-"} + + {batch.expiryDate ? formatDate(batch.expiryDate) : "-"} + + + {batch.lastInboundDate ? formatDate(batch.lastInboundDate) : "-"} + + + {batch.lastOutboundDate ? formatDate(batch.lastOutboundDate) : "-"} + + +
+ + + + + + + +
+ +
+
+ +

{batch.quantity > 0 ? "庫存須為 0 才可刪除" : "刪除"}

+
+
+
+
+
+
+ ); + })} +
+
+
+ ) : ( +
+ +

此商品尚無庫存批號

+

請點擊「新增庫存」進行入庫

+
+ )} + + + + ); + })} - {/* 最新入庫 */} - - {item.lastInboundDate ? formatDate(item.lastInboundDate) : "-"} - - - {/* 最新出庫 */} - - {item.lastOutboundDate ? formatDate(item.lastOutboundDate) : "-"} - - - {/* 安全庫存 */} - - {item.safetyStock !== null && item.safetyStock >= 0 ? ( - - {item.safetyStock} {item.unit} - - ) : ( - 未設定 - )} - - - {/* 狀態 */} - - {(item.safetyStock !== null && item.safetyStock !== undefined) ? getStatusBadge(item.quantity, item.safetyStock) : ( - 正常 - )} - - - {/* 操作 */} - -
- - - - -
-
- - ))} - - - + setAdjustmentTarget(null)} + batch={adjustmentTarget || undefined} + onConfirm={(data) => { + if (adjustmentTarget) { + onAdjust(adjustmentTarget.id, data); + setAdjustmentTarget(null); + } + }} + /> + + ); } diff --git a/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx b/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx index 6f1a5f5..206b6c5 100644 --- a/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx +++ b/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx @@ -8,13 +8,15 @@ export interface Transaction { reason: string | null; userName: string; actualTime: string; + batchNumber?: string; // 商品層級查詢時顯示批號 } interface TransactionTableProps { transactions: Transaction[]; + showBatchNumber?: boolean; // 是否顯示批號欄位 } -export default function TransactionTable({ transactions }: TransactionTableProps) { +export default function TransactionTable({ transactions, showBatchNumber = false }: TransactionTableProps) { if (transactions.length === 0) { return (
@@ -23,6 +25,9 @@ export default function TransactionTable({ transactions }: TransactionTableProps ); } + // 自動偵測是否需要顯示批號(如果任一筆記錄有 batchNumber) + const shouldShowBatchNumber = showBatchNumber || transactions.some(tx => tx.batchNumber); + return (
@@ -30,6 +35,7 @@ export default function TransactionTable({ transactions }: TransactionTableProps + {shouldShowBatchNumber && } @@ -42,6 +48,9 @@ export default function TransactionTable({ transactions }: TransactionTableProps + {shouldShowBatchNumber && ( + + )}
# 時間批號類型 變動數量 結餘
{index + 1} {tx.actualTime}{tx.batchNumber || '-'} 0 ? 'bg-green-100 text-green-800' diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx index aa2162b..39aa0fd 100644 --- a/resources/js/Pages/Production/Create.tsx +++ b/resources/js/Pages/Production/Create.tsx @@ -4,15 +4,18 @@ */ import { useState, useEffect } from "react"; -import { Factory, Plus, Trash2, ArrowLeft, Save, AlertTriangle, Calendar } from 'lucide-react'; +import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react'; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, router, useForm } from "@inertiajs/react"; +import toast, { Toaster } from 'react-hot-toast'; import { getBreadcrumbs } from "@/utils/breadcrumb"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; import { Textarea } from "@/Components/ui/textarea"; +import { Link } from "@inertiajs/react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; interface Product { id: number; @@ -42,16 +45,35 @@ interface InventoryOption { arrival_date: string | null; expiry_date: string | null; unit_name: string | null; + base_unit_id?: number; + base_unit_name?: string; + large_unit_id?: number; + large_unit_name?: string; + conversion_rate?: number; } interface BomItem { - inventory_id: string; - quantity_used: string; - unit_id: string; - // 顯示用 - product_name?: string; - batch_number?: string; - available_qty?: number; + // Backend required + inventory_id: string; // The selected inventory record ID (Specific Batch) + quantity_used: string; // The converted final quantity (Base Unit) + unit_id: string; // The unit ID (Base Unit ID usually) + + // UI State + ui_warehouse_id: string; // Source Warehouse + ui_product_id: string; // Filter for batch list + ui_input_quantity: string; // User typed quantity + ui_selected_unit: 'base' | 'large'; // User selected unit + + // UI Helpers / Cache + ui_product_name?: string; + ui_batch_number?: string; + ui_available_qty?: number; + ui_expiry_date?: string; + ui_conversion_rate?: number; + ui_base_unit_name?: string; + ui_large_unit_name?: string; + ui_base_unit_id?: number; + ui_large_unit_id?: number; } interface Props { @@ -60,10 +82,12 @@ interface Props { units: Unit[]; } -export default function ProductionCreate({ products, warehouses, units }: Props) { - const [selectedWarehouse, setSelectedWarehouse] = useState(""); - const [inventoryOptions, setInventoryOptions] = useState([]); - const [isLoadingInventory, setIsLoadingInventory] = useState(false); +export default function ProductionCreate({ products, warehouses }: Props) { + const [selectedWarehouse, setSelectedWarehouse] = useState(""); // Output Warehouse + // Cache map: warehouse_id -> inventories + const [inventoryMap, setInventoryMap] = useState>({}); + const [loadingWarehouses, setLoadingWareStates] = useState>({}); + const [bomItems, setBomItems] = useState([]); const { data, setData, processing, errors } = useForm({ @@ -78,23 +102,23 @@ export default function ProductionCreate({ products, warehouses, units }: Props) items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], }); - // 當選擇倉庫時,載入該倉庫的可用庫存 - useEffect(() => { - if (selectedWarehouse) { - setIsLoadingInventory(true); - fetch(route('api.production.warehouses.inventories', selectedWarehouse)) - .then(res => res.json()) - .then((inventories: InventoryOption[]) => { - setInventoryOptions(inventories); - setIsLoadingInventory(false); - }) - .catch(() => setIsLoadingInventory(false)); - } else { - setInventoryOptions([]); - } - }, [selectedWarehouse]); + // Helper to fetch warehouse data + const fetchWarehouseInventory = async (warehouseId: string) => { + if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return; - // 同步 warehouse_id 到 form data + setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true })); + try { + const res = await fetch(route('api.production.warehouses.inventories', warehouseId)); + const data = await res.json(); + setInventoryMap(prev => ({ ...prev, [warehouseId]: data })); + } catch (e) { + console.error(e); + } finally { + setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false })); + } + }; + + // 同步 warehouse_id 到 form data (Output) useEffect(() => { setData('warehouse_id', selectedWarehouse); }, [selectedWarehouse]); @@ -105,6 +129,10 @@ export default function ProductionCreate({ products, warehouses, units }: Props) inventory_id: "", quantity_used: "", unit_id: "", + ui_warehouse_id: "", + ui_product_id: "", + ui_input_quantity: "", + ui_selected_unit: 'base', }]); }; @@ -113,45 +141,167 @@ export default function ProductionCreate({ products, warehouses, units }: Props) setBomItems(bomItems.filter((_, i) => i !== index)); }; - // 更新 BOM 項目 - const updateBomItem = (index: number, field: keyof BomItem, value: string) => { + // 更新 BOM 項目邏輯 + const updateBomItem = (index: number, field: keyof BomItem, value: any) => { const updated = [...bomItems]; - updated[index] = { ...updated[index], [field]: value }; + const item = { ...updated[index], [field]: value }; - // 如果選擇了庫存,自動填入顯示資訊 - if (field === 'inventory_id' && value) { - const inv = inventoryOptions.find(i => String(i.id) === value); - if (inv) { - updated[index].product_name = inv.product_name; - updated[index].batch_number = inv.batch_number; - updated[index].available_qty = inv.quantity; + // 0. 當選擇來源倉庫變更時 + if (field === 'ui_warehouse_id') { + // 重置後續欄位 + item.ui_product_id = ""; + item.inventory_id = ""; + item.quantity_used = ""; + item.unit_id = ""; + item.ui_input_quantity = ""; + item.ui_selected_unit = "base"; + delete item.ui_product_name; + delete item.ui_batch_number; + delete item.ui_available_qty; + delete item.ui_expiry_date; + delete item.ui_conversion_rate; + delete item.ui_base_unit_name; + delete item.ui_large_unit_name; + delete item.ui_base_unit_id; + delete item.ui_large_unit_id; + + // 觸發載入資料 + if (value) { + fetchWarehouseInventory(value); } } + // 1. 當選擇商品變更時 -> 清空批號與相關資訊 + if (field === 'ui_product_id') { + item.inventory_id = ""; + item.quantity_used = ""; + item.unit_id = ""; + item.ui_input_quantity = ""; + item.ui_selected_unit = "base"; + // 清除 cache 資訊 + delete item.ui_product_name; + delete item.ui_batch_number; + delete item.ui_available_qty; + delete item.ui_expiry_date; + delete item.ui_conversion_rate; + delete item.ui_base_unit_name; + delete item.ui_large_unit_name; + delete item.ui_base_unit_id; + delete item.ui_large_unit_id; + } + + // 2. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊 + if (field === 'inventory_id' && value) { + const currentOptions = inventoryMap[item.ui_warehouse_id] || []; + const inv = currentOptions.find(i => String(i.id) === value); + if (inv) { + item.ui_product_id = String(inv.product_id); // 確保商品也被選中 (雖通常是先選商品) + item.ui_product_name = inv.product_name; + item.ui_batch_number = inv.batch_number; + item.ui_available_qty = inv.quantity; + item.ui_expiry_date = inv.expiry_date || ''; + + // 單位與轉換率 + item.ui_base_unit_name = inv.base_unit_name || inv.unit_name || ''; + item.ui_large_unit_name = inv.large_unit_name || ''; + item.ui_base_unit_id = inv.base_unit_id; + item.ui_large_unit_id = inv.large_unit_id; + item.ui_conversion_rate = inv.conversion_rate || 1; + + // 預設單位 + item.ui_selected_unit = 'base'; + item.unit_id = String(inv.base_unit_id || ''); + } + } + + // 3. 計算最終數量 (Base Quantity) + // 當 輸入數量 或 選擇單位 變更時 + if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { + const inputQty = parseFloat(item.ui_input_quantity || '0'); + const rate = item.ui_conversion_rate || 1; + + if (item.ui_selected_unit === 'large') { + item.quantity_used = String(inputQty * rate); + // 注意:後端需要的是 Base Unit ID? 這裡我們都送 Base Unit ID,因為 quantity_used 是 Base Unit + // 但為了保留 User 的選擇,我們可能可以在 remark 註記? 目前先從簡 + item.unit_id = String(item.ui_base_unit_id || ''); + } else { + item.quantity_used = String(inputQty); + item.unit_id = String(item.ui_base_unit_id || ''); + } + } + + updated[index] = item; setBomItems(updated); }; - // 產生成品批號建議 - const generateBatchNumber = () => { + // 同步 BOM items 到表單 data + useEffect(() => { + setData('items', bomItems.map(item => ({ + inventory_id: Number(item.inventory_id), + quantity_used: Number(item.quantity_used), + unit_id: item.unit_id ? Number(item.unit_id) : null + }))); + }, [bomItems]); + + // 自動產生成品批號(當選擇商品或日期變動時) + useEffect(() => { if (!data.product_id) return; + const product = products.find(p => String(p.id) === data.product_id); if (!product) return; - const date = data.production_date.replace(/-/g, ''); - const suggested = `${product.code}-TW-${date}-01`; - setData('output_batch_number', suggested); - }; + const datePart = data.production_date; // YYYY-MM-DD + const dateFormatted = datePart.replace(/-/g, ''); + const originCountry = 'TW'; + + // 呼叫 API 取得下一組流水號 + // 複用庫存批號 API,但這裡可能沒有選 warehouse,所以用第一個預設 + const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1'); + + fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`) + .then(res => res.json()) + .then(result => { + const seq = result.nextSequence || '01'; + const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`; + setData('output_batch_number', suggested); + }) + .catch(() => { + // Fallback:若 API 失敗,使用預設 01 + const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`; + setData('output_batch_number', suggested); + }); + }, [data.product_id, data.production_date]); // 提交表單 - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const submit = (status: 'draft' | 'completed') => { + // 驗證(簡單前端驗證,完整驗證在後端) + if (status === 'completed') { + const missingFields = []; + if (!data.product_id) missingFields.push('成品商品'); + if (!data.output_quantity) missingFields.push('生產數量'); + if (!data.output_batch_number) missingFields.push('成品批號'); + if (!data.production_date) missingFields.push('生產日期'); + if (!selectedWarehouse) missingFields.push('入庫倉庫'); + if (bomItems.length === 0) missingFields.push('原物料明細'); + + if (missingFields.length > 0) { + toast.error( +
+ 請填寫必要欄位 + 缺漏:{missingFields.join('、')} +
+ ); + return; + } + } // 轉換 BOM items 格式 const formattedItems = bomItems - .filter(item => item.inventory_id && item.quantity_used) + .filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) .map(item => ({ - inventory_id: parseInt(item.inventory_id), - quantity_used: parseFloat(item.quantity_used), + inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null, + quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0, unit_id: item.unit_id ? parseInt(item.unit_id) : null, })); @@ -159,33 +309,74 @@ export default function ProductionCreate({ products, warehouses, units }: Props) router.post(route('production-orders.store'), { ...data, items: formattedItems, + status: status, + }, { + onError: (errors) => { + const errorCount = Object.keys(errors).length; + toast.error( +
+ 建立失敗,請檢查表單 + 共有 {errorCount} 個欄位有誤,請修正後再試 +
+ ); + } }); }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + submit('completed'); + }; + return ( -
-
- -
-

- - 建立生產單 -

-

- 記錄生產使用的原物料與產出成品 -

+ +
+
+ + + + +
+
+

+ + 建立生產工單 +

+

+ 建立新的生產排程,選擇原物料並記錄產出 +

+
+
+ + +
-
+ {/* 成品資訊 */}

成品資訊

@@ -220,23 +411,12 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
-
- setData('output_batch_number', e.target.value)} - placeholder="例如: AB-TW-20260121-01" - className="h-9 font-mono" - /> - -
+ setData('output_batch_number', e.target.value)} + placeholder="選擇商品後自動產生" + className="h-9 font-mono" + /> {errors.output_batch_number &&

{errors.output_batch_number}

}
@@ -313,7 +493,6 @@ export default function ProductionCreate({ products, warehouses, units }: Props) type="button" variant="outline" onClick={addBomItem} - disabled={!selectedWarehouse} className="gap-2 button-filled-primary text-white" > @@ -321,20 +500,7 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
- {!selectedWarehouse && ( -
- - 請先選擇「入庫倉庫」以取得可用原物料清單 -
- )} - - {selectedWarehouse && isLoadingInventory && ( -
- 載入中... -
- )} - - {selectedWarehouse && !isLoadingInventory && bomItems.length === 0 && ( + {bomItems.length === 0 && (
點擊「新增原物料」開始建立 BOM @@ -342,99 +508,125 @@ export default function ProductionCreate({ products, warehouses, units }: Props) )} {bomItems.length > 0 && ( -
- {bomItems.map((item, index) => ( -
-
- - updateBomItem(index, 'inventory_id', v)} - options={inventoryOptions.map(inv => ({ - label: `${inv.product_name} - ${inv.batch_number} (庫存: ${inv.quantity})`, - value: String(inv.id), - }))} - placeholder="選擇原物料與批號" - className="w-full h-9" - /> -
+
+ + + + 來源倉庫 * + 商品 * + 批號 * + 數量 * + 單位 + + + + + {bomItems.map((item, index) => { + // 取得此列已載入的 Inventory Options + const currentOptions = inventoryMap[item.ui_warehouse_id] || []; -
- -
- updateBomItem(index, 'quantity_used', e.target.value)} - placeholder="0.00" - className="h-9 pr-12" - /> -
- 單位 -
-
- {item.available_qty && ( -

可用庫存: {item.available_qty.toLocaleString()}

- )} -
+ // 過濾商品 + const uniqueProductOptions = Array.from(new Map( + currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }]) + ).values()); -
- - updateBomItem(index, 'unit_id', v)} - options={units.map(u => ({ - label: u.name, - value: String(u.id), - }))} - placeholder="選擇單位" - className="w-full h-9" - /> -
+ // 過濾批號 + const batchOptions = currentOptions + .filter(inv => String(inv.product_id) === item.ui_product_id) + .map(inv => ({ + label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, + value: String(inv.id) + })); -
- -
- - ))} + + + return ( + + {/* 0. 選擇來源倉庫 */} + + updateBomItem(index, 'ui_warehouse_id', v)} + options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))} + placeholder="選擇倉庫" + className="w-full" + /> + + + {/* 1. 選擇商品 */} + + updateBomItem(index, 'ui_product_id', v)} + options={uniqueProductOptions} + placeholder="選擇商品" + className="w-full" + disabled={!item.ui_warehouse_id} + /> + + + {/* 2. 選擇批號 */} + + updateBomItem(index, 'inventory_id', v)} + options={batchOptions} + placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"} + className="w-full" + disabled={!item.ui_product_id} + /> + {item.inventory_id && (() => { + const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id); + if (selectedInv) return ( +
+ 有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity} +
+ ); + return null; + })()} +
+ + {/* 3. 輸入數量 */} + + updateBomItem(index, 'ui_input_quantity', e.target.value)} + placeholder="0" + className="h-9" + disabled={!item.inventory_id} + /> + + + {/* 4. 選擇單位 */} + + {item.ui_base_unit_name} + + + + + + + +
+ ); + })} +
+
)} {errors.items &&

{errors.items}

}
- - {/* 提交按鈕 */} -
- - -
diff --git a/resources/js/Pages/Production/Edit.tsx b/resources/js/Pages/Production/Edit.tsx new file mode 100644 index 0000000..3e16883 --- /dev/null +++ b/resources/js/Pages/Production/Edit.tsx @@ -0,0 +1,710 @@ +/** + * 編輯生產工單頁面 + * 僅限草稿狀態可編輯 + */ + +import { useState, useEffect } from "react"; +import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react'; +import { Button } from "@/Components/ui/button"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, router, useForm, Link } from "@inertiajs/react"; +import toast, { Toaster } from 'react-hot-toast'; +import { getBreadcrumbs } from "@/utils/breadcrumb"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Textarea } from "@/Components/ui/textarea"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; + +interface Product { + id: number; + name: string; + code: string; + base_unit?: { id: number; name: string } | null; +} + +interface Warehouse { + id: number; + name: string; +} + +interface Unit { + id: number; + name: string; +} + +interface InventoryOption { + id: number; + product_id: number; + product_name: string; + product_code: string; + batch_number: string; + box_number: string | null; + quantity: number; + arrival_date: string | null; + expiry_date: string | null; + unit_name: string | null; + base_unit_id?: number; + base_unit_name?: string; + large_unit_id?: number; + large_unit_name?: string; + conversion_rate?: number; +} + +interface BomItem { + // Backend required + inventory_id: string; + quantity_used: string; + unit_id: string; + + // UI State + ui_warehouse_id: string; // Source Warehouse + ui_product_id: string; + ui_input_quantity: string; + ui_selected_unit: 'base' | 'large'; + + // UI Helpers / Cache + ui_product_name?: string; + ui_batch_number?: string; + ui_available_qty?: number; + ui_expiry_date?: string; + ui_conversion_rate?: number; + ui_base_unit_name?: string; + ui_large_unit_name?: string; + ui_base_unit_id?: number; + ui_large_unit_id?: number; +} + +interface ProductionOrderItem { + id: number; + production_order_id: number; + inventory_id: number; + quantity_used: number; + unit_id: number | null; + inventory?: { + product_id: number; + product?: { + name: string; + code: string; + base_unit?: { name: string }; + }; + batch_number: string; + quantity: number; + expiry_date?: string; + warehouse_id?: number; + }; + unit?: { + name: string; + }; +} + +interface ProductionOrder { + id: number; + code: string; + product_id: number; + warehouse_id: number | null; + output_quantity: number; + output_batch_number: string; + output_box_count: string | null; + production_date: string; + expiry_date: string | null; + remark: string | null; + status: string; + items: ProductionOrderItem[]; + product?: Product; + warehouse?: Warehouse; +} + +interface Props { + productionOrder: ProductionOrder; + products: Product[]; + warehouses: Warehouse[]; + units: Unit[]; +} + +export default function ProductionEdit({ productionOrder, products, warehouses }: Props) { + // 日期格式轉換輔助函數 + const formatDate = (dateValue: string | null | undefined): string => { + if (!dateValue) return ''; + // 處理可能的 ISO 格式或 YYYY-MM-DD 格式 + const date = new Date(dateValue); + if (isNaN(date.getTime())) return dateValue; + return date.toISOString().split('T')[0]; + }; + + const [selectedWarehouse, setSelectedWarehouse] = useState( + productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "" + ); // Output Warehouse + + // Cache map: warehouse_id -> inventories + const [inventoryMap, setInventoryMap] = useState>({}); + const [loadingWarehouses, setLoadingWareStates] = useState>({}); + + // Helper to fetch warehouse data + const fetchWarehouseInventory = async (warehouseId: string) => { + if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return; + + setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true })); + try { + const res = await fetch(route('api.production.warehouses.inventories', warehouseId)); + const data = await res.json(); + setInventoryMap(prev => ({ ...prev, [warehouseId]: data })); + } catch (e) { + console.error(e); + } finally { + setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false })); + } + }; + + // 初始化 BOM items + const initialBomItems: BomItem[] = productionOrder.items.map(item => ({ + inventory_id: String(item.inventory_id), + quantity_used: String(item.quantity_used), + unit_id: item.unit_id ? String(item.unit_id) : "", + + // UI Initial State (復原) + ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "", + ui_product_id: item.inventory ? String(item.inventory.product_id) : "", + ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位 + ui_selected_unit: 'base', + + // UI Helpers + ui_product_name: item.inventory?.product?.name, + ui_batch_number: item.inventory?.batch_number, + ui_available_qty: item.inventory?.quantity, + ui_expiry_date: item.inventory?.expiry_date, + })); + const [bomItems, setBomItems] = useState(initialBomItems); + + const { data, setData, processing, errors } = useForm({ + product_id: String(productionOrder.product_id), + warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "", + output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "", + output_batch_number: productionOrder.output_batch_number || "", + output_box_count: productionOrder.output_box_count || "", + production_date: formatDate(productionOrder.production_date) || new Date().toISOString().split('T')[0], + expiry_date: formatDate(productionOrder.expiry_date), + remark: productionOrder.remark || "", + items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], + }); + + // 初始化載入既有 BOM 的來源倉庫資料 + useEffect(() => { + initialBomItems.forEach(item => { + if (item.ui_warehouse_id) { + fetchWarehouseInventory(item.ui_warehouse_id); + } + }); + }, []); + + // 當 inventoryOptions (Map) 載入後,更新現有 BOM items 的詳細資訊 (如單位、轉換率) + // 監聽 inventoryMap 變更 + useEffect(() => { + setBomItems(prevItems => prevItems.map(item => { + if (item.ui_warehouse_id && inventoryMap[item.ui_warehouse_id] && item.inventory_id && !item.ui_conversion_rate) { + const inv = inventoryMap[item.ui_warehouse_id].find(i => String(i.id) === item.inventory_id); + if (inv) { + return { + ...item, + ui_product_id: String(inv.product_id), + ui_product_name: inv.product_name, + ui_batch_number: inv.batch_number, + ui_available_qty: inv.quantity, + ui_expiry_date: inv.expiry_date || '', + ui_base_unit_name: inv.base_unit_name || inv.unit_name || '', + ui_large_unit_name: inv.large_unit_name || '', + ui_base_unit_id: inv.base_unit_id, + ui_large_unit_id: inv.large_unit_id, + ui_conversion_rate: inv.conversion_rate || 1, + }; + } + } + return item; + })); + }, [inventoryMap]); + + // 同步 warehouse_id 到 form data + useEffect(() => { + setData('warehouse_id', selectedWarehouse); + }, [selectedWarehouse]); + + // 新增 BOM 項目 + const addBomItem = () => { + setBomItems([...bomItems, { + inventory_id: "", + quantity_used: "", + unit_id: "", + ui_warehouse_id: "", + ui_product_id: "", + ui_input_quantity: "", + ui_selected_unit: 'base', + }]); + }; + + // 移除 BOM 項目 + const removeBomItem = (index: number) => { + setBomItems(bomItems.filter((_, i) => i !== index)); + }; + + // 更新 BOM 項目邏輯 + const updateBomItem = (index: number, field: keyof BomItem, value: any) => { + const updated = [...bomItems]; + const item = { ...updated[index], [field]: value }; + + // 0. 當選擇來源倉庫變更時 + if (field === 'ui_warehouse_id') { + item.ui_product_id = ""; + item.inventory_id = ""; + item.quantity_used = ""; + item.unit_id = ""; + item.ui_input_quantity = ""; + item.ui_selected_unit = "base"; + delete item.ui_product_name; + delete item.ui_batch_number; + delete item.ui_available_qty; + delete item.ui_expiry_date; + delete item.ui_conversion_rate; + delete item.ui_base_unit_name; + delete item.ui_large_unit_name; + delete item.ui_base_unit_id; + delete item.ui_large_unit_id; + + if (value) { + fetchWarehouseInventory(value); + } + } + + // 1. 當選擇商品變更時 -> 清空批號與相關資訊 + if (field === 'ui_product_id') { + item.inventory_id = ""; + item.quantity_used = ""; + item.unit_id = ""; + item.ui_input_quantity = ""; + item.ui_selected_unit = "base"; + delete item.ui_product_name; + delete item.ui_batch_number; + delete item.ui_available_qty; + delete item.ui_expiry_date; + delete item.ui_conversion_rate; + delete item.ui_base_unit_name; + delete item.ui_large_unit_name; + delete item.ui_base_unit_id; + delete item.ui_large_unit_id; + } + + // 2. 當選擇批號變更時 + if (field === 'inventory_id' && value) { + const currentOptions = inventoryMap[item.ui_warehouse_id] || []; + const inv = currentOptions.find(i => String(i.id) === value); + if (inv) { + item.ui_product_id = String(inv.product_id); + item.ui_product_name = inv.product_name; + item.ui_batch_number = inv.batch_number; + item.ui_available_qty = inv.quantity; + item.ui_expiry_date = inv.expiry_date || ''; + + item.ui_base_unit_name = inv.base_unit_name || inv.unit_name || ''; + item.ui_large_unit_name = inv.large_unit_name || ''; + item.ui_base_unit_id = inv.base_unit_id; + item.ui_large_unit_id = inv.large_unit_id; + item.ui_conversion_rate = inv.conversion_rate || 1; + + item.ui_selected_unit = 'base'; + item.unit_id = String(inv.base_unit_id || ''); + } + } + + // 3. 計算最終數量 + if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { + const inputQty = parseFloat(item.ui_input_quantity || '0'); + const rate = item.ui_conversion_rate || 1; + + if (item.ui_selected_unit === 'large') { + item.quantity_used = String(inputQty * rate); + item.unit_id = String(item.ui_base_unit_id || ''); + } else { + item.quantity_used = String(inputQty); + item.unit_id = String(item.ui_base_unit_id || ''); + } + } + + updated[index] = item; + setBomItems(updated); + }; + + // 同步 BOM items 到表單 data + useEffect(() => { + setData('items', bomItems.map(item => ({ + inventory_id: Number(item.inventory_id), + quantity_used: Number(item.quantity_used), + unit_id: item.unit_id ? Number(item.unit_id) : null + }))); + }, [bomItems]); + + // 提交表單(完成模式) + // 提交表單(完成模式) + // 提交表單(完成模式) + const submit = (status: 'draft' | 'completed') => { + // 驗證(簡單前端驗證) + if (status === 'completed') { + const missingFields = []; + if (!data.product_id) missingFields.push('成品商品'); + if (!data.output_quantity) missingFields.push('生產數量'); + if (!data.output_batch_number) missingFields.push('成品批號'); + if (!data.production_date) missingFields.push('生產日期'); + if (!selectedWarehouse) missingFields.push('入庫倉庫'); + if (bomItems.length === 0) missingFields.push('原物料明細'); + + if (missingFields.length > 0) { + toast.error( +
+ 請填寫必要欄位 + 缺漏:{missingFields.join('、')} +
+ ); + return; + } + } + + const formattedItems = bomItems + .filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) + .map(item => ({ + inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null, + quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0, + unit_id: item.unit_id ? parseInt(item.unit_id) : null, + })); + + router.put(route('production-orders.update', productionOrder.id), { + ...data, + items: formattedItems, + status: status, + }, { + onError: (errors) => { + const errorCount = Object.keys(errors).length; + toast.error( +
+ 更新失敗,請檢查表單 + 共有 {errorCount} 個欄位有誤,請修正後再試 +
+ ); + } + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + submit('completed'); + }; + + + + return ( + + + +
+
+ + + + +
+
+

+ + 編輯生產工單 +

+

+ 編輯工單內容與排程 +

+
+
+ + +
+
+
+ +
+ {/* 成品資訊 */} +
+ +

成品資訊

+
+
+ + setData('product_id', v)} + options={products.map(p => ({ + label: `${p.name} (${p.code})`, + value: String(p.id), + }))} + placeholder="選擇成品" + className="w-full h-9" + /> + {errors.product_id &&

{errors.product_id}

} +
+ +
+ + setData('output_quantity', e.target.value)} + placeholder="例如: 50" + className="h-9" + /> + {errors.output_quantity &&

{errors.output_quantity}

} +
+ +
+ + setData('output_batch_number', e.target.value)} + placeholder="例如: AB-TW-20260122-01" + className="h-9 font-mono" + /> + {errors.output_batch_number &&

{errors.output_batch_number}

} +
+ +
+ + setData('output_box_count', e.target.value)} + placeholder="例如: 10" + className="h-9" + /> +
+ +
+ +
+ + setData('production_date', e.target.value)} + className="h-9 pl-9" + /> +
+ {errors.production_date &&

{errors.production_date}

} +
+ +
+ +
+ + setData('expiry_date', e.target.value)} + className="h-9 pl-9" + /> +
+
+ +
+ + ({ + label: w.name, + value: String(w.id), + }))} + placeholder="選擇倉庫" + className="w-full h-9" + /> + {errors.warehouse_id &&

{errors.warehouse_id}

} +
+
+ +
+ +