/** * 建立生產工單頁面 * 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量 */ import { useState, useEffect } from "react"; import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react"; import { formatQuantity } from "@/lib/utils"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { router, useForm, Head, Link } from "@inertiajs/react"; import { toast } from "sonner"; import { getBreadcrumbs } from "@/utils/breadcrumb"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; import { Textarea } from "@/Components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; interface Product { id: number; name: string; code: string; base_unit?: { id: number; name: string } | null; } interface Warehouse { id: number; name: string; } interface InventoryOption { id: number; product_id: number; product_name: string; product_code: string; warehouse_id: number; warehouse_name: string; batch_number: string; box_number: string | null; quantity: number; arrival_date: string | null; expiry_date: string | null; unit_name: string | null; base_unit_id?: number; base_unit_name?: string; large_unit_id?: number; large_unit_name?: string; conversion_rate?: number; } interface BomItem { // 後端必填 inventory_id: string; // 所選庫存記錄 ID(特定批號) quantity_used: string; // 轉換後的最終數量(基本單位) unit_id: string; // 單位 ID(通常為基本單位 ID) // UI 狀態 ui_warehouse_id: string; // 來源倉庫 ui_product_id: string; // 批號列表篩選 ui_input_quantity: string; // 使用者輸入數量 ui_selected_unit: 'base' | 'large'; // 使用者選擇單位 // UI 輔助 / 快取 ui_product_name?: string; ui_batch_number?: string; ui_available_qty?: number; ui_expiry_date?: string; ui_conversion_rate?: number; ui_base_unit_name?: string; ui_large_unit_name?: string; ui_base_unit_id?: number; ui_large_unit_id?: number; } interface Props { products: Product[]; warehouses: Warehouse[]; } export default function Create({ products, warehouses }: Props) { const [selectedWarehouse, setSelectedWarehouse] = useState(""); // 產出倉庫 // 快取對照表:product_id -> inventories across warehouses const [productInventoryMap, setProductInventoryMap] = useState>({}); const [loadingProducts, setLoadingProducts] = useState>({}); const [bomItems, setBomItems] = useState([]); // 多配方支援 const [recipes, setRecipes] = useState([]); const [selectedRecipeId, setSelectedRecipeId] = useState(""); const { data, setData, processing, errors } = useForm({ product_id: "", warehouse_id: "", output_quantity: "", // 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄 remark: "", items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], }); // 獲取特定商品在各倉庫的庫存分佈 const fetchProductInventories = async (productId: string) => { if (!productId) return; // 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數 if (loadingProducts[productId]) return; setLoadingProducts(prev => ({ ...prev, [productId]: true })); try { const res = await fetch(route('api.production.products.inventories', productId)); const data = await res.json(); setProductInventoryMap(prev => ({ ...prev, [productId]: data })); } catch (e) { console.error(e); } finally { setLoadingProducts(prev => ({ ...prev, [productId]: false })); } }; // 同步 warehouse_id 到 form data (Output) useEffect(() => { setData('warehouse_id', selectedWarehouse); }, [selectedWarehouse]); // 新增 BOM 項目 const addBomItem = () => { setBomItems([...bomItems, { inventory_id: "", quantity_used: "", unit_id: "", ui_warehouse_id: "", ui_product_id: "", ui_input_quantity: "", ui_selected_unit: 'base', }]); }; // 移除 BOM 項目 const removeBomItem = (index: number) => { setBomItems(bomItems.filter((_, i) => i !== index)); }; // 更新 BOM 項目邏輯 const updateBomItem = (index: number, field: keyof BomItem, value: any) => { const updated = [...bomItems]; const item = { ...updated[index], [field]: value }; // 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位 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"; // 清除 cache 資訊 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) { 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. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量) if (field === 'ui_warehouse_id') { item.inventory_id = ""; // 不重置數量 // item.quantity_used = ""; // 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 = productInventoryMap[item.ui_product_id] || []; const inv = currentOptions.find(i => String(i.id) === value); if (inv) { 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.unit_name || ''; item.ui_base_unit_id = inv.base_unit_id; item.ui_conversion_rate = inv.conversion_rate || 1; // 預設單位 item.ui_selected_unit = 'base'; item.unit_id = String(inv.base_unit_id || ''); // 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方) if (!item.ui_input_quantity) { item.ui_input_quantity = formatQuantity(inv.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); item.unit_id = String(item.ui_base_unit_id || ''); } else { item.quantity_used = String(inputQty); item.unit_id = String(item.ui_base_unit_id || ''); } } updated[index] = item; setBomItems(updated); }; // 同步 BOM items 到表單 data useEffect(() => { setData('items', bomItems.map(item => ({ inventory_id: Number(item.inventory_id), quantity_used: Number(item.quantity_used), unit_id: item.unit_id ? Number(item.unit_id) : null }))); }, [bomItems]); // 應用配方到表單 (獨立函式) const applyRecipe = (recipe: any) => { if (!recipe || !recipe.items) return; const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1; // 自動帶入配方標準產量 setData('output_quantity', String(yieldQty)); const newBomItems: BomItem[] = recipe.items.map((item: any) => { const baseQty = parseFloat(item.quantity || "0"); 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: "", ui_product_id: String(item.product_id), ui_product_name: item.product_name, ui_batch_number: "", ui_available_qty: 0, ui_input_quantity: String(calculatedQty), ui_selected_unit: 'base', ui_base_unit_name: item.unit_name, ui_base_unit_id: item.unit_id, ui_conversion_rate: 1, }; }); setBomItems(newBomItems); toast.success(`已自動載入配方: ${recipe.name}`, { description: `標準產量: ${yieldQty} 份` }); }; // 當手動切換配方時 useEffect(() => { if (!selectedRecipeId) return; const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId); if (targetRecipe) { applyRecipe(targetRecipe); } }, [selectedRecipeId]); // 自動產生成品批號與載入配方 useEffect(() => { if (!data.product_id) return; // 2. 自動載入配方列表 const fetchRecipes = async () => { try { // 改為抓取所有配方 const res = await fetch(route('api.production.recipes.by-product', data.product_id)); const recipesData = await res.json(); if (Array.isArray(recipesData) && recipesData.length > 0) { setRecipes(recipesData); // 預設選取最新的 (第一個) const latest = recipesData[0]; setSelectedRecipeId(String(latest.id)); } else { // 若無配方 setRecipes([]); setSelectedRecipeId(""); setBomItems([]); // 清空 BOM } } catch (e) { console.error("Failed to fetch recipes", e); setRecipes([]); setBomItems([]); } }; fetchRecipes(); }, [data.product_id]); // 當生產數量變動時,如果是從配方載入的,則按比例更新用量 useEffect(() => { if (bomItems.length > 0 && data.output_quantity) { // 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號 // 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾 // 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性 } }, [data.output_quantity]); // 提交表單 const submit = (status: 'draft' | 'completed') => { // 驗證(簡單前端驗證,完整驗證在後端) if (status === 'completed') { const missingFields = []; if (!data.product_id) missingFields.push('成品商品'); if (!data.output_quantity) missingFields.push('生產數量'); if (!selectedWarehouse) missingFields.push('預計入庫倉庫'); if (bomItems.length === 0) missingFields.push('原物料明細'); if (missingFields.length > 0) { toast.error("請填寫必要欄位", { description: `缺漏:${missingFields.join('、')}` }); return; } } // 轉換 BOM items 格式 const formattedItems = bomItems .filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) .map(item => ({ inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null, quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0, unit_id: item.unit_id ? parseInt(item.unit_id) : null, })); // 使用 router.post 提交完整資料 router.post(route('production-orders.store'), { ...data, warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, items: formattedItems, status: status, }, { onError: (errors) => { const errorCount = Object.keys(errors).length; toast.error("建立失敗,請檢查表單", { description: `共有 ${errorCount} 個欄位有誤,請修正後再試` }); } }); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); submit('completed'); }; return (

建立生產工單

建立新的生產排程,選擇原物料並記錄產出

{/* 成品資訊 */}

成品資訊

setData('product_id', v)} options={products.map(p => ({ label: `${p.name} (${p.code})`, value: String(p.id), }))} placeholder="選擇成品" className="w-full h-9" /> {errors.product_id &&

{errors.product_id}

} {/* 配方選擇 (放在成品商品底下) */} {recipes.length > 0 && (
切換將重置明細
({ label: `${r.name} (${r.code})`, value: String(r.id), }))} placeholder="選擇配方" className="w-full h-9" />
)}
setData('output_quantity', e.target.value)} placeholder="例如: 50" className="h-9 font-mono" /> {errors.output_quantity &&

{errors.output_quantity}

}
({ label: w.name, value: String(w.id), }))} placeholder="選擇倉庫" className="w-full h-9" /> {errors.warehouse_id &&

{errors.warehouse_id}

}