Files
star-erp/resources/js/Pages/Production/Edit.tsx
sky121113 106de4e945
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 修正庫存與撥補單邏輯並整合文件
1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
2026-01-26 14:59:24 +08:00

711 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 編輯生產工單頁面
* 僅限草稿狀態可編輯
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } 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 { 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 Unit {
id: number;
name: string;
}
interface InventoryOption {
id: number;
product_id: number;
product_name: string;
product_code: 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;
quantity_used: string;
unit_id: string;
// 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 ProductionOrderItem {
id: number;
production_order_id: number;
inventory_id: number;
quantity_used: number;
unit_id: number | null;
inventory?: {
product_id: number;
product?: {
name: string;
code: string;
base_unit?: { name: string };
};
batch_number: string;
quantity: number;
expiry_date?: string;
warehouse_id?: number;
};
unit?: {
name: string;
};
}
interface ProductionOrder {
id: number;
code: string;
product_id: number;
warehouse_id: number | null;
output_quantity: number;
output_batch_number: string;
output_box_count: string | null;
production_date: string;
expiry_date: string | null;
remark: string | null;
status: string;
items: ProductionOrderItem[];
product?: Product;
warehouse?: Warehouse;
}
interface Props {
productionOrder: ProductionOrder;
products: Product[];
warehouses: Warehouse[];
units: Unit[];
}
export default function ProductionEdit({ productionOrder, products, warehouses }: Props) {
// 日期格式轉換輔助函數
const formatDate = (dateValue: string | null | undefined): string => {
if (!dateValue) return '';
// 處理可能的 ISO 格式或 YYYY-MM-DD 格式
const date = new Date(dateValue);
if (isNaN(date.getTime())) return dateValue;
return date.toISOString().split('T')[0];
};
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
); // 產出倉庫
// 快取對照表warehouse_id -> inventories
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
// 獲取倉庫資料的輔助函式
const fetchWarehouseInventory = async (warehouseId: string) => {
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
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 }));
}
};
// 初始化 BOM items
const initialBomItems: BomItem[] = productionOrder.items.map(item => ({
inventory_id: String(item.inventory_id),
quantity_used: String(item.quantity_used),
unit_id: item.unit_id ? String(item.unit_id) : "",
// UI Initial State (復原)
ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "",
ui_product_id: item.inventory ? String(item.inventory.product_id) : "",
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
ui_selected_unit: 'base',
// UI 輔助
ui_product_name: item.inventory?.product?.name,
ui_batch_number: item.inventory?.batch_number,
ui_available_qty: item.inventory?.quantity,
ui_expiry_date: item.inventory?.expiry_date,
}));
const [bomItems, setBomItems] = useState<BomItem[]>(initialBomItems);
const { data, setData, processing, errors } = useForm({
product_id: String(productionOrder.product_id),
warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "",
output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "",
output_batch_number: productionOrder.output_batch_number || "",
output_box_count: productionOrder.output_box_count || "",
production_date: formatDate(productionOrder.production_date) || new Date().toISOString().split('T')[0],
expiry_date: formatDate(productionOrder.expiry_date),
remark: productionOrder.remark || "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 初始化載入既有 BOM 的來源倉庫資料
useEffect(() => {
initialBomItems.forEach(item => {
if (item.ui_warehouse_id) {
fetchWarehouseInventory(item.ui_warehouse_id);
}
});
}, []);
// 當 inventoryOptions (Map) 載入後,更新現有 BOM items 的詳細資訊 (如單位、轉換率)
// 監聽 inventoryMap 變更
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);
if (inv) {
return {
...item,
ui_product_id: String(inv.product_id),
ui_product_name: inv.product_name,
ui_batch_number: inv.batch_number,
ui_available_qty: inv.quantity,
ui_expiry_date: inv.expiry_date || '',
ui_base_unit_name: inv.base_unit_name || inv.unit_name || '',
ui_large_unit_name: inv.large_unit_name || '',
ui_base_unit_id: inv.base_unit_id,
ui_large_unit_id: inv.large_unit_id,
ui_conversion_rate: inv.conversion_rate || 1,
};
}
}
return item;
}));
}, [inventoryMap]);
// 同步 warehouse_id 到 form data
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 };
// 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";
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. 當選擇批號變更時
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. 計算最終數量
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 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;
}
}
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.put(route('production-orders.update', productionOrder.id), {
...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={`編輯生產單 - ${productionOrder.code}`} />
<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} 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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={data.product_id}
onValueChange={(v) => 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 && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
type="number"
step="0.01"
value={data.output_quantity}
onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50"
className="h-9"
/>
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="例如: AB-TW-20260122-01"
className="h-9 font-mono"
/>
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Input
value={data.output_box_count}
onChange={(e) => setData('output_box_count', e.target.value)}
placeholder="例如: 10"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.production_date}
onChange={(e) => setData('production_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
{errors.production_date && <p className="text-red-500 text-xs mt-1">{errors.production_date}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.expiry_date}
onChange={(e) => setData('expiry_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={selectedWarehouse}
onValueChange={setSelectedWarehouse}
options={warehouses.map(w => ({
label: w.name,
value: String(w.id),
}))}
placeholder="選擇倉庫"
className="w-full h-9"
/>
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
</div>
</div>
<div className="mt-4 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Textarea
value={data.remark}
onChange={(e) => setData('remark', e.target.value)}
placeholder="生產備註..."
rows={2}
className="resize-none"
/>
</div>
</div>
{/* BOM 原物料明細 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">使 (BOM)</h2>
<Button
type="button"
variant="outline"
onClick={addBomItem}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{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
</div>
)}
{bomItems.length > 0 && (
<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] || [];
const uniqueProductOptions = Array.from(new Map(
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
).values());
// 在獲取前初始狀態的備案
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
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)
}));
// 備案
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}
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={displayBatchOptions}
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>
</form>
</div>
</AuthenticatedLayout>
);
}