From 5be4d496799c40d84e5ad0cb08b6cc14cf7974f5 Mon Sep 17 00:00:00 2001
From: sky121113
Date: Thu, 12 Feb 2026 16:30:34 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=AD=A3=20BOM=20=E5=96=AE?=
=?UTF-8?q?=E4=BD=8D=E9=A1=AF=E7=A4=BA=E8=88=87=E5=AE=8C=E5=B7=A5=E5=85=A5?=
=?UTF-8?q?=E5=BA=AB=E5=BD=88=E7=AA=97=20UI=20=E7=B5=B1=E4=B8=80=E8=A6=8F?=
=?UTF-8?q?=E7=AF=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent/rules/framework.md | 8 +-
.../Controllers/ProductionOrderController.php | 226 ++++-----
.../Production/Models/ProductionOrder.php | 53 ++
app/Modules/Production/Routes/web.php | 6 +
..._production_order_approval_permissions.php | 49 ++
...status_enum_in_production_orders_table.php | 34 ++
...er_nullable_on_production_orders_table.php | 28 ++
...es_nullable_on_production_orders_table.php | 30 ++
database/seeders/PermissionSeeder.php | 2 +
.../ProductionOrderStatusBadge.tsx | 46 ++
.../ProductionStatusProgressBar.tsx | 94 ++++
.../WarehouseSelectionModal.tsx | 147 ++++++
.../js/Components/ui/searchable-select.tsx | 18 +-
resources/js/Pages/Production/Create.tsx | 181 +++----
resources/js/Pages/Production/Edit.tsx | 196 ++------
resources/js/Pages/Production/Index.tsx | 65 +--
resources/js/Pages/Production/Show.tsx | 463 ++++++++++++------
resources/js/constants/production-order.ts | 41 ++
resources/js/lib/date.ts | 41 ++
resources/js/lib/utils.ts | 7 +
20 files changed, 1186 insertions(+), 549 deletions(-)
create mode 100644 database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php
create mode 100644 database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php
create mode 100644 database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php
create mode 100644 database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php
create mode 100644 resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx
create mode 100644 resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx
create mode 100644 resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx
create mode 100644 resources/js/constants/production-order.ts
create mode 100644 resources/js/lib/date.ts
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 (
+
+ {config.label}
+
+ );
+}
diff --git a/resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx b/resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx
new file mode 100644
index 0000000..06e78ab
--- /dev/null
+++ b/resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx
@@ -0,0 +1,94 @@
+/**
+ * 生產工單狀態流程條組件
+ */
+
+import { Check } from "lucide-react";
+import { ProductionOrderStatus, PRODUCTION_ORDER_STATUS } from "@/constants/production-order";
+
+interface ProductionStatusProgressBarProps {
+ currentStatus: ProductionOrderStatus;
+}
+
+// 流程步驟定義
+const FLOW_STEPS: { key: ProductionOrderStatus; label: string }[] = [
+ { key: PRODUCTION_ORDER_STATUS.DRAFT, label: "草稿" },
+ { key: PRODUCTION_ORDER_STATUS.PENDING, label: "簽核中" },
+ { key: PRODUCTION_ORDER_STATUS.APPROVED, label: "已核准" },
+ { key: PRODUCTION_ORDER_STATUS.IN_PROGRESS, label: "製作中" },
+ { key: PRODUCTION_ORDER_STATUS.COMPLETED, label: "製作完成" },
+];
+
+export function ProductionStatusProgressBar({ currentStatus }: ProductionStatusProgressBarProps) {
+ // 對於已作廢狀態,我們顯示到它作廢前的最後一個有效狀態(通常顯示到核准後或簽核中)
+ // 這裡我們比照採購單邏輯,如果已作廢,可能停在最後一個有效位置
+ const effectiveStatus = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED ? PRODUCTION_ORDER_STATUS.PENDING : currentStatus;
+
+ // 找到當前狀態在流程中的位置
+ const currentIndex = FLOW_STEPS.findIndex((step) => step.key === effectiveStatus);
+
+ return (
+
+
生產工單處理進度
+
+ {/* 進度條背景 */}
+
+
+ {/* 進度條進度 */}
+ {currentIndex >= 0 && (
+
+ )}
+
+ {/* 步驟標記 */}
+
+ {FLOW_STEPS.map((step, index) => {
+ const isCompleted = index < currentIndex;
+ const isCurrent = index === currentIndex;
+ const isRejectedAtThisStep = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED && step.key === PRODUCTION_ORDER_STATUS.PENDING;
+
+ return (
+
+ {/* 圓點 */}
+
+ {isCompleted && !isRejectedAtThisStep ? (
+
+ ) : (
+ {index + 1}
+ )}
+
+
+ {/* 標籤 */}
+
+
+ {isRejectedAtThisStep ? "已作廢" : step.label}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx
new file mode 100644
index 0000000..c500cb6
--- /dev/null
+++ b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx
@@ -0,0 +1,147 @@
+/**
+ * 生產工單完工入庫 - 選擇倉庫彈窗
+ */
+
+import React from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog";
+import { Button } from "@/Components/ui/button";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
+import { Input } from "@/Components/ui/input";
+import { Label } from "@/Components/ui/label";
+import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
+
+interface Warehouse {
+ id: number;
+ name: string;
+}
+
+interface WarehouseSelectionModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: (data: {
+ warehouseId: number;
+ batchNumber: string;
+ expiryDate: string;
+ }) => void;
+ warehouses: Warehouse[];
+ processing?: boolean;
+ // 新增商品資訊以利產生批號
+ productCode?: string;
+ productId?: number;
+}
+
+export default function WarehouseSelectionModal({
+ isOpen,
+ onClose,
+ onConfirm,
+ warehouses,
+ processing = false,
+ productCode,
+ productId,
+}: WarehouseSelectionModalProps) {
+ const [selectedId, setSelectedId] = React.useState(null);
+ const [batchNumber, setBatchNumber] = React.useState("");
+ const [expiryDate, setExpiryDate] = React.useState("");
+
+ // 當開啟時,嘗試產生成品批號 (若有資訊)
+ React.useEffect(() => {
+ if (isOpen && productCode && productId) {
+ const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
+ const originCountry = 'TW';
+
+ // 先放一個預設值,實際序號由後端在儲存時再次確認或提供 API
+ fetch(`/api/warehouses/${selectedId || warehouses[0]?.id || 1}/inventory/batches/${productId}?originCountry=${originCountry}&arrivalDate=${new Date().toISOString().split('T')[0]}`)
+ .then(res => res.json())
+ .then(result => {
+ const seq = result.nextSequence || '01';
+ setBatchNumber(`${productCode}-${originCountry}-${today}-${seq}`);
+ })
+ .catch(() => {
+ setBatchNumber(`${productCode}-${originCountry}-${today}-01`);
+ });
+ }
+ }, [isOpen, productCode, productId]);
+
+ const handleConfirm = () => {
+ if (selectedId && batchNumber) {
+ onConfirm({
+ warehouseId: selectedId,
+ batchNumber,
+ expiryDate
+ });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx
index 8e411b9..0236bf3 100644
--- a/resources/js/Components/ui/searchable-select.tsx
+++ b/resources/js/Components/ui/searchable-select.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Check, ChevronsUpDown } from "lucide-react";
+import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -36,6 +36,8 @@ interface SearchableSelectProps {
searchThreshold?: number;
/** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
showSearch?: boolean;
+ /** 是否可清除選取 */
+ isClearable?: boolean;
}
export function SearchableSelect({
@@ -49,6 +51,7 @@ export function SearchableSelect({
className,
searchThreshold = 10,
showSearch,
+ isClearable = false,
}: SearchableSelectProps) {
const [open, setOpen] = React.useState(false);
@@ -86,7 +89,18 @@ export function SearchableSelect({
{selectedOption ? selectedOption.label : placeholder}
-
+
+ {isClearable && value && !disabled && (
+ {
+ e.stopPropagation();
+ onValueChange("");
+ }}
+ />
+ )}
+
+
(""); // 產出倉庫
// 快取對照表:product_id -> inventories across warehouses
const [productInventoryMap, setProductInventoryMap] = useState>({});
@@ -100,11 +100,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
product_id: "",
warehouse_id: "",
output_quantity: "",
- output_batch_number: "",
- // 移除 Box Count UI
- // 移除相關邏輯
- production_date: new Date().toISOString().split('T')[0],
- expiry_date: "",
+ // 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
@@ -184,13 +180,14 @@ export default function ProductionCreate({ products, warehouses }: Props) {
}
}
- // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊
+ // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
- item.quantity_used = "";
- item.unit_id = "";
- item.ui_input_quantity = "";
- item.ui_selected_unit = "base";
+ // 不重置數量
+ // item.quantity_used = "";
+ // item.ui_input_quantity = "";
+ // item.ui_selected_unit = "base";
+
// 清除某些 cache
delete item.ui_batch_number;
delete item.ui_available_qty;
@@ -215,6 +212,11 @@ export default function ProductionCreate({ products, warehouses }: Props) {
// 預設單位
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
+
+ // 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
+ if (!item.ui_input_quantity) {
+ item.ui_input_quantity = formatQuantity(inv.quantity);
+ }
}
}
@@ -298,27 +300,6 @@ export default function ProductionCreate({ products, warehouses }: Props) {
useEffect(() => {
if (!data.product_id) return;
- // 1. 自動產生成品批號
- const product = products.find(p => String(p.id) === data.product_id);
- if (product) {
- const datePart = data.production_date;
- const dateFormatted = datePart.replace(/-/g, '');
- const originCountry = 'TW';
- const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
-
- fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
- .then(res => res.json())
- .then(result => {
- const seq = result.nextSequence || '01';
- const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
- setData('output_batch_number', suggested);
- })
- .catch(() => {
- const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
- setData('output_batch_number', suggested);
- });
- }
-
// 2. 自動載入配方列表
const fetchRecipes = async () => {
try {
@@ -362,9 +343,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const missingFields = [];
if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量');
- if (!data.output_batch_number) missingFields.push('成品批號');
- if (!data.production_date) missingFields.push('生產日期');
- if (!selectedWarehouse) missingFields.push('入庫倉庫');
+ if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
@@ -387,6 +366,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), {
...data,
+ warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems,
status: status,
}, {
@@ -430,25 +410,14 @@ export default function ProductionCreate({ products, warehouses }: Props) {
建立新的生產排程,選擇原物料並記錄產出
-
-
-
-
+
@@ -499,56 +468,16 @@ export default function ProductionCreate({ products, warehouses }: Props) {
setData('output_quantity', e.target.value)}
placeholder="例如: 50"
- className="h-9"
+ className="h-9 font-mono"
/>
{errors.output_quantity && {errors.output_quantity}
}
-
-
setData('output_batch_number', e.target.value)}
- placeholder="選擇商品後自動產生"
- className="h-9 font-mono"
- />
- {errors.output_batch_number &&
{errors.output_batch_number}
}
-
-
-
-
-
-
-
-
- setData('production_date', e.target.value)}
- className="h-9 pl-9"
- />
-
- {errors.production_date &&
{errors.production_date}
}
-
-
-
-
-
-
- setData('expiry_date', e.target.value)}
- className="h-9 pl-9"
- />
-
-
-
-
-
+
- 商品 *
- 來源倉庫 *
- 批號 *
- 數量 *
- 單位
-
+ 商品 *
+ 來源倉庫 *
+ 批號 *
+ 數量 *
+ 單位
+
@@ -618,7 +547,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
value: String(p.id)
}));
- // 2. 來源倉庫選項 (根據商品库庫存過濾)
+ // 2. 來源倉庫選項 (根據商品庫存過濾)
const currentInventories = productInventoryMap[item.ui_product_id] || [];
const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
@@ -629,12 +558,13 @@ export default function ProductionCreate({ products, warehouses }: Props) {
? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
- // 3. 批號選項 (根據商品與倉庫過濾)
+ // 3. 批號選項 (利用 sublabel 顯示詳細資訊,保持選中後簡潔)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
- label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
- value: String(inv.id)
+ label: inv.batch_number,
+ value: String(inv.id),
+ sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
}));
return (
@@ -678,11 +608,16 @@ export default function ProductionCreate({ products, warehouses }: Props) {
/>
{item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
- if (selectedInv) return (
-
- 有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
-
- );
+ if (selectedInv) {
+ const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
+ return (
+
+ 有效日期: {selectedInv.expiry_date || '無'} |
+ 庫存: {formatQuantity(selectedInv.quantity)}
+ {isInsufficient && ' (庫存不足!)'}
+
+ );
+ }
return null;
})()}
@@ -692,7 +627,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0"
className="h-9 text-right"
@@ -700,20 +635,22 @@ export default function ProductionCreate({ products, warehouses }: Props) {
/>
- {/* 4. 選擇單位 */}
-
- {item.ui_base_unit_name}
+ {/* 4. 單位 */}
+
+
+ {item.ui_base_unit_name || '-'}
+
-
diff --git a/resources/js/Pages/Production/Edit.tsx b/resources/js/Pages/Production/Edit.tsx
index 9615f01..2743cde 100644
--- a/resources/js/Pages/Production/Edit.tsx
+++ b/resources/js/Pages/Production/Edit.tsx
@@ -4,10 +4,11 @@
*/
import { useState, useEffect } from "react";
-import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
+import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react";
import { Button } from "@/Components/ui/button";
+import { formatQuantity } from "@/lib/utils";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
-import { Head, router, useForm, Link } from "@inertiajs/react";
+import { router, useForm, Head, Link } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
@@ -107,10 +108,6 @@ interface ProductionOrder {
product_id: number;
warehouse_id: number | null;
output_quantity: number;
- output_batch_number: string;
- output_box_count: string | null;
- production_date: string;
- expiry_date: string | null;
remark: string | null;
status: string;
items: ProductionOrderItem[];
@@ -126,18 +123,9 @@ interface Props {
}
export default function ProductionEdit({ productionOrder, products, warehouses }: Props) {
- // 日期格式轉換輔助函數
- const formatDate = (dateValue: string | null | undefined): string => {
- if (!dateValue) return '';
- // 處理可能的 ISO 格式或 YYYY-MM-DD 格式
- const date = new Date(dateValue);
- if (isNaN(date.getTime())) return dateValue;
- return date.toISOString().split('T')[0];
- };
-
const [selectedWarehouse, setSelectedWarehouse] = useState(
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
- ); // 產出倉庫
+ ); // 預計入庫倉庫
// 快取對照表:product_id -> inventories
const [productInventoryMap, setProductInventoryMap] = useState>({});
@@ -169,7 +157,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
// UI Initial State (復原)
ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "",
ui_product_id: item.inventory ? String(item.inventory.product_id) : "",
- ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
+ ui_input_quantity: formatQuantity(item.quantity_used),
ui_selected_unit: 'base',
// UI 輔助
@@ -183,11 +171,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const { data, setData, processing, errors } = useForm({
product_id: String(productionOrder.product_id),
warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "",
- output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "",
- output_batch_number: productionOrder.output_batch_number || "",
- output_box_count: productionOrder.output_box_count || "",
- production_date: formatDate(productionOrder.production_date) || new Date().toISOString().split('T')[0],
- expiry_date: formatDate(productionOrder.expiry_date),
+ output_quantity: productionOrder.output_quantity ? formatQuantity(productionOrder.output_quantity) : "",
remark: productionOrder.remark || "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
@@ -210,7 +194,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
if (inv) {
return {
...item,
- ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
+ ui_warehouse_id: String(inv.warehouse_id),
ui_product_name: inv.product_name,
ui_batch_number: inv.batch_number,
ui_available_qty: inv.quantity,
@@ -255,7 +239,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const updated = [...bomItems];
const item = { ...updated[index], [field]: value };
- // 0. 當選擇商品變更時 (第一層)
+ // 0. 當選擇商品變更時
if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
item.inventory_id = "";
@@ -263,7 +247,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
- // 保留基本資訊
if (value) {
const prod = products.find(p => String(p.id) === value);
if (prod) {
@@ -274,16 +257,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
}
}
- // 1. 當選擇來源倉庫變更時 (第二層)
+ // 1. 當選擇來源倉庫變更時
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
- item.quantity_used = "";
- item.unit_id = "";
- item.ui_input_quantity = "";
- item.ui_selected_unit = "base";
}
- // 2. 當選擇批號 (Inventory) 變更時 (第三層)
+ // 2. 當選擇批號 (Inventory) 變更時
if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value);
@@ -302,6 +281,10 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
+
+ if (!item.ui_input_quantity) {
+ item.ui_input_quantity = String(inv.quantity);
+ }
}
}
@@ -332,18 +315,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
})));
}, [bomItems]);
- // 提交表單(完成模式)
- // 提交表單(完成模式)
- // 提交表單(完成模式)
+ // 提交表單
const submit = (status: 'draft' | 'completed') => {
- // 驗證(簡單前端驗證)
if (status === 'completed') {
const missingFields = [];
if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量');
- if (!data.output_batch_number) missingFields.push('成品批號');
- if (!data.production_date) missingFields.push('生產日期');
- if (!selectedWarehouse) missingFields.push('入庫倉庫');
+ if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
@@ -384,24 +362,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
- submit('completed');
+ submit('draft');
};
-
-
return (
-
+
@@ -409,38 +385,27 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
- 編輯生產工單
+ 編輯生產工單:{productionOrder.code}
- 編輯工單內容與排程
+ 僅限草稿狀態可進行內容修正
-