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 */}
+
+
+
+
+
+
+
+
+ 建立採購退回單
+
+
+ 填寫將商品退回給原進貨供應商的詳細資訊與品項
+
+
+
+
+
+ {/* 步驟一:基本資訊 */}
+
+
+
+
+
+
+
+
({ 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"
+ />
+
+
+
+
+
+
+
+
+
+ {/* 步驟二:退回品項明細 */}
+
+
+
+
+ {!hasVendor && (
+
+
+
+ 請先在步驟一選擇「退回的供應商」,才能從該供應商的常用項目中選取欲退貨的商品。
+
+
+ )}
+
+
+
+ {hasVendor && items.length > 0 && (
+
+
+
+ 總退款金額
+
+ {formatCurrency(totalAmount)}
+
+
+
+
+ )}
+
+
+
+
+ {/* 底部按鈕 */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/PurchaseReturn/Edit.tsx b/resources/js/Pages/PurchaseReturn/Edit.tsx
new file mode 100644
index 0000000..14867f8
--- /dev/null
+++ b/resources/js/Pages/PurchaseReturn/Edit.tsx
@@ -0,0 +1,294 @@
+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 { getEditBreadcrumbs } from "@/utils/breadcrumb";
+
+interface Props {
+ purchaseReturn: PurchaseReturn;
+ vendors: Supplier[];
+ warehouses: Warehouse[];
+}
+
+export default function EditPurchaseReturn({
+ purchaseReturn,
+ 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 canEdit = isSuperAdmin || permissions.includes('purchase_returns.edit');
+
+ const {
+ vendorId,
+ warehouseId,
+ returnDate,
+ items,
+ remarks,
+ selectedVendor,
+ setVendorId,
+ setWarehouseId,
+ setReturnDate,
+ setRemarks,
+ addItem,
+ removeItem,
+ updateItem,
+ } = usePurchaseReturnForm({ purchaseReturn, suppliers: vendors });
+
+ const totalAmount = items.reduce((sum, item) => sum + (Number(item.total_amount) || 0), 0);
+ const isDraft = purchaseReturn.status === 'draft';
+
+ 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.put(`/purchase-returns/${purchaseReturn.id}`, data, {
+ onSuccess: () => {
+ // Controller 會有 Flash Message
+ },
+ 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 */}
+
+
+
+
+
+
+
+
+ 編輯採購退回單 {purchaseReturn.code}
+
+
+ 修改退回單的詳細資訊與品項
+
+
+
+
+ {!isDraft && (
+
+
+
+ 此退回單狀態為「{purchaseReturn.status}」,目前無法編輯草稿內容。
+
+
+ )}
+
+
+ {/* 步驟一:基本資訊 */}
+
+
+
+
+
+
+
+ ({ 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"
+ />
+
+
+
+
+
+
+
+
+
+ {/* 步驟二:退回品項明細 */}
+
+
+
+
+ {!hasVendor && (
+
+
+
+ 請先在步驟一選擇「退回的供應商」,才能從該供應商的常用項目中選取欲退貨的商品。
+
+
+ )}
+
+
+
+ {hasVendor && items.length > 0 && (
+
+
+
+ 總退款金額
+
+ {formatCurrency(totalAmount)}
+
+
+
+
+ )}
+
+
+
+
+ {/* 底部按鈕 */}
+
+
+
+
+ {isDraft && (
+
+ )}
+
+
+
+ );
+}
diff --git a/resources/js/Pages/PurchaseReturn/Index.tsx b/resources/js/Pages/PurchaseReturn/Index.tsx
new file mode 100644
index 0000000..3664913
--- /dev/null
+++ b/resources/js/Pages/PurchaseReturn/Index.tsx
@@ -0,0 +1,172 @@
+/**
+ * 採購退回單管理主頁面
+ */
+
+import { useState, useEffect } from "react";
+import { Plus, Package, Search, RotateCcw } from 'lucide-react';
+import { Button } from "@/Components/ui/button";
+import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
+import { Head, router } from "@inertiajs/react";
+import PurchaseReturnTable from "@/Components/PurchaseReturn/PurchaseReturnTable";
+import type { PurchaseReturn } from "@/types/purchase-return";
+import Pagination from "@/Components/shared/Pagination";
+import { getBreadcrumbs } from "@/utils/breadcrumb";
+import { Can } from "@/Components/Permission/Can";
+import { Input } from "@/Components/ui/input";
+import { Label } from "@/Components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/Components/ui/select";
+import { PURCHASE_RETURN_STATUS_CONFIG } from "@/types/purchase-return";
+
+interface Props {
+ purchaseReturns: {
+ data: PurchaseReturn[];
+ links: any[];
+ total: number;
+ from: number;
+ to: number;
+ };
+ filters: {
+ search?: string;
+ status?: string;
+ };
+}
+
+export default function PurchaseReturnIndex({ purchaseReturns, filters }: Props) {
+ const [search, setSearch] = useState(filters.search || "");
+ const [status, setStatus] = useState(filters.status || "all");
+
+ // 同步 URL 參數
+ useEffect(() => {
+ setSearch(filters.search || "");
+ setStatus(filters.status || "all");
+ }, [filters]);
+
+ const handleFilter = () => {
+ router.get(
+ route('purchase-returns.index'),
+ {
+ search,
+ status: status === 'all' ? undefined : status,
+ },
+ { preserveState: true, replace: true }
+ );
+ };
+
+ const handleReset = () => {
+ setSearch("");
+ setStatus("all");
+ router.get(route('purchase-returns.index'));
+ };
+
+ const handleNavigateToCreate = () => {
+ router.get(route('purchase-returns.create'));
+ };
+
+ const statusOptions = Object.entries(PURCHASE_RETURN_STATUS_CONFIG).map(([key, config]) => ({
+ value: key,
+ label: config.label
+ }));
+
+ return (
+ // TODO: 加入 Breadcrumbs 對應到 purchaseReturns (如果目前 getBreadcrumbs 尚未支援,可先傳入自訂或加入 utils 支援)
+
+
+
+
+
+
+
+ 採購退回單管理
+
+
+ 管理退回給供應商的商品紀錄與庫存扣減
+
+
+
+
+
+ {/* 篩選區塊 */}
+
+
+ {/* Search */}
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10 h-9 block"
+ onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
+ />
+
+
+
+ {/* Status */}
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+
+ {/* 分頁元件 */}
+
+
+
+ );
+}
diff --git a/resources/js/Pages/PurchaseReturn/Show.tsx b/resources/js/Pages/PurchaseReturn/Show.tsx
new file mode 100644
index 0000000..32ee780
--- /dev/null
+++ b/resources/js/Pages/PurchaseReturn/Show.tsx
@@ -0,0 +1,218 @@
+import { ArrowLeft, Package, CheckCircle, XCircle, FileEdit } from "lucide-react";
+import { Button } from "@/Components/ui/button";
+import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
+import { Head, Link, useForm, usePage, router } from "@inertiajs/react";
+import CopyButton from "@/Components/shared/CopyButton";
+import { PurchaseReturnItemsTable } from "@/Components/PurchaseReturn/PurchaseReturnItemsTable";
+import type { PurchaseReturn } from "@/types/purchase-return";
+import { formatCurrency, formatDateTime } from "@/utils/format";
+import { getShowBreadcrumbs } from "@/utils/breadcrumb";
+import { toast } from "sonner";
+import { PageProps } from "@/types/global";
+
+interface Props {
+ purchaseReturn: PurchaseReturn;
+}
+
+export default function ViewPurchaseReturnPage({ purchaseReturn }: Props) {
+ const isDraft = purchaseReturn.status === 'draft';
+
+ return (
+
+
+
+ {/* 返回按鈕 */}
+
+
+ {/* 頁面標題與操作 */}
+
+
+
+
+ 採購退回單詳情
+
+
+
+
單號:{purchaseReturn.code}
+
+
+
+
+
+
+ {/* 基本資訊卡片 */}
+
+
+
基本資訊
+ {isDraft && (
+
+
+
+ )}
+
+
+
+
退回單號
+
+ {purchaseReturn.code}
+
+
+
+
+ 供應商
+ {purchaseReturn.vendor?.name}
+
+
+ 退貨倉庫
+
+ {purchaseReturn.warehouse_name}
+
+
+
+ 建立者
+
+ {purchaseReturn.user?.name}
+
+
+
+ 建立時間
+ {formatDateTime(purchaseReturn.created_at)}
+
+
+ 退回日期
+ {purchaseReturn.return_date || "-"}
+
+
+ {purchaseReturn.remarks && (
+
+
退回原因/備註
+
+ {purchaseReturn.remarks}
+
+
+ )}
+
+
+ {/* 退回項目卡片 */}
+
+
+
退回項目明細
+
+
+
+
+
+
+ 總退款金額
+
+ {formatCurrency(purchaseReturn.total_amount)}
+
+
+ {purchaseReturn.tax_amount > 0 && (
+
+ 含稅額
+
+ {formatCurrency(purchaseReturn.tax_amount)}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function PurchaseReturnStatusBadge({ status }: { status: string }) {
+ switch (status) {
+ case 'draft':
+ return 草稿;
+ case 'completed':
+ return 已完成;
+ case 'cancelled':
+ return 已取消;
+ default:
+ return {status};
+ }
+}
+
+function PurchaseReturnActions({ purchaseReturn }: { purchaseReturn: PurchaseReturn }) {
+ const { auth } = usePage().props;
+ const permissions = auth.user?.permissions || [];
+ const { processing } = useForm({});
+
+ const handleAction = (routeUri: string, method: 'post' | 'put' | 'patch' | 'delete', confirmMessage?: string) => {
+ if (confirmMessage && !confirm(confirmMessage)) return;
+
+ router[method](routeUri, {}, {
+ onSuccess: () => {
+ // UI feedback handled by Flash Message toast via Middleware
+ },
+ onError: (errors: any) => {
+ console.error("Action Error:", errors);
+ toast.error(errors.error || "操作失敗");
+ }
+ });
+ };
+
+ const isSuperAdmin = auth.user?.roles?.some((r: any) => r.name === 'super-admin');
+ const canApprove = isSuperAdmin || permissions.includes('purchase_returns.approve');
+ const canCancel = isSuperAdmin || permissions.includes('purchase_returns.cancel');
+ const canDelete = isSuperAdmin || permissions.includes('purchase_returns.delete');
+
+ return (
+
+ {purchaseReturn.status === 'draft' && canDelete && (
+
+ )}
+
+ {purchaseReturn.status === 'draft' && canApprove && (
+
+ )}
+
+ {purchaseReturn.status === 'completed' && canCancel && (
+
+ )}
+
+ );
+}
diff --git a/resources/js/hooks/usePurchaseReturnForm.ts b/resources/js/hooks/usePurchaseReturnForm.ts
new file mode 100644
index 0000000..08ffabe
--- /dev/null
+++ b/resources/js/hooks/usePurchaseReturnForm.ts
@@ -0,0 +1,123 @@
+import { useState, useEffect } from "react";
+import type { PurchaseReturn, PurchaseReturnItem, PurchaseReturnStatus } from "@/types/purchase-return";
+import type { Supplier } from "@/types/purchase-order";
+
+interface UsePurchaseReturnFormProps {
+ purchaseReturn?: PurchaseReturn;
+ suppliers: Supplier[];
+}
+
+export function usePurchaseReturnForm({ purchaseReturn, suppliers }: UsePurchaseReturnFormProps) {
+ const [vendorId, setVendorId] = useState(purchaseReturn?.vendor_id || "");
+ const [warehouseId, setWarehouseId] = useState(purchaseReturn?.warehouse_id || "");
+ const [returnDate, setReturnDate] = useState(purchaseReturn?.return_date || new Date().toISOString().split('T')[0]);
+ const [items, setItems] = useState(purchaseReturn?.items || []);
+ const [remarks, setRemarks] = useState(purchaseReturn?.remarks || "");
+ const [status, setStatus] = useState(purchaseReturn?.status || "draft");
+ const [taxAmount, setTaxAmount] = useState(purchaseReturn?.tax_amount || 0);
+
+ // 同步外部傳入的 order 更新 (例如重新執行 edit 路由)
+ useEffect(() => {
+ if (purchaseReturn) {
+ setVendorId(purchaseReturn.vendor_id);
+ setWarehouseId(purchaseReturn.warehouse_id);
+ setReturnDate(purchaseReturn.return_date);
+ setItems(purchaseReturn.items || []);
+ setRemarks(purchaseReturn.remarks || "");
+ setStatus(purchaseReturn.status);
+ setTaxAmount(purchaseReturn.tax_amount || 0);
+ }
+ }, [purchaseReturn]);
+
+ const resetForm = () => {
+ setVendorId("");
+ setWarehouseId("");
+ setReturnDate(new Date().toISOString().split('T')[0]);
+ setItems([]);
+ setRemarks("");
+ setStatus("draft");
+ setTaxAmount(0);
+ };
+
+ const selectedVendor = suppliers.find((s) => String(s.id) === String(vendorId));
+
+ // 新增商品項目
+ const addItem = () => {
+ if (!selectedVendor) return;
+
+ setItems([
+ ...items,
+ {
+ product_id: 0,
+ quantity_returned: 1,
+ unit_price: 0,
+ total_amount: 0,
+ batch_number: "",
+ },
+ ]);
+ };
+
+ // 移除商品項目
+ const removeItem = (index: number) => {
+ setItems(items.filter((_, i) => i !== index));
+ };
+
+ // 更新商品項目
+ const updateItem = (index: number, field: keyof PurchaseReturnItem, value: any) => {
+ const newItems = [...items];
+ const item = { ...newItems[index] };
+
+ if (field === "product_id" && selectedVendor) {
+ const product = selectedVendor.commonProducts.find((p) => String(p.productId) === String(value));
+ if (product) {
+ // @ts-ignore
+ item.product_id = Number(value);
+ item.product_name = product.productName;
+ item.unit_price = product.lastPrice || 0;
+ item.total_amount = Number(item.quantity_returned) * Number(item.unit_price);
+ }
+ } else if (field === "total_amount") {
+ item.total_amount = Number(value);
+ if (item.quantity_returned > 0) {
+ item.unit_price = Number(item.total_amount) / Number(item.quantity_returned);
+ }
+ } else if (field === "quantity_returned" || field === "unit_price") {
+ // @ts-ignore
+ item[field] = Number(value);
+ if (field === "quantity_returned" && item.unit_price > 0) {
+ item.total_amount = Number(value) * item.unit_price;
+ } else if (field === "unit_price" && item.quantity_returned > 0) {
+ item.total_amount = item.quantity_returned * Number(value);
+ }
+ } else {
+ // @ts-ignore
+ item[field] = value;
+ }
+
+ newItems[index] = item;
+ setItems(newItems);
+ };
+
+ return {
+ vendorId,
+ warehouseId,
+ returnDate,
+ items,
+ remarks,
+ status,
+ selectedVendor,
+ taxAmount,
+
+ setVendorId,
+ setWarehouseId,
+ setReturnDate,
+ setRemarks,
+ setStatus,
+ setTaxAmount,
+
+ addItem,
+ removeItem,
+ updateItem,
+ resetForm,
+ };
+}
diff --git a/resources/js/types/purchase-return.ts b/resources/js/types/purchase-return.ts
new file mode 100644
index 0000000..3b0b72b
--- /dev/null
+++ b/resources/js/types/purchase-return.ts
@@ -0,0 +1,48 @@
+export type PurchaseReturnStatus = "draft" | "completed" | "cancelled";
+
+export interface PurchaseReturnItem {
+ id?: number;
+ purchase_return_id?: number;
+ product_id: number;
+ quantity_returned: number;
+ unit_price: number;
+ total_amount: number;
+ batch_number?: string | null;
+ product_code?: string;
+ product_name?: string;
+ product?: any; // 當有關聯商品時
+}
+
+export interface PurchaseReturn {
+ id: number;
+ code: string;
+ vendor_id: number;
+ warehouse_id: number;
+ user_id: number;
+ return_date: string;
+ status: PurchaseReturnStatus;
+ total_amount: number;
+ tax_amount: number;
+ grand_total: number;
+ remarks?: string | null;
+ created_at: string;
+ updated_at: string;
+
+ // 關聯資料
+ vendor?: {
+ id: number;
+ name: string;
+ };
+ user?: {
+ id: number;
+ name: string;
+ };
+ items?: PurchaseReturnItem[];
+ warehouse_name?: string;
+}
+
+export const PURCHASE_RETURN_STATUS_CONFIG: Record = {
+ draft: { label: "草稿", color: "bg-gray-100 text-gray-800" },
+ completed: { label: "已完成", color: "bg-green-100 text-green-800" },
+ cancelled: { label: "已作廢", color: "bg-red-100 text-red-800" },
+};
diff --git a/resources/js/utils/breadcrumb.ts b/resources/js/utils/breadcrumb.ts
index b902f99..74ae4d6 100644
--- a/resources/js/utils/breadcrumb.ts
+++ b/resources/js/utils/breadcrumb.ts
@@ -22,6 +22,10 @@ export const BREADCRUMB_MAP: Record = {
{ label: "供應鏈管理", href: '#' },
{ label: "採購單管理", href: "/purchase-orders", isPage: true }
],
+ purchaseReturns: [
+ { label: "供應鏈管理", href: '#' },
+ { label: "採購退回單管理", href: "/purchase-returns", isPage: true }
+ ],
productionOrders: [
{ label: "生產管理" },
{ label: "生產工單", href: "/production-orders", isPage: true }