feat(production): 優化生產單 BOM 原物料選取邏輯,支援商品 -> 倉庫 -> 批號連動與 API 分佈查詢
This commit is contained in:
@@ -38,6 +38,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;
|
||||
@@ -73,6 +75,7 @@ interface BomItem {
|
||||
ui_large_unit_name?: string;
|
||||
ui_base_unit_id?: number;
|
||||
ui_large_unit_id?: number;
|
||||
ui_product_code?: string;
|
||||
}
|
||||
|
||||
interface ProductionOrderItem {
|
||||
@@ -136,23 +139,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
||||
); // 產出倉庫
|
||||
|
||||
// 快取對照表:warehouse_id -> inventories
|
||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
||||
// 快取對照表:product_id -> inventories
|
||||
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 獲取倉庫資料的輔助函式
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,25 +192,25 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
|
||||
// 初始化載入既有 BOM 的來源倉庫資料
|
||||
// 初始化載入既有 BOM 的商品庫存資料
|
||||
useEffect(() => {
|
||||
initialBomItems.forEach(item => {
|
||||
if (item.ui_warehouse_id) {
|
||||
fetchWarehouseInventory(item.ui_warehouse_id);
|
||||
if (item.ui_product_id) {
|
||||
fetchProductInventories(item.ui_product_id);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 當 inventoryOptions (Map) 載入後,更新現有 BOM items 的詳細資訊 (如單位、轉換率)
|
||||
// 監聽 inventoryMap 變更
|
||||
// 當 inventoryOptions 載入後,更新現有 BOM items 的詳細資訊
|
||||
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);
|
||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||
if (currentOptions.length > 0 && item.inventory_id && !item.ui_conversion_rate) {
|
||||
const inv = currentOptions.find(i => String(i.id) === item.inventory_id);
|
||||
if (inv) {
|
||||
return {
|
||||
...item,
|
||||
ui_product_id: String(inv.product_id),
|
||||
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
|
||||
ui_product_name: inv.product_name,
|
||||
ui_batch_number: inv.batch_number,
|
||||
ui_available_qty: inv.quantity,
|
||||
@@ -221,7 +225,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}, [inventoryMap]);
|
||||
}, [productInventoryMap]);
|
||||
|
||||
// 同步 warehouse_id 到 form data
|
||||
useEffect(() => {
|
||||
@@ -251,53 +255,40 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
const updated = [...bomItems];
|
||||
const item = { ...updated[index], [field]: value };
|
||||
|
||||
// 0. 當選擇來源倉庫變更時
|
||||
if (field === 'ui_warehouse_id') {
|
||||
item.ui_product_id = "";
|
||||
// 0. 當選擇商品變更時 (第一層)
|
||||
if (field === 'ui_product_id') {
|
||||
item.ui_warehouse_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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 當選擇商品變更時 -> 清空批號與相關資訊
|
||||
if (field === 'ui_product_id') {
|
||||
// 1. 當選擇來源倉庫變更時 (第二層)
|
||||
if (field === 'ui_warehouse_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. 當選擇批號變更時
|
||||
// 2. 當選擇批號 (Inventory) 變更時 (第三層)
|
||||
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_warehouse_id = String(inv.warehouse_id);
|
||||
item.ui_product_name = inv.product_name;
|
||||
item.ui_batch_number = inv.batch_number;
|
||||
item.ui_available_qty = inv.quantity;
|
||||
@@ -583,8 +574,8 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
<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>
|
||||
@@ -593,19 +584,31 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
</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 displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
|
||||
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
|
||||
? filteredWarehouseOptions
|
||||
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||||
|
||||
const batchOptions = currentOptions
|
||||
.filter(inv => String(inv.product_id) === item.ui_product_id)
|
||||
.map(inv => ({
|
||||
// 備案 (初始載入時)
|
||||
const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
|
||||
? uniqueWarehouseOptions
|
||||
: (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_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)
|
||||
}));
|
||||
@@ -614,44 +617,47 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
||||
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_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={displayProductOptions}
|
||||
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={displayWarehouseOptions 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={displayBatchOptions}
|
||||
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
|
||||
options={displayBatchOptions 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}
|
||||
|
||||
Reference in New Issue
Block a user