From 1ae21febb56971c1b04cf97c2e94fe30f180e0d8 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 21 Jan 2026 17:19:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=94=9F=E7=94=A2/=E5=BA=AB=E5=AD=98):=20?= =?UTF-8?q?=E5=AF=A6=E4=BD=9C=E7=94=9F=E7=94=A2=E7=AE=A1=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E7=B5=84=E8=88=87=E6=89=B9=E8=99=9F=E8=BF=BD=E6=BA=AF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/InventoryController.php | 36 +- .../Controllers/ProductionOrderController.php | 196 ++++++++ app/Models/Inventory.php | 45 ++ app/Models/ProductionOrder.php | 120 +++++ app/Models/ProductionOrderItem.php | 47 ++ ...add_batch_columns_to_inventories_table.php | 90 ++++ ..._164523_create_production_orders_table.php | 58 +++ ...30_create_production_order_items_table.php | 46 ++ ...65250_add_production_order_permissions.php | 57 +++ resources/js/Layouts/AuthenticatedLayout.tsx | 15 + resources/js/Pages/Production/Create.tsx | 442 ++++++++++++++++++ resources/js/Pages/Production/Index.tsx | 287 ++++++++++++ resources/js/Pages/Production/Show.tsx | 254 ++++++++++ resources/js/Pages/Warehouse/AddInventory.tsx | 59 +-- resources/js/types/warehouse.ts | 2 + resources/js/utils/breadcrumb.ts | 14 + routes/web.php | 18 + 17 files changed, 1753 insertions(+), 33 deletions(-) create mode 100644 app/Http/Controllers/ProductionOrderController.php create mode 100644 app/Models/ProductionOrder.php create mode 100644 app/Models/ProductionOrderItem.php create mode 100644 database/migrations/tenant/2026_01_21_163715_add_batch_columns_to_inventories_table.php create mode 100644 database/migrations/tenant/2026_01_21_164523_create_production_orders_table.php create mode 100644 database/migrations/tenant/2026_01_21_164530_create_production_order_items_table.php create mode 100644 database/migrations/tenant/2026_01_21_165250_add_production_order_permissions.php create mode 100644 resources/js/Pages/Production/Create.tsx create mode 100644 resources/js/Pages/Production/Index.tsx create mode 100644 resources/js/Pages/Production/Show.tsx diff --git a/app/Http/Controllers/InventoryController.php b/app/Http/Controllers/InventoryController.php index 4911336..b4ced82 100644 --- a/app/Http/Controllers/InventoryController.php +++ b/app/Http/Controllers/InventoryController.php @@ -39,8 +39,8 @@ class InventoryController extends Controller 'quantity' => (float) $inv->quantity, 'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null, 'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態 - 'batchNumber' => 'BATCH-' . $inv->id, // DB 無批號,暫時模擬,某些 UI 可能還會用到 - 'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬 + 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, // 優先使用 DB 批號,若無則 fallback + 'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, 'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null, 'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null, ]; @@ -98,15 +98,39 @@ class InventoryController extends Controller 'items' => 'required|array|min:1', 'items.*.productId' => 'required|exists:products,id', 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.batchNumber' => 'nullable|string', + 'items.*.expiryDate' => 'nullable|date', ]); return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) { foreach ($validated['items'] as $item) { - // 取得或建立庫存紀錄 - // 取得或初始化庫存紀錄 + $batchNumber = $item['batchNumber'] ?? null; + // 如果未提供批號,且系統設定需要批號,則自動產生 (這裡先保留彈性,若無則為 null 或預設) + if (empty($batchNumber)) { + // 嘗試自動產生:需要 product_code, country, date + $product = \App\Models\Product::find($item['productId']); + if ($product) { + $batchNumber = \App\Models\Inventory::generateBatchNumber( + $product->code ?? 'UNK', + 'TW', // 預設來源 + $validated['inboundDate'] + ); + } + } + + // 取得或建立庫存紀錄 (加入批號判斷) $inventory = $warehouse->inventories()->firstOrNew( - ['product_id' => $item['productId']], - ['quantity' => 0, 'safety_stock' => null] + [ + 'product_id' => $item['productId'], + 'batch_number' => $batchNumber + ], + [ + 'quantity' => 0, + 'safety_stock' => null, + 'arrival_date' => $validated['inboundDate'], + 'expiry_date' => $item['expiryDate'] ?? null, + 'origin_country' => 'TW', // 預設 + ] ); $currentQty = $inventory->quantity; diff --git a/app/Http/Controllers/ProductionOrderController.php b/app/Http/Controllers/ProductionOrderController.php new file mode 100644 index 0000000..f487ea2 --- /dev/null +++ b/app/Http/Controllers/ProductionOrderController.php @@ -0,0 +1,196 @@ +filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('code', 'like', "%{$search}%") + ->orWhere('output_batch_number', 'like', "%{$search}%") + ->orWhereHas('product', fn($pq) => $pq->where('name', 'like', "%{$search}%")); + }); + } + + // 狀態篩選 + if ($request->filled('status') && $request->status !== 'all') { + $query->where('status', $request->status); + } + + // 排序 + $sortField = $request->input('sort_field', 'created_at'); + $sortDirection = $request->input('sort_direction', 'desc'); + $allowedSorts = ['id', 'code', 'production_date', 'output_quantity', 'created_at']; + if (!in_array($sortField, $allowedSorts)) { + $sortField = 'created_at'; + } + $query->orderBy($sortField, $sortDirection); + + // 分頁 + $perPage = $request->input('per_page', 10); + $productionOrders = $query->paginate($perPage)->withQueryString(); + + return Inertia::render('Production/Index', [ + 'productionOrders' => $productionOrders, + 'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']), + ]); + } + + /** + * 新增生產單表單 + */ + public function create(): Response + { + return Inertia::render('Production/Create', [ + 'products' => Product::with(['baseUnit'])->get(), + 'warehouses' => Warehouse::all(), + 'units' => Unit::all(), + ]); + } + + /** + * 儲存生產單(含自動扣料與成品入庫) + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'output_quantity' => 'required|numeric|min:0.01', + 'output_batch_number' => 'required|string|max:50', + 'output_box_count' => 'nullable|string|max:10', + 'production_date' => 'required|date', + 'expiry_date' => 'nullable|date|after_or_equal:production_date', + 'remark' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.inventory_id' => 'required|exists:inventories,id', + 'items.*.quantity_used' => 'required|numeric|min:0.0001', + 'items.*.unit_id' => 'nullable|exists:units,id', + ], [ + 'product_id.required' => '請選擇成品商品', + 'warehouse_id.required' => '請選擇入庫倉庫', + 'output_quantity.required' => '請輸入生產數量', + 'output_batch_number.required' => '請輸入成品批號', + 'production_date.required' => '請選擇生產日期', + 'items.required' => '請至少新增一項原物料', + 'items.min' => '請至少新增一項原物料', + ]); + + DB::transaction(function () use ($validated, $request) { + // 1. 建立生產工單 + $productionOrder = ProductionOrder::create([ + 'code' => ProductionOrder::generateCode(), + 'product_id' => $validated['product_id'], + 'warehouse_id' => $validated['warehouse_id'], + 'output_quantity' => $validated['output_quantity'], + 'output_batch_number' => $validated['output_batch_number'], + 'output_box_count' => $validated['output_box_count'] ?? null, + 'production_date' => $validated['production_date'], + 'expiry_date' => $validated['expiry_date'] ?? null, + 'user_id' => auth()->id(), + 'status' => 'completed', + 'remark' => $validated['remark'] ?? null, + ]); + + // 2. 建立明細並扣減原物料庫存 + foreach ($validated['items'] as $item) { + // 建立明細 + ProductionOrderItem::create([ + 'production_order_id' => $productionOrder->id, + 'inventory_id' => $item['inventory_id'], + 'quantity_used' => $item['quantity_used'], + 'unit_id' => $item['unit_id'] ?? null, + ]); + + // 扣減原物料庫存 + $inventory = Inventory::findOrFail($item['inventory_id']); + $inventory->decrement('quantity', $item['quantity_used']); + } + + // 3. 成品入庫:在目標倉庫建立新的庫存紀錄 + $product = Product::findOrFail($validated['product_id']); + Inventory::create([ + 'warehouse_id' => $validated['warehouse_id'], + 'product_id' => $validated['product_id'], + 'quantity' => $validated['output_quantity'], + 'batch_number' => $validated['output_batch_number'], + 'box_number' => $validated['output_box_count'], + 'origin_country' => 'TW', // 生產預設為本地 + 'arrival_date' => $validated['production_date'], + 'expiry_date' => $validated['expiry_date'] ?? null, + 'quality_status' => 'normal', + ]); + }); + + return redirect()->route('production-orders.index') + ->with('success', '生產單已建立,原物料已扣減,成品已入庫'); + } + + /** + * 檢視生產單詳情(含追溯資訊) + */ + public function show(ProductionOrder $productionOrder): Response + { + $productionOrder->load([ + 'product.baseUnit', + 'warehouse', + 'user', + 'items.inventory.product', + 'items.inventory.sourcePurchaseOrder.vendor', + 'items.unit', + ]); + + return Inertia::render('Production/Show', [ + 'productionOrder' => $productionOrder, + ]); + } + + /** + * 取得倉庫內可用庫存(供 BOM 選擇) + */ + public function getWarehouseInventories(Warehouse $warehouse) + { + $inventories = Inventory::with(['product.baseUnit']) + ->where('warehouse_id', $warehouse->id) + ->where('quantity', '>', 0) + ->where('quality_status', 'normal') + ->orderBy('arrival_date', 'asc') // FIFO:舊的排前面 + ->get() + ->map(function ($inv) { + return [ + 'id' => $inv->id, + 'product_id' => $inv->product_id, + 'product_name' => $inv->product->name, + 'product_code' => $inv->product->code, + 'batch_number' => $inv->batch_number, + 'box_number' => $inv->box_number, + 'quantity' => $inv->quantity, + 'arrival_date' => $inv->arrival_date?->format('Y-m-d'), + 'expiry_date' => $inv->expiry_date?->format('Y-m-d'), + 'unit_name' => $inv->product->baseUnit?->name, + ]; + }); + + return response()->json($inventories); + } +} diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index f4fc40f..618d7d9 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -17,6 +17,20 @@ class Inventory extends Model 'quantity', 'safety_stock', 'location', + // 批號追溯欄位 + 'batch_number', + 'box_number', + 'origin_country', + 'arrival_date', + 'expiry_date', + 'source_purchase_order_id', + 'quality_status', + 'quality_remark', + ]; + + protected $casts = [ + 'arrival_date' => 'date:Y-m-d', + 'expiry_date' => 'date:Y-m-d', ]; /** @@ -89,4 +103,35 @@ class Inventory extends Model $query->where('quantity', '>', 0); }); } + + /** + * 來源採購單 + */ + public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id'); + } + + /** + * 產生批號 + * 格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號} + */ + public static function generateBatchNumber(string $productCode, string $originCountry, string $arrivalDate): string + { + $dateFormatted = date('Ymd', strtotime($arrivalDate)); + $prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-"; + + $lastBatch = static::where('batch_number', 'like', "{$prefix}%") + ->orderByDesc('batch_number') + ->first(); + + if ($lastBatch) { + $lastNumber = (int) substr($lastBatch->batch_number, -2); + $nextNumber = str_pad($lastNumber + 1, 2, '0', STR_PAD_LEFT); + } else { + $nextNumber = '01'; + } + + return $prefix . $nextNumber; + } } diff --git a/app/Models/ProductionOrder.php b/app/Models/ProductionOrder.php new file mode 100644 index 0000000..81642a3 --- /dev/null +++ b/app/Models/ProductionOrder.php @@ -0,0 +1,120 @@ + 'date:Y-m-d', + 'expiry_date' => 'date:Y-m-d', + 'output_quantity' => 'decimal:2', + ]; + + /** + * 成品商品 + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * 入庫倉庫 + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * 操作人員 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 生產工單明細 (BOM) + */ + public function items(): HasMany + { + return $this->hasMany(ProductionOrderItem::class); + } + + /** + * 活動日誌設定 + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + /** + * 活動日誌快照 + */ + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + $snapshot = $properties['snapshot'] ?? []; + + // 快照關鍵名稱 + $snapshot['production_code'] = $this->code; + $snapshot['product_name'] = $this->product ? $this->product->name : null; + $snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null; + $snapshot['user_name'] = $this->user ? $this->user->name : null; + + $properties['attributes'] = $attributes; + $properties['snapshot'] = $snapshot; + $activity->properties = $properties; + } + + /** + * 產生生產單號 + */ + public static function generateCode(): string + { + $date = now()->format('Ymd'); + $prefix = "PRO-{$date}-"; + + $lastOrder = static::where('code', 'like', "{$prefix}%") + ->orderByDesc('code') + ->first(); + + if ($lastOrder) { + $lastNumber = (int) substr($lastOrder->code, -3); + $nextNumber = str_pad($lastNumber + 1, 3, '0', STR_PAD_LEFT); + } else { + $nextNumber = '001'; + } + + return $prefix . $nextNumber; + } +} diff --git a/app/Models/ProductionOrderItem.php b/app/Models/ProductionOrderItem.php new file mode 100644 index 0000000..d8cf3fe --- /dev/null +++ b/app/Models/ProductionOrderItem.php @@ -0,0 +1,47 @@ + 'decimal:4', + ]; + + /** + * 所屬生產工單 + */ + public function productionOrder(): BelongsTo + { + return $this->belongsTo(ProductionOrder::class); + } + + /** + * 使用的庫存紀錄 + */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** + * 單位 + */ + public function unit(): BelongsTo + { + return $this->belongsTo(Unit::class); + } +} diff --git a/database/migrations/tenant/2026_01_21_163715_add_batch_columns_to_inventories_table.php b/database/migrations/tenant/2026_01_21_163715_add_batch_columns_to_inventories_table.php new file mode 100644 index 0000000..8e5985d --- /dev/null +++ b/database/migrations/tenant/2026_01_21_163715_add_batch_columns_to_inventories_table.php @@ -0,0 +1,90 @@ +string('batch_number', 50)->nullable()->after('location') + ->comment('批號 (格式: AB-VN-20260119-01)'); + $table->string('box_number', 10)->nullable()->after('batch_number') + ->comment('箱號 (如: 01, 02)'); + + // 批號解析欄位(方便查詢與排序) + $table->string('origin_country', 10)->nullable()->after('box_number') + ->comment('來源國家代碼 (如: VN, TW)'); + $table->date('arrival_date')->nullable()->after('origin_country') + ->comment('入庫日期'); + $table->date('expiry_date')->nullable()->after('arrival_date') + ->comment('效期'); + + // 來源追溯 + $table->foreignId('source_purchase_order_id')->nullable()->after('expiry_date') + ->constrained('purchase_orders')->nullOnDelete() + ->comment('來源採購單'); + + // 品質狀態 + $table->enum('quality_status', ['normal', 'frozen', 'rejected']) + ->default('normal')->after('source_purchase_order_id') + ->comment('品質狀態:正常/凍結/退貨'); + $table->text('quality_remark')->nullable()->after('quality_status') + ->comment('品質異常備註'); + }); + + // Step 2: 為現有資料設定預設批號 (LEGACY-{id}) + DB::statement("UPDATE inventories SET batch_number = CONCAT('LEGACY-', id) WHERE batch_number IS NULL"); + + // Step 3: 將 batch_number 改為必填 + Schema::table('inventories', function (Blueprint $table) { + $table->string('batch_number', 50)->nullable(false)->change(); + }); + + // Step 4: 新增批號相關索引 (不刪除舊索引,因為有外鍵依賴) + // 舊的 warehouse_product_unique 保留,新增更精確的批號索引 + Schema::table('inventories', function (Blueprint $table) { + $table->index(['warehouse_id', 'product_id', 'batch_number'], 'inventories_batch_lookup'); + $table->index(['arrival_date'], 'inventories_arrival_date'); + $table->index(['quality_status'], 'inventories_quality_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventories', function (Blueprint $table) { + // 移除索引 + $table->dropIndex('inventories_batch_lookup'); + $table->dropIndex('inventories_arrival_date'); + $table->dropIndex('inventories_quality_status'); + + // 移除新增欄位 + $table->dropForeign(['source_purchase_order_id']); + $table->dropColumn([ + 'batch_number', + 'box_number', + 'origin_country', + 'arrival_date', + 'expiry_date', + 'source_purchase_order_id', + 'quality_status', + 'quality_remark', + ]); + }); + } +}; diff --git a/database/migrations/tenant/2026_01_21_164523_create_production_orders_table.php b/database/migrations/tenant/2026_01_21_164523_create_production_orders_table.php new file mode 100644 index 0000000..d790d6c --- /dev/null +++ b/database/migrations/tenant/2026_01_21_164523_create_production_orders_table.php @@ -0,0 +1,58 @@ +id(); + $table->string('code', 50)->unique()->comment('生產單號 (如: PRO-20260121-001)'); + + // 成品資訊 + $table->foreignId('product_id')->constrained()->onDelete('restrict') + ->comment('成品商品'); + $table->string('output_batch_number', 50)->comment('成品批號'); + $table->string('output_box_count', 10)->nullable()->comment('成品箱數'); + $table->decimal('output_quantity', 10, 2)->comment('生產數量'); + + // 入庫倉庫 + $table->foreignId('warehouse_id')->constrained()->onDelete('restrict') + ->comment('入庫倉庫'); + + // 生產資訊 + $table->date('production_date')->comment('生產日期'); + $table->date('expiry_date')->nullable()->comment('成品效期'); + + // 操作人員 + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete() + ->comment('操作人員'); + + // 狀態與備註 + $table->enum('status', ['draft', 'completed', 'cancelled']) + ->default('completed')->comment('狀態:草稿/完成/取消'); + $table->text('remark')->nullable()->comment('備註'); + + $table->timestamps(); + + // 索引 + $table->index(['production_date', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('production_orders'); + } +}; diff --git a/database/migrations/tenant/2026_01_21_164530_create_production_order_items_table.php b/database/migrations/tenant/2026_01_21_164530_create_production_order_items_table.php new file mode 100644 index 0000000..ec1b6b3 --- /dev/null +++ b/database/migrations/tenant/2026_01_21_164530_create_production_order_items_table.php @@ -0,0 +1,46 @@ +id(); + + // 所屬生產工單 + $table->foreignId('production_order_id')->constrained()->onDelete('cascade') + ->comment('所屬生產工單'); + + // 使用的庫存(含商品與批號) + $table->foreignId('inventory_id')->constrained()->onDelete('restrict') + ->comment('使用的庫存紀錄 (含 product, batch)'); + + // 使用量 + $table->decimal('quantity_used', 10, 4)->comment('使用量'); + $table->foreignId('unit_id')->nullable()->constrained('units')->nullOnDelete() + ->comment('單位'); + + $table->timestamps(); + + // 索引 + $table->index(['production_order_id', 'inventory_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('production_order_items'); + } +}; diff --git a/database/migrations/tenant/2026_01_21_165250_add_production_order_permissions.php b/database/migrations/tenant/2026_01_21_165250_add_production_order_permissions.php new file mode 100644 index 0000000..76b83eb --- /dev/null +++ b/database/migrations/tenant/2026_01_21_165250_add_production_order_permissions.php @@ -0,0 +1,57 @@ + '檢視生產工單', + 'production_orders.create' => '建立生產工單', + 'production_orders.edit' => '編輯生產工單', + 'production_orders.delete' => '刪除生產工單', + ]; + + 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.view', + 'production_orders.create', + 'production_orders.edit', + 'production_orders.delete', + ]; + + foreach ($permissions as $name) { + Permission::where('name', $name)->delete(); + } + } +}; diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 65736d3..f558424 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -129,6 +129,21 @@ export default function AuthenticatedLayout({ }, ], }, + { + id: "production-management", + label: "生產管理", + icon: , + permission: "production_orders.view", + children: [ + { + id: "production-order-list", + label: "生產工單", + icon: , + route: "/production-orders", + permission: "production_orders.view", + }, + ], + }, { id: "finance-management", label: "財務管理", diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx new file mode 100644 index 0000000..aa2162b --- /dev/null +++ b/resources/js/Pages/Production/Create.tsx @@ -0,0 +1,442 @@ +/** + * 建立生產工單頁面 + * 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量 + */ + +import { useState, useEffect } from "react"; +import { Factory, Plus, Trash2, ArrowLeft, Save, AlertTriangle, Calendar } from 'lucide-react'; +import { Button } from "@/Components/ui/button"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, router, useForm } from "@inertiajs/react"; +import { getBreadcrumbs } from "@/utils/breadcrumb"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Textarea } from "@/Components/ui/textarea"; + +interface Product { + id: number; + name: string; + code: string; + base_unit?: { id: number; name: string } | null; +} + +interface Warehouse { + id: number; + name: string; +} + +interface Unit { + id: number; + name: string; +} + +interface InventoryOption { + id: number; + product_id: number; + product_name: string; + product_code: string; + batch_number: string; + box_number: string | null; + quantity: number; + arrival_date: string | null; + expiry_date: string | null; + unit_name: string | null; +} + +interface BomItem { + inventory_id: string; + quantity_used: string; + unit_id: string; + // 顯示用 + product_name?: string; + batch_number?: string; + available_qty?: number; +} + +interface Props { + products: Product[]; + warehouses: Warehouse[]; + units: Unit[]; +} + +export default function ProductionCreate({ products, warehouses, units }: Props) { + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + const [inventoryOptions, setInventoryOptions] = useState([]); + const [isLoadingInventory, setIsLoadingInventory] = useState(false); + const [bomItems, setBomItems] = useState([]); + + const { data, setData, processing, errors } = useForm({ + product_id: "", + warehouse_id: "", + output_quantity: "", + output_batch_number: "", + output_box_count: "", + production_date: new Date().toISOString().split('T')[0], + expiry_date: "", + remark: "", + items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], + }); + + // 當選擇倉庫時,載入該倉庫的可用庫存 + useEffect(() => { + if (selectedWarehouse) { + setIsLoadingInventory(true); + fetch(route('api.production.warehouses.inventories', selectedWarehouse)) + .then(res => res.json()) + .then((inventories: InventoryOption[]) => { + setInventoryOptions(inventories); + setIsLoadingInventory(false); + }) + .catch(() => setIsLoadingInventory(false)); + } else { + setInventoryOptions([]); + } + }, [selectedWarehouse]); + + // 同步 warehouse_id 到 form data + useEffect(() => { + setData('warehouse_id', selectedWarehouse); + }, [selectedWarehouse]); + + // 新增 BOM 項目 + const addBomItem = () => { + setBomItems([...bomItems, { + inventory_id: "", + quantity_used: "", + unit_id: "", + }]); + }; + + // 移除 BOM 項目 + const removeBomItem = (index: number) => { + setBomItems(bomItems.filter((_, i) => i !== index)); + }; + + // 更新 BOM 項目 + const updateBomItem = (index: number, field: keyof BomItem, value: string) => { + const updated = [...bomItems]; + updated[index] = { ...updated[index], [field]: value }; + + // 如果選擇了庫存,自動填入顯示資訊 + if (field === 'inventory_id' && value) { + const inv = inventoryOptions.find(i => String(i.id) === value); + if (inv) { + updated[index].product_name = inv.product_name; + updated[index].batch_number = inv.batch_number; + updated[index].available_qty = inv.quantity; + } + } + + setBomItems(updated); + }; + + // 產生成品批號建議 + const generateBatchNumber = () => { + if (!data.product_id) return; + const product = products.find(p => String(p.id) === data.product_id); + if (!product) return; + + const date = data.production_date.replace(/-/g, ''); + const suggested = `${product.code}-TW-${date}-01`; + setData('output_batch_number', suggested); + }; + + // 提交表單 + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // 轉換 BOM items 格式 + const formattedItems = bomItems + .filter(item => item.inventory_id && item.quantity_used) + .map(item => ({ + inventory_id: parseInt(item.inventory_id), + quantity_used: parseFloat(item.quantity_used), + unit_id: item.unit_id ? parseInt(item.unit_id) : null, + })); + + // 使用 router.post 提交完整資料 + router.post(route('production-orders.store'), { + ...data, + items: formattedItems, + }); + }; + + return ( + + +
+
+ +
+

+ + 建立生產單 +

+

+ 記錄生產使用的原物料與產出成品 +

+
+
+ +
+ {/* 成品資訊 */} +
+

成品資訊

+
+
+ + setData('product_id', v)} + options={products.map(p => ({ + label: `${p.name} (${p.code})`, + value: String(p.id), + }))} + placeholder="選擇成品" + className="w-full h-9" + /> + {errors.product_id &&

{errors.product_id}

} +
+ +
+ + setData('output_quantity', e.target.value)} + placeholder="例如: 50" + className="h-9" + /> + {errors.output_quantity &&

{errors.output_quantity}

} +
+ +
+ +
+ setData('output_batch_number', e.target.value)} + placeholder="例如: AB-TW-20260121-01" + className="h-9 font-mono" + /> + +
+ {errors.output_batch_number &&

{errors.output_batch_number}

} +
+ +
+ + setData('output_box_count', e.target.value)} + placeholder="例如: 10" + className="h-9" + /> +
+ +
+ +
+ + 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" + /> +
+
+ +
+ + ({ + label: w.name, + value: String(w.id), + }))} + placeholder="選擇倉庫" + className="w-full h-9" + /> + {errors.warehouse_id &&

{errors.warehouse_id}

} +
+
+ +
+ +