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 })} 元
+
+
}
+
+ {/* 生產單預估總成本區塊 */}
+
+
+
+ 生產單總預估成本
+ {totalEstimatedCost.toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
+
+
+ 預估單位生產成本
+ (共 {Number(data.output_quantity || 0).toLocaleString(undefined, { maximumFractionDigits: 4 })} 份)
+
+
+ {(parseFloat(data.output_quantity) > 0 ? totalEstimatedCost / parseFloat(data.output_quantity) : 0).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
+
+
+
+
-
+
);
}
diff --git a/resources/js/Pages/Production/Edit.tsx b/resources/js/Pages/Production/Edit.tsx
index 2743cde..e270a71 100644
--- a/resources/js/Pages/Production/Edit.tsx
+++ b/resources/js/Pages/Production/Edit.tsx
@@ -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(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 (
@@ -492,10 +519,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
商品 *
來源倉庫 *
- 批號 *
- 數量 *
- 單位
-
+ 批號 *
+ 數量 *
+ 單位
+ 預估單價
+ 成本小計
+
@@ -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 }
+
+
+ {getBomItemUnitCost(item).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
+
+
+
+
+
+ {(getBomItemUnitCost(item) * parseFloat(item.ui_input_quantity || '0')).toLocaleString(undefined, { maximumFractionDigits: 2 })} 元
+
+
+