Compare commits
2 Commits
58bd995cd8
...
f543b98d0f
| Author | SHA1 | Date | |
|---|---|---|---|
| f543b98d0f | |||
| 183583c739 |
@@ -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),以便於視覺比較。
|
||||
|
||||
---
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -29,3 +29,7 @@ Thumbs.db
|
||||
/docs/presentation
|
||||
docs/Monthly_Report_2026_01.pptx
|
||||
docs/f6_1770350984272.xlsx
|
||||
公共事業費-描述.md
|
||||
.gitignore
|
||||
BOM表自動計算成本.md
|
||||
公共事業費-類別維護.md
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -167,7 +167,162 @@ class GoodsReceiptController extends Controller
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
$validated = $request->validate($this->getItemValidationRules());
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->store($request->all());
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯進貨單(僅草稿/退回狀態)
|
||||
*/
|
||||
public function edit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, 'rejected'])) {
|
||||
return redirect()->route('goods-receipts.show', $goodsReceipt->id)
|
||||
->with('error', '只有草稿或被退回的進貨單可以編輯。');
|
||||
}
|
||||
|
||||
// 載入品項與產品資訊
|
||||
$goodsReceipt->load('items');
|
||||
|
||||
// 取得品項關聯的商品資訊
|
||||
$productIds = $goodsReceipt->items->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 如果是標準採購,取得對應採購單的品項,以帶出預定數量與已收數量
|
||||
$poItems = collect();
|
||||
$po = null;
|
||||
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id) {
|
||||
$po = clone $this->procurementService->getPurchaseOrdersByIds([$goodsReceipt->purchase_order_id], ['items', 'vendor'])->first();
|
||||
if ($po) {
|
||||
$poItems = $po->items->keyBy('id');
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化品項資料
|
||||
$formattedItems = $goodsReceipt->items->map(function ($item) use ($products, $poItems) {
|
||||
$product = $products->get($item->product_id);
|
||||
$poItem = $poItems->get($item->purchase_order_item_id);
|
||||
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'purchase_order_item_id' => $item->purchase_order_item_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個',
|
||||
'quantity_ordered' => $poItem ? $poItem->quantity : null,
|
||||
'quantity_received_so_far' => $poItem ? ($poItem->received_quantity ?? 0) : null,
|
||||
'quantity_received' => (float) $item->quantity_received,
|
||||
'unit_price' => (float) $item->unit_price,
|
||||
'subtotal' => (float) $item->total_amount,
|
||||
'batch_number' => $item->batch_number ?? '',
|
||||
'batchMode' => 'existing',
|
||||
'originCountry' => 'TW',
|
||||
'expiry_date' => $item->expiry_date ?? '',
|
||||
];
|
||||
})->values();
|
||||
|
||||
// 同 create() 一樣傳入所需的 props
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
$productIdsForPOs = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$productsForPOs = $this->inventoryService->getProductsByIds($productIdsForPOs)->keyBy('id');
|
||||
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($productsForPOs) {
|
||||
return [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'vendor_id' => $po->vendor_id,
|
||||
'vendor_name' => $po->vendor?->name ?? '',
|
||||
'warehouse_id' => $po->warehouse_id,
|
||||
'order_date' => $po->order_date,
|
||||
'items' => $po->items->map(function ($item) use ($productsForPOs) {
|
||||
$product = $productsForPOs->get($item->product_id);
|
||||
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $product?->name ?? '',
|
||||
'product_code' => $product?->code ?? '',
|
||||
'unit' => $product?->baseUnit?->name ?? '個',
|
||||
'quantity' => $item->quantity,
|
||||
'received_quantity' => $item->received_quantity ?? 0,
|
||||
'remaining' => $remaining,
|
||||
'unit_price' => $item->unit_price,
|
||||
];
|
||||
})->filter(fn($item) => $item['remaining'] > 0)->values(),
|
||||
];
|
||||
})->filter(fn($po) => $po['items']->count() > 0)->values();
|
||||
|
||||
$vendors = $this->procurementService->getAllVendors();
|
||||
|
||||
// Manual Hydration for Vendor
|
||||
$vendor = null;
|
||||
if ($goodsReceipt->vendor_id) {
|
||||
$vendor = $this->procurementService->getVendorsByIds([$goodsReceipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 格式化 Purchase Order 給前端顯示
|
||||
$formattedPO = null;
|
||||
if ($po) {
|
||||
$formattedPO = [
|
||||
'id' => $po->id,
|
||||
'code' => $po->code,
|
||||
'status' => $po->status,
|
||||
'vendor_id' => $po->vendor_id,
|
||||
'vendor_name' => $po->vendor?->name ?? '',
|
||||
'warehouse_id' => $po->warehouse_id,
|
||||
'order_date' => $po->order_date,
|
||||
'items' => $po->items->toArray(), // simplified since we just need items.length for display
|
||||
];
|
||||
}
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
'receipt' => [
|
||||
'id' => $goodsReceipt->id,
|
||||
'code' => $goodsReceipt->code,
|
||||
'type' => $goodsReceipt->type,
|
||||
'warehouse_id' => $goodsReceipt->warehouse_id,
|
||||
'vendor_id' => $goodsReceipt->vendor_id,
|
||||
'vendor' => $vendor,
|
||||
'purchase_order_id' => $goodsReceipt->purchase_order_id,
|
||||
'purchase_order' => $formattedPO,
|
||||
'received_date' => \Carbon\Carbon::parse($goodsReceipt->received_date)->format('Y-m-d'),
|
||||
'remarks' => $goodsReceipt->remarks,
|
||||
'items' => $formattedItems,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新進貨單
|
||||
*/
|
||||
public function update(Request $request, GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
$validated = $request->validate($this->getItemValidationRules());
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->update($goodsReceipt, $request->all());
|
||||
return redirect()->route('goods-receipts.show', $goodsReceipt->id)->with('success', '進貨單已更新');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得品項驗證規則
|
||||
*/
|
||||
private function getItemValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'type' => 'required|in:standard,miscellaneous,other',
|
||||
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
|
||||
@@ -178,18 +333,12 @@ class GoodsReceiptController extends Controller
|
||||
'items.*.product_id' => 'required|integer|exists:products,id',
|
||||
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
|
||||
'items.*.quantity_received' => 'required|numeric|min:0',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.unit_price' => 'nullable|numeric|min:0',
|
||||
'items.*.subtotal' => 'nullable|numeric|min:0',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.expiry_date' => 'nullable|date',
|
||||
'force' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->goodsReceiptService->store($request->all());
|
||||
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,6 +378,7 @@ class GoodsReceiptController extends Controller
|
||||
}
|
||||
|
||||
// API to search Products for Manual Entry
|
||||
// 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單)
|
||||
public function searchProducts(Request $request)
|
||||
{
|
||||
$search = $request->input('query');
|
||||
@@ -236,7 +386,12 @@ class GoodsReceiptController extends Controller
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
// 萬用字元:回傳所有商品
|
||||
if ($search === '*') {
|
||||
$products = $this->inventoryService->getProductsByName('');
|
||||
} else {
|
||||
$products = $this->inventoryService->getProductsByName($search);
|
||||
}
|
||||
|
||||
// Format for frontend
|
||||
$mapped = $products->map(function($product) {
|
||||
@@ -244,8 +399,8 @@ class GoodsReceiptController extends Controller
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
|
||||
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
|
||||
'unit' => $product->baseUnit?->name ?? '個',
|
||||
'price' => $product->purchase_price ?? 0,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
|
||||
Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
|
||||
Route::get('/goods-receipts/{goods_receipt}/edit', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'edit'])->middleware('permission:goods_receipts.edit')->name('goods-receipts.edit');
|
||||
Route::put('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'update'])->middleware('permission:goods_receipts.edit')->name('goods-receipts.update');
|
||||
Route::post('/goods-receipts/check-duplicate', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'checkDuplicate'])
|
||||
->middleware('permission:goods_receipts.create')
|
||||
->name('goods-receipts.check-duplicate');
|
||||
|
||||
@@ -48,13 +48,18 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
|
||||
? (float) $itemData['subtotal']
|
||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||
|
||||
// Create GR Item
|
||||
$grItem = new GoodsReceiptItem([
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
||||
'total_amount' => $totalAmount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
@@ -66,7 +71,7 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
'new' => [
|
||||
'quantity_received' => (float)$itemData['quantity_received'],
|
||||
'unit_price' => (float)$itemData['unit_price'],
|
||||
'total_amount' => (float)($itemData['quantity_received'] * $itemData['unit_price']),
|
||||
'total_amount' => (float)$totalAmount,
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -142,12 +147,17 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
$goodsReceipt->items()->delete();
|
||||
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
|
||||
? (float) $itemData['subtotal']
|
||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||
|
||||
$grItem = new GoodsReceiptItem([
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'],
|
||||
'total_amount' => $totalAmount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Link, useForm } from "@inertiajs/react";
|
||||
import { toast } from "sonner";
|
||||
@@ -62,6 +62,22 @@ export default function GoodsReceiptActions({
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 草稿或退回狀態才可編輯 */}
|
||||
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
|
||||
<Can permission="goods_receipts.edit">
|
||||
<Link href={route('goods-receipts.edit', receipt.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{/* 只允許刪除草稿或已退回的進貨單 */}
|
||||
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
|
||||
<Can permission="goods_receipts.delete">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
import { Head, useForm, Link, router } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
@@ -25,7 +25,7 @@ import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Calendar as CalendarIcon,
|
||||
Save,
|
||||
@@ -52,7 +52,7 @@ interface PendingPOItem {
|
||||
received_quantity: number;
|
||||
remaining: number;
|
||||
unit_price: number;
|
||||
batchMode?: 'existing' | 'new';
|
||||
batchMode?: 'existing' | 'new' | 'none';
|
||||
originCountry?: string; // For new batch generation
|
||||
}
|
||||
|
||||
@@ -75,52 +75,92 @@ interface Vendor {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// 編輯模式的進貨單資料
|
||||
interface ReceiptData {
|
||||
id: number;
|
||||
code: string;
|
||||
type: string;
|
||||
warehouse_id: number;
|
||||
vendor_id: number | null;
|
||||
vendor: Vendor | null;
|
||||
purchase_order_id: number | null;
|
||||
purchase_order?: any;
|
||||
received_date: string;
|
||||
remarks: string;
|
||||
items: any[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
warehouses: { id: number; name: string; type: string }[];
|
||||
pendingPurchaseOrders: PendingPO[];
|
||||
vendors: Vendor[];
|
||||
receipt?: ReceiptData;
|
||||
}
|
||||
|
||||
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) {
|
||||
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors, receipt }: Props) {
|
||||
const isEditMode = !!receipt;
|
||||
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
|
||||
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Manual Product Search States
|
||||
const [productSearch, setProductSearch] = useState('');
|
||||
const [foundProducts, setFoundProducts] = useState<any[]>([]);
|
||||
|
||||
// 全商品清單(用於雜項入庫/其他類型的 SearchableSelect)
|
||||
const [allProducts, setAllProducts] = useState<any[]>([]);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
|
||||
// Duplicate Check States
|
||||
const [warningOpen, setWarningOpen] = useState(false);
|
||||
const [warnings, setWarnings] = useState<any[]>([]);
|
||||
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
type: 'standard', // 'standard', 'miscellaneous', 'other'
|
||||
warehouse_id: '',
|
||||
purchase_order_id: '',
|
||||
vendor_id: '',
|
||||
received_date: new Date().toISOString().split('T')[0],
|
||||
remarks: '',
|
||||
items: [] as any[],
|
||||
const { data, setData, processing, errors } = useForm({
|
||||
type: receipt?.type || 'standard',
|
||||
warehouse_id: receipt?.warehouse_id?.toString() || '',
|
||||
purchase_order_id: receipt?.purchase_order_id?.toString() || '',
|
||||
vendor_id: receipt?.vendor_id?.toString() || '',
|
||||
received_date: receipt?.received_date || new Date().toISOString().split('T')[0],
|
||||
remarks: receipt?.remarks || '',
|
||||
items: (receipt?.items || []) as any[],
|
||||
});
|
||||
|
||||
// 搜尋商品 API(用於雜項入庫/其他類型)
|
||||
const searchProducts = async () => {
|
||||
if (!productSearch) return;
|
||||
setIsSearching(true);
|
||||
// 編輯模式下初始化 vendor 與 PO 狀態
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
if (receipt?.vendor) {
|
||||
setSelectedVendor(receipt.vendor);
|
||||
}
|
||||
if (receipt?.purchase_order) {
|
||||
setSelectedPO(receipt.purchase_order);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 判斷是否為非標準採購類型
|
||||
const isNonStandard = data.type !== 'standard';
|
||||
|
||||
|
||||
|
||||
// 載入所有商品(用於雜項入庫/其他類型的 SearchableSelect)
|
||||
const fetchAllProducts = async () => {
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const response = await axios.get(route('goods-receipts.search-products'), {
|
||||
params: { query: productSearch },
|
||||
params: { query: '*' },
|
||||
});
|
||||
setFoundProducts(response.data);
|
||||
setAllProducts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to search products', error);
|
||||
console.error('Failed to load products', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 當選擇非標準類型且已選供應商時,載入所有商品
|
||||
useEffect(() => {
|
||||
if (isNonStandard && selectedVendor) {
|
||||
fetchAllProducts();
|
||||
}
|
||||
}, [isNonStandard, selectedVendor]);
|
||||
|
||||
// 選擇採購單
|
||||
const handleSelectPO = (po: PendingPO) => {
|
||||
setSelectedPO(po);
|
||||
@@ -159,21 +199,37 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProduct = (product: any) => {
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
const newItem = {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
product_code: product.code,
|
||||
product_id: '',
|
||||
product_name: '',
|
||||
product_code: '',
|
||||
quantity_received: 0,
|
||||
unit_price: product.price || 0,
|
||||
unit_price: 0,
|
||||
subtotal: 0,
|
||||
batch_number: '',
|
||||
batchMode: 'new',
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
};
|
||||
setData('items', [...data.items, newItem]);
|
||||
setFoundProducts([]);
|
||||
setProductSearch('');
|
||||
};
|
||||
|
||||
// 選擇商品後填入該列(用於空白列的 SearchableSelect)
|
||||
const handleSelectProduct = (index: number, productId: string) => {
|
||||
const product = allProducts.find(p => p.id.toString() === productId);
|
||||
if (product) {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = {
|
||||
...newItems[index],
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
product_code: product.code,
|
||||
unit_price: product.price || 0,
|
||||
};
|
||||
setData('items', newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
@@ -184,7 +240,26 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
|
||||
const updateItem = (index: number, field: string, value: any) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
const item = { ...newItems[index], [field]: value };
|
||||
|
||||
const qty = parseFloat(item.quantity_received) || 0;
|
||||
const price = parseFloat(item.unit_price) || 0;
|
||||
const subtotal = parseFloat(item.subtotal) || 0;
|
||||
|
||||
if (field === 'quantity_received') {
|
||||
// 修改數量 -> 更新小計 (保持單價)
|
||||
item.subtotal = (qty * price).toString();
|
||||
} else if (field === 'unit_price') {
|
||||
// 修改單價 -> 更新小計 (價格 * 數量)
|
||||
item.subtotal = (qty * price).toString();
|
||||
} else if (field === 'subtotal') {
|
||||
// 修改小計 -> 更新單價 (小計 / 數量)
|
||||
if (qty > 0) {
|
||||
item.unit_price = (subtotal / qty).toString();
|
||||
}
|
||||
}
|
||||
|
||||
newItems[index] = item;
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
@@ -261,23 +336,23 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
|
||||
useEffect(() => {
|
||||
data.items.forEach((item, index) => {
|
||||
if (item.batchMode === 'new' && item.originCountry && data.received_date) {
|
||||
if (item.batchMode === 'none') {
|
||||
if (item.batch_number !== 'NO-BATCH') {
|
||||
const newItems = [...data.items];
|
||||
newItems[index].batch_number = 'NO-BATCH';
|
||||
setData('items', newItems);
|
||||
}
|
||||
} else if (item.batchMode === 'new' && item.originCountry && data.received_date) {
|
||||
const country = item.originCountry;
|
||||
// Use date from form or today
|
||||
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
|
||||
const seqKey = `${item.product_id}-${country}-${dateStr}`;
|
||||
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
|
||||
|
||||
// Only generate if we have a sequence (or default)
|
||||
// Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
|
||||
|
||||
const datePart = dateStr.replace(/-/g, '');
|
||||
const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`;
|
||||
|
||||
if (item.batch_number !== generatedBatch) {
|
||||
// Update WITHOUT triggering re-render loop
|
||||
// Need a way to update item silently or check condition carefully
|
||||
// Using setBatchNumber might trigger this effect again but value will be same.
|
||||
const newItems = [...data.items];
|
||||
newItems[index].batch_number = generatedBatch;
|
||||
setData('items', newItems);
|
||||
@@ -289,25 +364,54 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
const submit = async (e: React.FormEvent, force: boolean = false) => {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
// 如果不是強制提交,先檢查重複
|
||||
// 格式化日期數據
|
||||
const formattedItems = data.items.map(item => ({
|
||||
...item,
|
||||
expiry_date: item.expiry_date && item.expiry_date.includes('T')
|
||||
? item.expiry_date.split('T')[0]
|
||||
: item.expiry_date
|
||||
}));
|
||||
|
||||
const formattedDate = data.received_date.includes('T')
|
||||
? data.received_date.split('T')[0]
|
||||
: data.received_date;
|
||||
|
||||
// 建立一個臨時的提交資料對象
|
||||
const submitData = {
|
||||
...data,
|
||||
received_date: formattedDate,
|
||||
items: formattedItems
|
||||
};
|
||||
|
||||
// 編輯模式直接 PUT 更新
|
||||
if (isEditMode) {
|
||||
// 使用 router.put 因為 useForm 的 put 不支援傳入自定義 data 物件而不影響 state
|
||||
// 或者先 setData 再 put,但 setData 是非同步的
|
||||
// 這裡採用在提交前手動傳遞格式化後的資料給 router
|
||||
router.put(route('goods-receipts.update', receipt!.id), submitData, {
|
||||
onSuccess: () => setWarningOpen(false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 新增模式:先檢查重複
|
||||
if (!force) {
|
||||
setIsCheckingDuplicate(true);
|
||||
try {
|
||||
const response = await axios.post(route('goods-receipts.check-duplicate'), data);
|
||||
const response = await axios.post(route('goods-receipts.check-duplicate'), submitData);
|
||||
if (response.data.has_warnings) {
|
||||
setWarnings(response.data.warnings);
|
||||
setWarningOpen(true);
|
||||
return; // 停止並顯示警告
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Duplicate check failed", error);
|
||||
// 檢查失敗則繼續,或視為阻擋?這裡選擇繼續
|
||||
} finally {
|
||||
setIsCheckingDuplicate(false);
|
||||
}
|
||||
}
|
||||
|
||||
post(route('goods-receipts.store'), {
|
||||
router.post(route('goods-receipts.store'), submitData, {
|
||||
onSuccess: () => setWarningOpen(false),
|
||||
});
|
||||
};
|
||||
@@ -319,28 +423,33 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
breadcrumbs={[
|
||||
{ label: '供應鏈管理', href: '#' },
|
||||
{ label: '進貨單管理', href: route('goods-receipts.index') },
|
||||
{ label: '新增進貨單', href: route('goods-receipts.create'), isPage: true },
|
||||
{ label: isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單', href: isEditMode ? route('goods-receipts.edit', receipt!.id) : route('goods-receipts.create'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="新增進貨單" />
|
||||
<Head title={isEditMode ? `編輯進貨單 - ${receipt!.code}` : '新增進貨單'} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('goods-receipts.index')}>
|
||||
<Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary">
|
||||
<Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary" onClick={(e) => {
|
||||
if (isEditMode) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
}}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回進貨單
|
||||
{isEditMode ? '返回' : '返回進貨單'}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-primary-main" />
|
||||
新增進貨單
|
||||
{isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單'}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
建立新的進貨單並入庫
|
||||
{isEditMode ? '修改進貨單內容' : '建立新的進貨單並入庫'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -358,6 +467,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
if (isEditMode) return; // 編輯模式禁止切換類型
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
type: t.id,
|
||||
@@ -368,10 +478,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
setSelectedPO(null);
|
||||
if (t.id !== 'standard') setSelectedVendor(null);
|
||||
}}
|
||||
disabled={isEditMode}
|
||||
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
|
||||
? 'border-primary-main bg-primary-main/5'
|
||||
: 'border-gray-100 hover:border-gray-200'
|
||||
}`}
|
||||
} ${isEditMode ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
>
|
||||
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
|
||||
{t.label}
|
||||
@@ -457,9 +568,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<span className="font-bold text-gray-800">{selectedPO.items.length} 項</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
{!isEditMode && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -497,9 +610,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
{!isEditMode && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -507,7 +622,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
</div>
|
||||
|
||||
{/* Step 2: Details & Items */}
|
||||
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
||||
{(isEditMode || (data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
|
||||
@@ -560,42 +675,24 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-700">商品明細</h3>
|
||||
{data.type !== 'standard' && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜尋商品加入..."
|
||||
value={productSearch}
|
||||
onChange={(e) => setProductSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
|
||||
className="h-9 w-64 pl-9"
|
||||
/>
|
||||
{foundProducts.length > 0 && (
|
||||
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
|
||||
{foundProducts.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAddProduct(p)}
|
||||
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
|
||||
>
|
||||
<span className="font-bold text-sm">{p.name}</span>
|
||||
<span className="text-xs text-gray-500">{p.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
|
||||
加入
|
||||
</Button>
|
||||
</div>
|
||||
{isNonStandard && (
|
||||
<Button
|
||||
onClick={handleAddEmptyItem}
|
||||
className="button-filled-primary h-10 gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> 新增品項
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calculated Totals for usage in Table Footer or Summary */}
|
||||
{(() => {
|
||||
const subTotal = data.items.reduce((acc, item) => {
|
||||
if (isNonStandard) {
|
||||
// 非標準類型:使用手動輸入的小計
|
||||
return acc + (parseFloat(item.subtotal) || 0);
|
||||
}
|
||||
// 標準類型:自動計算 qty × price
|
||||
const qty = parseFloat(item.quantity_received) || 0;
|
||||
const price = parseFloat(item.unit_price) || 0;
|
||||
return acc + (qty * price);
|
||||
@@ -609,21 +706,28 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">商品資訊</TableHead>
|
||||
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
||||
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
||||
<TableHead className="w-[120px]">本次收貨 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className={isNonStandard ? 'w-[220px]' : 'w-[180px]'}>商品資訊</TableHead>
|
||||
{!isNonStandard && (
|
||||
<>
|
||||
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
||||
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="w-[120px]">{isNonStandard ? '數量' : '本次收貨'} <span className="text-red-500">*</span></TableHead>
|
||||
{isNonStandard && (
|
||||
<TableHead className="w-[100px]">單價</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[200px]">批號設定 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[150px]">效期</TableHead>
|
||||
<TableHead className="w-[80px] text-right">小計</TableHead>
|
||||
<TableHead className={isNonStandard ? 'w-[120px]' : 'w-[80px] text-right'}>小計{isNonStandard && <> <span className="text-red-500">*</span></>}</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-400 italic">
|
||||
尚無明細,請搜尋商品加入。
|
||||
<TableCell colSpan={isNonStandard ? 6 : 8} className="text-center py-8 text-gray-400 italic">
|
||||
{isNonStandard ? '尚無明細,請點擊「新增品項」加入。' : '尚無明細,請搜尋商品加入。'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -635,25 +739,41 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
||||
{/* Product Info */}
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.product_name}</span>
|
||||
<span className="text-xs text-gray-500">{item.product_code}</span>
|
||||
</div>
|
||||
{isNonStandard && !item.product_id ? (
|
||||
<SearchableSelect
|
||||
value=""
|
||||
onValueChange={(val) => handleSelectProduct(index, val)}
|
||||
options={allProducts.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: p.id.toString()
|
||||
}))}
|
||||
placeholder={isLoadingProducts ? '載入中...' : '選擇商品...'}
|
||||
searchPlaceholder="搜尋商品名稱或代碼..."
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.product_name}</span>
|
||||
<span className="text-xs text-gray-500">{item.product_code}</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Total Quantity */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{Math.round(item.quantity_ordered)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Remaining */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-900 font-medium text-sm">
|
||||
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* Total Quantity & Remaining - 僅標準採購顯示 */}
|
||||
{!isNonStandard && (
|
||||
<>
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{Math.round(item.quantity_ordered)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-900 font-medium text-sm">
|
||||
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Received Quantity */}
|
||||
<TableCell>
|
||||
@@ -670,19 +790,88 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Unit Price - 僅非標準類型顯示 */}
|
||||
{isNonStandard && (
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
value={item.unit_price || ''}
|
||||
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
|
||||
className="w-full text-right"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Batch Settings */}
|
||||
<TableCell>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={item.originCountry || 'TW'}
|
||||
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
||||
placeholder="產地"
|
||||
maxLength={2}
|
||||
className="w-16 text-center px-1"
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 統一批號選擇器 */}
|
||||
<SearchableSelect
|
||||
value={item.batchMode === 'none' ? 'no_batch' : (item.batchMode === 'new' ? 'new_batch' : (item.inventory_id || ""))}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'new_batch') {
|
||||
const updatedItem = {
|
||||
...item,
|
||||
batchMode: 'new',
|
||||
inventory_id: undefined,
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
};
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = updatedItem;
|
||||
setData('items', newItems);
|
||||
} else if (value === 'no_batch') {
|
||||
const updatedItem = {
|
||||
...item,
|
||||
batchMode: 'none',
|
||||
batch_number: 'NO-BATCH',
|
||||
inventory_id: undefined,
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
};
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = updatedItem;
|
||||
setData('items', newItems);
|
||||
} else {
|
||||
// 選擇現有批號 (如果有快照的話,目前架構下先保留 basic 選項)
|
||||
updateItem(index, 'batchMode', 'existing');
|
||||
updateItem(index, 'inventory_id', value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "📦 不使用批號 (自動累加)", value: "no_batch" },
|
||||
{ label: "+ 建立新批號", value: "new_batch" },
|
||||
// 若有現有批號列表可在此擴充,目前進貨單主要處理 new/none
|
||||
]}
|
||||
placeholder="選擇或建立批號"
|
||||
className="border-gray-200"
|
||||
/>
|
||||
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
|
||||
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
|
||||
</div>
|
||||
|
||||
{/* 建立新批號時的附加欄位 */}
|
||||
{item.batchMode === 'new' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item.originCountry || 'TW'}
|
||||
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
||||
placeholder="產地"
|
||||
maxLength={2}
|
||||
className="w-12 h-8 text-center px-1 text-xs"
|
||||
/>
|
||||
<div className="flex-1 text-[10px] font-mono px-2 py-1.5 rounded truncate bg-primary-50 text-primary-main border border-primary-100 flex items-center">
|
||||
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 不使用批號時的提示 */}
|
||||
{item.batchMode === 'none' && (
|
||||
<p className="text-[10px] text-amber-600 bg-amber-50/50 px-2 py-1 rounded border border-amber-100/50">
|
||||
系統將自動累計至該商品的通用庫存紀錄
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -701,8 +890,20 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
</TableCell>
|
||||
|
||||
{/* Subtotal */}
|
||||
<TableCell className="text-right font-medium">
|
||||
${itemTotal.toLocaleString()}
|
||||
<TableCell className={isNonStandard ? '' : 'text-right font-medium'}>
|
||||
{isNonStandard ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
value={item.subtotal || ''}
|
||||
onChange={(e) => updateItem(index, 'subtotal', e.target.value)}
|
||||
className="w-full text-right"
|
||||
placeholder="0"
|
||||
/>
|
||||
) : (
|
||||
<span>${itemTotal.toLocaleString()}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -765,10 +966,10 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
size="lg"
|
||||
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
onClick={submit}
|
||||
disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
|
||||
disabled={processing || isCheckingDuplicate || (!isEditMode && (data.type === 'standard' ? !selectedPO : !selectedVendor))}
|
||||
>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
{processing || isCheckingDuplicate ? '處理中...' : '確認進貨'}
|
||||
{processing || isCheckingDuplicate ? '處理中...' : (isEditMode ? '儲存變更' : '確認進貨')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -136,8 +136,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
|
||||
| `warehouse_id` | Integer | 否 | 指定出貨倉庫的系統 ID (若已知) |
|
||||
| `warehouse` | String | 否 | 指定出貨倉庫名稱。若 `warehouse_id` 與此欄皆未傳,系統將預設寫入並建立「銷售倉庫」 |
|
||||
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`STORE-001`)。若找不到對應倉庫將直接拒絕請求 |
|
||||
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
|
||||
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
||||
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
|
||||
@@ -154,7 +153,7 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
```json
|
||||
{
|
||||
"external_order_id": "ORD-20231026-0001",
|
||||
"warehouse": "台北大安門市",
|
||||
"warehouse_code": "STORE-001",
|
||||
"payment_method": "credit_card",
|
||||
"sold_at": "2023-10-26 14:30:00",
|
||||
"items": [
|
||||
|
||||
@@ -22,6 +22,10 @@ Route::get('/api/docs', function () {
|
||||
|
||||
$markdown = file_get_contents($path);
|
||||
|
||||
// 動態替換 API Base URL 的租戶網域
|
||||
$currentHost = request()->getHost();
|
||||
$markdown = str_replace('[租戶網域]', $currentHost, $markdown);
|
||||
|
||||
// 解析 Markdown 內容
|
||||
$content = Illuminate\Support\Str::markdown($markdown, [
|
||||
'html_input' => 'strip',
|
||||
|
||||
Reference in New Issue
Block a user