feat: 修正 BOM 單位顯示與完工入庫彈窗 UI 統一規範

This commit is contained in:
2026-02-12 16:30:34 +08:00
parent eb5ab58093
commit 5be4d49679
20 changed files with 1186 additions and 549 deletions

View File

@@ -106,23 +106,16 @@ class ProductionOrderController extends Controller
{
$status = $request->input('status', 'draft');
$baseRules = [
$rules = [
'product_id' => 'required',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable',
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric',
'items' => 'nullable|array',
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable',
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric',
];
$completedRules = [
'warehouse_id' => 'required',
'output_quantity' => 'required|numeric|min:0.01',
'production_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
];
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status) {
@@ -132,12 +125,12 @@ class ProductionOrderController extends Controller
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_batch_number' => $request->output_batch_number, // 建立時改為選填
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'production_date' => $request->production_date,
'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(),
'status' => $status,
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
'remark' => $request->remark,
]);
@@ -155,43 +148,12 @@ class ProductionOrderController extends Controller
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
if ($status === 'completed') {
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
// 3. 成品入庫
if ($status === 'completed') {
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
return redirect()->route('production-orders.index')
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
->with('success', '生產單草稿已建立');
}
/**
@@ -204,7 +166,9 @@ class ProductionOrderController extends Controller
if ($productionOrder->product) {
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
}
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->warehouse = $productionOrder->warehouse_id
? $this->inventoryService->getWarehouse($productionOrder->warehouse_id)
: null;
$productionOrder->user = $this->coreService->getUser($productionOrder->user_id);
// 手動水和明細資料
@@ -214,7 +178,7 @@ class ProductionOrderController extends Controller
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit']
['product.baseUnit', 'warehouse']
)->keyBy('id');
// 手動載入 Purchase Orders
@@ -238,6 +202,7 @@ class ProductionOrderController extends Controller
return Inertia::render('Production/Show', [
'productionOrder' => $productionOrder,
'warehouses' => $this->inventoryService->getAllWarehouses(),
]);
}
@@ -308,7 +273,9 @@ class ProductionOrderController extends Controller
// 基本水和
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->warehouse = $productionOrder->warehouse_id
? $this->inventoryService->getWarehouse($productionOrder->warehouse_id)
: null;
// 手動水和明細資料
$items = $productionOrder->items;
@@ -346,39 +313,27 @@ class ProductionOrderController extends Controller
$status = $request->input('status', 'draft');
// 基礎驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'required|in:draft,completed',
$rules = [
'product_id' => 'required',
'remark' => 'nullable|string',
'warehouse_id' => 'nullable',
'output_quantity' => 'nullable|numeric',
'items' => 'nullable|array',
'items.*.inventory_id' => 'required',
'items.*.quantity_used' => 'required|numeric',
];
// 完工時的嚴格驗證規則
$completedRules = [
'warehouse_id' => 'required|exists:warehouses,id',
'output_quantity' => 'required|numeric|min:0.01',
'production_date' => 'required|date',
'expiry_date' => 'nullable|date',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required|exists:inventories,id',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
];
// 若狀態切換為 completed需合併驗證規則
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
DB::transaction(function () use ($validated, $request, $productionOrder) {
$productionOrder->update([
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_batch_number' => $request->output_batch_number ?? $productionOrder->output_batch_number,
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $request->expiry_date,
'status' => $status,
'production_date' => $request->production_date ?? $productionOrder->production_date,
'expiry_date' => $request->expiry_date ?? $productionOrder->expiry_date,
'remark' => $request->remark,
]);
@@ -398,38 +353,8 @@ class ProductionOrderController extends Controller
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
if ($status === 'completed') {
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
if ($status === 'completed') {
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
return redirect()->route('production-orders.index')
@@ -437,23 +362,102 @@ class ProductionOrderController extends Controller
}
/**
* 刪除生產單
* 更新生產工單狀態
*/
public function updateStatus(Request $request, ProductionOrder $productionOrder)
{
$newStatus = $request->input('status');
if (!$productionOrder->canTransitionTo($newStatus)) {
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
}
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
$oldStatus = $productionOrder->status;
// 1. 執行特定狀態的業務邏輯
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
// 開始製作 -> 扣除原料庫存
$items = $productionOrder->items;
foreach ($items as $item) {
$this->inventoryService->decreaseInventoryQuantity(
$item->inventory_id,
$item->quantity_used,
"生產單 #{$productionOrder->code} 開始製作 (扣料)",
ProductionOrder::class,
$productionOrder->id
);
}
}
elseif ($oldStatus === ProductionOrder::STATUS_IN_PROGRESS && $newStatus === ProductionOrder::STATUS_COMPLETED) {
// 完成製作 -> 成品入庫
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
if (!$warehouseId) {
throw new \Exception('必須選擇入庫倉庫');
}
if (!$batchNumber) {
throw new \Exception('必須提供成品批號');
}
// 更新單據資訊:批號、效期與自動記錄生產日期
$productionOrder->output_batch_number = $batchNumber;
$productionOrder->expiry_date = $expiryDate;
$productionOrder->production_date = now()->toDateString();
$productionOrder->warehouse_id = $warehouseId;
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $warehouseId,
'product_id' => $productionOrder->product_id,
'quantity' => $productionOrder->output_quantity,
'batch_number' => $batchNumber,
'box_number' => $productionOrder->output_box_count,
'arrival_date' => now()->toDateString(),
'expiry_date' => $expiryDate,
'reason' => "生產單 #{$productionOrder->code} 製作完成 (入庫)",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
}
// 2. 更新狀態
$productionOrder->status = $newStatus;
$productionOrder->save();
// 3. 紀錄 Activity Log
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->withProperties([
'old_status' => $oldStatus,
'new_status' => $newStatus
])
->log("status_updated_to_{$newStatus}");
});
return back()->with('success', '狀態已更新');
}
/**
* 從儲存體中移除指定資源。
*/
public function destroy(ProductionOrder $productionOrder)
{
if ($productionOrder->status === 'completed') {
return redirect()->back()->with('error', '已完工的生產單無法刪除');
// 僅允許刪除草稿或已作廢的單據
if (!in_array($productionOrder->status, [ProductionOrder::STATUS_DRAFT, ProductionOrder::STATUS_CANCELLED])) {
return redirect()->back()->with('error', '僅有草稿或已作廢的生產單可以刪除');
}
DB::transaction(function () use ($productionOrder) {
// 紀錄刪除動作 (需在刪除前或使用軟刪除)
$productionOrder->items()->delete();
$productionOrder->delete();
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('deleted');
$productionOrder->items()->delete();
$productionOrder->delete();
});
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');

View File

@@ -11,6 +11,14 @@ class ProductionOrder extends Model
{
use HasFactory, LogsActivity;
// 狀態常數
const STATUS_DRAFT = 'draft';
const STATUS_PENDING = 'pending';
const STATUS_APPROVED = 'approved';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'code',
'product_id',
@@ -25,6 +33,51 @@ class ProductionOrder extends Model
'remark',
];
/**
* 檢查是否可以轉移至新狀態,並驗證權限。
*/
public function canTransitionTo(string $newStatus, $user = null): bool
{
$user = $user ?? auth()->user();
if (!$user) return false;
if ($user->hasRole('super-admin')) return true;
$currentStatus = $this->status;
// 定義合法的狀態轉移路徑與所需權限
$transitions = [
self::STATUS_DRAFT => [
self::STATUS_PENDING => 'production_orders.view', // 基本檢視者即可送審
self::STATUS_CANCELLED => 'production_orders.cancel',
],
self::STATUS_PENDING => [
self::STATUS_APPROVED => 'production_orders.approve',
self::STATUS_DRAFT => 'production_orders.approve', // 退回草稿
self::STATUS_CANCELLED => 'production_orders.cancel',
],
self::STATUS_APPROVED => [
self::STATUS_IN_PROGRESS => 'production_orders.edit', // 啟動製作需要編輯權限
self::STATUS_CANCELLED => 'production_orders.cancel',
],
self::STATUS_IN_PROGRESS => [
self::STATUS_COMPLETED => 'production_orders.edit', // 完成製作需要編輯權限
self::STATUS_CANCELLED => 'production_orders.cancel',
],
];
if (!isset($transitions[$currentStatus])) {
return false;
}
if (!array_key_exists($newStatus, $transitions[$currentStatus])) {
return false;
}
$requiredPermission = $transitions[$currentStatus][$newStatus];
return $requiredPermission ? $user->can($requiredPermission) : true;
}
protected $casts = [
'production_date' => 'date',
'expiry_date' => 'date',

View File

@@ -23,6 +23,12 @@ Route::middleware('auth')->group(function () {
Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit');
Route::put('/production-orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('production-orders.update');
});
Route::patch('/production-orders/{productionOrder}/update-status', [ProductionOrderController::class, 'updateStatus'])->name('production-orders.update-status');
Route::middleware('permission:production_orders.delete')->group(function () {
Route::delete('/production-orders/{productionOrder}', [ProductionOrderController::class, 'destroy'])->name('production-orders.destroy');
});
});
// 生產管理 API