From c4908533a8ee79682bf1be01941f5797b3e3ff9e Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 25 Feb 2026 13:49:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(procurement):=20=E5=AF=A6=E4=BD=9C?= =?UTF-8?q?=E6=8E=A1=E8=B3=BC=E9=80=80=E5=9B=9E=E5=96=AE=E6=A8=A1=E7=B5=84?= =?UTF-8?q?=E4=B8=A6=E4=BF=AE=E5=BE=A9=E5=95=86=E5=93=81=E9=81=B8=E5=96=AE?= =?UTF-8?q?=E5=A0=B1=E9=8C=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Controllers/RoleController.php | 1 + .../Controllers/PurchaseReturnController.php | 250 +++++++++++++++ .../Procurement/Models/PurchaseReturn.php | 93 ++++++ .../Procurement/Models/PurchaseReturnItem.php | 33 ++ app/Modules/Procurement/Routes/web.php | 21 ++ .../Services/PurchaseReturnService.php | 210 +++++++++++++ ...5_125537_create_purchase_returns_table.php | 49 +++ ...551_create_purchase_return_items_table.php | 38 +++ database/seeders/PermissionSeeder.php | 12 + .../PurchaseReturn/PurchaseReturnActions.tsx | 100 ++++++ .../PurchaseReturnItemsTable.tsx | 214 +++++++++++++ .../PurchaseReturnStatusBadge.tsx | 26 ++ .../PurchaseReturn/PurchaseReturnTable.tsx | 213 +++++++++++++ resources/js/Layouts/AuthenticatedLayout.tsx | 10 +- resources/js/Pages/PurchaseReturn/Create.tsx | 281 +++++++++++++++++ resources/js/Pages/PurchaseReturn/Edit.tsx | 294 ++++++++++++++++++ resources/js/Pages/PurchaseReturn/Index.tsx | 172 ++++++++++ resources/js/Pages/PurchaseReturn/Show.tsx | 218 +++++++++++++ resources/js/hooks/usePurchaseReturnForm.ts | 123 ++++++++ resources/js/types/purchase-return.ts | 48 +++ resources/js/utils/breadcrumb.ts | 4 + 21 files changed, 2409 insertions(+), 1 deletion(-) create mode 100644 app/Modules/Procurement/Controllers/PurchaseReturnController.php create mode 100644 app/Modules/Procurement/Models/PurchaseReturn.php create mode 100644 app/Modules/Procurement/Models/PurchaseReturnItem.php create mode 100644 app/Modules/Procurement/Services/PurchaseReturnService.php create mode 100644 database/migrations/tenant/2026_02_25_125537_create_purchase_returns_table.php create mode 100644 database/migrations/tenant/2026_02_25_125551_create_purchase_return_items_table.php create mode 100644 resources/js/Components/PurchaseReturn/PurchaseReturnActions.tsx create mode 100644 resources/js/Components/PurchaseReturn/PurchaseReturnItemsTable.tsx create mode 100644 resources/js/Components/PurchaseReturn/PurchaseReturnStatusBadge.tsx create mode 100644 resources/js/Components/PurchaseReturn/PurchaseReturnTable.tsx create mode 100644 resources/js/Pages/PurchaseReturn/Create.tsx create mode 100644 resources/js/Pages/PurchaseReturn/Edit.tsx create mode 100644 resources/js/Pages/PurchaseReturn/Index.tsx create mode 100644 resources/js/Pages/PurchaseReturn/Show.tsx create mode 100644 resources/js/hooks/usePurchaseReturnForm.ts create mode 100644 resources/js/types/purchase-return.ts diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index 5497787..2e97eb8 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -187,6 +187,7 @@ class RoleController extends Controller 'inventory_report' => '庫存報表', 'vendors' => '廠商資料管理', 'purchase_orders' => '採購單管理', + 'purchase_returns' => '採購退回管理', 'goods_receipts' => '進貨單管理', 'delivery_notes' => '出貨單管理', 'recipes' => '配方管理', diff --git a/app/Modules/Procurement/Controllers/PurchaseReturnController.php b/app/Modules/Procurement/Controllers/PurchaseReturnController.php new file mode 100644 index 0000000..42e8301 --- /dev/null +++ b/app/Modules/Procurement/Controllers/PurchaseReturnController.php @@ -0,0 +1,250 @@ +orderBy('id', 'desc'); + + if ($request->filled('search')) { + $search = $request->search; + $query->where('code', 'like', "%{$search}%") + ->orWhereHas('vendor', function($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + if ($request->filled('status') && $request->status !== 'all') { + $query->where('status', $request->status); + } + + $purchaseReturns = $query->paginate(15)->withQueryString(); + + return Inertia::render('PurchaseReturn/Index', [ + 'purchaseReturns' => $purchaseReturns, + 'filters' => $request->only(['search', 'status']), + ]); + } + + public function create() + { + // 取得可用的倉庫與廠商資料供前端選單使用 + $warehouses = $this->inventoryService->getAllWarehouses(); + $vendors = Vendor::all(); + + // 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致) + $vendorIds = $vendors->pluck('id')->toArray(); + $pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get(); + $productIds = $pivots->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + $vendors = $vendors->map(function ($vendor) use ($pivots, $products) { + $vendorProductPivots = $pivots->where('vendor_id', $vendor->id); + + $commonProducts = $vendorProductPivots->map(function($pivot) use ($products) { + $product = $products->get($pivot->product_id); + if (!$product) return null; + + return [ + 'productId' => (string) $product->id, + 'productName' => $product->name, + 'lastPrice' => (float) $pivot->last_price, + ]; + })->filter()->values(); + + return [ + 'id' => (string) $vendor->id, + 'name' => $vendor->name, + 'commonProducts' => $commonProducts + ]; + }); + + return Inertia::render('PurchaseReturn/Create', [ + 'warehouses' => $warehouses, + 'vendors' => $vendors, + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'vendor_id' => 'required|exists:vendors,id', + 'warehouse_id' => 'required|integer', // 透過 interface 無法直接 exists + 'return_date' => 'required|date', + 'remarks' => 'nullable|string', + 'tax_amount' => 'nullable|numeric|min:0', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|integer', + 'items.*.quantity_returned' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.batch_number' => 'nullable|string', + ]); + + try { + $pr = $this->purchaseReturnService->store($validated); + + return redirect()->route('procurement.purchase-returns.show', $pr->id) + ->with('flash', ['success' => '退貨單草稿建立成功']); + } catch (\Exception $e) { + return back()->with('flash', ['error' => $e->getMessage()]); + } + } + + public function show(PurchaseReturn $purchaseReturn) + { + $purchaseReturn->load(['vendor', 'user', 'items']); + + // 取出 product name (依賴反轉) + $productIds = $purchaseReturn->items->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + // 取出 warehouse name + $warehouse = $this->inventoryService->getWarehouse($purchaseReturn->warehouse_id); + $purchaseReturn->warehouse_name = $warehouse ? $warehouse->name : '未知倉庫'; + + $purchaseReturn->items->transform(function($item) use ($products) { + $item->product_name = $products->get($item->product_id)->name ?? '未知商品'; + $item->product_code = $products->get($item->product_id)->code ?? ''; + return $item; + }); + + // 整理歷史紀錄 + $activities = \Spatie\Activitylog\Models\Activity::where('subject_type', PurchaseReturn::class) + ->where('subject_id', $purchaseReturn->id) + ->orderBy('created_at', 'desc') + ->get(); + + return Inertia::render('PurchaseReturn/Show', [ + 'purchaseReturn' => $purchaseReturn, + 'activities' => $activities, + ]); + } + + public function edit(PurchaseReturn $purchaseReturn) + { + if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { + return redirect()->route('procurement.purchase-returns.show', $purchaseReturn->id) + ->with('flash', ['error' => '只有草稿狀態的退貨單能編輯']); + } + + $purchaseReturn->load(['items']); + + $productIds = $purchaseReturn->items->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + $purchaseReturn->items->transform(function($item) use ($products) { + $product = $products->get($item->product_id); + $item->product = $product; + return $item; + }); + + $warehouses = $this->inventoryService->getAllWarehouses(); + $vendors = Vendor::all(); + + // 手動注入:獲取廠商商品 (與 create 邏輯一致) + $vendorIds = $vendors->pluck('id')->toArray(); + $pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get(); + $allProductIds = $pivots->pluck('product_id')->unique()->toArray(); + $allProducts = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id'); + + $vendors = $vendors->map(function ($vendor) use ($pivots, $allProducts) { + $vendorProductPivots = $pivots->where('vendor_id', $vendor->id); + + $commonProducts = $vendorProductPivots->map(function($pivot) use ($allProducts) { + $product = $allProducts->get($pivot->product_id); + if (!$product) return null; + + return [ + 'productId' => (string) $product->id, + 'productName' => $product->name, + 'lastPrice' => (float) $pivot->last_price, + ]; + })->filter()->values(); + + return [ + 'id' => (string) $vendor->id, + 'name' => $vendor->name, + 'commonProducts' => $commonProducts + ]; + }); + + return Inertia::render('PurchaseReturn/Edit', [ + 'purchaseReturn' => $purchaseReturn, + 'warehouses' => $warehouses, + 'vendors' => $vendors, + ]); + } + + public function update(Request $request, PurchaseReturn $purchaseReturn) + { + $validated = $request->validate([ + 'vendor_id' => 'required|exists:vendors,id', + 'warehouse_id' => 'required|integer', + 'return_date' => 'required|date', + 'remarks' => 'nullable|string', + 'tax_amount' => 'nullable|numeric|min:0', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|integer', + 'items.*.quantity_returned' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.batch_number' => 'nullable|string', + ]); + + try { + $this->purchaseReturnService->update($purchaseReturn, $validated); + + return redirect()->route('procurement.purchase-returns.show', $purchaseReturn->id) + ->with('flash', ['success' => '退貨單已更新']); + } catch (\Exception $e) { + return back()->with('flash', ['error' => $e->getMessage()]); + } + } + + public function submit(PurchaseReturn $purchaseReturn) + { + try { + $this->purchaseReturnService->submit($purchaseReturn); + return back()->with('flash', ['success' => '退貨單已確認完成,庫存已成功扣減。']); + } catch (\Exception $e) { + return back()->with('flash', ['error' => '退貨失敗: ' . $e->getMessage()]); + } + } + + public function cancel(PurchaseReturn $purchaseReturn) + { + try { + $this->purchaseReturnService->cancel($purchaseReturn); + return back()->with('flash', ['success' => '退貨單已取消']); + } catch (\Exception $e) { + return back()->with('flash', ['error' => $e->getMessage()]); + } + } + + public function destroy(PurchaseReturn $purchaseReturn) + { + try { + $this->purchaseReturnService->delete($purchaseReturn); + return redirect()->route('procurement.purchase-returns.index') + ->with('flash', ['success' => '退貨單草稿已刪除']); + } catch (\Exception $e) { + return back()->with('flash', ['error' => $e->getMessage()]); + } + } +} diff --git a/app/Modules/Procurement/Models/PurchaseReturn.php b/app/Modules/Procurement/Models/PurchaseReturn.php new file mode 100644 index 0000000..3e7751b --- /dev/null +++ b/app/Modules/Procurement/Models/PurchaseReturn.php @@ -0,0 +1,93 @@ + 'date', + 'total_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'grand_total' => 'decimal:2', + ]; + + public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions + { + return \Spatie\Activitylog\LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + // 活動紀錄名稱解析 (依 activity-logging.md) + $properties = $activity->properties instanceof \Illuminate\Support\Collection + ? $activity->properties->toArray() + : $activity->properties; + + $snapshot = $properties['snapshot'] ?? []; + $snapshot['return_no'] = $this->code; + $snapshot['warehouse_name'] = $this->warehouse?->name; + $properties['snapshot'] = $snapshot; + + $resolver = function (&$data) { + if (empty($data) || !is_array($data)) return; + + foreach (['user_id', 'created_by', 'updated_by'] as $f) { + if (isset($data[$f]) && is_numeric($data[$f])) { + $data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name; + } + } + if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) { + $data['vendor_id'] = Vendor::find($data['vendor_id'])?->name; + } + // Strict Mode: Warehouse relation may not be available directly here if it's across modules, + // but we might need it for display. + }; + + if (isset($properties['attributes'])) $resolver($properties['attributes']); + if (isset($properties['old'])) $resolver($properties['old']); + + $activity->properties = $properties; + } + + public function items() + { + return $this->hasMany(PurchaseReturnItem::class); + } + + public function vendor() + { + return $this->belongsTo(Vendor::class); + } + + public function user() + { + return $this->belongsTo(\App\Modules\Core\Models\User::class); + } +} diff --git a/app/Modules/Procurement/Models/PurchaseReturnItem.php b/app/Modules/Procurement/Models/PurchaseReturnItem.php new file mode 100644 index 0000000..dda6795 --- /dev/null +++ b/app/Modules/Procurement/Models/PurchaseReturnItem.php @@ -0,0 +1,33 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'total_amount' => 'decimal:2', + ]; + + public function purchaseReturn() + { + return $this->belongsTo(PurchaseReturn::class); + } + + // Strict Mode: product relation via ID only for external module. +} diff --git a/app/Modules/Procurement/Routes/web.php b/app/Modules/Procurement/Routes/web.php index 1d1fa1b..f4f0ef5 100644 --- a/app/Modules/Procurement/Routes/web.php +++ b/app/Modules/Procurement/Routes/web.php @@ -36,6 +36,27 @@ Route::middleware('auth')->group(function () { Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy'); }); + // 採購退貨單管理 + Route::middleware('permission:purchase_returns.view')->group(function () { + Route::get('/purchase-returns', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'index'])->name('purchase-returns.index'); + + Route::middleware('permission:purchase_returns.create')->group(function () { + Route::get('/purchase-returns/create', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'create'])->name('purchase-returns.create'); + Route::post('/purchase-returns', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'store'])->name('purchase-returns.store'); + }); + + Route::get('/purchase-returns/{purchaseReturn}', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'show'])->name('purchase-returns.show'); + + Route::middleware('permission:purchase_returns.edit')->group(function () { + Route::get('/purchase-returns/{purchaseReturn}/edit', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'edit'])->name('purchase-returns.edit'); + Route::put('/purchase-returns/{purchaseReturn}', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'update'])->name('purchase-returns.update'); + Route::post('/purchase-returns/{purchaseReturn}/submit', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'submit'])->name('purchase-returns.submit'); + Route::post('/purchase-returns/{purchaseReturn}/cancel', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'cancel'])->name('purchase-returns.cancel'); + }); + + Route::delete('/purchase-returns/{purchaseReturn}', [\App\Modules\Procurement\Controllers\PurchaseReturnController::class, 'destroy'])->middleware('permission:purchase_returns.delete')->name('purchase-returns.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'); diff --git a/app/Modules/Procurement/Services/PurchaseReturnService.php b/app/Modules/Procurement/Services/PurchaseReturnService.php new file mode 100644 index 0000000..cd2b0cf --- /dev/null +++ b/app/Modules/Procurement/Services/PurchaseReturnService.php @@ -0,0 +1,210 @@ +inventoryService = $inventoryService; + } + + /** + * 建立退貨單 (草稿) + */ + public function store(array $data) + { + return DB::transaction(function () use ($data) { + $data['code'] = $this->generateCode($data['return_date']); + $data['user_id'] = auth()->id(); + $data['status'] = PurchaseReturn::STATUS_DRAFT; + + $totalAmount = 0; + + $purchaseReturn = PurchaseReturn::create($data); + + foreach ($data['items'] as $itemData) { + $amount = $itemData['quantity_returned'] * $itemData['unit_price']; + $totalAmount += $amount; + + $prItem = new PurchaseReturnItem([ + 'product_id' => $itemData['product_id'], + 'quantity_returned' => $itemData['quantity_returned'], + 'unit_price' => $itemData['unit_price'], + 'total_amount' => $amount, + 'batch_number' => $itemData['batch_number'] ?? null, + ]); + + $purchaseReturn->items()->save($prItem); + } + + // 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount) + $taxAmount = $data['tax_amount'] ?? 0; + $purchaseReturn->update([ + 'total_amount' => $totalAmount, + 'tax_amount' => $taxAmount, + 'grand_total' => $totalAmount + $taxAmount, + ]); + + return $purchaseReturn; + }); + } + + /** + * 更新退貨單 (限草稿) + */ + public function update(PurchaseReturn $purchaseReturn, array $data) + { + if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { + throw new Exception('只有草稿狀態的退回單可以修改。'); + } + + return DB::transaction(function () use ($purchaseReturn, $data) { + $updateData = [ + 'vendor_id' => $data['vendor_id'] ?? $purchaseReturn->vendor_id, + 'warehouse_id' => $data['warehouse_id'] ?? $purchaseReturn->warehouse_id, + 'return_date' => $data['return_date'] ?? $purchaseReturn->return_date, + 'remarks' => $data['remarks'] ?? $purchaseReturn->remarks, + ]; + + if (isset($data['tax_amount'])) { + $updateData['tax_amount'] = $data['tax_amount']; + } + + $purchaseReturn->update($updateData); + + if (isset($data['items'])) { + $purchaseReturn->items()->delete(); + $totalAmount = 0; + + foreach ($data['items'] as $itemData) { + $amount = $itemData['quantity_returned'] * $itemData['unit_price']; + $totalAmount += $amount; + + $prItem = new PurchaseReturnItem([ + 'product_id' => $itemData['product_id'], + 'quantity_returned' => $itemData['quantity_returned'], + 'unit_price' => $itemData['unit_price'], + 'total_amount' => $amount, + 'batch_number' => $itemData['batch_number'] ?? null, + ]); + $purchaseReturn->items()->save($prItem); + } + + $taxAmount = $purchaseReturn->tax_amount; + $purchaseReturn->update([ + 'total_amount' => $totalAmount, + 'grand_total' => $totalAmount + $taxAmount, + ]); + } + + return $purchaseReturn->fresh('items'); + }); + } + + /** + * 送出審核 / 確認退貨 (扣減倉庫庫存) + */ + public function submit(PurchaseReturn $purchaseReturn) + { + if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) { + throw new Exception('只有草稿狀態的退回單可以提交。'); + } + + return DB::transaction(function () use ($purchaseReturn) { + // 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為) + $purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED; + $purchaseReturn->saveQuietly(); + + // 2. 扣減庫存 + $updatedItems = []; + foreach ($purchaseReturn->items as $prItem) { + // 調用 Inventory Service 的 FIFO 扣庫存邏輯 + $this->inventoryService->decreaseStock( + $prItem->product_id, + $purchaseReturn->warehouse_id, + $prItem->quantity_returned, + '採購退回 (' . $purchaseReturn->code . ')' + ); + + $updatedItems[] = [ + 'product_id' => $prItem->product_id, + 'quantity_returned' => $prItem->quantity_returned, + ]; + } + + // 3. 手動觸發合併的操作紀錄 + activity() + ->performedOn($purchaseReturn) + ->withProperties([ + 'items_diff' => ['returned' => $updatedItems], + 'attributes' => ['status' => PurchaseReturn::STATUS_COMPLETED], + 'old' => ['status' => PurchaseReturn::STATUS_DRAFT], + 'snapshot' => [ + 'return_no' => $purchaseReturn->code, + 'vendor_id' => $purchaseReturn->vendor_id, + ] // Service 層若無法拿到 warehouse 名稱,依賴 Model tapActivity 解析 + ]) + ->log('submitted'); + + return $purchaseReturn; + }); + } + + /** + * 取消退貨單 + */ + public function cancel(PurchaseReturn $purchaseReturn) + { + if ($purchaseReturn->status === PurchaseReturn::STATUS_COMPLETED) { + throw new Exception('已完成扣庫的退貨單無法直接取消,需進行逆向調整。'); + } + + $purchaseReturn->update(['status' => PurchaseReturn::STATUS_CANCELLED]); + return $purchaseReturn; + } + + /** + * 刪除退貨單 + */ + public function delete(PurchaseReturn $purchaseReturn) + { + if ($purchaseReturn->status === PurchaseReturn::STATUS_COMPLETED) { + throw new Exception('已完成的退貨單無法刪除。'); + } + + return DB::transaction(function () use ($purchaseReturn) { + $purchaseReturn->items()->delete(); + $purchaseReturn->delete(); + }); + } + + private function generateCode(string $date) + { + // 格式: PR-YYYYMMDD-NN + $prefix = 'PR-' . date('Ymd', strtotime($date)) . '-'; + + $last = PurchaseReturn::where('code', 'like', $prefix . '%') + ->orderBy('id', 'desc') + ->lockForUpdate() + ->first(); + + if ($last) { + $seq = intval(substr($last->code, -2)) + 1; + } else { + $seq = 1; + } + + return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT); + } +} diff --git a/database/migrations/tenant/2026_02_25_125537_create_purchase_returns_table.php b/database/migrations/tenant/2026_02_25_125537_create_purchase_returns_table.php new file mode 100644 index 0000000..1ed6de6 --- /dev/null +++ b/database/migrations/tenant/2026_02_25_125537_create_purchase_returns_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('code')->unique()->comment('退回單號'); + $table->unsignedBigInteger('vendor_id')->comment('廠商 ID'); + $table->unsignedBigInteger('warehouse_id')->comment('退貨出庫倉庫 ID'); + $table->unsignedBigInteger('user_id')->comment('建立者 ID'); + $table->date('return_date')->comment('退貨日期'); + + $table->string('status', 20)->default('draft')->comment('狀態: draft, completed, cancelled'); + + $table->decimal('total_amount', 12, 2)->default(0)->comment('總金額 (不含稅)'); + $table->decimal('tax_amount', 12, 2)->default(0)->comment('稅金'); + $table->decimal('grand_total', 12, 2)->default(0)->comment('總計含稅金額'); + + $table->text('remarks')->nullable()->comment('備註'); + + $table->timestamps(); + $table->softDeletes(); + + // 由於 Modular Monolith 規範,外部模組的 Foreign Key 不一定建立實體約束以避免牽一髮動全身 + // $table->foreign('vendor_id')->references('id')->on('vendors')->onDelete('restrict'); + $table->index('code'); + $table->index('vendor_id'); + $table->index('warehouse_id'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchase_returns'); + } +}; diff --git a/database/migrations/tenant/2026_02_25_125551_create_purchase_return_items_table.php b/database/migrations/tenant/2026_02_25_125551_create_purchase_return_items_table.php new file mode 100644 index 0000000..b87e3fe --- /dev/null +++ b/database/migrations/tenant/2026_02_25_125551_create_purchase_return_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('purchase_return_id')->constrained('purchase_returns')->onDelete('cascade'); + $table->unsignedBigInteger('product_id')->comment('商品 ID'); + + $table->decimal('quantity_returned', 10, 2)->comment('退貨數量'); + $table->decimal('unit_price', 12, 2)->comment('退貨單價'); + $table->decimal('total_amount', 12, 2)->comment('小計金額'); + + $table->string('batch_number', 100)->nullable()->comment('指定退回之批號'); + + $table->timestamps(); + + $table->index('product_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchase_return_items'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index e518a7d..467a949 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -33,6 +33,14 @@ class PermissionSeeder extends Seeder 'purchase_orders.approve' => '核准', 'purchase_orders.cancel' => '作廢', + // 採購退回管理 + 'purchase_returns.view' => '檢視', + 'purchase_returns.create' => '建立', + 'purchase_returns.edit' => '編輯', + 'purchase_returns.delete' => '刪除', + 'purchase_returns.approve' => '核准', + 'purchase_returns.cancel' => '作廢', + // 庫存管理 'inventory.view' => '檢視', 'inventory.view_cost' => '檢視成本', @@ -173,6 +181,8 @@ class PermissionSeeder extends Seeder 'products.view', 'products.create', 'products.edit', 'products.delete', 'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit', 'purchase_orders.delete', 'purchase_orders.approve', 'purchase_orders.cancel', + 'purchase_returns.view', 'purchase_returns.create', 'purchase_returns.edit', + 'purchase_returns.delete', 'purchase_returns.approve', 'purchase_returns.cancel', 'inventory.view', 'inventory.view_cost', 'inventory.delete', 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', @@ -215,6 +225,7 @@ class PermissionSeeder extends Seeder $purchaser->givePermissionTo([ 'products.view', 'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit', + 'purchase_returns.view', 'purchase_returns.create', 'purchase_returns.edit', 'vendors.view', 'vendors.create', 'vendors.edit', 'inventory.view', 'goods_receipts.view', 'goods_receipts.create', @@ -224,6 +235,7 @@ class PermissionSeeder extends Seeder $viewer->givePermissionTo([ 'products.view', 'purchase_orders.view', + 'purchase_returns.view', 'inventory.view', 'goods_receipts.view', 'vendors.view', diff --git a/resources/js/Components/PurchaseReturn/PurchaseReturnActions.tsx b/resources/js/Components/PurchaseReturn/PurchaseReturnActions.tsx new file mode 100644 index 0000000..d2d9652 --- /dev/null +++ b/resources/js/Components/PurchaseReturn/PurchaseReturnActions.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { Pencil, Eye, Trash2 } from "lucide-react"; +import { Button } from "@/Components/ui/button"; +import { Link, useForm } from "@inertiajs/react"; +import type { PurchaseReturn } from "@/types/purchase-return"; +import { toast } from "sonner"; +import { Can } from "@/Components/Permission/Can"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; + +export function PurchaseReturnActions({ + purchaseReturn, +}: { purchaseReturn: PurchaseReturn }) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { delete: destroy, processing } = useForm({}); + + const handleConfirmDelete = () => { + // @ts-ignore + destroy(route('purchase-returns.destroy', purchaseReturn.id), { + onSuccess: () => { + toast.success("採購退回單已成功刪除"); + setShowDeleteDialog(false); + }, + onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"), + }); + }; + + return ( +
+ + + + + {purchaseReturn.status === 'draft' && ( + + + + + + )} + + {purchaseReturn.status === 'draft' && ( + + + + + + + 確認刪除採購退回單 + + 確定要刪除採購退回單 「{purchaseReturn.code}」 嗎?此操作無法撤銷。 + + + + 取消 + + 確認刪除 + + + + + + )} +
+ ); +} diff --git a/resources/js/Components/PurchaseReturn/PurchaseReturnItemsTable.tsx b/resources/js/Components/PurchaseReturn/PurchaseReturnItemsTable.tsx new file mode 100644 index 0000000..1dd5480 --- /dev/null +++ b/resources/js/Components/PurchaseReturn/PurchaseReturnItemsTable.tsx @@ -0,0 +1,214 @@ +import { Trash2 } from "lucide-react"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/Components/ui/alert-dialog"; +import type { PurchaseReturnItem } from "@/types/purchase-return"; +import type { Supplier } from "@/types/purchase-order"; +import { formatCurrency } from "@/utils/format"; + +interface PurchaseReturnItemsTableProps { + items: PurchaseReturnItem[]; + vendor?: Supplier; + isReadOnly?: boolean; + isDisabled?: boolean; + onRemoveItem?: (index: number) => void; + onItemChange?: (index: number, field: keyof PurchaseReturnItem, value: string | number) => void; +} + +export function PurchaseReturnItemsTable({ + items, + vendor, + isReadOnly = false, + isDisabled = false, + onRemoveItem, + onItemChange, +}: PurchaseReturnItemsTableProps) { + return ( +
+ + + + 退回商品 + 退回數量 + 退回單價 + 總退款金額 + 批號 / 備註 + {!isReadOnly && } + + + + {items.length === 0 ? ( + + + {isDisabled ? "請先選擇供應商後才能加入退回商品" : "尚未新增任何退回品項"} + + + ) : ( + items.map((item, index) => { + return ( + + {/* 商品選擇 */} + + {isReadOnly ? ( + {item.product?.name || item.product_name} + ) : ( + + onItemChange?.(index, "product_id", value) + } + disabled={isDisabled} + options={vendor?.commonProducts?.map((p) => ({ label: p.productName, value: String(p.productId) })) || []} + placeholder="選擇退回商品" + searchPlaceholder="搜尋商品..." + emptyText="此供應商無可用商品" + className="w-full" + /> + )} + + + {/* 數量 */} + + {isReadOnly ? ( + {item.quantity_returned} + ) : ( + + onItemChange?.(index, "quantity_returned", Number(e.target.value)) + } + disabled={isDisabled} + className="text-right w-full" + /> + )} + + + {/* 單價 */} + + {isReadOnly ? ( + {formatCurrency(item.unit_price)} + ) : ( + + onItemChange?.(index, "unit_price", Number(e.target.value)) + } + disabled={isDisabled} + className="text-right w-full" + /> + )} + + + {/* 總退款金額 */} + + {isReadOnly ? ( + {formatCurrency(item.total_amount)} + ) : ( +
+ + onItemChange?.(index, "total_amount", Number(e.target.value)) + } + disabled={isDisabled} + className={`text-right w-full ${item.quantity_returned > 0 && (!item.total_amount || item.total_amount <= 0) + ? "border-red-400 bg-red-50 focus-visible:ring-red-500" + : "" + }`} + /> +
+ )} +
+ + {/* 批號 */} + + {isReadOnly ? ( + {item.batch_number} + ) : ( + + onItemChange?.(index, "batch_number", e.target.value) + } + disabled={isDisabled} + className="w-full" + placeholder="輸入批號或備註..." + /> + )} + + + {/* 刪除按鈕 */} + {!isReadOnly && onRemoveItem && ( + + + + + + + + 確定要移除此退回項嗎? + + 此動作將從退回清單中移除該商品。 + + + + 取消 + onRemoveItem(index)} + className="bg-red-600 hover:bg-red-700" + > + 確定移除 + + + + + + )} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/resources/js/Components/PurchaseReturn/PurchaseReturnStatusBadge.tsx b/resources/js/Components/PurchaseReturn/PurchaseReturnStatusBadge.tsx new file mode 100644 index 0000000..8b08c96 --- /dev/null +++ b/resources/js/Components/PurchaseReturn/PurchaseReturnStatusBadge.tsx @@ -0,0 +1,26 @@ +import { Badge } from "@/Components/ui/badge"; +import { PurchaseReturnStatus, PURCHASE_RETURN_STATUS_CONFIG } from "@/types/purchase-return"; + +interface PurchaseReturnStatusBadgeProps { + status: PurchaseReturnStatus | string; + className?: string; +} + +export default function PurchaseReturnStatusBadge({ + status, + className = "", +}: PurchaseReturnStatusBadgeProps) { + const config = PURCHASE_RETURN_STATUS_CONFIG[status as PurchaseReturnStatus] || { + label: status, + color: "bg-gray-100 text-gray-800", + }; + + return ( + + {config.label} + + ); +} diff --git a/resources/js/Components/PurchaseReturn/PurchaseReturnTable.tsx b/resources/js/Components/PurchaseReturn/PurchaseReturnTable.tsx new file mode 100644 index 0000000..fb67955 --- /dev/null +++ b/resources/js/Components/PurchaseReturn/PurchaseReturnTable.tsx @@ -0,0 +1,213 @@ +import { useState, useMemo } from "react"; +import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { PurchaseReturnActions } from "./PurchaseReturnActions"; +import PurchaseReturnStatusBadge from "./PurchaseReturnStatusBadge"; +import CopyButton from "@/Components/shared/CopyButton"; +import type { PurchaseReturn } from "@/types/purchase-return"; +import { formatCurrency, formatDateTime } from "@/utils/format"; + +interface PurchaseReturnTableProps { + purchaseReturns: PurchaseReturn[]; +} + +type SortField = "code" | "warehouse_name" | "vendor_name" | "return_date" | "total_amount" | "status"; +type SortDirection = "asc" | "desc" | null; + +export default function PurchaseReturnTable({ + purchaseReturns, +}: PurchaseReturnTableProps) { + const [sortField, setSortField] = useState(null); + const [sortDirection, setSortDirection] = useState(null); + + // 處理排序 + const handleSort = (field: SortField) => { + if (sortField === field) { + if (sortDirection === "asc") { + setSortDirection("desc"); + } else if (sortDirection === "desc") { + setSortDirection(null); + setSortField(null); + } else { + setSortDirection("asc"); + } + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + // 排序後的退回單列表 + const sortedReturns = useMemo(() => { + if (!sortField || !sortDirection || !purchaseReturns) { + return purchaseReturns || []; + } + + return [...purchaseReturns].sort((a, b) => { + let aValue: string | number; + let bValue: string | number; + + switch (sortField) { + case "code": + aValue = a.code; + bValue = b.code; + break; + case "warehouse_name": + aValue = a.warehouse_name || ""; + bValue = b.warehouse_name || ""; + break; + case "vendor_name": + aValue = a.vendor?.name || ""; + bValue = b.vendor?.name || ""; + break; + case "return_date": + aValue = a.return_date; + bValue = b.return_date; + break; + case "total_amount": + aValue = a.total_amount; + bValue = b.total_amount; + break; + case "status": + aValue = a.status; + bValue = b.status; + break; + default: + return 0; + } + + if (typeof aValue === "string" && typeof bValue === "string") { + return sortDirection === "asc" + ? aValue.localeCompare(bValue, "zh-TW") + : bValue.localeCompare(aValue, "zh-TW"); + } else { + return sortDirection === "asc" + ? (aValue as number) - (bValue as number) + : (bValue as number) - (aValue as number); + } + }); + }, [purchaseReturns, sortField, sortDirection]); + + const SortIcon = ({ field }: { field: SortField }) => { + if (sortField !== field) { + return ; + } + if (sortDirection === "asc") { + return ; + } + if (sortDirection === "desc") { + return ; + } + return ; + }; + + return ( +
+
+ + + + # + + + + + + + + + + + + + + + + 操作 + + + + {!sortedReturns || sortedReturns.length === 0 ? ( + + + 尚無採購退回單 + + + ) : ( + sortedReturns.map((item, index) => ( + + + {index + 1} + + +
+ {item.code} + +
+
+ +
+ {item.vendor?.name} +
{item.user?.name}
+
+
+ + {item.return_date} + + + {formatCurrency(item.total_amount)} + + + + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index d99f2cd..972c6a0 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -26,7 +26,8 @@ import { ArrowLeftRight, TrendingUp, FileUp, - Store + Store, + RotateCcw } from "lucide-react"; import { toast, Toaster } from "sonner"; import { useState, useEffect, useMemo, useRef } from "react"; @@ -168,6 +169,13 @@ export default function AuthenticatedLayout({ route: "/goods-receipts", permission: "goods_receipts.view", }, + { + id: "purchase-return-list", + label: "採購退回單", + icon: , + route: "/purchase-returns", + permission: "purchase_returns.view", + }, { id: "delivery-note-list", label: "出貨單管理 (功能製作中)", diff --git a/resources/js/Pages/PurchaseReturn/Create.tsx b/resources/js/Pages/PurchaseReturn/Create.tsx new file mode 100644 index 0000000..9a1d403 --- /dev/null +++ b/resources/js/Pages/PurchaseReturn/Create.tsx @@ -0,0 +1,281 @@ +import { ArrowLeft, Plus, Info, Package } from "lucide-react"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Textarea } from "@/Components/ui/textarea"; +import { Alert, AlertDescription } from "@/Components/ui/alert"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, Link, router, usePage } from "@inertiajs/react"; +import { PurchaseReturnItemsTable } from "@/Components/PurchaseReturn/PurchaseReturnItemsTable"; +import type { PurchaseReturn } from "@/types/purchase-return"; +import type { Supplier } from "@/types/purchase-order"; +import type { Warehouse } from "@/types/requester"; +import { usePurchaseReturnForm } from "@/hooks/usePurchaseReturnForm"; +import { formatCurrency } from "@/utils/format"; +import { toast } from "sonner"; +import { getCreateBreadcrumbs } from "@/utils/breadcrumb"; + +interface Props { + vendors: Supplier[]; + warehouses: Warehouse[]; +} + +export default function CreatePurchaseReturn({ + vendors, + warehouses, +}: Props) { + const { auth } = usePage().props; + const permissions = auth.user?.permissions || []; + const isSuperAdmin = auth.user?.roles?.some((r: any) => r.name === 'super-admin'); + + const canCreate = isSuperAdmin || permissions.includes('purchase_returns.create'); + + const { + vendorId, + warehouseId, + returnDate, + items, + remarks, + selectedVendor, + setVendorId, + setWarehouseId, + setReturnDate, + setRemarks, + addItem, + removeItem, + updateItem, + } = usePurchaseReturnForm({ suppliers: vendors }); + + const totalAmount = items.reduce((sum, item) => sum + (Number(item.total_amount) || 0), 0); + + const handleSave = () => { + if (!warehouseId) { + toast.error("請選擇退出的倉庫"); + return; + } + + if (!vendorId) { + toast.error("請選擇供應商"); + return; + } + + if (!returnDate) { + toast.error("請選擇退回日期"); + return; + } + + if (items.length === 0) { + toast.error("請至少新增一項退回商品"); + return; + } + + const itemsWithQuantity = items.filter(item => item.quantity_returned > 0); + if (itemsWithQuantity.length === 0) { + toast.error("請填寫有效的退回數量(必須大於 0)"); + return; + } + + const itemsWithoutPrice = itemsWithQuantity.filter(item => !item.unit_price || item.unit_price < 0); + if (itemsWithoutPrice.length > 0) { + toast.error("單價不能為負數"); + return; + } + + const validItems = items.filter(i => i.product_id && i.quantity_returned > 0); + if (validItems.length === 0) { + toast.error("請確保所有商品都有正確選擇並填寫數量"); + return; + } + + const data = { + vendor_id: vendorId, + warehouse_id: warehouseId, + return_date: returnDate, + remarks: remarks, + items: validItems.map(item => ({ + product_id: item.product_id, + quantity_returned: item.quantity_returned, + unit_price: item.unit_price, + total_amount: item.total_amount, + batch_number: item.batch_number || null, + })), + }; + + router.post("/purchase-returns", data, { + onSuccess: () => { + // Controller 會有 Flash Message 透過 Middleware 傳給 Toast + }, + onError: (errors) => { + if (errors.items) { + toast.error("商品資料有誤,請檢查數量和單價是否正確填寫"); + } else if (errors.error) { + toast.error(errors.error); + } else { + toast.error("建立失敗,請檢查輸入內容"); + } + console.error(errors); + } + }); + }; + + const hasVendor = !!vendorId; + + return ( + + +
+ {/* Header */} +
+ + + + +
+

+ + 建立採購退回單 +

+

+ 填寫將商品退回給原進貨供應商的詳細資訊與品項 +

+
+
+ +
+ {/* 步驟一:基本資訊 */} +
+
+
1
+

基本資訊

+
+ +
+
+
+ + ({ label: w.name, value: String(w.id) }))} + placeholder="請選擇倉庫" + searchPlaceholder="搜尋倉庫..." + /> +

注意:退回單一經提交審核,將會從此倉庫扣除對應的商品庫存

+
+ +
+ + ({ label: s.name, value: String(s.id) }))} + placeholder="選擇退回廠商" + searchPlaceholder="搜尋廠商..." + /> +
+
+ +
+
+ + setReturnDate(e.target.value)} + className="block w-full" + /> +
+
+ +
+ +