From ad91b08dbc6648c131cbe46d234d79af1d7327b2 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 25 Feb 2026 11:11:28 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=A7=8B=20VendorProduct?= =?UTF-8?q?=20API=20=E8=88=87=E6=96=B0=E5=A2=9E=E9=80=B2=E8=B2=A8=E5=96=AE?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E6=AA=A2=E6=9F=A5=E5=89=8D=E7=AB=AF=E9=82=8F?= =?UTF-8?q?=E8=BC=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 將 VendorProductController 中的 Eloquent 關聯操作改為透過 ProcurementService 使用 DB 操作,解除跨模組 Model 直接依賴。 2. ProcurementService 加入 vendor product 的資料存取方法。 3. 進貨單建立前端 (GoodsReceipt/Create.tsx) 新增重複進貨檢查與警告對話框邏輯。 --- .agent/rules/framework.md | 2 - .../cross-module-communication/SKILL.md | 145 +++++++++++++ .../Controllers/GoodsReceiptController.php | 20 +- app/Modules/Inventory/Routes/web.php | 3 + .../Services/DuplicateCheckService.php | 192 ++++++++++++++++++ .../Contracts/ProcurementServiceInterface.php | 25 +++ .../Controllers/VendorProductController.php | 28 ++- .../Services/ProcurementService.php | 48 +++++ .../Pages/Inventory/GoodsReceipt/Create.tsx | 45 +++- .../components/DuplicateWarningDialog.tsx | 176 ++++++++++++++++ test.php | 28 +++ 11 files changed, 689 insertions(+), 23 deletions(-) create mode 100644 .agent/skills/cross-module-communication/SKILL.md create mode 100644 app/Modules/Inventory/Services/DuplicateCheckService.php create mode 100644 resources/js/Pages/Inventory/GoodsReceipt/components/DuplicateWarningDialog.tsx create mode 100644 test.php diff --git a/.agent/rules/framework.md b/.agent/rules/framework.md index f1c5b2d..a6dfa4c 100644 --- a/.agent/rules/framework.md +++ b/.agent/rules/framework.md @@ -67,8 +67,6 @@ trigger: always_on * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 - * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 - ## 8. 多租戶開發規範 (Multi-tenancy Standards) 本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: * **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。 diff --git a/.agent/skills/cross-module-communication/SKILL.md b/.agent/skills/cross-module-communication/SKILL.md new file mode 100644 index 0000000..8e26c9f --- /dev/null +++ b/.agent/skills/cross-module-communication/SKILL.md @@ -0,0 +1,145 @@ +--- +name: 跨模組調用與通訊規範 (Cross-Module Communication) +description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。 +--- + +# 跨模組調用與通訊規範 (Cross-Module Communication) + +為了確保專案的「模組化單體架構 (Modular Monolith)」的獨立性與可維護性,當遇到**需要跨越不同業務模組存取資料或調用功能**的情境時,請嚴格遵守以下規範。 + +## 🚫 絕對禁止的行為 (Strict Prohibitions) + +* **禁止跨模組 Eloquent 關聯** + * **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }`。 + * **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema,`Sales` 模組會無預警崩壞。 +* **禁止跨模組直接引入 (use) Model** + * **錯誤**:在 `app/Modules/Procurement/Controllers/PurchaseOrderController.php` 頂端寫 `use App\Modules\Inventory\Models\Warehouse;`。 +* **禁止跨模組直接實例化 (new) Service** + * **錯誤**:`$inventoryService = new \App\Modules\Inventory\Services\InventoryService();` + +--- + +## ✅ 正確的跨模組調用流程:合約與依賴反轉 + +所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。 + +### Step 1: 在被調用的模組定義合約 (Interface) + +如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。 + +```php +// app/Modules/Inventory/Contracts/InventoryServiceInterface.php +namespace App\Modules\Inventory\Contracts; + +use Illuminate\Support\Collection; + +interface InventoryServiceInterface +{ + /** + * 取得可用的倉庫清單 + * + * @return Collection 包含每個倉庫的 id, name, code 等基本資料 + */ + public function getActiveWarehouses(): Collection; +} +``` + +### Step 2: 實作介面並在自己模組的 ServiceProvider 註冊 + +由 `Inventory` 模組自己的 Service 來實作上述介面。 + +```php +// app/Modules/Inventory/Services/InventoryService.php +namespace App\Modules\Inventory\Services; + +use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use App\Modules\Inventory\Models\Warehouse; +use Illuminate\Support\Collection; + +class InventoryService implements InventoryServiceInterface +{ + public function getActiveWarehouses(): Collection + { + // 建議只取出需要的欄位,或者轉換為 DTO / 陣列 + // 避免將完整的 Eloquent Model 實例拋出模組外 + return Warehouse::where('is_active', true) + ->select(['id', 'name', 'code']) + ->get(); + } +} +``` + +然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定: + +```php +// app/Modules/Inventory/InventoryServiceProvider.php +namespace App\Modules\Inventory; + +use Illuminate\Support\ServiceProvider; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use App\Modules\Inventory\Services\InventoryService; + +class InventoryServiceProvider extends ServiceProvider +{ + public function register(): void + { + // 綁定介面與實體 + $this->app->bind(InventoryServiceInterface::class, InventoryService::class); + } +} +``` + +### Step 3: 調用方透過依賴注入 (DI) 使用服務 + +當 `Procurement` 模組需要取得倉庫資料時,禁止直接 new 服務或呼叫倉庫 Model。必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`。 + +```php +// app/Modules/Procurement/Controllers/PurchaseOrderController.php +namespace App\Modules\Procurement\Controllers; + +use App\Http\Controllers\Controller; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use Inertia\Inertia; + +class PurchaseOrderController extends Controller +{ + // 透過建構子注入介面 + public function __construct( + protected InventoryServiceInterface $inventoryService + ) {} + + public function create() + { + // 僅能呼叫介面有定義的方法 + $warehouses = $this->inventoryService->getActiveWarehouses(); + + return Inertia::render('Procurement/PurchaseOrder/Create', [ + 'warehouses' => $warehouses + ]); + } +} +``` + +--- + +## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration) + +* **回傳純粹資料**:為了防止其他模組意外觸發 Lazy Loading (`$item->product->name`),請盡量在 Service 中就用 `with()` 載入好關聯,或者直接轉為原生的 Array、`stdClass`、或具體的 DTO。 +* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,這也是被允許的,但必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。 + +### 範例:手動合併資料 +```php +// 錯誤示範(禁止在 OrderService 中去查使用者的關聯) +$orders = Order::with('user')->get(); // 如果 user 表在 Core 模組,這是不允許的 + +// 正確示範:在各自模組取資料,並手動組裝 +$orders = $this->orderService->getOrders(); +$userIds = $orders->pluck('user_id')->unique()->toArray(); +$users = $this->coreUserService->getUsersByIds($userIds)->keyBy('id'); + +$mergedData = $orders->map(function ($order) use ($users) { + // 將使用者資料手動附加上去 + $order->user_name = $users->get($order->user_id)->name ?? 'Unknown'; + return $order; +}); +``` diff --git a/app/Modules/Inventory/Controllers/GoodsReceiptController.php b/app/Modules/Inventory/Controllers/GoodsReceiptController.php index e005793..49e8c38 100644 --- a/app/Modules/Inventory/Controllers/GoodsReceiptController.php +++ b/app/Modules/Inventory/Controllers/GoodsReceiptController.php @@ -8,6 +8,7 @@ use App\Modules\Inventory\Services\InventoryService; use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use Illuminate\Http\Request; use App\Modules\Procurement\Models\Vendor; +use App\Modules\Inventory\Services\DuplicateCheckService; use Inertia\Inertia; use App\Modules\Inventory\Models\GoodsReceipt; use Illuminate\Support\Facades\DB; @@ -17,15 +18,18 @@ class GoodsReceiptController extends Controller protected $goodsReceiptService; protected $inventoryService; protected $procurementService; + protected $duplicateCheckService; public function __construct( GoodsReceiptService $goodsReceiptService, InventoryService $inventoryService, - ProcurementServiceInterface $procurementService + ProcurementServiceInterface $procurementService, + DuplicateCheckService $duplicateCheckService ) { $this->goodsReceiptService = $goodsReceiptService; $this->inventoryService = $inventoryService; $this->procurementService = $procurementService; + $this->duplicateCheckService = $duplicateCheckService; } public function index(Request $request) @@ -159,10 +163,6 @@ class GoodsReceiptController extends Controller 'warehouse_id' => 'required|exists:warehouses,id', 'type' => 'required|in:standard,miscellaneous,other', 'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id', - // Vendor ID is required if standard, but optional/nullable for misc/other? - // Stick to existing logic: if standard, we infer vendor from PO usually, or frontend sends it. - // For now let's make vendor_id optional for misc/other or user must select one? - // "雜項入庫" might not have a vendor. Let's make it nullable. 'vendor_id' => 'nullable|integer', 'received_date' => 'required|date', 'remarks' => 'nullable|string', @@ -173,6 +173,7 @@ class GoodsReceiptController extends Controller 'items.*.unit_price' => 'required|numeric|min:0', 'items.*.batch_number' => 'nullable|string', 'items.*.expiry_date' => 'nullable|date', + 'force' => 'nullable|boolean', ]); try { @@ -183,6 +184,15 @@ class GoodsReceiptController extends Controller } } + /** + * 預檢重複進貨 API + */ + public function checkDuplicate(Request $request) + { + $result = $this->duplicateCheckService->checkDuplicateReceipt($request->all()); + return response()->json($result); + } + public function submit(GoodsReceipt $goodsReceipt) { if (!auth()->user()->can('goods_receipts.edit')) { diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index c71d924..e9af2bc 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -179,6 +179,9 @@ Route::middleware('auth')->group(function () { Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index'); Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create'); Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show'); + Route::post('/goods-receipts/check-duplicate', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'checkDuplicate']) + ->middleware('permission:goods_receipts.create') + ->name('goods-receipts.check-duplicate'); Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store'); // 點收提交路由 diff --git a/app/Modules/Inventory/Services/DuplicateCheckService.php b/app/Modules/Inventory/Services/DuplicateCheckService.php new file mode 100644 index 0000000..0625cda --- /dev/null +++ b/app/Modules/Inventory/Services/DuplicateCheckService.php @@ -0,0 +1,192 @@ +where('status', '!=', 'cancelled') + ->withCount('items') + ->get(); + + if ($existingReceipts->isNotEmpty()) { + $warnings[] = [ + 'level' => 'high', + 'type' => 'same_po', + 'title' => '同一採購單已有進貨紀錄', + 'message' => "此採購單已被引用於 {$existingReceipts->count()} 筆進貨單中,請確認是否為分批交貨。", + 'related_receipts' => $existingReceipts->map(fn($r) => [ + 'id' => $r->id, + 'code' => $r->code, + 'received_date' => $r->received_date->format('Y-m-d'), + 'status' => match($r->status) { + 'completed' => '已完成', + 'draft' => '草稿', + 'pending_audit' => '待審核', + 'rejected' => '已退回', + default => $r->status + }, + 'item_count' => $r->items_count, + ]), + ]; + } + } + + // 2. 近期同品項檢查 (針對每一個 item) + $duplicatedItems = []; + $recentDate = Carbon::now()->subDays($daysRange); + + foreach ($items as $item) { + $productId = $item['product_id']; + $qty = (float)$item['quantity_received']; + + // 尋找近期同供應商、同品項的進貨紀錄 + $recentHits = GoodsReceiptItem::whereHas('goodsReceipt', function($query) use ($vendorId, $recentDate) { + $query->where('vendor_id', $vendorId) + ->where('received_date', '>=', $recentDate) + ->where('status', '!=', 'cancelled'); + }) + ->where('product_id', $productId) + ->with(['goodsReceipt:id,code,received_date']) + ->get(); + + foreach ($recentHits as $hit) { + $hitQty = (float)$hit->quantity_received; + + // 如果數量完全相同,視為高風險 + $isExactMatch = abs($hitQty - $qty) < 0.001; + + $duplicatedItems[] = [ + 'product_id' => $productId, + 'product_name' => $item['product_name'] ?? '未知商品', + 'last_receipt_code' => $hit->goodsReceipt->code, + 'last_receipt_date' => $hit->goodsReceipt->received_date->format('Y-m-d'), + 'last_quantity' => $hitQty, + 'current_quantity' => $qty, + 'is_high_risk' => $isExactMatch + ]; + } + } + + if (!empty($duplicatedItems)) { + // 按商品分組顯示 + $warnings[] = [ + 'level' => collect($duplicatedItems)->contains('is_high_risk', true) ? 'high' : 'medium', + 'type' => 'recent_duplicate_product', + 'title' => '近期同供應商商品進貨紀錄', + 'message' => "偵測到以下商品在近 {$daysRange} 天內有過進貨紀錄,請確認是否重複收貨。", + 'duplicated_items' => $duplicatedItems, + ]; + } + + // 3. 長期未調整單價檢查 (90 天內同供應商、同品項單價未變動) + $stalePriceItems = $this->checkStalePrice($items, $vendorId); + if (!empty($stalePriceItems)) { + $warnings[] = [ + 'level' => 'medium', + 'type' => 'stale_price', + 'title' => '長期未調整進貨單價', + 'message' => '以下商品的進貨單價在過去 90 天內未曾變動,建議確認是否需要重新議價。', + 'stale_items' => $stalePriceItems, + ]; + } + + return [ + 'has_warnings' => !empty($warnings), + 'warnings' => $warnings, + ]; + } + + /** + * 檢查長期未調整單價的品項 + * + * 邏輯:針對每個品項,查詢同供應商在過去 90 天內的所有進貨單價。 + * 若所有紀錄的單價皆相同,且至少有 1 筆以上紀錄,則視為「未調整」。 + * + * @param array $items 本次進貨的品項列表 + * @param mixed $vendorId 供應商 ID + * @return array + */ + private function checkStalePrice(array $items, $vendorId): array + { + // dump("Entering checkStalePrice", count($items), $vendorId); + $staleDays = 90; + $sinceDate = Carbon::now()->subDays($staleDays); + $staleItems = []; + + foreach ($items as $item) { + $productId = (int) $item['product_id']; + $currentPrice = (float) ($item['unit_price'] ?? 0); + + // 查詢過去 90 天內的所有進貨單品項 + $query = GoodsReceiptItem::whereHas('goodsReceipt', function ($query) use ($vendorId, $sinceDate) { + if ($vendorId) { + $query->where('vendor_id', $vendorId); + } + $query->where('received_date', '>=', $sinceDate->format('Y-m-d')) + ->where('status', '!=', 'cancelled'); + }) + ->where('product_id', $productId) + ->with(['goodsReceipt:id,code,received_date']) + ->orderBy('id', 'desc'); + + $historicalPrices = $query->get(); + + // dump("Prod $productId count: " . $historicalPrices->count()); + + // 至少需要 1 筆歷史紀錄 + if ($historicalPrices->count() < 1) { + continue; + } + + // 檢查所有歷史單價是否完全相同 (排除 Decimal 物件比對問題) + $prices = $historicalPrices->map(fn($h) => round((float)$h->unit_price, 2)); + $distinctPricesCount = $prices->unique()->count(); + + if ($distinctPricesCount > 1) { + continue; // 歷史上有變動過 + } + + $historicalPrice = $prices->first(); + + // 本次單價也跟歷史相同 (容許小數差距) + if (round($currentPrice, 2) === round($historicalPrice, 2)) { + $oldestReceipt = $historicalPrices->last(); + $newestReceipt = $historicalPrices->first(); + + $staleItems[] = [ + 'product_id' => $productId, + 'product_name' => $item['product_name'] ?? '未知商品', + 'unit_price' => $currentPrice, + 'record_count' => $historicalPrices->count(), + 'earliest_date' => $oldestReceipt->goodsReceipt->received_date->format('Y-m-d'), + 'latest_date' => $newestReceipt->goodsReceipt->received_date->format('Y-m-d'), + 'latest_code' => $newestReceipt->goodsReceipt->code, + ]; + } + } + + return $staleItems; + } +} diff --git a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php index c0025fa..d8f17a7 100644 --- a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php +++ b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php @@ -79,4 +79,29 @@ interface ProcurementServiceInterface * @return Collection */ public function getVendorsByIds(array $ids): Collection; + + /** + * 新增供貨商品關聯 + */ + public function attachProductToVendor(int $vendorId, int $productId, ?float $lastPrice): void; + + /** + * 更新供貨商品價格 + */ + public function updateVendorProductPrice(int $vendorId, int $productId, ?float $lastPrice): void; + + /** + * 檢查廠商是否已有該供貨商品 + */ + public function checkVendorHasProduct(int $vendorId, int $productId): bool; + + /** + * 取得供貨商品的價格 + */ + public function getVendorProductPrice(int $vendorId, int $productId): ?float; + + /** + * 移除供貨商品關聯 + */ + public function detachProductFromVendor(int $vendorId, int $productId): void; } diff --git a/app/Modules/Procurement/Controllers/VendorProductController.php b/app/Modules/Procurement/Controllers/VendorProductController.php index d242f5d..b073bc7 100644 --- a/app/Modules/Procurement/Controllers/VendorProductController.php +++ b/app/Modules/Procurement/Controllers/VendorProductController.php @@ -5,13 +5,15 @@ namespace App\Modules\Procurement\Controllers; use App\Http\Controllers\Controller; use App\Modules\Procurement\Models\Vendor; use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class VendorProductController extends Controller { public function __construct( - protected InventoryServiceInterface $inventoryService + protected InventoryServiceInterface $inventoryService, + protected ProcurementServiceInterface $procurementService ) {} /** @@ -25,13 +27,15 @@ class VendorProductController extends Controller ]); // 檢查是否已存在 - if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) { + if ($this->procurementService->checkVendorHasProduct($vendor->id, $validated['product_id'])) { return redirect()->back()->with('error', '該商品已在供貨清單中'); } - $vendor->products()->attach($validated['product_id'], [ - 'last_price' => $validated['last_price'] ?? null - ]); + $this->procurementService->attachProductToVendor( + $vendor->id, + $validated['product_id'], + $validated['last_price'] ?? null + ); // 記錄操作 $product = $this->inventoryService->getProduct($validated['product_id']); @@ -65,11 +69,13 @@ class VendorProductController extends Controller ]); // 獲取舊價格 - $old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price; + $old_price = $this->procurementService->getVendorProductPrice($vendor->id, $productId); - $vendor->products()->updateExistingPivot($productId, [ - 'last_price' => $validated['last_price'] ?? null - ]); + $this->procurementService->updateVendorProductPrice( + $vendor->id, + $productId, + $validated['last_price'] ?? null + ); // 記錄操作 $product = $this->inventoryService->getProduct($productId); @@ -102,9 +108,9 @@ class VendorProductController extends Controller { // 記錄操作 (需在 detach 前獲取資訊) $product = $this->inventoryService->getProduct($productId); - $old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price; + $old_price = $this->procurementService->getVendorProductPrice($vendor->id, $productId); - $vendor->products()->detach($productId); + $this->procurementService->detachProductFromVendor($vendor->id, $productId); if ($product) { activity() diff --git a/app/Modules/Procurement/Services/ProcurementService.php b/app/Modules/Procurement/Services/ProcurementService.php index cd74a67..a3a12d3 100644 --- a/app/Modules/Procurement/Services/ProcurementService.php +++ b/app/Modules/Procurement/Services/ProcurementService.php @@ -99,4 +99,52 @@ class ProcurementService implements ProcurementServiceInterface { return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']); } + + public function attachProductToVendor(int $vendorId, int $productId, ?float $lastPrice): void + { + \Illuminate\Support\Facades\DB::table('product_vendor')->insert([ + 'vendor_id' => $vendorId, + 'product_id' => $productId, + 'last_price' => $lastPrice, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function updateVendorProductPrice(int $vendorId, int $productId, ?float $lastPrice): void + { + \Illuminate\Support\Facades\DB::table('product_vendor') + ->where('vendor_id', $vendorId) + ->where('product_id', $productId) + ->update([ + 'last_price' => $lastPrice, + 'updated_at' => now(), + ]); + } + + public function checkVendorHasProduct(int $vendorId, int $productId): bool + { + return \Illuminate\Support\Facades\DB::table('product_vendor') + ->where('vendor_id', $vendorId) + ->where('product_id', $productId) + ->exists(); + } + + public function getVendorProductPrice(int $vendorId, int $productId): ?float + { + $pivot = \Illuminate\Support\Facades\DB::table('product_vendor') + ->where('vendor_id', $vendorId) + ->where('product_id', $productId) + ->first(); + + return $pivot ? (float) $pivot->last_price : null; + } + + public function detachProductFromVendor(int $vendorId, int $productId): void + { + \Illuminate\Support\Facades\DB::table('product_vendor') + ->where('vendor_id', $vendorId) + ->where('product_id', $productId) + ->delete(); + } } diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx index 7af8384..a048264 100644 --- a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx +++ b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx @@ -35,6 +35,7 @@ import { import axios from 'axios'; import { PurchaseOrderStatus } from '@/types/purchase-order'; import { STATUS_CONFIG } from '@/constants/purchase-order'; +import { DuplicateWarningDialog } from './components/DuplicateWarningDialog'; @@ -89,6 +90,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, const [productSearch, setProductSearch] = useState(''); const [foundProducts, setFoundProducts] = useState([]); + // Duplicate Check States + const [warningOpen, setWarningOpen] = useState(false); + const [warnings, setWarnings] = useState([]); + const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false); + const { data, setData, post, processing, errors } = useForm({ type: 'standard', // 'standard', 'miscellaneous', 'other' warehouse_id: '', @@ -280,9 +286,30 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, }); }, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.product_code, p: i.product_id }))), data.received_date]); - const submit = (e: React.FormEvent) => { - e.preventDefault(); - post(route('goods-receipts.store')); + const submit = async (e: React.FormEvent, force: boolean = false) => { + if (e) e.preventDefault(); + + // 如果不是強制提交,先檢查重複 + if (!force) { + setIsCheckingDuplicate(true); + try { + const response = await axios.post(route('goods-receipts.check-duplicate'), data); + if (response.data.has_warnings) { + setWarnings(response.data.warnings); + setWarningOpen(true); + return; // 停止並顯示警告 + } + } catch (error) { + console.error("Duplicate check failed", error); + // 檢查失敗則繼續,或視為阻擋?這裡選擇繼續 + } finally { + setIsCheckingDuplicate(false); + } + } + + post(route('goods-receipts.store'), { + onSuccess: () => setWarningOpen(false), + }); }; @@ -738,13 +765,21 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, size="lg" className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]" onClick={submit} - disabled={processing || (data.type === 'standard' ? !selectedPO : !selectedVendor)} + disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)} > - {processing ? '處理中...' : '確認進貨'} + {processing || isCheckingDuplicate ? '處理中...' : '確認進貨'} + + setWarningOpen(false)} + onConfirm={() => submit(null as any, true)} + warnings={warnings} + processing={processing} + /> ); } diff --git a/resources/js/Pages/Inventory/GoodsReceipt/components/DuplicateWarningDialog.tsx b/resources/js/Pages/Inventory/GoodsReceipt/components/DuplicateWarningDialog.tsx new file mode 100644 index 0000000..cf40106 --- /dev/null +++ b/resources/js/Pages/Inventory/GoodsReceipt/components/DuplicateWarningDialog.tsx @@ -0,0 +1,176 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { AlertTriangle, ArrowRight } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; + +interface DuplicateWarningDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + warnings: any[]; + processing?: boolean; +} + +export function DuplicateWarningDialog({ open, onClose, onConfirm, warnings, processing }: DuplicateWarningDialogProps) { + return ( + !val && onClose()}> + + +
+ + 偵測到疑似重複進貨 +
+

+ 系統偵測到目前填寫的內容與現有紀錄高度相似,請確認是否仍要繼續建立此單據? +

+
+ +
+ {warnings.map((warning, idx) => ( +
+
+
+

+ {warning.title} +

+
+

{warning.message}

+ + {/* Same PO Warning Details */} + {warning.type === 'same_po' && warning.related_receipts && ( +
+ + + + 進貨單號 + 日期 + 狀態 + 品項 + + + + {warning.related_receipts.map((r: any) => ( + + {r.code} + {r.received_date} + + {r.status} + + {r.item_count} 項 + + ))} + +
+
+ )} + + {/* Recent Products Warning Details */} + {warning.type === 'recent_duplicate_product' && warning.duplicated_items && ( +
+ + + + 商品 + 上次日期 / 單號 + 上次數量 + 本次數量 + + + + {warning.duplicated_items.map((item: any, i: number) => ( + + +
{item.product_name}
+
{item.product_id}
+
+ +
{item.last_receipt_date}
+
{item.last_receipt_code}
+
+ {item.last_quantity} + + + {item.current_quantity} + {item.is_high_risk && 數量相同} + +
+ ))} +
+
+
+ )} + + {/* Stale Price Warning Details */} + {warning.type === 'stale_price' && warning.stale_items && ( +
+ + + + 商品 + 固定單價 + 紀錄筆數 + 未變動期間 + + + + {warning.stale_items.map((item: any, i: number) => ( + + +
{item.product_name}
+
{item.product_id}
+
+ + ${item.unit_price.toLocaleString()} + + + {item.record_count} 筆 + + +
{item.earliest_date} ~ {item.latest_date}
+
最近單號:{item.latest_code}
+
+
+ ))} +
+
+
+ )} +
+ ))} +
+ + + + + + +
+ ); +} diff --git a/test.php b/test.php new file mode 100644 index 0000000..6cc1ff9 --- /dev/null +++ b/test.php @@ -0,0 +1,28 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +// 初始化租戶環境 (如果有的話) +tenancy()->initialize(\App\Modules\Core\Models\Tenant::where('id', 'koori')->first()); + +$v = App\Modules\Procurement\Models\Vendor::find(2); +$p = app(App\Modules\Procurement\Contracts\ProcurementServiceInterface::class); + +echo "\n=== START TEST ===\n"; +try { + echo 'Check: ' . ($p->checkVendorHasProduct($v->id, 7) ? 'Yes' : 'No') . PHP_EOL; + + $p->attachProductToVendor($v->id, 7, 100); + echo 'Check after attach: ' . ($p->checkVendorHasProduct($v->id, 7) ? 'Yes' : 'No') . PHP_EOL; + echo 'Price: ' . $p->getVendorProductPrice($v->id, 7) . PHP_EOL; + + $p->updateVendorProductPrice($v->id, 7, 120); + echo 'Price after update: ' . $p->getVendorProductPrice($v->id, 7) . PHP_EOL; + + $p->detachProductFromVendor($v->id, 7); + echo 'Check after detach: ' . ($p->checkVendorHasProduct($v->id, 7) ? 'Yes' : 'No') . PHP_EOL; +} catch (\Exception $e) { + echo "ERROR: " . $e->getMessage() . PHP_EOL; +} +echo "=== END TEST ===\n";