diff --git a/.agent/rules/framework.md b/.agent/rules/framework.md
index eca2e04..f1c5b2d 100644
--- a/.agent/rules/framework.md
+++ b/.agent/rules/framework.md
@@ -84,4 +84,10 @@ trigger: always_on
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
* **執行 Composer**: `./vendor/bin/sail composer install`
-* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
\ No newline at end of file
+* **執行 Node/NPM**: `./vendor/bin/sail npm run dev`
+
+## 10. 日期處理 (Date Handling)
+
+- 前端顯示日期時預設使用 `resources/js/lib/date.ts` 提供的 `formatDate` 工具。
+- 避免直接顯示原始 ISO 字串(如 `...Z` 結尾的格式)。
+- **智慧格式切換**:`formatDate` 會自動判斷原始資料,若時間部分為 `00:00:00` 則僅顯示 `YYYY-MM-DD`,否則顯示 `YYYY-MM-DD HH:mm:ss`。
\ No newline at end of file
diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php
index 41af550..fe69c93 100644
--- a/app/Modules/Production/Controllers/ProductionOrderController.php
+++ b/app/Modules/Production/Controllers/ProductionOrderController.php
@@ -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', '生產單已刪除');
diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php
index 60fbe20..1197842 100644
--- a/app/Modules/Production/Models/ProductionOrder.php
+++ b/app/Modules/Production/Models/ProductionOrder.php
@@ -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',
diff --git a/app/Modules/Production/Routes/web.php b/app/Modules/Production/Routes/web.php
index b0ceb15..ba87643 100644
--- a/app/Modules/Production/Routes/web.php
+++ b/app/Modules/Production/Routes/web.php
@@ -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
diff --git a/database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php b/database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php
new file mode 100644
index 0000000..7ed76c5
--- /dev/null
+++ b/database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php
@@ -0,0 +1,49 @@
+ '核准生產工單',
+ 'production_orders.cancel' => '作廢生產工單',
+ ];
+
+ foreach ($permissions as $name => $description) {
+ Permission::firstOrCreate(
+ ['name' => $name, 'guard_name' => $guard],
+ ['name' => $name, 'guard_name' => $guard]
+ );
+ }
+
+ // 授予 super-admin 所有新權限
+ $superAdmin = Role::where('name', 'super-admin')->first();
+ if ($superAdmin) {
+ $superAdmin->givePermissionTo(array_keys($permissions));
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ $permissions = [
+ 'production_orders.approve',
+ 'production_orders.cancel',
+ ];
+
+ foreach ($permissions as $name) {
+ Permission::where('name', $name)->delete();
+ }
+ }
+};
diff --git a/database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php b/database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php
new file mode 100644
index 0000000..56447c8
--- /dev/null
+++ b/database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php
@@ -0,0 +1,34 @@
+enum('status', ['draft', 'pending', 'approved', 'in_progress', 'completed', 'cancelled'])
+ ->default('draft')
+ ->comment('狀態:草稿/待審/核准/製作中/完成/取消')
+ ->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('production_orders', function (Blueprint $table) {
+ $table->enum('status', ['draft', 'completed', 'cancelled'])
+ ->default('completed')
+ ->comment('狀態:草稿/完成/取消')
+ ->change();
+ });
+ }
+};
diff --git a/database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php b/database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php
new file mode 100644
index 0000000..db98318
--- /dev/null
+++ b/database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php
@@ -0,0 +1,28 @@
+string('output_batch_number')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('production_orders', function (Blueprint $table) {
+ $table->string('output_batch_number')->nullable(false)->change();
+ });
+ }
+};
diff --git a/database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php b/database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php
new file mode 100644
index 0000000..309cf7f
--- /dev/null
+++ b/database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php
@@ -0,0 +1,30 @@
+date('production_date')->nullable()->change();
+ $table->date('expiry_date')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('production_orders', function (Blueprint $table) {
+ $table->date('production_date')->nullable(false)->change();
+ $table->date('expiry_date')->nullable(false)->change();
+ });
+ }
+};
diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php
index c161f66..7ab10d1 100644
--- a/database/seeders/PermissionSeeder.php
+++ b/database/seeders/PermissionSeeder.php
@@ -77,6 +77,8 @@ class PermissionSeeder extends Seeder
'production_orders.create' => '建立',
'production_orders.edit' => '編輯',
'production_orders.delete' => '刪除',
+ 'production_orders.approve' => '核准',
+ 'production_orders.cancel' => '作廢',
// 配方管理
'recipes.view' => '檢視',
diff --git a/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx b/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx
new file mode 100644
index 0000000..54414bb
--- /dev/null
+++ b/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx
@@ -0,0 +1,46 @@
+/**
+ * 生產工單狀態標籤組件
+ */
+
+import { Badge } from "@/Components/ui/badge";
+import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order";
+
+interface ProductionOrderStatusBadgeProps {
+ status: ProductionOrderStatus;
+ className?: string;
+}
+
+export default function ProductionOrderStatusBadge({
+ status,
+ className,
+}: ProductionOrderStatusBadgeProps) {
+ const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
+
+ const getStatusStyles = (status: string) => {
+ switch (status) {
+ case 'draft':
+ return 'bg-gray-100 text-gray-600 border-gray-200';
+ case 'pending':
+ return 'bg-blue-50 text-blue-600 border-blue-200';
+ case 'approved':
+ return 'bg-primary text-primary-foreground border-transparent';
+ case 'in_progress':
+ return 'bg-amber-50 text-amber-600 border-amber-200';
+ case 'completed':
+ return 'bg-primary text-primary-foreground border-transparent transition-all shadow-sm';
+ case 'cancelled':
+ return 'bg-destructive text-destructive-foreground border-transparent';
+ default:
+ return 'bg-gray-50 text-gray-500 border-gray-200';
+ }
+ };
+
+ return (
+
+ {isRejectedAtThisStep ? "已作廢" : step.label} +
+{errors.output_quantity}
}{errors.output_batch_number}
} -{errors.production_date}
} -- 編輯工單內容與排程 + 僅限草稿狀態可進行內容修正