load([ 'inventories.product.category', 'inventories.product.baseUnit', 'inventories.lastIncomingTransaction', 'inventories.lastOutgoingTransaction' ]); $allProducts = \App\Models\Product::with('category')->get(); // 1. 準備 availableProducts $availableProducts = $allProducts->map(function ($product) { return [ 'id' => (string) $product->id, 'name' => $product->name, 'type' => $product->category?->name ?? '其他', ]; }); // 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 = '低於'; } } return [ '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, 'safetyStockSettings' => $safetyStockSettings, 'availableProducts' => $availableProducts, ]); } public function create(\App\Models\Warehouse $warehouse) { // 取得所有商品供前端選單使用 $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, ]; }); return \Inertia\Inertia::render('Warehouse/AddInventory', [ 'warehouse' => $warehouse, 'products' => $products, ]); } public function store(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse) { $validated = $request->validate([ 'inboundDate' => 'required|date', 'reason' => 'required|string', 'notes' => 'nullable|string', 'items' => 'required|array|min:1', 'items.*.productId' => 'required|exists:products,id', 'items.*.quantity' => 'required|numeric|min:0.01', '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) { $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']); $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(); } } $currentQty = $inventory->quantity; $newQty = $currentQty + $item['quantity']; $inventory->quantity = $newQty; $inventory->save(); // 寫入異動紀錄 $inventory->transactions()->create([ 'type' => '手動入庫', 'quantity' => $item['quantity'], 'balance_before' => $currentQty, 'balance_after' => $newQty, 'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''), 'actual_time' => $validated['inboundDate'], 'user_id' => auth()->id(), ]); } return redirect()->route('warehouses.inventory.index', $warehouse->id) ->with('success', '庫存記錄已儲存成功'); }); } /** * 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),需要特殊處理 if (str_starts_with($inventoryId, 'mock-inv-')) { return redirect()->back()->with('error', '無法編輯範例資料'); } $inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) { $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); }, 'transactions.user'])->findOrFail($inventoryId); // 轉換為前端需要的格式 $inventoryData = [ 'id' => (string) $inventory->id, 'warehouseId' => (string) $inventory->warehouse_id, 'productId' => (string) $inventory->product_id, 'productName' => $inventory->product?->name ?? '未知商品', 'quantity' => (float) $inventory->quantity, 'batchNumber' => $inventory->batch_number ?? '-', 'expiryDate' => $inventory->expiry_date ?? null, 'lastInboundDate' => $inventory->updated_at->format('Y-m-d'), 'lastOutboundDate' => null, ]; // 整理異動紀錄 $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/EditInventory', [ 'warehouse' => $warehouse, 'inventory' => $inventoryData, 'transactions' => $transactions, ]); } public function update(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId) { // 若是 product ID (舊邏輯),先轉為 inventory // 但新路由我們傳的是 inventory ID // 為了相容,我們先判斷 $inventoryId 是 inventory ID $inventory = \App\Models\Inventory::find($inventoryId); // 如果找不到 (可能是舊路由傳 product ID) if (!$inventory) { $inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first(); } if (!$inventory) { return redirect()->back()->with('error', '找不到庫存紀錄'); } $validated = $request->validate([ 'quantity' => 'required|numeric|min:0', // 以下欄位改為 nullable,支援新表單 'type' => 'nullable|string', 'operation' => 'nullable|in:add,subtract,set', 'reason' => 'nullable|string', 'notes' => 'nullable|string', // 新增日期欄位驗證 (雖然暫不儲存到 DB) 'batchNumber' => 'nullable|string', 'expiryDate' => 'nullable|date', 'lastInboundDate' => 'nullable|date', 'lastOutboundDate' => 'nullable|date', ]); return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) { $currentQty = (float) $inventory->quantity; $newQty = (float) $validated['quantity']; // 判斷是否來自調整彈窗 (包含 operation 參數) $isAdjustment = isset($validated['operation']); $changeQty = 0; if ($isAdjustment) { switch ($validated['operation']) { case 'add': $changeQty = (float) $validated['quantity']; $newQty = $currentQty + $changeQty; break; case 'subtract': $changeQty = -(float) $validated['quantity']; $newQty = $currentQty + $changeQty; break; case 'set': $changeQty = $newQty - $currentQty; break; } } else { // 來自編輯頁面,直接 Set $changeQty = $newQty - $currentQty; } // 更新庫存 $inventory->update(['quantity' => $newQty]); // 異動類型映射 $type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment'); $typeMapping = [ 'manual_adjustment' => '手動調整庫存', 'adjustment' => '盤點調整', 'purchase_in' => '採購進貨', 'sales_out' => '銷售出庫', 'return_in' => '退貨入庫', 'return_out' => '退貨出庫', 'transfer_in' => '撥補入庫', 'transfer_out' => '撥補出庫', ]; $chineseType = $typeMapping[$type] ?? $type; // 如果是編輯頁面來的,且沒傳 type,設為手動編輯 if (!$isAdjustment && !isset($validated['type'])) { $chineseType = '手動編輯'; } // 整理原因 $reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新'); if (isset($validated['notes'])) { $reason .= ' - ' . $validated['notes']; } // 寫入異動紀錄 if (abs($changeQty) > 0.0001) { $inventory->transactions()->create([ 'type' => $chineseType, 'quantity' => $changeQty, 'balance_before' => $currentQty, 'balance_after' => $newQty, 'reason' => $reason, 'actual_time' => now(), 'user_id' => auth()->id(), ]); } return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id) ->with('success', '庫存資料已更新'); }); } public function destroy(\App\Models\Warehouse $warehouse, $inventoryId) { $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, 'balance_before' => $inventory->quantity, 'balance_after' => 0, 'reason' => '刪除庫存品項', 'actual_time' => now(), 'user_id' => auth()->id(), ]); } $inventory->delete(); return redirect()->route('warehouses.inventory.index', $warehouse->id) ->with('success', '庫存品項已刪除'); } public function history(Request $request, \App\Models\Warehouse $warehouse) { $inventoryId = $request->query('inventoryId'); $productId = $request->query('productId'); 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(); 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', '未提供查詢參數'); } }