refactor: 重構 VendorProduct API 與新增進貨單重複檢查前端邏輯
1. 將 VendorProductController 中的 Eloquent 關聯操作改為透過 ProcurementService 使用 DB 操作,解除跨模組 Model 直接依賴。 2. ProcurementService 加入 vendor product 的資料存取方法。 3. 進貨單建立前端 (GoodsReceipt/Create.tsx) 新增重複進貨檢查與警告對話框邏輯。
This commit is contained in:
@@ -67,8 +67,6 @@ trigger: always_on
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
|
||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。
|
||||
|
||||
145
.agent/skills/cross-module-communication/SKILL.md
Normal file
145
.agent/skills/cross-module-communication/SKILL.md
Normal 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;
|
||||
});
|
||||
```
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 點收提交路由
|
||||
|
||||
192
app/Modules/Inventory/Services/DuplicateCheckService.php
Normal file
192
app/Modules/Inventory/Services/DuplicateCheckService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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({
|
||||
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)}
|
||||
>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
{processing ? '處理中...' : '確認進貨'}
|
||||
{processing || isCheckingDuplicate ? '處理中...' : '確認進貨'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DuplicateWarningDialog
|
||||
open={warningOpen}
|
||||
onClose={() => setWarningOpen(false)}
|
||||
onConfirm={() => submit(null as any, true)}
|
||||
warnings={warnings}
|
||||
processing={processing}
|
||||
/>
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
28
test.php
Normal 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";
|
||||
Reference in New Issue
Block a user