diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index b3a6502..e318b6e 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -53,12 +53,18 @@ class InventoryController extends Controller ->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) { + // 判斷是否為販賣機並調整分組 + $isVending = $warehouse->type === 'vending'; + + $inventories = $items->groupBy(function ($item) use ($isVending) { + return $isVending + ? $item->product_id . '-' . ($item->location ?? 'NO-SLOT') + : $item->product_id; + })->map(function ($batchItems) use ($safetyStockMap, $isVending) { $firstItem = $batchItems->first(); $product = $firstItem->product; $totalQuantity = $batchItems->sum('quantity'); diff --git a/app/Modules/Inventory/Controllers/SafetyStockController.php b/app/Modules/Inventory/Controllers/SafetyStockController.php index 69c0168..462f5a5 100644 --- a/app/Modules/Inventory/Controllers/SafetyStockController.php +++ b/app/Modules/Inventory/Controllers/SafetyStockController.php @@ -31,7 +31,51 @@ class SafetyStockController extends Controller ]; }); - // 準備現有庫存列表 (用於庫存量對比) + // 獲取現有庫存 (用於抓取「已在倉庫中」的商品) + $inventoryProductIds = Inventory::where('warehouse_id', $warehouse->id)->pluck('product_id')->unique(); + + // 準備安全庫存設定列表 (從資料庫讀取) + $existingSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id) + ->with(['product.category', 'product.baseUnit']) + ->get(); + + $existingProductIds = $existingSettings->pluck('product_id')->toArray(); + + // 找出:有庫存但是「還沒設定過安全庫存」的商品 + $missingProductIds = $inventoryProductIds->diff($existingProductIds); + + $missingProducts = Product::whereIn('id', $missingProductIds) + ->with(['category', 'baseUnit']) + ->get(); + + // 合併:已設定的 + 有庫存未設定的 (預設值 0) + $safetyStockSettings = $existingSettings->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(), + 'isNew' => false, // 標記為舊有設定 + ]; + })->concat($missingProducts->map(function ($product) use ($warehouse) { + return [ + 'id' => 'temp_' . $product->id, // 暫時 ID + 'warehouseId' => (string) $warehouse->id, + 'productId' => (string) $product->id, + 'productName' => $product->name, + 'productType' => $product->category ? $product->category->name : '其他', + 'safetyStock' => 0, // 預設 0 + 'unit' => $product->baseUnit?->name ?? '個', + 'updatedAt' => now()->toIso8601String(), + 'isNew' => true, // 標記為建議新增 + ]; + }))->values(); + + // 原本的 inventories 映射 (供顯示對比) $inventories = Inventory::where('warehouse_id', $warehouse->id) ->select('product_id', DB::raw('SUM(quantity) as total_quantity')) ->groupBy('product_id') @@ -43,23 +87,6 @@ class SafetyStockController extends Controller ]; }); - // 準備安全庫存設定列表 (從新表格讀取) - $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, 'safetyStockSettings' => $safetyStockSettings, diff --git a/app/Modules/Inventory/Controllers/WarehouseController.php b/app/Modules/Inventory/Controllers/WarehouseController.php index c764460..715807f 100644 --- a/app/Modules/Inventory/Controllers/WarehouseController.php +++ b/app/Modules/Inventory/Controllers/WarehouseController.php @@ -156,8 +156,9 @@ class WarehouseController extends Controller public function destroy(Warehouse $warehouse) { - // 檢查是否有相關聯的採購單 - if ($warehouse->purchaseOrders()->exists()) { + // 檢查是否有相關聯的採購單 (跨模組檢查,不使用模型關聯以符合解耦規範) + $hasPurchaseOrders = \App\Modules\Procurement\Models\PurchaseOrder::where('warehouse_id', $warehouse->id)->exists(); + if ($hasPurchaseOrders) { return redirect()->back()->with('error', '無法刪除:該倉庫有相關聯的採購單,請先處理採購單。'); } diff --git a/app/Modules/Inventory/Exports/InventoryTemplateExport.php b/app/Modules/Inventory/Exports/InventoryTemplateExport.php index fb2b861..4a18572 100644 --- a/app/Modules/Inventory/Exports/InventoryTemplateExport.php +++ b/app/Modules/Inventory/Exports/InventoryTemplateExport.php @@ -34,7 +34,6 @@ class InventoryDataSheet implements FromArray, WithHeadings, WithTitle, ShouldAu '商品代號', '商品名稱', '數量', - '單位', '入庫單價', '儲位/貨道', '批號', @@ -58,7 +57,6 @@ class InventoryInstructionSheet implements FromArray, WithHeadings, WithTitle, S ['商品代號', '擇一輸入', '若條碼未填寫,系統會依據代號匹配商品'], ['商品名稱', '選填', '僅供對照參考,匯入時系統會自動忽略此欄位內容'], ['數量', '必填', '入庫的商品數量,須為大於 0 的數字'], - ['單位', '必填', '單位 (如:個、件)'], ['入庫單價', '選填', '未填寫時將預設使用商品的「採購成本價」'], ['儲位/貨道', '選填', '一般倉庫請填寫「儲位(位址)」,販賣機倉庫請填寫「貨道編號」(如: A1)'], ['批號', '選填', '如需批次控管請填寫,若留空系統會自動標記為 "NO-BATCH"'], diff --git a/app/Modules/Inventory/Imports/InventoryImport.php b/app/Modules/Inventory/Imports/InventoryImport.php index b6cd9ec..c3c5294 100644 --- a/app/Modules/Inventory/Imports/InventoryImport.php +++ b/app/Modules/Inventory/Imports/InventoryImport.php @@ -9,10 +9,11 @@ use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithMapping; +use Maatwebsite\Excel\Concerns\SkipsEmptyRows; use Maatwebsite\Excel\Imports\HeadingRowFormatter; use Illuminate\Support\Facades\DB; -class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMapping +class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMapping, SkipsEmptyRows { private $warehouse; private $inboundDate; @@ -35,6 +36,9 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa if (isset($row['商品代號'])) { $row['商品代號'] = (string) $row['商品代號']; } + if (isset($row['儲位/貨道'])) { + $row['儲位/貨道'] = (string) $row['儲位/貨道']; + } return $row; } @@ -100,8 +104,11 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa 'batch_number' => $inventory->batch_number, 'quantity' => $quantity, 'unit_cost' => $unitCost, - 'transaction_type' => '手動入庫', + 'type' => '手動入庫', 'reason' => 'Excel 匯入入庫', + 'balance_before' => $oldQty, + 'balance_after' => $inventory->quantity, + 'actual_time' => $this->inboundDate, 'notes' => $this->notes, 'expiry_date' => $inventory->expiry_date, ]); @@ -115,8 +122,11 @@ class InventoryImport implements ToModel, WithHeadingRow, WithValidation, WithMa return [ '商品條碼' => ['nullable', 'string'], '商品代號' => ['nullable', 'string'], - '數量' => ['required', 'numeric', 'min:0.01'], - '單位' => ['required', 'string'], + '數量' => [ + 'required_with:商品條碼,商品代號', // 只有在有商品資訊時,數量才是必填 + 'numeric', + 'min:0' // 允許數量為 0 + ], '入庫單價' => ['nullable', 'numeric', 'min:0'], '儲位/貨道' => ['nullable', 'string', 'max:50'], '批號' => ['nullable', 'string'], diff --git a/database/migrations/tenant/2026_02_09_090204_update_inventories_unique_index_to_include_location.php b/database/migrations/tenant/2026_02_09_090204_update_inventories_unique_index_to_include_location.php new file mode 100644 index 0000000..3a33dd4 --- /dev/null +++ b/database/migrations/tenant/2026_02_09_090204_update_inventories_unique_index_to_include_location.php @@ -0,0 +1,31 @@ +unique(['warehouse_id', 'product_id', 'batch_number', 'location'], 'warehouse_product_batch_location_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventories', function (Blueprint $table) { + $table->dropUnique('warehouse_product_batch_location_unique'); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_09_090508_force_remove_old_inventory_unique_index.php b/database/migrations/tenant/2026_02_09_090508_force_remove_old_inventory_unique_index.php new file mode 100644 index 0000000..00dc637 --- /dev/null +++ b/database/migrations/tenant/2026_02_09_090508_force_remove_old_inventory_unique_index.php @@ -0,0 +1,39 @@ + void; warehouseId: string; + warehouseName: string; } -export default function InventoryImportDialog({ open, onOpenChange, warehouseId }: Props) { +export default function InventoryImportDialog({ open, onOpenChange, warehouseId, warehouseName }: Props) { const { data, setData, post, processing, errors, reset, clearErrors } = useForm({ file: null as File | null, inboundDate: new Date().toISOString().split('T')[0], @@ -63,6 +64,9 @@ export default function InventoryImportDialog({ open, onOpenChange, warehouseId 匯入庫存資料 請先下載範本,填寫完畢後上傳檔案進行批次入庫。 +
+ 目標倉庫:{warehouseName} +
diff --git a/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx b/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx index d54034c..a4fea8f 100644 --- a/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx +++ b/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx @@ -135,9 +135,19 @@ export default function InventoryTable({ ) : ( )} -

{group.productName}

+

+ {group.productName} + {isVending && group.batches.length > 0 && (() => { + const locations = Array.from(new Set(group.batches.map(b => b.location).filter(Boolean))); + return locations.length > 0 ? ( + + {locations.map(loc => `[${loc}]`).join('')} + + ) : null; + })()} +

- {hasInventory ? `${group.batches.length} 個批號` : '無庫存'} + {isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')} {group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && ( @@ -220,7 +230,7 @@ export default function InventoryTable({ {index + 1} {batch.batchNumber || "-"} - {batch.location || "-"} + {batch.location || "-"} {batch.quantity} {batch.unit} diff --git a/resources/js/Components/Warehouse/SafetyStock/SafetyStockList.tsx b/resources/js/Components/Warehouse/SafetyStock/SafetyStockList.tsx index 195d77f..6724bfe 100644 --- a/resources/js/Components/Warehouse/SafetyStock/SafetyStockList.tsx +++ b/resources/js/Components/Warehouse/SafetyStock/SafetyStockList.tsx @@ -53,7 +53,16 @@ export default function SafetyStockList({ }); // 獲取狀態徽章 (與 InventoryTable 保持一致) - const getStatusBadge = (quantity: number, safetyStock: number) => { + const getStatusBadge = (quantity: number, safetyStock: number, isNew?: boolean) => { + // 如果是自動帶入的品項且尚未存檔,顯示「未設定」 + if (isNew) { + return ( + + 未設定 + + ); + } + const status = getSafetyStockStatus(quantity, safetyStock); switch (status) { case "正常": @@ -122,7 +131,7 @@ export default function SafetyStockList({ - {getStatusBadge(currentStock, setting.safetyStock)} + {getStatusBadge(currentStock, setting.safetyStock, setting.isNew)}
diff --git a/resources/js/Components/Warehouse/WarehouseCard.tsx b/resources/js/Components/Warehouse/WarehouseCard.tsx index 67bee6a..44fa883 100644 --- a/resources/js/Components/Warehouse/WarehouseCard.tsx +++ b/resources/js/Components/Warehouse/WarehouseCard.tsx @@ -11,6 +11,9 @@ import { Edit, Info, FileText, + CupSoda, + QrCode, + Milk, } from "lucide-react"; import { Warehouse, WarehouseStats } from "@/types/warehouse"; import { Button } from "@/Components/ui/button"; @@ -50,17 +53,28 @@ export default function WarehouseCard({ onEdit, }: WarehouseCardProps) { const [showInfoDialog, setShowInfoDialog] = useState(false); + const isVending = warehouse.type === 'vending'; return ( + {/* 裝飾性背景元素 (僅限販賣機) */} + {isVending && ( + <> + {/* LED 裝飾線條 - 保持主色調 */} +
+ + )} + {/* 警告橫幅 */} {hasWarning && ( -
+
低庫存警告 @@ -71,12 +85,14 @@ export default function WarehouseCard({ {/* 上半部:資訊區域 */} -
+
{/* 標題區塊 */} -
+
-

{warehouse.name}

+

+ {warehouse.name} +

- {warehouse.description || "無描述"} + {warehouse.description || (isVending ? "管理此機台的商品配貨與補貨狀況" : "無描述")}
- - {/* 統計區塊 - 狀態標籤 */} + {/* 統計區塊 */}
- - {/* 帳面庫存總計 (金額) - 瑕疵倉隱藏此項以減少重複 */} {warehouse.type !== 'quarantine' && ( -
-
- - 帳面庫存總計 +
+
+ + 帳面庫存估值
${Number(stats.totalValue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} @@ -124,7 +132,6 @@ export default function WarehouseCard({ )} - {/* 過期統計 (金額) */} {Number(stats.abnormalValue || 0) > 0 && (
@@ -141,12 +148,31 @@ export default function WarehouseCard({ )} - + {/* 販賣機特色視覺:投幣、取物口裝飾 (移動至帳面庫存下方,顏色更顯眼) */} + {isVending && ( +
+
+
+
+ + + + + +
+
+
+
+ +
+
+
+ )}
{/* 下半部:操作按鈕 */} -
+
{/* 操作按鈕 (位於標題下方) */} -
+
{/* 安全庫存設定按鈕 */} @@ -231,6 +231,7 @@ export default function WarehouseInventoryPage({ open={importDialogOpen} onOpenChange={setImportDialogOpen} warehouseId={warehouse.id} + warehouseName={warehouse.name} />
diff --git a/resources/js/Pages/Warehouse/SafetyStockSettings.tsx b/resources/js/Pages/Warehouse/SafetyStockSettings.tsx index a23ac79..bbf9758 100644 --- a/resources/js/Pages/Warehouse/SafetyStockSettings.tsx +++ b/resources/js/Pages/Warehouse/SafetyStockSettings.tsx @@ -69,18 +69,39 @@ export default function SafetyStockPage({ }; const handleEdit = (updatedSetting: SafetyStockSetting) => { - router.put(route('warehouses.safety-stock.update', [warehouse.id, updatedSetting.id]), { - safetyStock: updatedSetting.safetyStock, - }, { - onSuccess: () => { - setEditingSetting(null); - toast.success(`成功更新 ${updatedSetting.productName} 的安全庫存`); - }, - onError: (errors) => { - const firstError = Object.values(errors)[0]; - toast.error(typeof firstError === 'string' ? firstError : "更新失敗"); - } - }); + // 如果 ID 包含 temp_,表示這是一個「自動帶入但尚未存入資料庫」的建議項 + // 這種情況應該呼叫 POST (store) 而非 PUT (update),以避免被後端路由模型綁定報 404 + if (updatedSetting.id.includes('temp_')) { + router.post(route('warehouses.safety-stock.store', warehouse.id), { + settings: [{ + productId: updatedSetting.productId, + quantity: updatedSetting.safetyStock + }], + }, { + onSuccess: () => { + setEditingSetting(null); + toast.success(`成功設定 ${updatedSetting.productName} 的安全庫存`); + }, + onError: (errors) => { + const firstError = Object.values(errors)[0]; + toast.error(typeof firstError === 'string' ? firstError : "設定失敗"); + } + }); + } else { + // 已存在的項目,正常執行 PUT 更新 + router.put(route('warehouses.safety-stock.update', [warehouse.id, updatedSetting.id]), { + safetyStock: updatedSetting.safetyStock, + }, { + onSuccess: () => { + setEditingSetting(null); + toast.success(`成功更新 ${updatedSetting.productName} 的安全庫存`); + }, + onError: (errors) => { + const firstError = Object.values(errors)[0]; + toast.error(typeof firstError === 'string' ? firstError : "更新失敗"); + } + }); + } }; const handleDelete = () => { diff --git a/resources/js/types/warehouse.ts b/resources/js/types/warehouse.ts index 14841d5..c4e47a0 100644 --- a/resources/js/types/warehouse.ts +++ b/resources/js/types/warehouse.ts @@ -113,6 +113,7 @@ export interface SafetyStockSetting { unit?: string; createdAt: string; updatedAt: string; + isNew?: boolean; } /**