From 04f38912759a66ef71dba9e8569727db449e1a19 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 5 Feb 2026 09:33:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=A6=E4=BD=9C=E5=87=BA=E8=B2=A8?= =?UTF-8?q?=E5=96=AE=E6=A8=A1=E7=B5=84=E4=B8=A6=E6=9A=AB=E6=99=82=E5=B0=8E?= =?UTF-8?q?=E5=90=91=E9=80=9A=E7=94=A8=E8=A3=BD=E4=BD=9C=E4=B8=AD=E9=A0=81?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E5=90=8C=E6=AD=A5=E5=84=AA=E5=8C=96=E7=9B=A4?= =?UTF-8?q?=E9=BB=9E=E8=88=87=E8=AA=BF=E6=92=A5=E5=8A=9F=E8=83=BD=E7=9A=84?= =?UTF-8?q?=E6=B4=BB=E5=8B=95=E6=97=A5=E8=AA=8C=E9=A1=AF=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contracts/InventoryServiceInterface.php | 10 + .../Controllers/TransferOrderController.php | 20 +- .../Models/InventoryTransferItem.php | 1 + .../Inventory/Services/InventoryService.php | 8 + .../Inventory/Services/TransferService.php | 21 +- .../Controllers/ShippingOrderController.php | 133 +++++++ .../Procurement/Models/ShippingOrder.php | 89 +++++ .../Procurement/Models/ShippingOrderItem.php | 35 ++ app/Modules/Procurement/Routes/web.php | 20 ++ .../Procurement/Services/ShippingService.php | 118 +++++++ ...t_quantity_to_inventory_transfer_items.php | 28 ++ ...26_02_05_100000_create_shipping_tables.php | 62 ++++ database/seeders/PermissionSeeder.php | 7 + resources/js/Layouts/AuthenticatedLayout.tsx | 14 +- .../js/Pages/Common/UnderConstruction.tsx | 61 ++++ resources/js/Pages/Inventory/Adjust/Show.tsx | 2 +- resources/js/Pages/Inventory/Count/Index.tsx | 2 +- resources/js/Pages/Inventory/Count/Show.tsx | 26 +- .../js/Pages/Inventory/Transfer/Index.tsx | 2 +- .../js/Pages/Inventory/Transfer/Show.tsx | 8 +- resources/js/Pages/ShippingOrder/Create.tsx | 330 ++++++++++++++++++ resources/js/Pages/ShippingOrder/Index.tsx | 207 +++++++++++ resources/js/Pages/ShippingOrder/Show.tsx | 236 +++++++++++++ 23 files changed, 1410 insertions(+), 30 deletions(-) create mode 100644 app/Modules/Procurement/Controllers/ShippingOrderController.php create mode 100644 app/Modules/Procurement/Models/ShippingOrder.php create mode 100644 app/Modules/Procurement/Models/ShippingOrderItem.php create mode 100644 app/Modules/Procurement/Services/ShippingService.php create mode 100644 database/migrations/tenant/2026_02_05_085924_add_snapshot_quantity_to_inventory_transfer_items.php create mode 100644 database/migrations/tenant/2026_02_05_100000_create_shipping_tables.php create mode 100644 resources/js/Pages/Common/UnderConstruction.tsx create mode 100644 resources/js/Pages/ShippingOrder/Create.tsx create mode 100644 resources/js/Pages/ShippingOrder/Index.tsx create mode 100644 resources/js/Pages/ShippingOrder/Show.tsx diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index d72538a..f77cd98 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -106,6 +106,16 @@ interface InventoryServiceInterface */ public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null); + /** + * Find a specific inventory record by warehouse, product and batch. + * + * @param int $warehouseId + * @param int $productId + * @param string|null $batchNumber + * @return object|null + */ + public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber); + /** * Get statistics for the dashboard. * diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index 5c1c493..d6fc56d 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -127,7 +127,7 @@ class TransferOrderController extends Controller 'batch_number' => $item->batch_number, 'unit' => $item->product->baseUnit?->name, 'quantity' => (float) $item->quantity, - 'max_quantity' => $stock ? (float) $stock->quantity : 0.0, + 'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0), 'notes' => $item->notes, ]; }), @@ -154,14 +154,22 @@ class TransferOrderController extends Controller ]); // 1. 先更新資料 + $itemsChanged = false; if ($request->has('items')) { - $this->transferService->updateItems($order, $validated['items']); + $itemsChanged = $this->transferService->updateItems($order, $validated['items']); } - $order->fill($request->only(['remarks'])); + $remarksChanged = $order->remarks !== ($validated['remarks'] ?? null); - // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌 - $order->touch(); + if ($itemsChanged || $remarksChanged) { + $order->remarks = $validated['remarks'] ?? null; + // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌 + $order->touch(); + $message = '儲存成功'; + } else { + $message = '資料未變更'; + // 如果沒變更,就不執行 touch(),也不會產生 Activity Log + } // 2. 判斷是否需要過帳 if ($request->input('action') === 'post') { @@ -174,7 +182,7 @@ class TransferOrderController extends Controller } } - return redirect()->back()->with('success', '儲存成功'); + return redirect()->back()->with('success', $message); } public function destroy(InventoryTransferOrder $order) diff --git a/app/Modules/Inventory/Models/InventoryTransferItem.php b/app/Modules/Inventory/Models/InventoryTransferItem.php index 6b2386b..43366d2 100644 --- a/app/Modules/Inventory/Models/InventoryTransferItem.php +++ b/app/Modules/Inventory/Models/InventoryTransferItem.php @@ -15,6 +15,7 @@ class InventoryTransferItem extends Model 'product_id', 'batch_number', 'quantity', + 'snapshot_quantity', 'notes', ]; diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index d8e0d99..314371d 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -188,6 +188,14 @@ class InventoryService implements InventoryServiceInterface }); } + public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber) + { + return Inventory::where('warehouse_id', $warehouseId) + ->where('product_id', $productId) + ->where('batch_number', $batchNumber) + ->first(); + } + public function getDashboardStats(): array { // 庫存總表 join 安全庫存表,計算低庫存 diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index 6a0bb16..70a47c5 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -31,9 +31,9 @@ class TransferService /** * 更新調撥單明細 (支援精確 Diff 與自動日誌整合) */ - public function updateItems(InventoryTransferOrder $order, array $itemsData): void + public function updateItems(InventoryTransferOrder $order, array $itemsData): bool { - DB::transaction(function () use ($order, $itemsData) { + return DB::transaction(function () use ($order, $itemsData) { // 1. 準備舊資料索引 (Key: product_id . '_' . batch_number) $oldItemsMap = $order->items->mapWithKeys(function ($item) { $key = $item->product_id . '_' . ($item->batch_number ?? ''); @@ -88,9 +88,13 @@ class TransferService ]; } } else { - // 新增 - $diff['added'][] = [ + // 新增 (使用者需求:顯示為更新,從 0 -> X) + $diff['updated'][] = [ 'product_name' => $item->product->name, + 'old' => [ + 'quantity' => 0, + 'notes' => null, + ], 'new' => [ 'quantity' => (float)$item->quantity, 'notes' => $item->notes, @@ -113,10 +117,12 @@ class TransferService } // 4. 將 Diff 注入到 Model 的暫存屬性中 - // 如果 Diff 有內容,才注入 - if (!empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated'])) { + $hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']); + if ($hasChanged) { $order->activityProperties['items_diff'] = $diff; } + + return $hasChanged; }); } @@ -149,6 +155,9 @@ class TransferService $oldSourceQty = $sourceInventory->quantity; $newSourceQty = $oldSourceQty - $item->quantity; + + // 儲存庫存快照 + $item->update(['snapshot_quantity' => $oldSourceQty]); $sourceInventory->quantity = $newSourceQty; // 更新總值 (假設成本不變) diff --git a/app/Modules/Procurement/Controllers/ShippingOrderController.php b/app/Modules/Procurement/Controllers/ShippingOrderController.php new file mode 100644 index 0000000..f817e8a --- /dev/null +++ b/app/Modules/Procurement/Controllers/ShippingOrderController.php @@ -0,0 +1,133 @@ +inventoryService = $inventoryService; + $this->coreService = $coreService; + $this->shippingService = $shippingService; + } + + public function index(Request $request) + { + return Inertia::render('Common/UnderConstruction', [ + 'featureName' => '出貨單管理' + ]); + + /* 原有邏輯暫存 + $query = ShippingOrder::query(); + + // 搜尋 + if ($request->search) { + $query->where(function($q) use ($request) { + $q->where('doc_no', 'like', "%{$request->search}%") + ->orWhere('customer_name', 'like', "%{$request->search}%"); + }); + } + + // 狀態篩選 + if ($request->status && $request->status !== 'all') { + $query->where('status', $request->status); + } + + $perPage = $request->input('per_page', 10); + $orders = $query->orderBy('id', 'desc')->paginate($perPage)->withQueryString(); + + // 水和倉庫與使用者 + $warehouses = $this->inventoryService->getAllWarehouses(); + $userIds = $orders->getCollection()->pluck('created_by')->filter()->unique()->toArray(); + $users = $this->coreService->getUsersByIds($userIds)->keyBy('id'); + + $orders->getCollection()->transform(function ($order) use ($warehouses, $users) { + $order->warehouse_name = $warehouses->firstWhere('id', $order->warehouse_id)?->name ?? 'Unknown'; + $order->creator_name = $users->get($order->created_by)?->name ?? 'System'; + return $order; + }); + + return Inertia::render('ShippingOrder/Index', [ + 'orders' => $orders, + 'filters' => $request->only(['search', 'status', 'per_page']), + 'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]), + ]); + */ + } + + public function create() + { + return Inertia::render('Common/UnderConstruction', [ + 'featureName' => '出貨單建立' + ]); + + /* 原有邏輯暫存 + $warehouses = $this->inventoryService->getAllWarehouses(); + $products = $this->inventoryService->getAllProducts(); + + return Inertia::render('ShippingOrder/Create', [ + 'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]), + 'products' => $products->map(fn($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'code' => $p->code, + 'unit_name' => $p->baseUnit?->name, + ]), + ]); + */ + } + + public function store(Request $request) + { + return back()->with('error', '出貨單管理功能正在製作中'); + } + + public function show($id) + { + return Inertia::render('Common/UnderConstruction', [ + 'featureName' => '出貨單詳情' + ]); + } + + public function edit($id) + { + return Inertia::render('Common/UnderConstruction', [ + 'featureName' => '出貨單編輯' + ]); + } + + public function update(Request $request, $id) + { + return back()->with('error', '出貨單管理功能正在製作中'); + } + + public function post($id) + { + return back()->with('error', '出貨單管理功能正在製作中'); + } + + public function destroy($id) + { + $order = ShippingOrder::findOrFail($id); + if ($order->status !== 'draft') { + return back()->withErrors(['error' => '僅能刪除草稿狀態的單據']); + } + $order->delete(); + return redirect()->route('delivery-notes.index')->with('success', '出貨單已刪除'); + } +} diff --git a/app/Modules/Procurement/Models/ShippingOrder.php b/app/Modules/Procurement/Models/ShippingOrder.php new file mode 100644 index 0000000..6a6f358 --- /dev/null +++ b/app/Modules/Procurement/Models/ShippingOrder.php @@ -0,0 +1,89 @@ + 'date', + 'posted_at' => 'datetime', + 'total_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'grand_total' => 'decimal:2', + ]; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function tapActivity(Activity $activity, string $eventName) + { + $snapshot = $activity->properties['snapshot'] ?? []; + $snapshot['doc_no'] = $this->doc_no; + $snapshot['customer_name'] = $this->customer_name; + + $activity->properties = $activity->properties->merge([ + 'snapshot' => $snapshot + ]); + } + + public function items() + { + return $this->hasMany(ShippingOrderItem::class); + } + + /** + * 自動產生單號 + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->doc_no)) { + $today = date('Ymd'); + $prefix = 'SHP-' . $today . '-'; + + $lastDoc = static::where('doc_no', 'like', $prefix . '%') + ->orderBy('doc_no', 'desc') + ->first(); + + if ($lastDoc) { + $lastNumber = substr($lastDoc->doc_no, -2); + $nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT); + } else { + $nextNumber = '01'; + } + + $model->doc_no = $prefix . $nextNumber; + } + }); + } +} diff --git a/app/Modules/Procurement/Models/ShippingOrderItem.php b/app/Modules/Procurement/Models/ShippingOrderItem.php new file mode 100644 index 0000000..58343b4 --- /dev/null +++ b/app/Modules/Procurement/Models/ShippingOrderItem.php @@ -0,0 +1,35 @@ + 'decimal:4', + 'unit_price' => 'decimal:4', + 'subtotal' => 'decimal:2', + ]; + + public function shippingOrder() + { + return $this->belongsTo(ShippingOrder::class); + } + + // 注意:在模組化架構下,跨模組關聯應謹慎使用或是直接在 Controller 水和 (Hydration) + // 但為了開發便利,暫時保留對 Product 的關聯(如果 Product 在不同模組,可能無法直接 lazy load) +} diff --git a/app/Modules/Procurement/Routes/web.php b/app/Modules/Procurement/Routes/web.php index 5275186..7d948b7 100644 --- a/app/Modules/Procurement/Routes/web.php +++ b/app/Modules/Procurement/Routes/web.php @@ -35,4 +35,24 @@ Route::middleware('auth')->group(function () { Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update'); Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy'); }); + + // 出貨單管理 (Delivery Notes) + Route::middleware('permission:delivery_notes.view')->group(function () { + Route::get('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'index'])->name('delivery-notes.index'); + + Route::middleware('permission:delivery_notes.create')->group(function () { + Route::get('/delivery-notes/create', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'create'])->name('delivery-notes.create'); + Route::post('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'store'])->name('delivery-notes.store'); + }); + + Route::get('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'show'])->name('delivery-notes.show'); + + Route::middleware('permission:delivery_notes.edit')->group(function () { + Route::get('/delivery-notes/{id}/edit', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'edit'])->name('delivery-notes.edit'); + Route::put('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'update'])->name('delivery-notes.update'); + Route::post('/delivery-notes/{id}/post', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'post'])->name('delivery-notes.post'); + }); + + Route::delete('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'destroy'])->middleware('permission:delivery_notes.delete')->name('delivery-notes.destroy'); + }); }); diff --git a/app/Modules/Procurement/Services/ShippingService.php b/app/Modules/Procurement/Services/ShippingService.php new file mode 100644 index 0000000..68dd8b4 --- /dev/null +++ b/app/Modules/Procurement/Services/ShippingService.php @@ -0,0 +1,118 @@ +inventoryService = $inventoryService; + } + + public function createShippingOrder(array $data) + { + return DB::transaction(function () use ($data) { + $order = ShippingOrder::create([ + 'warehouse_id' => $data['warehouse_id'], + 'customer_name' => $data['customer_name'] ?? null, + 'shipping_date' => $data['shipping_date'], + 'status' => 'draft', + 'remarks' => $data['remarks'] ?? null, + 'created_by' => auth()->id(), + 'total_amount' => $data['total_amount'] ?? 0, + 'tax_amount' => $data['tax_amount'] ?? 0, + 'grand_total' => $data['grand_total'] ?? 0, + ]); + + foreach ($data['items'] as $item) { + $order->items()->create([ + 'product_id' => $item['product_id'], + 'batch_number' => $item['batch_number'] ?? null, + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'] ?? 0, + 'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)), + 'remark' => $item['remark'] ?? null, + ]); + } + + return $order; + }); + } + + public function updateShippingOrder(ShippingOrder $order, array $data) + { + return DB::transaction(function () use ($order, $data) { + $order->update([ + 'warehouse_id' => $data['warehouse_id'], + 'customer_name' => $data['customer_name'] ?? null, + 'shipping_date' => $data['shipping_date'], + 'remarks' => $data['remarks'] ?? null, + 'total_amount' => $data['total_amount'] ?? 0, + 'tax_amount' => $data['tax_amount'] ?? 0, + 'grand_total' => $data['grand_total'] ?? 0, + ]); + + // 簡單處理:刪除舊項目並新增 + $order->items()->delete(); + foreach ($data['items'] as $item) { + $order->items()->create([ + 'product_id' => $item['product_id'], + 'batch_number' => $item['batch_number'] ?? null, + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'] ?? 0, + 'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)), + 'remark' => $item['remark'] ?? null, + ]); + } + + return $order; + }); + } + + public function post(ShippingOrder $order) + { + if ($order->status !== 'draft') { + throw new \Exception('該單據已過帳或已取消。'); + } + + return DB::transaction(function () use ($order) { + foreach ($order->items as $item) { + // 尋找對應的庫存紀錄 + $inventory = $this->inventoryService->findInventoryByBatch( + $order->warehouse_id, + $item->product_id, + $item->batch_number + ); + + if (!$inventory || $inventory->quantity < $item->quantity) { + $productName = $this->inventoryService->getProduct($item->product_id)?->name ?? 'Unknown'; + throw new \Exception("商品 [{$productName}] (批號: {$item->batch_number}) 庫存不足。"); + } + + // 扣除庫存 + $this->inventoryService->decreaseInventoryQuantity( + $inventory->id, + $item->quantity, + "出貨扣款: 單號 [{$order->doc_no}]", + 'ShippingOrder', + $order->id + ); + } + + $order->update([ + 'status' => 'completed', + 'posted_by' => auth()->id(), + 'posted_at' => now(), + ]); + + return $order; + }); + } +} diff --git a/database/migrations/tenant/2026_02_05_085924_add_snapshot_quantity_to_inventory_transfer_items.php b/database/migrations/tenant/2026_02_05_085924_add_snapshot_quantity_to_inventory_transfer_items.php new file mode 100644 index 0000000..de92e75 --- /dev/null +++ b/database/migrations/tenant/2026_02_05_085924_add_snapshot_quantity_to_inventory_transfer_items.php @@ -0,0 +1,28 @@ +decimal('snapshot_quantity', 10, 2)->nullable()->comment('過帳時的來源倉可用庫存快照')->after('quantity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_transfer_items', function (Blueprint $table) { + $table->dropColumn('snapshot_quantity'); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_05_100000_create_shipping_tables.php b/database/migrations/tenant/2026_02_05_100000_create_shipping_tables.php new file mode 100644 index 0000000..3e58004 --- /dev/null +++ b/database/migrations/tenant/2026_02_05_100000_create_shipping_tables.php @@ -0,0 +1,62 @@ +id(); + $table->string('doc_no')->unique()->comment('出貨單號'); + $table->string('customer_name')->nullable()->comment('客戶名稱'); + $table->unsignedBigInteger('warehouse_id')->comment('來源倉庫'); + $table->string('status')->default('draft')->comment('狀態: draft, completed, cancelled'); + $table->date('shipping_date')->comment('出貨日期'); + + $table->decimal('total_amount', 15, 2)->default(0)->comment('總金額 (不含稅)'); + $table->decimal('tax_amount', 15, 2)->default(0)->comment('稅額'); + $table->decimal('grand_total', 15, 2)->default(0)->comment('總計'); + + $table->text('remarks')->nullable()->comment('備註'); + + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('posted_by')->nullable(); + $table->timestamp('posted_at')->nullable(); + + $table->timestamps(); + + $table->index('warehouse_id'); + $table->index('status'); + $table->index('shipping_date'); + }); + + Schema::create('shipping_order_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('shipping_order_id')->constrained('shipping_orders')->onDelete('cascade'); + $table->unsignedBigInteger('product_id')->comment('商品 ID'); + $table->string('batch_number')->nullable()->comment('批號'); + $table->decimal('quantity', 15, 4)->comment('出貨數量'); + $table->decimal('unit_price', 15, 4)->default(0)->comment('單價'); + $table->decimal('subtotal', 15, 2)->default(0)->comment('小計'); + $table->string('remark')->nullable()->comment('項目備註'); + $table->timestamps(); + + $table->index('product_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_order_items'); + Schema::dropIfExists('shipping_orders'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index a05f462..daa946c 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -61,6 +61,12 @@ class PermissionSeeder extends Seeder 'goods_receipts.edit', 'goods_receipts.delete', + // 出貨單管理 (Delivery Notes / Shipping Orders) + 'delivery_notes.view', + 'delivery_notes.create', + 'delivery_notes.edit', + 'delivery_notes.delete', + // 生產工單管理 'production_orders.view', 'production_orders.create', @@ -138,6 +144,7 @@ class PermissionSeeder extends Seeder 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', + 'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete', 'recipes.view', 'recipes.create', 'recipes.edit', 'recipes.delete', 'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete', diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 65fdf7c..50f3117 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -150,13 +150,13 @@ export default function AuthenticatedLayout({ route: "/goods-receipts", permission: "goods_receipts.view", }, - // { - // id: "delivery-note-list", - // label: "出貨單管理 (開發中)", - // icon: , - // // route: "/delivery-notes", - // permission: "delivery_notes.view", - // }, + { + id: "delivery-note-list", + label: "出貨單管理 (功能製作中)", + icon: , + route: "/delivery-notes", + permission: "delivery_notes.view", + }, ], }, { diff --git a/resources/js/Pages/Common/UnderConstruction.tsx b/resources/js/Pages/Common/UnderConstruction.tsx new file mode 100644 index 0000000..cfaf3c3 --- /dev/null +++ b/resources/js/Pages/Common/UnderConstruction.tsx @@ -0,0 +1,61 @@ +import { Head, Link } from "@inertiajs/react"; +import { Hammer, Home, ArrowLeft } from "lucide-react"; +import { Button } from "@/Components/ui/button"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; + +interface Props { + featureName?: string; +} + +export default function UnderConstruction({ featureName = "此功能" }: Props) { + return ( + + + +
+
+
+
+ +
+
+ +

+ {featureName} 正在趕工中! +

+ +

+ 我們正在努力完善這個功能,以提供更優質的體驗。 + 這部分可能涉及與其他系統的深度整合,請稍候片刻。 +

+ +
+ + + + +
+ +
+ Coming Soon | Star ERP Design System +
+
+
+ ); +} diff --git a/resources/js/Pages/Inventory/Adjust/Show.tsx b/resources/js/Pages/Inventory/Adjust/Show.tsx index cabab2d..4a3999f 100644 --- a/resources/js/Pages/Inventory/Adjust/Show.tsx +++ b/resources/js/Pages/Inventory/Adjust/Show.tsx @@ -253,7 +253,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { <> | 來源盤點單: {doc.count_doc_no} diff --git a/resources/js/Pages/Inventory/Count/Index.tsx b/resources/js/Pages/Inventory/Count/Index.tsx index 2dbd42d..888666d 100644 --- a/resources/js/Pages/Inventory/Count/Index.tsx +++ b/resources/js/Pages/Inventory/Count/Index.tsx @@ -157,7 +157,7 @@ export default function Index({ docs, warehouses, filters }: any) { return ( diff --git a/resources/js/Pages/Inventory/Count/Show.tsx b/resources/js/Pages/Inventory/Count/Show.tsx index f78732f..930099f 100644 --- a/resources/js/Pages/Inventory/Count/Show.tsx +++ b/resources/js/Pages/Inventory/Count/Show.tsx @@ -28,6 +28,17 @@ import { Can } from '@/Components/Permission/Can'; export default function Show({ doc }: any) { + // Get query parameters for dynamic back button + const urlParams = new URLSearchParams(window.location.search); + const fromSource = urlParams.get('from'); + const adjustId = urlParams.get('adjust_id'); + + const backUrl = fromSource === 'adjust' && adjustId + ? route('inventory.adjust.show', [adjustId]) + : route('inventory.count.index'); + + const backLabel = fromSource === 'adjust' ? '返回盤調單' : '返回盤點單列表'; + // Transform items to form data structure const { data, setData, put, delete: destroy, processing, transform } = useForm({ items: doc.items.map((item: any) => ({ @@ -77,21 +88,28 @@ export default function Show({ doc }: any) {
- + diff --git a/resources/js/Pages/Inventory/Transfer/Index.tsx b/resources/js/Pages/Inventory/Transfer/Index.tsx index 27721b4..6fa5ee3 100644 --- a/resources/js/Pages/Inventory/Transfer/Index.tsx +++ b/resources/js/Pages/Inventory/Transfer/Index.tsx @@ -173,7 +173,7 @@ export default function Index({ warehouses, orders, filters }: any) { return ( diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 9c66df2..d5a1f99 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -154,7 +154,7 @@ export default function Show({ order }: any) { items: items, remarks: remarks, }, { - onSuccess: () => toast.success("儲存成功"), + onSuccess: () => { }, onError: () => toast.error("儲存失敗,請檢查輸入"), }); } finally { @@ -168,7 +168,6 @@ export default function Show({ order }: any) { }, { onSuccess: () => { setIsPostDialogOpen(false); - toast.success("過帳成功"); } }); }; @@ -177,7 +176,6 @@ export default function Show({ order }: any) { router.delete(route('inventory.transfer.destroy', [order.id]), { onSuccess: () => { setDeleteId(null); - toast.success("已成功刪除"); } }); }; @@ -469,7 +467,9 @@ export default function Show({ order }: any) { # 商品名稱 / 代號 批號 - 可用庫存 + + {order.status === 'completed' ? '過帳時庫存' : '可用庫存'} + 調撥數量 單位 備註 diff --git a/resources/js/Pages/ShippingOrder/Create.tsx b/resources/js/Pages/ShippingOrder/Create.tsx new file mode 100644 index 0000000..185a650 --- /dev/null +++ b/resources/js/Pages/ShippingOrder/Create.tsx @@ -0,0 +1,330 @@ +import { useState, useEffect } from "react"; +import { ArrowLeft, Plus, Trash2, Package, Info, Calculator } from "lucide-react"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Textarea } from "@/Components/ui/textarea"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, Link, router } from "@inertiajs/react"; +import { toast } from "sonner"; +import axios from "axios"; + +interface Product { + id: number; + name: string; + code: string; + unit_name: string; +} + +interface Item { + product_id: number | null; + product_name?: string; + product_code?: string; + unit_name?: string; + batch_number: string; + quantity: number; + unit_price: number; + subtotal: number; + remark: string; + available_batches: any[]; +} + +interface Props { + order?: any; + warehouses: { id: number; name: string }[]; + products: Product[]; +} + +export default function ShippingOrderCreate({ order, warehouses, products }: Props) { + const isEdit = !!order; + const [warehouseId, setWarehouseId] = useState(order?.warehouse_id?.toString() || ""); + const [customerName, setCustomerName] = useState(order?.customer_name || ""); + const [shippingDate, setShippingDate] = useState(order?.shipping_date || new Date().toISOString().split('T')[0]); + const [remarks, setRemarks] = useState(order?.remarks || ""); + const [items, setItems] = useState(order?.items?.map((item: any) => ({ + product_id: item.product_id, + batch_number: item.batch_number, + quantity: Number(item.quantity), + unit_price: Number(item.unit_price), + subtotal: Number(item.subtotal), + remark: item.remark || "", + available_batches: [], + })) || []); + + const [taxAmount, setTaxAmount] = useState(Number(order?.tax_amount) || 0); + + const totalAmount = items.reduce((sum, item) => sum + item.subtotal, 0); + const grandTotal = totalAmount + taxAmount; + + // 當品項變動時,自動計算稅額 (預設 5%) + useEffect(() => { + if (!isEdit || (isEdit && order.status === 'draft')) { + setTaxAmount(Math.round(totalAmount * 0.05)); + } + }, [totalAmount]); + + const addItem = () => { + setItems([...items, { + product_id: null, + batch_number: "", + quantity: 1, + unit_price: 0, + subtotal: 0, + remark: "", + available_batches: [], + }]); + }; + + const removeItem = (index: number) => { + setItems(items.filter((_, i) => i !== index)); + }; + + const updateItem = (index: number, updates: Partial) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], ...updates }; + + // 計算小計 + if ('quantity' in updates || 'unit_price' in updates) { + newItems[index].subtotal = Number(newItems[index].quantity) * Number(newItems[index].unit_price); + } + + setItems(newItems); + + // 如果商品變動,抓取批號 + if ('product_id' in updates && updates.product_id && warehouseId) { + fetchBatches(index, updates.product_id, warehouseId); + } + }; + + const fetchBatches = async (index: number, productId: number, wId: string) => { + try { + const response = await axios.get(route('api.warehouses.inventory.batches', { warehouse: wId, productId })); + const newItems = [...items]; + newItems[index].available_batches = response.data; + setItems(newItems); + } catch (error) { + console.error("Failed to fetch batches", error); + } + }; + + const handleSave = () => { + if (!warehouseId) { + toast.error("請選擇出貨倉庫"); + return; + } + if (!shippingDate) { + toast.error("請選擇出貨日期"); + return; + } + if (items.length === 0) { + toast.error("請至少新增一個品項"); + return; + } + + const data = { + warehouse_id: warehouseId, + customer_name: customerName, + shipping_date: shippingDate, + remarks: remarks, + total_amount: totalAmount, + tax_amount: taxAmount, + grand_total: grandTotal, + items: items.map(item => ({ + product_id: item.product_id, + batch_number: item.batch_number, + quantity: item.quantity, + unit_price: item.unit_price, + subtotal: item.subtotal, + remark: item.remark, + })), + }; + + if (isEdit) { + router.put(route('delivery-notes.update', order.id), data); + } else { + router.post(route('delivery-notes.store'), data); + } + }; + + return ( + + + +
+
+ + + +

+ + {isEdit ? `編輯出貨單 ${order.doc_no}` : "建立新出貨單"} +

+
+ +
+ {/* 左側:基本資訊 */} +
+
+

+ 基本資料 +

+
+
+ + ({ label: w.name, value: w.id.toString() }))} + placeholder="選擇倉庫" + /> +
+
+ + setShippingDate(e.target.value)} + /> +
+
+ + setCustomerName(e.target.value)} + /> +
+
+ +