生產工單BOM以及批號完善
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 57s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-22 15:39:35 +08:00
parent 1ae21febb5
commit 1d134c9ad8
31 changed files with 2684 additions and 694 deletions

View File

@@ -2,7 +2,7 @@
* 新增庫存頁面(手動入庫)
*/
import { useState } from "react";
import { useState, useEffect } from "react";
import { Plus, Trash2, Calendar, ArrowLeft, Save, Boxes } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
@@ -27,11 +27,21 @@ import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
interface Product {
id: string;
name: string;
code: string;
baseUnit: string;
largeUnit?: string;
conversionRate?: number;
}
interface Batch {
inventoryId: string;
batchNumber: string;
originCountry: string;
expiryDate: string | null;
quantity: number;
isDeleted?: boolean;
}
interface Props {
warehouse: Warehouse;
products: Product[];
@@ -51,10 +61,61 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
const [notes, setNotes] = useState("");
const [items, setItems] = useState<InboundItem[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [batchesCache, setBatchesCache] = useState<Record<string, { batches: Batch[], nextSequences: Record<string, string> }>>({});
// 取得商品批號與流水號
const fetchProductBatches = async (productId: string, originCountry?: string, arrivalDate?: string) => {
if (!productId) return;
const country = originCountry || 'TW';
const date = arrivalDate || inboundDate.split('T')[0];
const cacheKey = `${country}_${date}`;
// 如果該商品的批號列表尚未載入,強制載入
const existingCache = batchesCache[productId];
const hasBatches = existingCache && existingCache.batches.length >= 0 && existingCache.batches !== undefined;
const hasThisSequence = existingCache?.nextSequences?.[cacheKey];
// 若 batches 尚未載入,或特定條件的 sequence 尚未載入,則呼叫 API
if (!hasBatches || !hasThisSequence) {
try {
const response = await fetch(`/api/warehouses/${warehouse.id}/inventory/batches/${productId}?originCountry=${country}&arrivalDate=${date}`);
const data = await response.json();
setBatchesCache(prev => {
const existingProductCache = prev[productId] || { batches: [], nextSequences: {} };
return {
...prev,
[productId]: {
batches: data.batches,
nextSequences: {
...existingProductCache.nextSequences,
[cacheKey]: data.nextSequence
}
}
};
});
} catch (error) {
console.error("Failed to fetch batches", error);
}
}
};
// 當 items 變動、日期變動時,確保資料同步
useEffect(() => {
items.forEach(item => {
if (item.productId) {
// 無論 batchMode 為何,都要載入批號列表
// 若使用者切換到 new 模式,則額外傳入 originCountry 以取得正確流水號
const country = item.batchMode === 'new' ? item.originCountry : undefined;
fetchProductBatches(item.productId, country, inboundDate.split('T')[0]);
}
});
}, [items, inboundDate]);
// 新增明細行
const handleAddItem = () => {
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", baseUnit: "個" };
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個" };
const newItem: InboundItem = {
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
productId: defaultProduct.id,
@@ -65,6 +126,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
largeUnit: defaultProduct.largeUnit,
conversionRate: defaultProduct.conversionRate,
selectedUnit: 'base',
batchMode: 'existing', // 預設選擇現有批號
originCountry: 'TW',
};
setItems([...items, newItem]);
};
@@ -96,6 +159,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
largeUnit: product.largeUnit,
conversionRate: product.conversionRate,
selectedUnit: 'base',
batchMode: 'existing',
inventoryId: undefined, // 清除已選擇的批號
expiryDate: undefined,
});
}
};
@@ -123,6 +189,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
if (item.quantity <= 0) {
newErrors[`item-${index}-quantity`] = "數量必須大於 0";
}
if (item.batchMode === 'existing' && !item.inventoryId) {
newErrors[`item-${index}-batch`] = "請選擇批號";
}
if (item.batchMode === 'new' && !item.originCountry) {
newErrors[`item-${index}-country`] = "新批號必須輸入產地";
}
});
setErrors(newErrors);
@@ -149,7 +221,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
return {
productId: item.productId,
quantity: finalQuantity,
batchNumber: item.batchNumber,
batchMode: item.batchMode,
inventoryId: item.inventoryId,
originCountry: item.originCountry,
expiryDate: item.expiryDate
};
})
@@ -165,6 +239,24 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
});
};
// 生成批號預覽
const getBatchPreview = (productId: string | undefined, productCode: string | undefined, country: string, dateStr: string) => {
if (!productCode || !productId) return "--";
try {
// 直接字串處理,避免時區問題且確保與 fetchProductBatches 的 key 一致
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
const [yyyy, mm, dd] = datePart.split('-');
const dateFormatted = `${yyyy}${mm}${dd}`;
const cacheKey = `${country}_${datePart}`;
const seq = batchesCache[productId]?.nextSequences?.[cacheKey] || "XX";
return `${productCode}-${country}-${dateFormatted}-${seq}`;
} catch (e) {
return "--";
}
};
return (
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name, "手動入庫")}>
<Head title={`新增庫存 - ${warehouse.name}`} />
@@ -301,17 +393,19 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[280px]">
<TableHead className="w-[180px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[120px]">
<TableHead className="w-[220px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[100px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[220px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -321,6 +415,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
? item.quantity * item.conversionRate
: item.quantity;
// Find product code
const product = products.find(p => p.id === item.productId);
return (
<TableRow key={item.tempId}>
{/* 商品 */}
@@ -342,6 +439,73 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
)}
</TableCell>
{/* 批號與產地控制 */}
<TableCell>
<div className="space-y-2">
<SearchableSelect
value={item.batchMode === 'new' ? 'new_batch' : (item.inventoryId || "")}
onValueChange={(value) => {
if (value === 'new_batch') {
handleUpdateItem(item.tempId, {
batchMode: 'new',
inventoryId: undefined,
originCountry: 'TW',
expiryDate: undefined
});
} else {
const selectedBatch = (batchesCache[item.productId]?.batches || []).find(b => b.inventoryId === value);
handleUpdateItem(item.tempId, {
batchMode: 'existing',
inventoryId: value,
originCountry: selectedBatch?.originCountry,
expiryDate: selectedBatch?.expiryDate || undefined
});
}
}}
options={[
{ label: "+ 建立新批號", value: "new_batch" },
...(batchesCache[item.productId]?.batches || []).map(b => ({
label: `${b.batchNumber} - 庫存: ${b.quantity}`,
value: b.inventoryId
}))
]}
placeholder="選擇或新增批號"
className="border-gray-300"
/>
{errors[`item-${index}-batch`] && (
<p className="text-xs text-red-500">
{errors[`item-${index}-batch`]}
</p>
)}
{item.batchMode === 'new' && (
<div className="flex items-center gap-2 mt-2">
<div className="flex-1">
<Input
value={item.originCountry || ""}
onChange={(e) => {
const val = e.target.value.toUpperCase().slice(0, 2);
handleUpdateItem(item.tempId, { originCountry: val });
}}
maxLength={2}
placeholder="產地"
className="h-8 text-xs text-center border-gray-300"
/>
</div>
<div className="flex-[3] text-xs bg-primary-50/50 text-primary-main px-2 py-1 rounded border border-primary-200/50 font-mono overflow-hidden whitespace-nowrap">
{getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)}
</div>
</div>
)}
{item.batchMode === 'existing' && item.inventoryId && (
<div className="text-xs text-gray-500 px-2 font-mono">
: {item.expiryDate || '未設定'}
</div>
)}
</div>
</TableCell>
{/* 數量 */}
<TableCell>
<Input
@@ -408,30 +572,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
expiryDate: e.target.value,
})
}
className="border-gray-300 pl-9"
disabled={item.batchMode === 'existing'}
className={`border-gray-300 pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
/>
</div>
</TableCell>
{/* 批號 */}
<TableCell>
<Input
value={item.batchNumber || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
batchNumber: e.target.value,
})
}
className="border-gray-300"
placeholder="系統自動生成"
/>
{errors[`item-${index}-batch`] && (
<p className="text-xs text-red-500 mt-1">
{errors[`item-${index}-batch`]}
</p>
)}
</TableCell>
{/* 刪除按鈕 */}
<TableCell>
<Button