From 6ca0bafd6025babb3efb2652d5b3dabeffe80c53 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 10 Mar 2026 15:32:52 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E6=96=B0=E5=A2=9E=E7=94=9F=E7=94=A2?= =?UTF-8?q?=E5=B7=A5=E5=96=AE=E5=AF=A6=E9=9A=9B=E7=94=A2=E9=87=8F=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E8=88=87=20UI=20=E8=A6=8F=E7=AF=84=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20database/migrations/tenant=20=E5=AF=A6=E9=9A=9B?= =?UTF-8?q?=E7=94=A2=E9=87=8F=E8=88=87=E8=80=97=E6=90=8D=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=20-=20ProductionOrder=20API=20=E7=8B=80=E6=85=8B=E6=8E=A8?= =?UTF-8?q?=E9=80=B2=E8=88=87=E5=AF=A6=E9=9A=9B=E7=94=A2=E9=87=8F=E8=A8=88?= =?UTF-8?q?=E7=AE=97=20-=20=E5=AE=8C=E5=B7=A5=E5=85=A5=E5=BA=AB=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=AF=A6=E9=9A=9B=E7=94=A2=E5=87=BA=E6=95=B8=E9=87=8F?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=95=B8=E5=AD=97=E8=BC=B8=E5=85=A5=E6=A1=86?= =?UTF-8?q?=20(step=3D1)=20-=20Create.tsx=20=E8=A3=9C=E4=B8=8A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=B3=87=E6=96=99=E9=A9=97=E8=AD=89=E8=88=87=E7=8B=80?= =?UTF-8?q?=E6=85=8B=E4=BF=9D=E8=AD=B7=20-=20=E5=BB=BA=E7=AB=8B=E4=B8=A6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20UI=20=E6=95=B8=E5=AD=97=E8=BC=B8=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E8=A8=AD=E8=A8=88=E8=A6=8F=E7=AF=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/ui-consistency/SKILL.md | 33 +++- .../Controllers/ProductionOrderController.php | 53 +++-- .../Production/Models/ProductionOrder.php | 5 + ...add_actual_output_to_production_orders.php | 37 ++++ .../WarehouseSelectionModal.tsx | 105 +++++++++- .../js/Components/ui/searchable-select.tsx | 6 + resources/js/Pages/Production/Create.tsx | 183 ++++++++---------- resources/js/Pages/Production/Show.tsx | 32 ++- 8 files changed, 325 insertions(+), 129 deletions(-) create mode 100644 database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php diff --git a/.agents/skills/ui-consistency/SKILL.md b/.agents/skills/ui-consistency/SKILL.md index 1df0d23..cea8f8e 100644 --- a/.agents/skills/ui-consistency/SKILL.md +++ b/.agents/skills/ui-consistency/SKILL.md @@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select"; - **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9` - **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。 -## 11.6 日期顯示規範 (Date Display) +## 11.6 數字輸入框規範 (Numeric Inputs) + +當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。 + +**必須使用原生 HTML5 數字輸入與屬性**: +1. 使用 `` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。 +2. 針對整數需求,固定加上 `step="1"` 屬性。 +3. 視需求加上 `min` 與 `max` 控制上下限。 + +這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。 + +```tsx +// ✅ 正確:依賴原生行為 + setActualOutputQuantity(e.target.value)} + className="h-9 w-24 text-center" +/> + +// ❌ 錯誤:過度設計、浪費空間與破壞一致性 +
+ + + +
+``` + +## 11.7 日期顯示規範 (Date Display) 前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。 diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index 296d85b..16b7daa 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -134,15 +134,15 @@ class ProductionOrderController extends Controller public function store(Request $request) { $status = $request->input('status', 'draft'); - + $rules = [ 'product_id' => 'required', - 'status' => 'nullable|in:draft,completed', - 'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', - 'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', + 'status' => 'nullable|in:draft,pending,completed', + 'warehouse_id' => 'required', + 'output_quantity' => 'required|numeric|min:0.01', 'items' => 'nullable|array', - 'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', - 'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', + 'items.*.inventory_id' => 'required', + 'items.*.quantity_used' => 'required|numeric|min:0.0001', ]; $validated = $request->validate($rules); @@ -159,7 +159,7 @@ class ProductionOrderController extends Controller 'production_date' => $request->production_date, 'expiry_date' => $request->expiry_date, 'user_id' => auth()->id(), - 'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 + 'status' => $status ?: ProductionOrder::STATUS_DRAFT, 'remark' => $request->remark, ]); @@ -414,6 +414,19 @@ class ProductionOrderController extends Controller 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) { // 使用鎖定重新獲取單據,防止併發狀態修改 $productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first(); @@ -444,6 +457,8 @@ class ProductionOrderController extends Controller $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 + $actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量 + $lossReason = $request->input('loss_reason'); // 耗損原因 if (!$warehouseId) { throw new \Exception('必須選擇入庫倉庫'); @@ -451,8 +466,14 @@ class ProductionOrderController extends Controller if (!$batchNumber) { throw new \Exception('必須提供成品批號'); } + if (!$actualOutputQuantity || $actualOutputQuantity <= 0) { + throw new \Exception('實際產出數量必須大於 0'); + } + if ($actualOutputQuantity > $productionOrder->output_quantity) { + throw new \Exception('實際產出數量不可大於預計產量'); + } - // --- 新增:計算原物料投入總成本 --- + // --- 計算原物料投入總成本 --- $totalCost = 0; $items = $productionOrder->items()->with('inventory')->get(); foreach ($items as $item) { @@ -461,23 +482,25 @@ class ProductionOrderController extends Controller } } - // 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤) - $unitCost = $productionOrder->output_quantity > 0 - ? $totalCost / $productionOrder->output_quantity + // 單位成本以「實際產出數量」為分母,反映真實生產效率 + $unitCost = $actualOutputQuantity > 0 + ? $totalCost / $actualOutputQuantity : 0; - // -------------------------------- - // 更新單據資訊:批號、效期與自動記錄生產日期 + // 更新單據資訊:批號、效期、實際產量與耗損原因 $productionOrder->output_batch_number = $batchNumber; $productionOrder->expiry_date = $expiryDate; $productionOrder->production_date = now()->toDateString(); $productionOrder->warehouse_id = $warehouseId; + $productionOrder->actual_output_quantity = $actualOutputQuantity; + $productionOrder->loss_reason = $lossReason; + // 成品入庫數量改用「實際產出數量」 $this->inventoryService->createInventoryRecord([ 'warehouse_id' => $warehouseId, 'product_id' => $productionOrder->product_id, - 'quantity' => $productionOrder->output_quantity, - 'unit_cost' => $unitCost, // 傳入計算後的單位成本 + 'quantity' => $actualOutputQuantity, + 'unit_cost' => $unitCost, 'batch_number' => $batchNumber, 'box_number' => $productionOrder->output_box_count, 'arrival_date' => now()->toDateString(), diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php index bc95e0d..c409034 100644 --- a/app/Modules/Production/Models/ProductionOrder.php +++ b/app/Modules/Production/Models/ProductionOrder.php @@ -24,6 +24,8 @@ class ProductionOrder extends Model 'product_id', 'warehouse_id', 'output_quantity', + 'actual_output_quantity', + 'loss_reason', 'output_batch_number', 'output_box_count', 'production_date', @@ -82,6 +84,7 @@ class ProductionOrder extends Model 'production_date' => 'date', 'expiry_date' => 'date', 'output_quantity' => 'decimal:2', + 'actual_output_quantity' => 'decimal:2', ]; public function getActivitylogOptions(): LogOptions @@ -91,6 +94,8 @@ class ProductionOrder extends Model 'code', 'status', 'output_quantity', + 'actual_output_quantity', + 'loss_reason', 'output_batch_number', 'production_date', 'remark' diff --git a/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php b/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php new file mode 100644 index 0000000..4c80abc --- /dev/null +++ b/database/migrations/tenant/2026_03_10_140000_add_actual_output_to_production_orders.php @@ -0,0 +1,37 @@ +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']); + }); + } +}; diff --git a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx index c500cb6..9e2adda 100644 --- a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx +++ b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx @@ -1,5 +1,6 @@ /** * 生產工單完工入庫 - 選擇倉庫彈窗 + * 含產出確認與耗損記錄功能 */ import React from 'react'; @@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Input } from "@/Components/ui/input"; 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 { id: number; @@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps { warehouseId: number; batchNumber: string; expiryDate: string; + actualOutputQuantity: number; + lossReason: string; }) => void; warehouses: Warehouse[]; processing?: boolean; - // 新增商品資訊以利產生批號 + // 商品資訊用於產生批號 productCode?: string; productId?: number; + // 預計產量(用於耗損計算) + outputQuantity: number; + // 成品單位名稱 + unitName?: string; } export default function WarehouseSelectionModal({ @@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({ processing = false, productCode, productId, + outputQuantity, + unitName = '', }: WarehouseSelectionModalProps) { const [selectedId, setSelectedId] = React.useState(null); const [batchNumber, setBatchNumber] = React.useState(""); const [expiryDate, setExpiryDate] = React.useState(""); + const [actualOutputQuantity, setActualOutputQuantity] = React.useState(""); + const [lossReason, setLossReason] = React.useState(""); + + // 當開啟時,初始化實際產出數量為預計產量 + React.useEffect(() => { + if (isOpen) { + setActualOutputQuantity(String(outputQuantity)); + setLossReason(""); + } + }, [isOpen, outputQuantity]); // 當開啟時,嘗試產生成品批號 (若有資訊) React.useEffect(() => { @@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({ } }, [isOpen, productCode, productId]); + // 計算耗損數量 + const actualQty = parseFloat(actualOutputQuantity) || 0; + const lossQuantity = outputQuantity - actualQty; + const hasLoss = lossQuantity > 0; + const handleConfirm = () => { - if (selectedId && batchNumber) { + if (selectedId && batchNumber && actualQty > 0) { onConfirm({ warehouseId: selectedId, batchNumber, - expiryDate + expiryDate, + actualOutputQuantity: actualQty, + lossReason: hasLoss ? lossReason : '', }); } }; + // 驗證:實際產出不可大於預計產量,也不可小於等於 0 + const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity; + return ( !open && onClose()}> - + - 選擇完工入庫倉庫 + 完工入庫確認
+ {/* 倉庫選擇 */}
+ {/* 成品批號 */}
+ {/* 成品效期 */}
+ + {/* 分隔線 - 產出確認區 */} +
+

產出確認

+ + {/* 預計產量(唯讀) */} +
+ 預計產量 + + {formatQuantity(outputQuantity)} {unitName} + +
+ + {/* 實際產出數量 */} +
+ +
+ setActualOutputQuantity(e.target.value)} + className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`} + /> + {unitName && {unitName}} +
+ {actualQty > outputQuantity && ( +

實際產出不可超過預計產量

+ )} +
+ + {/* 耗損顯示 */} + {hasLoss && ( +
+
+ + + 耗損數量:{formatQuantity(lossQuantity)} {unitName} + +
+
+ + setLossReason(e.target.value)} + placeholder="例如:製作過程損耗、品質不合格..." + className="h-9 border-orange-200 focus:ring-orange-400" + /> +
+
+ )} +
+
+ +
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) { }))} placeholder="選擇成品" className="w-full h-9" + aria-invalid={!!errors.product_id} /> {errors.product_id &&

{errors.product_id}

} @@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) { onChange={(e) => setData('output_quantity', e.target.value)} placeholder="例如: 50" className="h-9 font-mono" + aria-invalid={!!errors.output_quantity} /> {errors.output_quantity &&

{errors.output_quantity}

} @@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) { }))} placeholder="選擇倉庫" className="w-full h-9" + aria-invalid={!!errors.warehouse_id} /> {errors.warehouse_id &&

{errors.warehouse_id}

} @@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) { options={productOptions} placeholder="選擇商品" className="w-full" + aria-invalid={!!errors[`items.${index}.ui_product_id` as any]} /> @@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) { placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"} className="w-full" disabled={!item.ui_warehouse_id} + aria-invalid={!!errors[`items.${index}.inventory_id` as any]} /> {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" className="h-9 text-right" disabled={!item.inventory_id} + aria-invalid={!!errors[`items.${index}.quantity_used` as any]} /> diff --git a/resources/js/Pages/Production/Show.tsx b/resources/js/Pages/Production/Show.tsx index acb6e3c..7da30d6 100644 --- a/resources/js/Pages/Production/Show.tsx +++ b/resources/js/Pages/Production/Show.tsx @@ -56,6 +56,8 @@ interface ProductionOrder { output_batch_number: string; output_box_count: string | null; output_quantity: number; + actual_output_quantity: number | null; + loss_reason: string | null; production_date: string; expiry_date: string | null; status: ProductionOrderStatus; @@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr warehouseId?: number; batchNumber?: string; expiryDate?: string; + actualOutputQuantity?: number; + lossReason?: string; }) => { router.patch(route('production-orders.update-status', productionOrder.id), { status: newStatus, warehouse_id: extraData?.warehouseId, output_batch_number: extraData?.batchNumber, expiry_date: extraData?.expiryDate, + actual_output_quantity: extraData?.actualOutputQuantity, + loss_reason: extraData?.lossReason, }, { onSuccess: () => { setIsWarehouseModalOpen(false); @@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr processing={processing} productCode={productionOrder.product?.code} productId={productionOrder.product?.id} + outputQuantity={Number(productionOrder.output_quantity)} + unitName={productionOrder.product?.base_unit?.name} />
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr

-

預計/實際產量

+

預計產量

{formatQuantity(productionOrder.output_quantity)} @@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr )}

+ {/* 實際產量與耗損(僅完成狀態顯示) */} + {productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && ( +
+

實際產量

+
+

+ {formatQuantity(productionOrder.actual_output_quantity)} +

+ {productionOrder.product?.base_unit?.name && ( + {productionOrder.product.base_unit.name} + )} + {Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && ( + + 耗損 {formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))} + + )} +
+ {productionOrder.loss_reason && ( +

原因:{productionOrder.loss_reason}

+ )} +
+ )}

入庫倉庫