refactor: 重構 VendorProduct API 與新增進貨單重複檢查前端邏輯
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m9s
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m10s

1. 將 VendorProductController 中的 Eloquent 關聯操作改為透過 ProcurementService 使用 DB 操作,解除跨模組 Model 直接依賴。
2. ProcurementService 加入 vendor product 的資料存取方法。
3. 進貨單建立前端 (GoodsReceipt/Create.tsx) 新增重複進貨檢查與警告對話框邏輯。
This commit is contained in:
2026-02-25 11:11:28 +08:00
parent e406ecd63d
commit ad91b08dbc
11 changed files with 689 additions and 23 deletions

View File

@@ -67,8 +67,6 @@ trigger: always_on
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。 * 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
## 8. 多租戶開發規範 (Multi-tenancy Standards) ## 8. 多租戶開發規範 (Multi-tenancy Standards)
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: 本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。 * **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。

View File

@@ -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;
});
```

View File

@@ -8,6 +8,7 @@ use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Modules\Procurement\Models\Vendor; use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Services\DuplicateCheckService;
use Inertia\Inertia; use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt; use App\Modules\Inventory\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -17,15 +18,18 @@ class GoodsReceiptController extends Controller
protected $goodsReceiptService; protected $goodsReceiptService;
protected $inventoryService; protected $inventoryService;
protected $procurementService; protected $procurementService;
protected $duplicateCheckService;
public function __construct( public function __construct(
GoodsReceiptService $goodsReceiptService, GoodsReceiptService $goodsReceiptService,
InventoryService $inventoryService, InventoryService $inventoryService,
ProcurementServiceInterface $procurementService ProcurementServiceInterface $procurementService,
DuplicateCheckService $duplicateCheckService
) { ) {
$this->goodsReceiptService = $goodsReceiptService; $this->goodsReceiptService = $goodsReceiptService;
$this->inventoryService = $inventoryService; $this->inventoryService = $inventoryService;
$this->procurementService = $procurementService; $this->procurementService = $procurementService;
$this->duplicateCheckService = $duplicateCheckService;
} }
public function index(Request $request) public function index(Request $request)
@@ -159,10 +163,6 @@ class GoodsReceiptController extends Controller
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'type' => 'required|in:standard,miscellaneous,other', 'type' => 'required|in:standard,miscellaneous,other',
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id', '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', 'vendor_id' => 'nullable|integer',
'received_date' => 'required|date', 'received_date' => 'required|date',
'remarks' => 'nullable|string', 'remarks' => 'nullable|string',
@@ -173,6 +173,7 @@ class GoodsReceiptController extends Controller
'items.*.unit_price' => 'required|numeric|min:0', 'items.*.unit_price' => 'required|numeric|min:0',
'items.*.batch_number' => 'nullable|string', 'items.*.batch_number' => 'nullable|string',
'items.*.expiry_date' => 'nullable|date', 'items.*.expiry_date' => 'nullable|date',
'force' => 'nullable|boolean',
]); ]);
try { 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) public function submit(GoodsReceipt $goodsReceipt)
{ {
if (!auth()->user()->can('goods_receipts.edit')) { if (!auth()->user()->can('goods_receipts.edit')) {

View File

@@ -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', [\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/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::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'); Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
// 點收提交路由 // 點收提交路由

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\GoodsReceipt;
use App\Modules\Inventory\Models\GoodsReceiptItem;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class DuplicateCheckService
{
/**
* 檢查疑似重複進貨
*
* @param array $data
* @return array
*/
public function checkDuplicateReceipt(array $data): array
{
$warnings = [];
$vendorId = isset($data['vendor_id']) && $data['vendor_id'] !== '' ? (int)$data['vendor_id'] : null;
$poId = $data['purchase_order_id'] ?? null;
$items = $data['items'] ?? [];
$daysRange = 7; // 近期範圍天數
// 1. 同 PO 重複檢查 (僅限標準採購)
if ($data['type'] === 'standard' && $poId) {
$existingReceipts = GoodsReceipt::where('purchase_order_id', $poId)
->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;
}
}

View File

@@ -79,4 +79,29 @@ interface ProcurementServiceInterface
* @return Collection * @return Collection
*/ */
public function getVendorsByIds(array $ids): 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;
} }

View File

@@ -5,13 +5,15 @@ namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\Vendor; use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Contracts\InventoryServiceInterface; use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class VendorProductController extends Controller class VendorProductController extends Controller
{ {
public function __construct( 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', '該商品已在供貨清單中'); return redirect()->back()->with('error', '該商品已在供貨清單中');
} }
$vendor->products()->attach($validated['product_id'], [ $this->procurementService->attachProductToVendor(
'last_price' => $validated['last_price'] ?? null $vendor->id,
]); $validated['product_id'],
$validated['last_price'] ?? null
);
// 記錄操作 // 記錄操作
$product = $this->inventoryService->getProduct($validated['product_id']); $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, [ $this->procurementService->updateVendorProductPrice(
'last_price' => $validated['last_price'] ?? null $vendor->id,
]); $productId,
$validated['last_price'] ?? null
);
// 記錄操作 // 記錄操作
$product = $this->inventoryService->getProduct($productId); $product = $this->inventoryService->getProduct($productId);
@@ -102,9 +108,9 @@ class VendorProductController extends Controller
{ {
// 記錄操作 (需在 detach 前獲取資訊) // 記錄操作 (需在 detach 前獲取資訊)
$product = $this->inventoryService->getProduct($productId); $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) { if ($product) {
activity() activity()

View File

@@ -99,4 +99,52 @@ class ProcurementService implements ProcurementServiceInterface
{ {
return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']); 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();
}
} }

View File

@@ -35,6 +35,7 @@ import {
import axios from 'axios'; import axios from 'axios';
import { PurchaseOrderStatus } from '@/types/purchase-order'; import { PurchaseOrderStatus } from '@/types/purchase-order';
import { STATUS_CONFIG } from '@/constants/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 [productSearch, setProductSearch] = useState('');
const [foundProducts, setFoundProducts] = useState<any[]>([]); const [foundProducts, setFoundProducts] = useState<any[]>([]);
// Duplicate Check States
const [warningOpen, setWarningOpen] = useState(false);
const [warnings, setWarnings] = useState<any[]>([]);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
type: 'standard', // 'standard', 'miscellaneous', 'other' type: 'standard', // 'standard', 'miscellaneous', 'other'
warehouse_id: '', 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]); }, [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) => { const submit = async (e: React.FormEvent, force: boolean = false) => {
e.preventDefault(); if (e) e.preventDefault();
post(route('goods-receipts.store'));
// 如果不是強制提交,先檢查重複
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" 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]" 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} onClick={submit}
disabled={processing || (data.type === 'standard' ? !selectedPO : !selectedVendor)} disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
> >
<Save className="mr-2 h-5 w-5" /> <Save className="mr-2 h-5 w-5" />
{processing ? '處理中...' : '確認進貨'} {processing || isCheckingDuplicate ? '處理中...' : '確認進貨'}
</Button> </Button>
</div> </div>
</div> </div>
<DuplicateWarningDialog
open={warningOpen}
onClose={() => setWarningOpen(false)}
onConfirm={() => submit(null as any, true)}
warnings={warnings}
processing={processing}
/>
</AuthenticatedLayout > </AuthenticatedLayout >
); );
} }

View File

@@ -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 (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2 text-amber-600 mb-2">
<AlertTriangle className="h-6 w-6" />
<DialogTitle className="text-xl font-bold"></DialogTitle>
</div>
<p className="text-gray-500">
</p>
</DialogHeader>
<div className="py-4 space-y-6">
{warnings.map((warning, idx) => (
<div key={idx} className={`p-4 rounded-lg border ${warning.level === 'high' ? 'bg-red-50 border-red-100' : 'bg-amber-50 border-amber-100'}`}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2 h-2 rounded-full ${warning.level === 'high' ? 'bg-red-500' : 'bg-amber-500'}`} />
<h4 className={`font-bold ${warning.level === 'high' ? 'text-red-900' : 'text-amber-900'}`}>
{warning.title}
</h4>
</div>
<p className="text-sm text-gray-700 mb-4">{warning.message}</p>
{/* Same PO Warning Details */}
{warning.type === 'same_po' && warning.related_receipts && (
<div className="bg-white rounded border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{warning.related_receipts.map((r: any) => (
<TableRow key={r.id}>
<TableCell className="font-medium text-blue-600">{r.code}</TableCell>
<TableCell>{r.received_date}</TableCell>
<TableCell>
<StatusBadge variant="neutral">{r.status}</StatusBadge>
</TableCell>
<TableCell className="text-center">{r.item_count} </TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Recent Products Warning Details */}
{warning.type === 'recent_duplicate_product' && warning.duplicated_items && (
<div className="bg-white rounded border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> / </TableHead>
<TableHead className="text-xs text-right"></TableHead>
<TableHead className="text-xs text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{warning.duplicated_items.map((item: any, i: number) => (
<TableRow key={i}>
<TableCell>
<div>{item.product_name}</div>
<div className="text-[10px] text-gray-400">{item.product_id}</div>
</TableCell>
<TableCell>
<div>{item.last_receipt_date}</div>
<div className="text-[10px] text-gray-400">{item.last_receipt_code}</div>
</TableCell>
<TableCell className="text-right">{item.last_quantity}</TableCell>
<TableCell className="text-right font-bold flex items-center justify-end gap-1">
<ArrowRight className="h-3 w-3 text-gray-300" />
{item.current_quantity}
{item.is_high_risk && <span className="text-[10px] bg-red-100 text-red-600 px-1 rounded"></span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Stale Price Warning Details */}
{warning.type === 'stale_price' && warning.stale_items && (
<div className="bg-white rounded border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs text-right"></TableHead>
<TableHead className="text-xs text-center"></TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{warning.stale_items.map((item: any, i: number) => (
<TableRow key={i}>
<TableCell>
<div>{item.product_name}</div>
<div className="text-[10px] text-gray-400">{item.product_id}</div>
</TableCell>
<TableCell className="text-right font-mono font-bold text-amber-700">
${item.unit_price.toLocaleString()}
</TableCell>
<TableCell className="text-center">
{item.record_count}
</TableCell>
<TableCell>
<div>{item.earliest_date} ~ {item.latest_date}</div>
<div className="text-[10px] text-gray-400">{item.latest_code}</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
))}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={processing}
className="button-outlined-primary"
>
</Button>
<Button
onClick={onConfirm}
className="button-filled-primary"
disabled={processing}
>
{processing ? '處理中...' : '確認無重複,繼續建立'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

28
test.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->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";