feat(production): 優化生產單 BOM 原物料選取邏輯,支援商品 -> 倉庫 -> 批號連動與 API 分佈查詢

This commit is contained in:
2026-02-04 13:08:05 +08:00
parent a0c450d229
commit 4ba85ce446
17 changed files with 285 additions and 227 deletions

View File

@@ -39,6 +39,8 @@ interface InventoryOption {
product_id: number;
product_name: string;
product_code: string;
warehouse_id: number;
warehouse_name: string;
batch_number: string;
box_number: string | null;
quantity: number;
@@ -84,9 +86,9 @@ interface Props {
export default function ProductionCreate({ products, warehouses }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
// 快取對照表:warehouse_id -> inventories
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
// 快取對照表:product_id -> inventories across warehouses
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
const [bomItems, setBomItems] = useState<BomItem[]>([]);
@@ -107,19 +109,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 獲取倉庫資料的輔助函式
const fetchWarehouseInventory = async (warehouseId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
// 獲取特定商品在各倉庫的庫存分佈
const fetchProductInventories = async (productId: string) => {
if (!productId) return;
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return;
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
try {
const res = await fetch(route('api.production.warehouses.inventories', warehouseId));
const res = await fetch(route('api.production.products.inventories', productId));
const data = await res.json();
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
} catch (e) {
console.error(e);
} finally {
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false }));
setLoadingProducts(prev => ({ ...prev, [productId]: false }));
}
};
@@ -151,33 +155,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
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. 當選擇商品變更時 -> 清空批號與相關資訊
// 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位
if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
@@ -193,24 +173,43 @@ export default function ProductionCreate({ products, warehouses }: Props) {
delete item.ui_large_unit_name;
delete item.ui_base_unit_id;
delete item.ui_large_unit_id;
if (value) {
const prod = products.find(p => String(p.id) === value);
if (prod) {
item.ui_product_name = prod.name;
item.ui_base_unit_name = prod.base_unit?.name || '';
}
fetchProductInventories(value);
}
}
// 2. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
// 清除某些 cache
delete item.ui_batch_number;
delete item.ui_available_qty;
delete item.ui_expiry_date;
}
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) {
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
const currentOptions = productInventoryMap[item.ui_product_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_warehouse_id = String(inv.warehouse_id);
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_name = inv.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;
// 預設單位
@@ -219,16 +218,13 @@ export default function ProductionCreate({ products, warehouses }: Props) {
}
}
// 3. 計算最終數量 (Base Quantity)
// 當 輸入數量 或 選擇單位 變更時
// 4. 計算最終數量 (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);
@@ -256,17 +252,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', String(yieldQty));
const ratio = 1;
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0");
const calculatedQty = (baseQty * ratio).toFixed(4); // 保持精度
const calculatedQty = baseQty; // 保持精度
// 若有配方商品,預先載入庫存分佈
if (item.product_id) {
fetchProductInventories(String(item.product_id));
}
return {
inventory_id: "",
quantity_used: String(calculatedQty),
unit_id: String(item.unit_id),
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
ui_warehouse_id: "",
ui_product_id: String(item.product_id),
ui_product_name: item.product_name,
ui_batch_number: "",
@@ -280,11 +280,6 @@ export default function ProductionCreate({ products, warehouses }: Props) {
});
setBomItems(newBomItems);
// 若有選倉庫,預先載入庫存資料以供選擇
if (selectedWarehouse) {
fetchWarehouseInventory(selectedWarehouse);
}
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${yieldQty}`
});
@@ -607,8 +602,8 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead>
@@ -617,61 +612,72 @@ export default function ProductionCreate({ products, warehouses }: Props) {
</TableHeader>
<TableBody>
{bomItems.map((item, index) => {
// 取得此列已載入的 Inventory Options
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
// 1. 商品選項
const productOptions = products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id)
}));
// 過濾商品
const uniqueProductOptions = Array.from(new Map(
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
// 2. 來源倉庫選項 (根據商品库庫存過濾)
const currentInventories = productInventoryMap[item.ui_product_id] || [];
const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
).values());
// 過濾批號
const batchOptions = currentOptions
.filter(inv => String(inv.product_id) === item.ui_product_id)
.map(inv => ({
// 如果篩選後沒有倉庫(即該商品無庫存),則顯示所有倉庫以供選取(或顯示無庫存提示)
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
// 3. 批號選項 (根據商品與倉庫過濾)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
value: String(inv.id)
}));
return (
<TableRow key={index}>
{/* 0. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))}
placeholder="選擇倉庫"
className="w-full"
/>
</TableCell>
{/* 1. 選擇商品 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_product_id}
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
options={uniqueProductOptions}
options={productOptions}
placeholder="選擇商品"
className="w-full"
disabled={!item.ui_warehouse_id}
/>
</TableCell>
{/* 2. 選擇批號 */}
{/* 2. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={uniqueWarehouseOptions as any}
placeholder={item.ui_product_id
? (loadingProducts[item.ui_product_id]
? "載入庫存中..."
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
: "請先選商品"}
className="w-full"
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
/>
</TableCell>
{/* 3. 選擇批號 */}
<TableCell className="align-top">
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={batchOptions}
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
options={batchOptions as any}
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full"
disabled={!item.ui_product_id}
disabled={!item.ui_warehouse_id}
/>
{item.inventory_id && (() => {
const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id);
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) return (
<div className="text-xs text-gray-500 mt-1">
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity}