[FEAT] 新增生產工單實際產量欄位與 UI 規範

- 新增 database/migrations/tenant 實際產量與耗損原因
- ProductionOrder API 狀態推進與實際產量計算
- 完工入庫新增實際產出數量原生數字輸入框 (step=1)
- Create.tsx 補上前端資料驗證與狀態保護
- 建立並更新 UI 數字輸入框設計規範
This commit is contained in:
2026-03-10 15:32:52 +08:00
parent adf13410ba
commit 6ca0bafd60
8 changed files with 325 additions and 129 deletions

View File

@@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9` - **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。 - **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
## 11.6 日期顯示規範 (Date Display) ## 11.6 數字輸入框規範 (Numeric Inputs)
當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。
**必須使用原生 HTML5 數字輸入與屬性**
1. 使用 `<Input type="number" />` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。
2. 針對整數需求,固定加上 `step="1"` 屬性。
3. 視需求加上 `min``max` 控制上下限。
這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。
```tsx
// ✅ 正確:依賴原生行為
<Input
type="number"
step="1"
min="0"
max={outputQuantity}
value={actualOutputQuantity}
onChange={(e) => setActualOutputQuantity(e.target.value)}
className="h-9 w-24 text-center"
/>
// ❌ 錯誤:過度設計、浪費空間與破壞一致性
<div className="flex">
<Button><Minus /></Button>
<Input type="number" />
<Button><Plus /></Button>
</div>
```
## 11.7 日期顯示規範 (Date Display)
前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。 前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。

View File

@@ -137,12 +137,12 @@ class ProductionOrderController extends Controller
$rules = [ $rules = [
'product_id' => 'required', 'product_id' => 'required',
'status' => 'nullable|in:draft,completed', 'status' => 'nullable|in:draft,pending,completed',
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', 'warehouse_id' => 'required',
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', 'output_quantity' => 'required|numeric|min:0.01',
'items' => 'nullable|array', 'items' => 'nullable|array',
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', 'items.*.inventory_id' => 'required',
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', 'items.*.quantity_used' => 'required|numeric|min:0.0001',
]; ];
$validated = $request->validate($rules); $validated = $request->validate($rules);
@@ -159,7 +159,7 @@ class ProductionOrderController extends Controller
'production_date' => $request->production_date, 'production_date' => $request->production_date,
'expiry_date' => $request->expiry_date, 'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 'status' => $status ?: ProductionOrder::STATUS_DRAFT,
'remark' => $request->remark, 'remark' => $request->remark,
]); ]);
@@ -414,6 +414,19 @@ class ProductionOrderController extends Controller
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403); return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
} }
// 送審前的資料完整性驗證
if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) {
if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) {
return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」');
}
if (!$productionOrder->warehouse_id) {
return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」');
}
if ($productionOrder->items()->count() === 0) {
return back()->with('error', '送審工單前,請至少新增一項原物料明細');
}
}
DB::transaction(function () use ($newStatus, $productionOrder, $request) { DB::transaction(function () use ($newStatus, $productionOrder, $request) {
// 使用鎖定重新獲取單據,防止併發狀態修改 // 使用鎖定重新獲取單據,防止併發狀態修改
$productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first(); $productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first();
@@ -444,6 +457,8 @@ class ProductionOrderController extends Controller
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
$actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量
$lossReason = $request->input('loss_reason'); // 耗損原因
if (!$warehouseId) { if (!$warehouseId) {
throw new \Exception('必須選擇入庫倉庫'); throw new \Exception('必須選擇入庫倉庫');
@@ -451,8 +466,14 @@ class ProductionOrderController extends Controller
if (!$batchNumber) { if (!$batchNumber) {
throw new \Exception('必須提供成品批號'); throw new \Exception('必須提供成品批號');
} }
if (!$actualOutputQuantity || $actualOutputQuantity <= 0) {
throw new \Exception('實際產出數量必須大於 0');
}
if ($actualOutputQuantity > $productionOrder->output_quantity) {
throw new \Exception('實際產出數量不可大於預計產量');
}
// --- 新增:計算原物料投入總成本 --- // --- 計算原物料投入總成本 ---
$totalCost = 0; $totalCost = 0;
$items = $productionOrder->items()->with('inventory')->get(); $items = $productionOrder->items()->with('inventory')->get();
foreach ($items as $item) { foreach ($items as $item) {
@@ -461,23 +482,25 @@ class ProductionOrderController extends Controller
} }
} }
// 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤) // 單位成本以「實際產出數量」為分母,反映真實生產效率
$unitCost = $productionOrder->output_quantity > 0 $unitCost = $actualOutputQuantity > 0
? $totalCost / $productionOrder->output_quantity ? $totalCost / $actualOutputQuantity
: 0; : 0;
// --------------------------------
// 更新單據資訊:批號、效期與自動記錄生產日期 // 更新單據資訊:批號、效期、實際產量與耗損原因
$productionOrder->output_batch_number = $batchNumber; $productionOrder->output_batch_number = $batchNumber;
$productionOrder->expiry_date = $expiryDate; $productionOrder->expiry_date = $expiryDate;
$productionOrder->production_date = now()->toDateString(); $productionOrder->production_date = now()->toDateString();
$productionOrder->warehouse_id = $warehouseId; $productionOrder->warehouse_id = $warehouseId;
$productionOrder->actual_output_quantity = $actualOutputQuantity;
$productionOrder->loss_reason = $lossReason;
// 成品入庫數量改用「實際產出數量」
$this->inventoryService->createInventoryRecord([ $this->inventoryService->createInventoryRecord([
'warehouse_id' => $warehouseId, 'warehouse_id' => $warehouseId,
'product_id' => $productionOrder->product_id, 'product_id' => $productionOrder->product_id,
'quantity' => $productionOrder->output_quantity, 'quantity' => $actualOutputQuantity,
'unit_cost' => $unitCost, // 傳入計算後的單位成本 'unit_cost' => $unitCost,
'batch_number' => $batchNumber, 'batch_number' => $batchNumber,
'box_number' => $productionOrder->output_box_count, 'box_number' => $productionOrder->output_box_count,
'arrival_date' => now()->toDateString(), 'arrival_date' => now()->toDateString(),

View File

@@ -24,6 +24,8 @@ class ProductionOrder extends Model
'product_id', 'product_id',
'warehouse_id', 'warehouse_id',
'output_quantity', 'output_quantity',
'actual_output_quantity',
'loss_reason',
'output_batch_number', 'output_batch_number',
'output_box_count', 'output_box_count',
'production_date', 'production_date',
@@ -82,6 +84,7 @@ class ProductionOrder extends Model
'production_date' => 'date', 'production_date' => 'date',
'expiry_date' => 'date', 'expiry_date' => 'date',
'output_quantity' => 'decimal:2', 'output_quantity' => 'decimal:2',
'actual_output_quantity' => 'decimal:2',
]; ];
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
@@ -91,6 +94,8 @@ class ProductionOrder extends Model
'code', 'code',
'status', 'status',
'output_quantity', 'output_quantity',
'actual_output_quantity',
'loss_reason',
'output_batch_number', 'output_batch_number',
'production_date', 'production_date',
'remark' 'remark'

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 為生產工單新增「實際產出數量」與「耗損原因」欄位。
* 實際產出數量用於記錄完工時的真實產量(可能因耗損低於預計產量)。
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->decimal('actual_output_quantity', 10, 2)
->nullable()
->after('output_quantity')
->comment('實際產出數量(預設等於 output_quantity可於完工時調降');
$table->string('loss_reason', 255)
->nullable()
->after('actual_output_quantity')
->comment('耗損原因說明');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->dropColumn(['actual_output_quantity', 'loss_reason']);
});
}
};

View File

@@ -1,5 +1,6 @@
/** /**
* 生產工單完工入庫 - 選擇倉庫彈窗 * 生產工單完工入庫 - 選擇倉庫彈窗
* 含產出確認與耗損記錄功能
*/ */
import React from 'react'; import React from 'react';
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react"; import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
import { formatQuantity } from "@/lib/utils";
interface Warehouse { interface Warehouse {
id: number; id: number;
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
warehouseId: number; warehouseId: number;
batchNumber: string; batchNumber: string;
expiryDate: string; expiryDate: string;
actualOutputQuantity: number;
lossReason: string;
}) => void; }) => void;
warehouses: Warehouse[]; warehouses: Warehouse[];
processing?: boolean; processing?: boolean;
// 新增商品資訊以利產生批號 // 商品資訊用於產生批號
productCode?: string; productCode?: string;
productId?: number; productId?: number;
// 預計產量(用於耗損計算)
outputQuantity: number;
// 成品單位名稱
unitName?: string;
} }
export default function WarehouseSelectionModal({ export default function WarehouseSelectionModal({
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
processing = false, processing = false,
productCode, productCode,
productId, productId,
outputQuantity,
unitName = '',
}: WarehouseSelectionModalProps) { }: WarehouseSelectionModalProps) {
const [selectedId, setSelectedId] = React.useState<number | null>(null); const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [batchNumber, setBatchNumber] = React.useState<string>(""); const [batchNumber, setBatchNumber] = React.useState<string>("");
const [expiryDate, setExpiryDate] = React.useState<string>(""); const [expiryDate, setExpiryDate] = React.useState<string>("");
const [actualOutputQuantity, setActualOutputQuantity] = React.useState<string>("");
const [lossReason, setLossReason] = React.useState<string>("");
// 當開啟時,初始化實際產出數量為預計產量
React.useEffect(() => {
if (isOpen) {
setActualOutputQuantity(String(outputQuantity));
setLossReason("");
}
}, [isOpen, outputQuantity]);
// 當開啟時,嘗試產生成品批號 (若有資訊) // 當開啟時,嘗試產生成品批號 (若有資訊)
React.useEffect(() => { React.useEffect(() => {
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
} }
}, [isOpen, productCode, productId]); }, [isOpen, productCode, productId]);
// 計算耗損數量
const actualQty = parseFloat(actualOutputQuantity) || 0;
const lossQuantity = outputQuantity - actualQty;
const hasLoss = lossQuantity > 0;
const handleConfirm = () => { const handleConfirm = () => {
if (selectedId && batchNumber) { if (selectedId && batchNumber && actualQty > 0) {
onConfirm({ onConfirm({
warehouseId: selectedId, warehouseId: selectedId,
batchNumber, batchNumber,
expiryDate expiryDate,
actualOutputQuantity: actualQty,
lossReason: hasLoss ? lossReason : '',
}); });
} }
}; };
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-main"> <DialogTitle className="flex items-center gap-2 text-primary-main">
<WarehouseIcon className="h-5 w-5" /> <WarehouseIcon className="h-5 w-5" />
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-6 space-y-6"> <div className="py-6 space-y-6">
{/* 倉庫選擇 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<WarehouseIcon className="h-3 w-3" /> <WarehouseIcon className="h-3 w-3" />
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
/> />
</div> </div>
{/* 成品批號 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<Tag className="h-3 w-3" /> <Tag className="h-3 w-3" />
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
/> />
</div> </div>
{/* 成品效期 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<CalendarIcon className="h-3 w-3" /> <CalendarIcon className="h-3 w-3" />
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
className="h-9" className="h-9"
/> />
</div> </div>
{/* 分隔線 - 產出確認區 */}
<div className="border-t border-grey-4 pt-4">
<p className="text-xs font-bold text-grey-2 uppercase tracking-wider mb-4"></p>
{/* 預計產量(唯讀) */}
<div className="flex items-center justify-between mb-3 px-3 py-2 bg-grey-5 rounded-lg border border-grey-4">
<span className="text-sm text-grey-2"></span>
<span className="font-bold text-grey-0">
{formatQuantity(outputQuantity)} {unitName}
</span>
</div>
{/* 實際產出數量 */}
<div className="space-y-1 mb-3">
<Label className="text-xs font-medium text-grey-2">
*
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
step="1"
min="0"
max={outputQuantity}
value={actualOutputQuantity}
onChange={(e) => setActualOutputQuantity(e.target.value)}
className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`}
/>
{unitName && <span className="text-sm text-grey-2 whitespace-nowrap">{unitName}</span>}
</div>
{actualQty > outputQuantity && (
<p className="text-xs text-red-500 mt-1"></p>
)}
</div>
{/* 耗損顯示 */}
{hasLoss && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 space-y-2 animate-in fade-in duration-300">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
<span className="text-sm font-bold text-orange-700">
{formatQuantity(lossQuantity)} {unitName}
</span>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-orange-600">
()
</Label>
<Input
value={lossReason}
onChange={(e) => setLossReason(e.target.value)}
placeholder="例如:製作過程損耗、品質不合格..."
className="h-9 border-orange-200 focus:ring-orange-400"
/>
</div>
</div>
)}
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
</Button> </Button>
<Button <Button
onClick={handleConfirm} onClick={handleConfirm}
disabled={!selectedId || !batchNumber || processing} disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
className="gap-2 button-filled-primary" className="gap-2 button-filled-primary"
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />

View File

@@ -38,6 +38,8 @@ interface SearchableSelectProps {
showSearch?: boolean; showSearch?: boolean;
/** 是否可清除選取 */ /** 是否可清除選取 */
isClearable?: boolean; isClearable?: boolean;
/** 是否為無效狀態(顯示紅色邊框) */
"aria-invalid"?: boolean;
} }
export function SearchableSelect({ export function SearchableSelect({
@@ -52,6 +54,7 @@ export function SearchableSelect({
searchThreshold = 10, searchThreshold = 10,
showSearch, showSearch,
isClearable = false, isClearable = false,
"aria-invalid": ariaInvalid,
}: SearchableSelectProps) { }: SearchableSelectProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -79,12 +82,15 @@ export function SearchableSelect({
!selectedOption && "text-grey-3", !selectedOption && "text-grey-3",
// Focus state - primary border with ring // Focus state - primary border with ring
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]", "focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
// Error state
ariaInvalid && "border-destructive ring-destructive/20",
// Disabled state // Disabled state
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50", "disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
// Height // Height
"h-9", "h-9",
className className
)} )}
aria-invalid={ariaInvalid}
> >
<span className="truncate"> <span className="truncate">
{selectedOption ? selectedOption.label : placeholder} {selectedOption ? selectedOption.label : placeholder}

View File

@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
const [recipes, setRecipes] = useState<any[]>([]); const [recipes, setRecipes] = useState<any[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<string>(""); const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
const { data, setData, processing, errors } = useForm({ // 提交表單
const { data, setData, processing, errors, setError, clearErrors } = useForm({
product_id: "", product_id: "",
warehouse_id: "", warehouse_id: "",
output_quantity: "", output_quantity: "",
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
remark: "", remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
}); });
@@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
// 獲取特定商品在各倉庫的庫存分佈 // 獲取特定商品在各倉庫的庫存分佈
const fetchProductInventories = async (productId: string) => { const fetchProductInventories = async (productId: string) => {
if (!productId) return; if (!productId) return;
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return; if (loadingProducts[productId]) return;
setLoadingProducts(prev => ({ ...prev, [productId]: true })); setLoadingProducts(prev => ({ ...prev, [productId]: true }));
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
item.unit_id = ""; item.unit_id = "";
item.ui_input_quantity = ""; item.ui_input_quantity = "";
item.ui_selected_unit = "base"; item.ui_selected_unit = "base";
// 清除 cache 資訊
delete item.ui_product_name; delete item.ui_product_name;
delete item.ui_batch_number; delete item.ui_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
} }
} }
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
if (field === 'ui_warehouse_id') { if (field === 'ui_warehouse_id') {
item.inventory_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_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
delete item.ui_expiry_date; delete item.ui_expiry_date;
} }
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) { if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || []; const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value); const inv = currentOptions.find(i => String(i.id) === value);
@@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) {
item.ui_batch_number = inv.batch_number; item.ui_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity; item.ui_available_qty = inv.quantity;
item.ui_expiry_date = inv.expiry_date || ''; item.ui_expiry_date = inv.expiry_date || '';
// 單位與轉換率
item.ui_base_unit_name = inv.unit_name || ''; item.ui_base_unit_name = inv.unit_name || '';
item.ui_base_unit_id = inv.base_unit_id; item.ui_base_unit_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id; item.ui_large_unit_id = inv.large_unit_id;
item.ui_purchase_unit_id = inv.purchase_unit_id; item.ui_purchase_unit_id = inv.purchase_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1; item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_unit_cost = inv.unit_cost || 0; item.ui_unit_cost = inv.unit_cost || 0;
// 預設單位
item.ui_selected_unit = 'base'; item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || ''); item.unit_id = String(inv.base_unit_id || '');
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
if (!item.ui_input_quantity) { if (!item.ui_input_quantity) {
item.ui_input_quantity = formatQuantity(inv.quantity); item.ui_input_quantity = formatQuantity(inv.quantity);
} }
} }
} }
// 4. 計算最終數量 (Base Quantity)
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
const inputQty = parseFloat(item.ui_input_quantity || '0'); const inputQty = parseFloat(item.ui_input_quantity || '0');
const rate = item.ui_conversion_rate || 1; const rate = item.ui_conversion_rate || 1;
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
if (item.ui_selected_unit === 'large') {
item.quantity_used = String(inputQty * rate);
item.unit_id = String(item.ui_base_unit_id || ''); 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; updated[index] = item;
setBomItems(updated); setBomItems(updated);
}; };
// 同步 BOM items 到表單 data
useEffect(() => { useEffect(() => {
setData('items', bomItems.map(item => ({ setData('items', bomItems.map(item => ({
inventory_id: Number(item.inventory_id), inventory_id: Number(item.inventory_id),
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
}))); })));
}, [bomItems]); }, [bomItems]);
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => { const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return; if (!recipe || !recipe.items) return;
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1; const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', formatQuantity(yieldQty)); setData('output_quantity', formatQuantity(yieldQty));
const newBomItems: BomItem[] = recipe.items.map((item: any) => { const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0"); if (item.product_id) fetchProductInventories(String(item.product_id));
const calculatedQty = baseQty; // 保持精度
// 若有配方商品,預先載入庫存分佈
if (item.product_id) {
fetchProductInventories(String(item.product_id));
}
return { return {
inventory_id: "", inventory_id: "",
quantity_used: String(calculatedQty), quantity_used: String(item.quantity || "0"),
unit_id: String(item.unit_id), unit_id: String(item.unit_id),
ui_warehouse_id: "", ui_warehouse_id: "",
ui_product_id: String(item.product_id), ui_product_id: String(item.product_id),
ui_product_name: item.product_name, ui_product_name: item.product_name,
ui_batch_number: "", ui_batch_number: "",
ui_available_qty: 0, ui_available_qty: 0,
ui_input_quantity: formatQuantity(calculatedQty), ui_input_quantity: formatQuantity(item.quantity || "0"),
ui_selected_unit: 'base', ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name, ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id, ui_base_unit_id: item.unit_id,
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
}; };
}); });
setBomItems(newBomItems); setBomItems(newBomItems);
toast.success(`已自動載入配方: ${recipe.name}`);
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${formatQuantity(yieldQty)}`
});
}; };
// 當手動切換配方時
useEffect(() => { useEffect(() => {
if (!selectedRecipeId) return; if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId); const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
if (targetRecipe) { if (targetRecipe) applyRecipe(targetRecipe);
applyRecipe(targetRecipe);
}
}, [selectedRecipeId]); }, [selectedRecipeId]);
// 自動產生成品批號與載入配方
useEffect(() => { useEffect(() => {
if (!data.product_id) return; if (!data.product_id) return;
// 2. 自動載入配方列表
const fetchRecipes = async () => { const fetchRecipes = async () => {
try { try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id)); const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json(); const recipesData = await res.json();
if (Array.isArray(recipesData) && recipesData.length > 0) { if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData); setRecipes(recipesData);
// 預設選取最新的 (第一個) setSelectedRecipeId(String(recipesData[0].id));
const latest = recipesData[0];
setSelectedRecipeId(String(latest.id));
} else { } else {
// 若無配方
setRecipes([]); setRecipes([]);
setSelectedRecipeId(""); setSelectedRecipeId("");
setBomItems([]); // 清空 BOM setBomItems([]);
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch recipes", e);
setRecipes([]); setRecipes([]);
setBomItems([]); setBomItems([]);
} }
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
fetchRecipes(); fetchRecipes();
}, [data.product_id]); }, [data.product_id]);
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量 // 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
useEffect(() => { useEffect(() => {
if (bomItems.length > 0 && data.output_quantity) { const errorKeys = Object.keys(errors);
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號 if (errorKeys.length > 0) {
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾 // 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
// 但如果是剛載入inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性 setTimeout(() => {
const firstInvalid = document.querySelector('[aria-invalid="true"]');
if (firstInvalid instanceof HTMLElement) {
firstInvalid.focus();
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}, [data.output_quantity]); }, 100);
}
}, [errors]);
// 提交表單 const submit = (status: 'draft') => {
const submit = (status: 'draft' | 'completed') => { clearErrors();
// 驗證(簡單前端驗證,完整驗證在後端) let hasError = false;
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("請填寫必要欄位", { if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
description: `缺漏:${missingFields.join('、')}` if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
// 驗證 BOM 明細
bomItems.forEach((item, index) => {
if (!item.ui_product_id) {
setError(`items.${index}.ui_product_id` as any, '請選擇商品');
hasError = true;
} else {
if (!item.inventory_id) {
setError(`items.${index}.inventory_id` as any, '請選擇批號');
hasError = true;
}
if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
setError(`items.${index}.quantity_used` as any, '請輸入數量');
hasError = true;
}
}
}); });
if (hasError) {
toast.error("建立失敗,請檢查標單內紅框欄位");
return; return;
} }
}
// 轉換 BOM items 格式 const formattedItems = bomItems.map(item => ({
const formattedItems = bomItems inventory_id: parseInt(item.inventory_id),
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) quantity_used: parseFloat(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, unit_id: item.unit_id ? parseInt(item.unit_id) : null,
})); }));
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), { router.post(route('production-orders.store'), {
...data, ...data,
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems, items: formattedItems,
status: status, status: status,
}, { }, {
onError: (errors) => { onError: () => {
const errorCount = Object.keys(errors).length; toast.error("建立失敗,請檢查表單");
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
} }
}); });
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
submit('completed'); submit('draft');
}; };
const getBomItemUnitCost = (item: BomItem) => { const getBomItemUnitCost = (item: BomItem) => {
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" /> <Factory className="h-6 w-6 text-primary-main" />
</h1> </h1>
@@ -431,16 +394,20 @@ export default function Create({ products, warehouses }: Props) {
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<Button <Button
type="button"
variant="default"
onClick={() => submit('draft')} onClick={() => submit('draft')}
disabled={processing} disabled={processing}
className="gap-2 button-filled-primary" className="button-filled-primary gap-2"
> >
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
(稿) (稿)
</Button> </Button>
</div> </div>
</div> </div>
</div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* 成品資訊 */} {/* 成品資訊 */}
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
}))} }))}
placeholder="選擇成品" placeholder="選擇成品"
className="w-full h-9" className="w-full h-9"
aria-invalid={!!errors.product_id}
/> />
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>} {errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
@@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
onChange={(e) => setData('output_quantity', e.target.value)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
className="h-9 font-mono" className="h-9 font-mono"
aria-invalid={!!errors.output_quantity}
/> />
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>} {errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div> </div>
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
}))} }))}
placeholder="選擇倉庫" placeholder="選擇倉庫"
className="w-full h-9" className="w-full h-9"
aria-invalid={!!errors.warehouse_id}
/> />
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>} {errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
</div> </div>
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
options={productOptions} options={productOptions}
placeholder="選擇商品" placeholder="選擇商品"
className="w-full" className="w-full"
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
/> />
</TableCell> </TableCell>
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"} placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full" className="w-full"
disabled={!item.ui_warehouse_id} disabled={!item.ui_warehouse_id}
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
@@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder="0" placeholder="0"
className="h-9 text-right" className="h-9 text-right"
disabled={!item.inventory_id} disabled={!item.inventory_id}
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
/> />
</TableCell> </TableCell>

View File

@@ -56,6 +56,8 @@ interface ProductionOrder {
output_batch_number: string; output_batch_number: string;
output_box_count: string | null; output_box_count: string | null;
output_quantity: number; output_quantity: number;
actual_output_quantity: number | null;
loss_reason: string | null;
production_date: string; production_date: string;
expiry_date: string | null; expiry_date: string | null;
status: ProductionOrderStatus; status: ProductionOrderStatus;
@@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
warehouseId?: number; warehouseId?: number;
batchNumber?: string; batchNumber?: string;
expiryDate?: string; expiryDate?: string;
actualOutputQuantity?: number;
lossReason?: string;
}) => { }) => {
router.patch(route('production-orders.update-status', productionOrder.id), { router.patch(route('production-orders.update-status', productionOrder.id), {
status: newStatus, status: newStatus,
warehouse_id: extraData?.warehouseId, warehouse_id: extraData?.warehouseId,
output_batch_number: extraData?.batchNumber, output_batch_number: extraData?.batchNumber,
expiry_date: extraData?.expiryDate, expiry_date: extraData?.expiryDate,
actual_output_quantity: extraData?.actualOutputQuantity,
loss_reason: extraData?.lossReason,
}, { }, {
onSuccess: () => { onSuccess: () => {
setIsWarehouseModalOpen(false); setIsWarehouseModalOpen(false);
@@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
processing={processing} processing={processing}
productCode={productionOrder.product?.code} productCode={productionOrder.product?.code}
productId={productionOrder.product?.id} productId={productionOrder.product?.id}
outputQuantity={Number(productionOrder.output_quantity)}
unitName={productionOrder.product?.base_unit?.name}
/> />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500"> <div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
</p> </p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">/</p> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<p className="font-bold text-grey-0 text-xl"> <p className="font-bold text-grey-0 text-xl">
{formatQuantity(productionOrder.output_quantity)} {formatQuantity(productionOrder.output_quantity)}
@@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
)} )}
</div> </div>
</div> </div>
{/* 實際產量與耗損(僅完成狀態顯示) */}
{productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && (
<div className="space-y-1.5">
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="flex items-baseline gap-1.5">
<p className="font-bold text-grey-0 text-xl">
{formatQuantity(productionOrder.actual_output_quantity)}
</p>
{productionOrder.product?.base_unit?.name && (
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
)}
{Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && (
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold border border-orange-200">
{formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))}
</span>
)}
</div>
{productionOrder.loss_reason && (
<p className="text-xs text-orange-600 mt-1">{productionOrder.loss_reason}</p>
)}
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4"> <div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">