生產工單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

@@ -4,15 +4,18 @@
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, AlertTriangle, Calendar } from 'lucide-react';
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
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 { Link } from "@inertiajs/react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface Product {
id: number;
@@ -42,16 +45,35 @@ interface InventoryOption {
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;
quantity_used: string;
unit_id: string;
// 顯示用
product_name?: string;
batch_number?: string;
available_qty?: number;
// Backend required
inventory_id: string; // The selected inventory record ID (Specific Batch)
quantity_used: string; // The converted final quantity (Base Unit)
unit_id: string; // The unit ID (Base Unit ID usually)
// UI State
ui_warehouse_id: string; // Source Warehouse
ui_product_id: string; // Filter for batch list
ui_input_quantity: string; // User typed quantity
ui_selected_unit: 'base' | 'large'; // User selected unit
// UI Helpers / Cache
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 {
@@ -60,10 +82,12 @@ interface Props {
units: Unit[];
}
export default function ProductionCreate({ products, warehouses, units }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
const [inventoryOptions, setInventoryOptions] = useState<InventoryOption[]>([]);
const [isLoadingInventory, setIsLoadingInventory] = useState(false);
export default function ProductionCreate({ products, warehouses }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // Output Warehouse
// Cache map: warehouse_id -> inventories
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
const [bomItems, setBomItems] = useState<BomItem[]>([]);
const { data, setData, processing, errors } = useForm({
@@ -78,23 +102,23 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 當選擇倉庫時,載入該倉庫的可用庫存
useEffect(() => {
if (selectedWarehouse) {
setIsLoadingInventory(true);
fetch(route('api.production.warehouses.inventories', selectedWarehouse))
.then(res => res.json())
.then((inventories: InventoryOption[]) => {
setInventoryOptions(inventories);
setIsLoadingInventory(false);
})
.catch(() => setIsLoadingInventory(false));
} else {
setInventoryOptions([]);
}
}, [selectedWarehouse]);
// Helper to fetch warehouse data
const fetchWarehouseInventory = async (warehouseId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
// 同步 warehouse_id 到 form data
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
try {
const res = await fetch(route('api.production.warehouses.inventories', warehouseId));
const data = await res.json();
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
} catch (e) {
console.error(e);
} finally {
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false }));
}
};
// 同步 warehouse_id 到 form data (Output)
useEffect(() => {
setData('warehouse_id', selectedWarehouse);
}, [selectedWarehouse]);
@@ -105,6 +129,10 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
inventory_id: "",
quantity_used: "",
unit_id: "",
ui_warehouse_id: "",
ui_product_id: "",
ui_input_quantity: "",
ui_selected_unit: 'base',
}]);
};
@@ -113,45 +141,167 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
setBomItems(bomItems.filter((_, i) => i !== index));
};
// 更新 BOM 項目
const updateBomItem = (index: number, field: keyof BomItem, value: string) => {
// 更新 BOM 項目邏輯
const updateBomItem = (index: number, field: keyof BomItem, value: any) => {
const updated = [...bomItems];
updated[index] = { ...updated[index], [field]: value };
const item = { ...updated[index], [field]: value };
// 如果選擇了庫存,自動填入顯示資訊
if (field === 'inventory_id' && value) {
const inv = inventoryOptions.find(i => String(i.id) === value);
if (inv) {
updated[index].product_name = inv.product_name;
updated[index].batch_number = inv.batch_number;
updated[index].available_qty = inv.quantity;
// 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. 當選擇商品變更時 -> 清空批號與相關資訊
if (field === 'ui_product_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;
}
// 2. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) {
const currentOptions = inventoryMap[item.ui_warehouse_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_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_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1;
// 預設單位
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
}
}
// 3. 計算最終數量 (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);
item.unit_id = String(item.ui_base_unit_id || '');
}
}
updated[index] = item;
setBomItems(updated);
};
// 產生成品批號建議
const generateBatchNumber = () => {
// 同步 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]);
// 自動產生成品批號(當選擇商品或日期變動時)
useEffect(() => {
if (!data.product_id) return;
const product = products.find(p => String(p.id) === data.product_id);
if (!product) return;
const date = data.production_date.replace(/-/g, '');
const suggested = `${product.code}-TW-${date}-01`;
setData('output_batch_number', suggested);
};
const datePart = data.production_date; // YYYY-MM-DD
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
// 呼叫 API 取得下一組流水號
// 複用庫存批號 API但這裡可能沒有選 warehouse所以用第一個預設
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
// Fallback若 API 失敗,使用預設 01
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}, [data.product_id, data.production_date]);
// 提交表單
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const submit = (status: 'draft' | 'completed') => {
// 驗證(簡單前端驗證,完整驗證在後端)
if (status === 'completed') {
const missingFields = [];
if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量');
if (!data.output_batch_number) missingFields.push('成品批號');
if (!data.production_date) missingFields.push('生產日期');
if (!selectedWarehouse) missingFields.push('入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm">{missingFields.join('、')}</span>
</div>
);
return;
}
}
// 轉換 BOM items 格式
const formattedItems = bomItems
.filter(item => item.inventory_id && item.quantity_used)
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
.map(item => ({
inventory_id: parseInt(item.inventory_id),
quantity_used: parseFloat(item.quantity_used),
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,
}));
@@ -159,33 +309,74 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
router.post(route('production-orders.store'), {
...data,
items: formattedItems,
status: status,
}, {
onError: (errors) => {
const errorCount = Object.keys(errors).length;
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm"> {errorCount} </span>
</div>
);
}
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
submit('completed');
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<div className="container mx-auto p-6 max-w-4xl">
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
onClick={() => router.get(route('production-orders.index'))}
className="p-2"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
使
</p>
<Toaster position="top-right" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('production-orders.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => submit('draft')}
disabled={processing}
variant="outline"
className="button-outlined-primary"
>
<Save className="mr-2 h-4 w-4" />
稿
</Button>
<Button
onClick={() => submit('completed')}
disabled={processing}
className="button-filled-primary"
>
<Factory className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 成品資訊 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
@@ -220,23 +411,12 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="flex gap-2">
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="例如: AB-TW-20260121-01"
className="h-9 font-mono"
/>
<Button
type="button"
variant="outline"
onClick={generateBatchNumber}
disabled={!data.product_id}
className="h-9 button-outlined-primary shrink-0"
>
</Button>
</div>
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="選擇商品後自動產生"
className="h-9 font-mono"
/>
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
@@ -313,7 +493,6 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
type="button"
variant="outline"
onClick={addBomItem}
disabled={!selectedWarehouse}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
@@ -321,20 +500,7 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
</Button>
</div>
{!selectedWarehouse && (
<div className="text-center py-8 text-gray-500">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
</div>
)}
{selectedWarehouse && isLoadingInventory && (
<div className="text-center py-8 text-gray-500">
...
</div>
)}
{selectedWarehouse && !isLoadingInventory && bomItems.length === 0 && (
{bomItems.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Factory className="h-8 w-8 mx-auto mb-2 text-gray-300" />
BOM
@@ -342,99 +508,125 @@ export default function ProductionCreate({ products, warehouses, units }: Props)
)}
{bomItems.length > 0 && (
<div className="space-y-4">
{bomItems.map((item, index) => (
<div
key={index}
className="grid grid-cols-1 md:grid-cols-12 gap-3 items-end p-4 bg-gray-50/50 border border-gray-100 rounded-lg relative group"
>
<div className="md:col-span-5 space-y-1">
<Label className="text-xs font-medium text-grey-2"> ()</Label>
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={inventoryOptions.map(inv => ({
label: `${inv.product_name} - ${inv.batch_number} (庫存: ${inv.quantity})`,
value: String(inv.id),
}))}
placeholder="選擇原物料與批號"
className="w-full h-9"
/>
</div>
<div className="border rounded-lg overflow-hidden">
<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-[25%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItems.map((item, index) => {
// 取得此列已載入的 Inventory Options
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2">使</Label>
<div className="relative">
<Input
type="number"
step="0.0001"
value={item.quantity_used}
onChange={(e) => updateBomItem(index, 'quantity_used', e.target.value)}
placeholder="0.00"
className="h-9 pr-12"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">
</div>
</div>
{item.available_qty && (
<p className="text-xs text-gray-400 mt-1">: {item.available_qty.toLocaleString()}</p>
)}
</div>
// 過濾商品
const uniqueProductOptions = Array.from(new Map(
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
).values());
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2">/</Label>
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateBomItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id),
}))}
placeholder="選擇單位"
className="w-full h-9"
/>
</div>
// 過濾批號
const batchOptions = currentOptions
.filter(inv => String(inv.product_id) === item.ui_product_id)
.map(inv => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
value: String(inv.id)
}));
<div className="md:col-span-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeBomItem(index)}
className="button-outlined-error h-9 w-full md:w-9 p-0"
title="移除此項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
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}
placeholder="選擇商品"
className="w-full"
disabled={!item.ui_warehouse_id}
/>
</TableCell>
{/* 2. 選擇批號 */}
<TableCell className="align-top">
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={batchOptions}
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
className="w-full"
disabled={!item.ui_product_id}
/>
{item.inventory_id && (() => {
const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id);
if (selectedInv) return (
<div className="text-xs text-gray-500 mt-1">
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity}
</div>
);
return null;
})()}
</TableCell>
{/* 3. 輸入數量 */}
<TableCell className="align-top">
<Input
type="number"
step="1"
value={item.ui_input_quantity}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0"
className="h-9"
disabled={!item.inventory_id}
/>
</TableCell>
{/* 4. 選擇單位 */}
<TableCell className="align-top pt-3">
<span className="text-sm">{item.ui_base_unit_name}</span>
</TableCell>
<TableCell className="align-top">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeBomItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
{/* 提交按鈕 */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.get(route('production-orders.index'))}
className="h-10 px-6"
>
</Button>
<Button
type="submit"
disabled={processing || bomItems.length === 0}
className="gap-2 button-filled-primary h-10 px-8"
>
<Save className="h-4 w-4" />
{processing ? '處理中...' : '建立生產單'}
</Button>
</div>
</form>
</div>
</AuthenticatedLayout>