feat: API調整訂單與販賣機訂單同步強制使用warehouse_code,更新API對接文件,及優化生產與配方模組UI顯示
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s

This commit is contained in:
2026-03-03 14:28:15 +08:00
parent 58bd995cd8
commit 183583c739
19 changed files with 486 additions and 89 deletions

View File

@@ -124,7 +124,7 @@ export function SearchableSelect({
setOpen(false);
}}
disabled={option.disabled}
className="cursor-pointer"
className="cursor-pointer group"
>
<Check
className={cn(
@@ -137,7 +137,7 @@ export function SearchableSelect({
<div className="flex items-center justify-between flex-1">
<span>{option.label}</span>
{option.sublabel && (
<span className="text-xs text-muted-foreground ml-2">
<span className="text-xs text-muted-foreground ml-2 group-data-[selected=true]:text-white">
{option.sublabel}
</span>
)}

View File

@@ -48,7 +48,9 @@ interface InventoryOption {
base_unit_name?: string;
large_unit_id?: number;
large_unit_name?: string;
purchase_unit_id?: number;
conversion_rate?: number;
unit_cost?: number;
}
interface BomItem {
@@ -73,6 +75,8 @@ interface BomItem {
ui_large_unit_name?: string;
ui_base_unit_id?: number;
ui_large_unit_id?: number;
ui_purchase_unit_id?: number;
ui_unit_cost?: number;
}
interface Props {
@@ -203,7 +207,10 @@ export default function Create({ products, warehouses }: Props) {
// 單位與轉換率
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_purchase_unit_id = inv.purchase_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_unit_cost = inv.unit_cost || 0;
// 預設單位
item.ui_selected_unit = 'base';
@@ -249,7 +256,7 @@ export default function Create({ products, warehouses }: Props) {
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', String(yieldQty));
setData('output_quantity', formatQuantity(yieldQty));
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0");
@@ -269,7 +276,7 @@ export default function Create({ products, warehouses }: Props) {
ui_product_name: item.product_name,
ui_batch_number: "",
ui_available_qty: 0,
ui_input_quantity: String(calculatedQty),
ui_input_quantity: formatQuantity(calculatedQty),
ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id,
@@ -279,7 +286,7 @@ export default function Create({ products, warehouses }: Props) {
setBomItems(newBomItems);
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${yieldQty}`
description: `標準產量: ${formatQuantity(yieldQty)}`
});
};
@@ -380,6 +387,24 @@ export default function Create({ products, warehouses }: Props) {
submit('completed');
};
const getBomItemUnitCost = (item: BomItem) => {
if (!item.ui_unit_cost) return 0;
let cost = Number(item.ui_unit_cost);
// Check if selected unit is large_unit or purchase_unit
if (item.unit_id && (String(item.unit_id) === String(item.ui_large_unit_id) || String(item.unit_id) === String(item.ui_purchase_unit_id))) {
cost = cost * Number(item.ui_conversion_rate || 1);
}
return cost;
};
const totalEstimatedCost = bomItems.reduce((sum, item) => {
if (!item.ui_input_quantity || !item.ui_unit_cost) return sum;
const inputQty = parseFloat(item.ui_input_quantity || '0');
const unitCost = getBomItemUnitCost(item);
return sum + (unitCost * inputQty);
}, 0);
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
@@ -529,10 +554,12 @@ export default function Create({ products, warehouses }: Props) {
<TableRow className="bg-gray-50/50">
<TableHead className="w-[18%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[30%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[12%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -560,7 +587,7 @@ export default function Create({ products, warehouses }: Props) {
.map((inv: InventoryOption) => ({
label: inv.batch_number,
value: String(inv.id),
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
sublabel: `(存:${formatQuantity(inv.quantity)} | 效:${inv.expiry_date || '無'})`
}));
return (
@@ -638,6 +665,19 @@ export default function Create({ products, warehouses }: Props) {
</div>
</TableCell>
{/* 5. 預估單價 */}
<TableCell className="align-top text-right">
<div className="h-9 flex items-center justify-end px-1 text-sm text-gray-600 font-medium">
{getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
{/* 6. 成本小計 */}
<TableCell className="align-top text-right">
<div className="h-9 flex items-center justify-end px-1 text-sm font-bold text-gray-900">
{(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
<TableCell className="align-top">
<Button
@@ -660,9 +700,27 @@ export default function Create({ products, warehouses }: Props) {
)}
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
{/* 生產單預估總成本區塊 */}
<div className="mt-6 flex justify-end">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {Number(data.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} )</span>
</span>
<span className="text-md font-bold text-primary-main">
{(parseFloat(data.output_quantity) > 0 ? totalEstimatedCost / parseFloat(data.output_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
</div>
</div>
</div>
</form>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}

View File

@@ -51,7 +51,9 @@ interface InventoryOption {
base_unit_name?: string;
large_unit_id?: number;
large_unit_name?: string;
purchase_unit_id?: number;
conversion_rate?: number;
unit_cost?: number;
}
interface BomItem {
@@ -76,7 +78,9 @@ interface BomItem {
ui_large_unit_name?: string;
ui_base_unit_id?: number;
ui_large_unit_id?: number;
ui_purchase_unit_id?: number;
ui_product_code?: string;
ui_unit_cost?: number;
}
interface ProductionOrderItem {
@@ -165,6 +169,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
ui_batch_number: item.inventory?.batch_number,
ui_available_qty: item.inventory?.quantity,
ui_expiry_date: item.inventory?.expiry_date,
ui_unit_cost: 0,
}));
const [bomItems, setBomItems] = useState<BomItem[]>(initialBomItems);
@@ -203,7 +208,9 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
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_purchase_unit_id: inv.purchase_unit_id,
ui_conversion_rate: inv.conversion_rate || 1,
ui_unit_cost: inv.unit_cost || 0,
};
}
}
@@ -277,7 +284,9 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
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_purchase_unit_id = inv.purchase_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_unit_cost = inv.unit_cost || 0;
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
@@ -365,6 +374,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
submit('draft');
};
const getBomItemUnitCost = (item: BomItem) => {
if (!item.ui_unit_cost) return 0;
let cost = Number(item.ui_unit_cost);
// Check if selected unit is large_unit or purchase_unit
if (item.unit_id && (String(item.unit_id) === String(item.ui_large_unit_id) || String(item.unit_id) === String(item.ui_purchase_unit_id))) {
cost = cost * Number(item.ui_conversion_rate || 1);
}
return cost;
};
const totalEstimatedCost = bomItems.reduce((sum, item) => {
if (!item.ui_input_quantity || !item.ui_unit_cost) return sum;
const inputQty = parseFloat(item.ui_input_quantity || '0');
const unitCost = getBomItemUnitCost(item);
return sum + (unitCost * inputQty);
}, 0);
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title={`編輯生產單 - ${productionOrder.code}`} />
@@ -492,10 +519,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<TableRow className="bg-gray-50/50">
<TableHead className="w-[18%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[30%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[12%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -523,7 +552,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
.map((inv: InventoryOption) => ({
label: inv.batch_number,
value: String(inv.id),
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
sublabel: `(存:${formatQuantity(inv.quantity)} | 效:${inv.expiry_date || '無'})`
}));
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
@@ -598,6 +627,18 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
</div>
</TableCell>
<TableCell className="align-top text-right">
<div className="h-9 flex items-center justify-end px-1 text-sm text-gray-600 font-medium">
{getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
<TableCell className="align-top text-right">
<div className="h-9 flex items-center justify-end px-1 text-sm font-bold text-gray-900">
{(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
<TableCell className="align-top">
<Button
type="button"
@@ -621,8 +662,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
<div className="mt-6 flex justify-end">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {Number(data.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} )</span>
</span>
<span className="text-md font-bold text-primary-main">
{(parseFloat(data.output_quantity) > 0 ? totalEstimatedCost / parseFloat(data.output_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
</div>
</div>
</form>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}

View File

@@ -36,6 +36,8 @@ interface ProductionOrder {
production_date: string;
status: 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled';
created_at: string;
estimated_total_cost?: number;
estimated_unit_cost?: number;
}
interface Props {
@@ -201,6 +203,8 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
@@ -210,7 +214,7 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
<TableBody>
{productionOrders.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-32 text-center text-gray-500">
<TableCell colSpan={10} className="h-32 text-center text-gray-500">
<div className="flex flex-col items-center justify-center gap-2">
<Factory className="h-10 w-10 text-gray-300" />
<p></p>
@@ -239,6 +243,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
<TableCell className="text-right font-medium">
{formatQuantity(order.output_quantity)}
</TableCell>
<TableCell className="text-right font-medium text-primary-main">
{Number(order.estimated_unit_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right font-medium text-gray-700">
{Number(order.estimated_total_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-gray-600">
{order.warehouse?.name || '-'}
</TableCell>

View File

@@ -25,6 +25,16 @@ interface RecipeDetailModalProps {
export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: RecipeDetailModalProps) {
if (!isOpen) return null;
const getUnitCost = (product: any, unitId: string | number) => {
if (!product || !product.cost_price) return 0;
let cost = Number(product.cost_price);
if (String(unitId) === String(product.large_unit_id) || String(unitId) === String(product.purchase_unit_id)) {
cost = cost * Number(product.conversion_rate || 1);
}
return cost;
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0">
@@ -92,7 +102,7 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">
{Number(recipe.yield_quantity).toLocaleString()} {recipe.product?.base_unit?.name || '份'}
{Number(recipe.yield_quantity).toLocaleString(undefined, { maximumFractionDigits: 4 })} {recipe.product?.base_unit?.name || '份'}
</TableCell>
</TableRow>
{recipe.description && (
@@ -120,30 +130,42 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
<TableHead> / </TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipe.items?.length > 0 ? (
recipe.items.map((item: any, index: number) => (
<TableRow key={index} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="text-gray-900">{item.product?.name || 'Unknown'}</span>
<span className="text-xs text-gray-400">{item.product?.code}</span>
</div>
</TableCell>
<TableCell className="text-right font-medium text-gray-900">
{Number(item.quantity).toLocaleString()}
</TableCell>
<TableCell className="text-gray-600">
{item.unit?.name || '-'}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.remark || '-'}
</TableCell>
</TableRow>
))
recipe.items.map((item: any, index: number) => {
const unitCost = item.product ? getUnitCost(item.product, item.unit_id) : 0;
const subtotal = unitCost * Number(item.quantity);
return (
<TableRow key={index} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="text-gray-900">{item.product?.name || 'Unknown'}</span>
<span className="text-xs text-gray-400">{item.product?.code}</span>
</div>
</TableCell>
<TableCell className="text-right font-medium text-gray-900">
{Number(item.quantity).toLocaleString(undefined, { maximumFractionDigits: 4 })}
</TableCell>
<TableCell className="text-gray-600">
{item.unit?.name || '-'}
</TableCell>
<TableCell className="text-right text-gray-600">
{unitCost.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right font-medium text-gray-900">
{subtotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.remark || '-'}
</TableCell>
</TableRow>
)
})
) : (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-gray-500">
@@ -154,6 +176,20 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
</TableBody>
</Table>
</div>
<div className="mt-4 flex justify-end">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{(recipe.items || []).reduce((sum: number, item: any) => sum + (getUnitCost(item.product, item.unit_id) * Number(item.quantity)), 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {recipe.yield_quantity} )</span>
</span>
<span className="text-md font-bold text-primary-main">{(Number(recipe.yield_quantity) > 0 ? (recipe.items || []).reduce((sum: number, item: any) => sum + (getUnitCost(item.product, item.unit_id) * Number(item.quantity)), 0) / Number(recipe.yield_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
</div>
</div>
</div>
</div>
) : (

View File

@@ -20,6 +20,9 @@ interface Product {
code: string;
base_unit_id?: number;
large_unit_id?: number;
purchase_unit_id?: number;
cost_price?: number;
conversion_rate?: number;
}
interface Unit {
@@ -108,6 +111,25 @@ export default function RecipeCreate({ products, units }: Props) {
post(route('recipes.store'));
};
const getUnitCost = (productId: string, unitId: string) => {
const product = products.find(p => String(p.id) === productId);
if (!product || !product.cost_price) return 0;
let cost = Number(product.cost_price);
// Check if selected unit is large_unit or purchase_unit
if (unitId && (String(unitId) === String(product.large_unit_id) || String(unitId) === String(product.purchase_unit_id))) {
cost = cost * Number(product.conversion_rate || 1);
}
return cost;
};
const totalCost = data.items.reduce((sum, item) => {
const unitCost = getUnitCost(item.product_id, item.unit_id);
return sum + (unitCost * Number(item.quantity || 0));
}, 0);
const unitCost = Number(data.yield_quantity) > 0 ? totalCost / Number(data.yield_quantity) : 0;
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "新增", isPage: true }])}>
<Head title="新增配方" />
@@ -233,10 +255,12 @@ export default function RecipeCreate({ products, units }: Props) {
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[35%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[30%]"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
@@ -272,10 +296,23 @@ export default function RecipeCreate({ products, units }: Props) {
className="text-right"
/>
</TableCell>
<TableCell className="align-middle">
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
</div>
<TableCell className="align-top">
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id)
}))}
placeholder="單位"
className="w-full"
/>
</TableCell>
<TableCell className="align-middle text-right text-sm text-gray-600">
{getUnitCost(item.product_id, item.unit_id).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="align-middle text-right font-medium text-gray-900">
{(getUnitCost(item.product_id, item.unit_id) * Number(item.quantity || 0)).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="align-top">
<Input
@@ -300,6 +337,23 @@ export default function RecipeCreate({ products, units }: Props) {
</TableBody>
</Table>
</div>
{/* 配方成本總計區塊 */}
<div className="mt-6 flex justify-end">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{totalCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {Number(data.yield_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} )</span>
</span>
<span className="text-md font-bold text-primary-main">{unitCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
</div>
</div>
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
</div>

View File

@@ -20,6 +20,9 @@ interface Product {
code: string;
base_unit_id?: number;
large_unit_id?: number;
purchase_unit_id?: number;
cost_price?: number;
conversion_rate?: number;
}
interface Unit {
@@ -73,10 +76,10 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
code: recipe.code,
name: recipe.name,
description: recipe.description || "",
yield_quantity: String(recipe.yield_quantity),
yield_quantity: String(Number(recipe.yield_quantity || 0)),
items: recipe.items.map(item => ({
product_id: String(item.product_id),
quantity: String(item.quantity),
quantity: String(Number(item.quantity || 0)),
unit_id: String(item.unit_id),
remark: item.remark || "",
ui_product_name: item.product?.name,
@@ -133,6 +136,25 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
put(route('recipes.update', recipe.id));
};
const getUnitCost = (productId: string, unitId: string) => {
const product = products.find(p => String(p.id) === productId);
if (!product || !product.cost_price) return 0;
let cost = Number(product.cost_price);
// Check if selected unit is large_unit or purchase_unit
if (unitId && (String(unitId) === String(product.large_unit_id) || String(unitId) === String(product.purchase_unit_id))) {
cost = cost * Number(product.conversion_rate || 1);
}
return cost;
};
const totalCost = data.items.reduce((sum, item) => {
const unitCost = getUnitCost(item.product_id, item.unit_id);
return sum + (unitCost * Number(item.quantity || 0));
}, 0);
const unitCost = Number(data.yield_quantity) > 0 ? totalCost / Number(data.yield_quantity) : 0;
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes", [{ label: "編輯", isPage: true }])}>
<Head title="編輯配方" />
@@ -258,10 +280,12 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[35%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[30%]"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[10%] text-right"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
@@ -297,10 +321,23 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
className="text-right"
/>
</TableCell>
<TableCell className="align-middle">
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
</div>
<TableCell className="align-top">
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id)
}))}
placeholder="單位"
className="w-full"
/>
</TableCell>
<TableCell className="align-middle text-right text-sm text-gray-600">
{getUnitCost(item.product_id, item.unit_id).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="align-middle text-right font-medium text-gray-900">
{(getUnitCost(item.product_id, item.unit_id) * Number(item.quantity || 0)).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="align-top">
<Input
@@ -325,6 +362,23 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
</TableBody>
</Table>
</div>
{/* 配方成本總計區塊 */}
<div className="mt-6 flex justify-end">
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{totalCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {Number(data.yield_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} )</span>
</span>
<span className="text-md font-bold text-primary-main">{unitCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
</div>
</div>
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
</div>

View File

@@ -39,6 +39,8 @@ interface Recipe {
is_active: boolean;
description: string;
updated_at: string;
estimated_total_cost?: number;
estimated_unit_cost?: number;
}
interface Props {
@@ -187,6 +189,8 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
@@ -195,7 +199,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<TableBody>
{recipes.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-gray-500">
<TableCell colSpan={9} className="h-32 text-center text-gray-500">
<div className="flex flex-col items-center justify-center gap-2">
<BookOpen className="h-10 w-10 text-gray-300" />
<p></p>
@@ -227,7 +231,13 @@ export default function RecipeIndex({ recipes, filters }: Props) {
) : '-'}
</TableCell>
<TableCell className="text-right font-medium">
{recipe.yield_quantity}
{Number(recipe.yield_quantity).toLocaleString(undefined, { maximumFractionDigits: 4 })}
</TableCell>
<TableCell className="text-right font-medium text-primary-main">
{Number(recipe.estimated_unit_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right font-medium text-gray-700">
{Number(recipe.estimated_total_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-center">
{recipe.is_active ? (
@@ -338,6 +348,6 @@ export default function RecipeIndex({ recipes, filters }: Props) {
isLoading={isViewLoading}
/>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}

View File

@@ -36,6 +36,7 @@ interface ProductionOrderItem {
origin_country: string | null;
product: { id: number; name: string; code: string } | null;
warehouse?: { id: number; name: string } | null;
unit_cost?: number;
source_purchase_order?: {
id: number;
code: string;
@@ -109,6 +110,13 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
const canCancel = hasPermission('production_orders.cancel');
const canEdit = hasPermission('production_orders.edit');
// 計算總預估成本
const totalEstimatedCost = productionOrder.items.reduce((sum, item) => {
const qty = Number(item.quantity_used) || 0;
const cost = Number(item.inventory?.unit_cost) || 0;
return sum + (qty * cost);
}, 0);
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
<Head title={`生產單 ${productionOrder.code}`} />
@@ -368,6 +376,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使</TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-center"></TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使</TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-right"></TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-right"></TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none"></TableHead>
</TableRow>
</TableHeader>
@@ -404,6 +414,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
)}
</div>
</TableCell>
<TableCell className="px-6 py-5 text-right">
<div className="text-grey-0 font-medium">
{Number(item.inventory?.unit_cost || 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
<TableCell className="px-6 py-5 text-right">
<div className="font-bold text-grey-900">
{(Number(item.quantity_used) * Number(item.inventory?.unit_cost || 0)).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</div>
</TableCell>
<TableCell className="px-6 py-5">
{item.inventory?.source_purchase_order ? (
<div className="group flex flex-col">
@@ -428,6 +448,22 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
))}
</TableBody>
</Table>
<div className="bg-gray-50 p-6 border-t border-grey-4 flex justify-end">
<div className="min-w-[300px]">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-bold text-gray-900">{totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200">
<span className="text-sm font-medium text-gray-700">
<span className="text-xs text-gray-500 ml-1">( {Number(productionOrder.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} )</span>
</span>
<span className="text-md font-bold text-primary-main">
{(productionOrder.output_quantity > 0 ? totalEstimatedCost / productionOrder.output_quantity : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
</div>
</div>
</div>
)}
</div>