From 183583c7390a8019a52939a0d6c2d9d6e71ca767 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 3 Mar 2026 14:28:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=E8=AA=BF=E6=95=B4=E8=A8=82=E5=96=AE?= =?UTF-8?q?=E8=88=87=E8=B2=A9=E8=B3=A3=E6=A9=9F=E8=A8=82=E5=96=AE=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=BC=B7=E5=88=B6=E4=BD=BF=E7=94=A8warehouse=5Fcode?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0API=E5=B0=8D=E6=8E=A5=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E5=8F=8A=E5=84=AA=E5=8C=96=E7=94=9F=E7=94=A2?= =?UTF-8?q?=E8=88=87=E9=85=8D=E6=96=B9=E6=A8=A1=E7=B5=84UI=E9=A1=AF?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/ui-consistency.md | 6 ++ .gitignore | 4 + .../Integration/Actions/SyncOrderAction.php | 16 ++-- .../Actions/SyncVendingOrderAction.php | 16 ++-- .../Integration/Requests/SyncOrderRequest.php | 3 +- .../Requests/SyncVendingOrderRequest.php | 3 +- .../Controllers/ProductionOrderController.php | 46 +++++++++-- .../Controllers/RecipeController.php | 43 ++++++++++- .../js/Components/ui/searchable-select.tsx | 4 +- resources/js/Pages/Production/Create.tsx | 72 ++++++++++++++++-- resources/js/Pages/Production/Edit.tsx | 69 +++++++++++++++-- resources/js/Pages/Production/Index.tsx | 12 ++- .../Recipe/Components/RecipeDetailModal.tsx | 76 ++++++++++++++----- .../js/Pages/Production/Recipe/Create.tsx | 70 +++++++++++++++-- resources/js/Pages/Production/Recipe/Edit.tsx | 74 +++++++++++++++--- .../js/Pages/Production/Recipe/Index.tsx | 16 +++- resources/js/Pages/Production/Show.tsx | 36 +++++++++ resources/markdown/manual/api-integration.md | 5 +- routes/web.php | 4 + 19 files changed, 486 insertions(+), 89 deletions(-) diff --git a/.agents/rules/ui-consistency.md b/.agents/rules/ui-consistency.md index ea34fe7..2dfe0eb 100644 --- a/.agents/rules/ui-consistency.md +++ b/.agents/rules/ui-consistency.md @@ -80,6 +80,12 @@ description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI - **麵包屑**:使用 `BreadcrumbItemType`(屬性為 `label`, `href`, `isPage`),不需要包含「首頁」 - **Input 元件**:不額外設定 focus 樣式,直接使用 `@/Components/ui/input` 的內建樣式 - **日期顯示**:使用 `resources/js/lib/date.ts` 的 `formatDate` 工具 +- **數字顯示**: + - 所有的金額、數量等數值,應視情境使用千分位格式化(如 `1,234.56`)。 + - **精確顯示**:數值應按實際數值顯示,避免在小數點後出現多餘的零(例如:應顯示 `10.5` 而非 `10.500`)。 + - 數值應與單位(如 `元`、`kg`)保持適當間距或清晰呈現。 + - 負數應視情境明確標示(如使用紅色 `text-other-error` 或負號)。 + - 列表中的數值建議靠右對齊 (text-right),以便於視覺比較。 --- diff --git a/.gitignore b/.gitignore index 8549ccb..f99723e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ Thumbs.db /docs/presentation docs/Monthly_Report_2026_01.pptx docs/f6_1770350984272.xlsx +公共事業費-描述.md +.gitignore +BOM表自動計算成本.md +公共事業費-類別維護.md diff --git a/app/Modules/Integration/Actions/SyncOrderAction.php b/app/Modules/Integration/Actions/SyncOrderAction.php index fe668a9..a9b61c1 100644 --- a/app/Modules/Integration/Actions/SyncOrderAction.php +++ b/app/Modules/Integration/Actions/SyncOrderAction.php @@ -93,14 +93,16 @@ class SyncOrderAction 'source_label' => $data['source_label'] ?? null, ]); - // 2. 查找或建立倉庫 - $warehouseId = $data['warehouse_id'] ?? null; - - if (empty($warehouseId)) { - $warehouseName = $data['warehouse'] ?? '銷售倉庫'; - $warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName); - $warehouseId = $warehouse->id; + // 2. 查找倉庫 + $warehouseCode = $data['warehouse_code']; + $warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]); + + if ($warehouses->isEmpty()) { + throw ValidationException::withMessages([ + 'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."] + ]); } + $warehouseId = $warehouses->first()->id; $totalAmount = 0; diff --git a/app/Modules/Integration/Actions/SyncVendingOrderAction.php b/app/Modules/Integration/Actions/SyncVendingOrderAction.php index 4963287..7ec2061 100644 --- a/app/Modules/Integration/Actions/SyncVendingOrderAction.php +++ b/app/Modules/Integration/Actions/SyncVendingOrderAction.php @@ -90,14 +90,16 @@ class SyncVendingOrderAction 'source_label' => $data['machine_id'] ?? null, ]); - // 2. 查找或建立倉庫 - $warehouseId = $data['warehouse_id'] ?? null; - - if (empty($warehouseId)) { - $warehouseName = $data['warehouse'] ?? '販賣機倉庫'; - $warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName); - $warehouseId = $warehouse->id; + // 2. 查找倉庫 + $warehouseCode = $data['warehouse_code']; + $warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]); + + if ($warehouses->isEmpty()) { + throw ValidationException::withMessages([ + 'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."] + ]); } + $warehouseId = $warehouses->first()->id; $totalAmount = 0; diff --git a/app/Modules/Integration/Requests/SyncOrderRequest.php b/app/Modules/Integration/Requests/SyncOrderRequest.php index 5912fe4..3e5e5c3 100644 --- a/app/Modules/Integration/Requests/SyncOrderRequest.php +++ b/app/Modules/Integration/Requests/SyncOrderRequest.php @@ -23,8 +23,7 @@ class SyncOrderRequest extends FormRequest { return [ 'external_order_id' => 'required|string', - 'warehouse' => 'nullable|string', - 'warehouse_id' => 'nullable|integer', + 'warehouse_code' => 'required|string', 'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other', 'sold_at' => 'nullable|date', 'items' => 'required|array|min:1', diff --git a/app/Modules/Integration/Requests/SyncVendingOrderRequest.php b/app/Modules/Integration/Requests/SyncVendingOrderRequest.php index 8cb7561..297a792 100644 --- a/app/Modules/Integration/Requests/SyncVendingOrderRequest.php +++ b/app/Modules/Integration/Requests/SyncVendingOrderRequest.php @@ -24,8 +24,7 @@ class SyncVendingOrderRequest extends FormRequest return [ 'external_order_id' => 'required|string', 'machine_id' => 'nullable|string', - 'warehouse' => 'nullable|string', - 'warehouse_id' => 'nullable|integer', + 'warehouse_code' => 'required|string', 'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other', 'sold_at' => 'nullable|date', 'items' => 'required|array|min:1', diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index a1bebf9..8a5e571 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -67,21 +67,46 @@ class ProductionOrderController extends Controller if (!in_array((int)$perPage, [10, 20, 50, 100])) { $perPage = $defaultPerPage; } - $productionOrders = $query->paginate($perPage)->withQueryString(); + $productionOrders = $query->with('items')->paginate($perPage)->withQueryString(); // --- 手動資料水和 (Manual Hydration) --- - $productIds = $productionOrders->pluck('product_id')->unique()->filter()->toArray(); - $warehouseIds = $productionOrders->pluck('warehouse_id')->unique()->filter()->toArray(); - $userIds = $productionOrders->pluck('user_id')->unique()->filter()->toArray(); + $productIds = collect(); + $warehouseIds = collect(); + $userIds = collect(); + $inventoryIds = collect(); - $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); - $warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id'); - $users = $this->coreService->getUsersByIds($userIds)->keyBy('id'); + foreach ($productionOrders as $order) { + $productIds->push($order->product_id); + $warehouseIds->push($order->warehouse_id); + $userIds->push($order->user_id); + if ($order->items) { + $inventoryIds = $inventoryIds->merge($order->items->pluck('inventory_id')); + } + } - $productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users) { + $products = $this->inventoryService->getProductsByIds($productIds->unique()->filter()->toArray())->keyBy('id'); + $warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds->unique()->filter()->toArray())->keyBy('id'); + $users = $this->coreService->getUsersByIds($userIds->unique()->filter()->toArray())->keyBy('id'); + $inventories = $this->inventoryService->getInventoriesByIds($inventoryIds->unique()->filter()->toArray())->keyBy('id'); + + $productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users, $inventories) { $order->product = $products->get($order->product_id); $order->warehouse = $warehouses->get($order->warehouse_id); $order->user = $users->get($order->user_id); + + $totalCost = 0; + if ($order->items) { + foreach ($order->items as $item) { + $inventory = $inventories->get($item->inventory_id); + if ($inventory) { + $totalCost += $item->quantity_used * ($inventory->unit_cost ?? 0); + } + } + } + $order->estimated_total_cost = $totalCost; + $order->estimated_unit_cost = $order->output_quantity > 0 ? $totalCost / $order->output_quantity : 0; + unset($order->items); + return $order; }); @@ -231,7 +256,9 @@ class ProductionOrderController extends Controller 'unit_name' => $inv->product->baseUnit->name ?? '', 'base_unit_id' => $inv->product->base_unit_id ?? null, 'large_unit_id' => $inv->product->large_unit_id ?? null, + 'purchase_unit_id' => $inv->product->purchase_unit_id ?? null, 'conversion_rate' => $inv->product->conversion_rate ?? 1, + 'unit_cost' => (float) $inv->unit_cost, ]; }); @@ -258,7 +285,10 @@ class ProductionOrderController extends Controller 'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, 'unit_name' => $inv->product->baseUnit->name ?? '', 'base_unit_id' => $inv->product->base_unit_id ?? null, + 'large_unit_id' => $inv->product->large_unit_id ?? null, + 'purchase_unit_id' => $inv->product->purchase_unit_id ?? null, 'conversion_rate' => $inv->product->conversion_rate ?? 1, + 'unit_cost' => (float) $inv->unit_cost, ]; }); diff --git a/app/Modules/Production/Controllers/RecipeController.php b/app/Modules/Production/Controllers/RecipeController.php index 59d1de3..1b6e60c 100644 --- a/app/Modules/Production/Controllers/RecipeController.php +++ b/app/Modules/Production/Controllers/RecipeController.php @@ -46,14 +46,51 @@ class RecipeController extends Controller $perPage = $defaultPerPage; } - $recipes = $query->paginate($perPage)->withQueryString(); + $recipes = $query->with('items')->paginate($perPage)->withQueryString(); // Manual Hydration - $productIds = $recipes->pluck('product_id')->unique()->filter()->toArray(); - $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + $productIds = collect(); + $itemProductIds = collect(); + + foreach ($recipes as $recipe) { + $productIds->push($recipe->product_id); + if ($recipe->items) { + $itemProductIds = $itemProductIds->merge($recipe->items->pluck('product_id')); + } + } + + $allProductIds = array_unique(array_merge( + $productIds->unique()->filter()->toArray(), + $itemProductIds->unique()->filter()->toArray() + )); + + $products = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id'); $recipes->getCollection()->transform(function ($recipe) use ($products) { $recipe->product = $products->get($recipe->product_id); + + $totalCost = 0; + if ($recipe->items) { + foreach ($recipe->items as $item) { + $itemProduct = $products->get($item->product_id); + if ($itemProduct) { + $baseCost = $itemProduct->cost_price ?? 0; + $conversionRate = 1; + + if ($item->unit_id == $itemProduct->large_unit_id && !is_null($itemProduct->conversion_rate)) { + $conversionRate = $itemProduct->conversion_rate; + } elseif ($item->unit_id == $itemProduct->purchase_unit_id && !is_null($itemProduct->conversion_rate_purchase)) { + $conversionRate = $itemProduct->conversion_rate_purchase; + } + + $totalCost += ($item->quantity * $baseCost * $conversionRate); + } + } + } + $recipe->estimated_total_cost = $totalCost; + $recipe->estimated_unit_cost = $recipe->yield_quantity > 0 ? $totalCost / $recipe->yield_quantity : 0; + unset($recipe->items); + return $recipe; }); diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx index 0236bf3..44dbe1e 100644 --- a/resources/js/Components/ui/searchable-select.tsx +++ b/resources/js/Components/ui/searchable-select.tsx @@ -124,7 +124,7 @@ export function SearchableSelect({ setOpen(false); }} disabled={option.disabled} - className="cursor-pointer" + className="cursor-pointer group" > {option.label} {option.sublabel && ( - + {option.sublabel} )} diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx index cd8f936..3b819db 100644 --- a/resources/js/Pages/Production/Create.tsx +++ b/resources/js/Pages/Production/Create.tsx @@ -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 ( @@ -529,10 +554,12 @@ export default function Create({ products, warehouses }: Props) { 商品 * 來源倉庫 * - 批號 * + 批號 * 數量 * 單位 - + 預估單價 + 成本小計 + @@ -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) { + {/* 5. 預估單價 */} + +
+ {getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元 +
+
+ + {/* 6. 成本小計 */} + +
+ {(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元 +
+