diff --git a/app/Modules/Integration/Actions/SyncOrderAction.php b/app/Modules/Integration/Actions/SyncOrderAction.php index 107e5fc..ae2fe7d 100644 --- a/app/Modules/Integration/Actions/SyncOrderAction.php +++ b/app/Modules/Integration/Actions/SyncOrderAction.php @@ -58,29 +58,35 @@ class SyncOrderAction ]; } - // --- 預檢 (Pre-flight check) N+1 優化 --- + // --- 預檢 (Pre-flight check) 僅使用 product_id --- $items = $data['items']; - $posProductIds = array_column($items, 'pos_product_id'); + $targetErpIds = array_column($items, 'product_id'); // 一次性查出所有相關的 Product - $products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id'); + $productsById = $this->productService->findByIds($targetErpIds)->keyBy('id'); + $resolvedProducts = []; $missingIds = []; - foreach ($posProductIds as $id) { - if (!$products->has($id)) { - $missingIds[] = $id; + + foreach ($items as $index => $item) { + $productId = $item['product_id']; + $product = $productsById->get($productId); + + if ($product) { + $resolvedProducts[$index] = $product; + } else { + $missingIds[] = $productId; } } if (!empty($missingIds)) { - // 回報所有缺漏的 ID throw ValidationException::withMessages([ - 'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."] + 'items' => ["The following product IDs are not found: " . implode(', ', array_unique($missingIds)) . ". Please ensure these products exist in the system."] ]); } // --- 執行寫入交易 --- - $result = DB::transaction(function () use ($data, $items, $products) { + $result = DB::transaction(function () use ($data, $items, $resolvedProducts) { // 1. 建立訂單 $order = SalesOrder::create([ 'external_order_id' => $data['external_order_id'], @@ -108,11 +114,12 @@ class SyncOrderAction // 3. 處理訂單明細 $orderItemsData = []; - foreach ($items as $itemData) { - $product = $products->get($itemData['pos_product_id']); + foreach ($items as $index => $itemData) { + $product = $resolvedProducts[$index]; $qty = $itemData['qty']; $price = $itemData['price']; + $batchNumber = $itemData['batch_number'] ?? null; $lineTotal = $qty * $price; $totalAmount += $lineTotal; @@ -134,9 +141,10 @@ class SyncOrderAction $qty, "POS Order: " . $order->external_order_id, true, - null, + null, // Slot (location) \App\Modules\Integration\Models\SalesOrder::class, - $order->id + $order->id, + $batchNumber ); } diff --git a/app/Modules/Integration/Controllers/InventorySyncController.php b/app/Modules/Integration/Controllers/InventorySyncController.php index a1f44fa..6b78329 100644 --- a/app/Modules/Integration/Controllers/InventorySyncController.php +++ b/app/Modules/Integration/Controllers/InventorySyncController.php @@ -21,10 +21,13 @@ class InventorySyncController extends Controller * @param string $warehouseCode * @return JsonResponse */ - public function show(string $warehouseCode): JsonResponse + public function show(\Illuminate\Http\Request $request, string $warehouseCode): JsonResponse { - // 透過 Service 調用跨模組庫存查詢功能 - $inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode); + // 透過 Service 調用跨模組庫存查詢功能,傳入篩選條件 + $inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode( + $warehouseCode, + $request->only(['product_id', 'barcode', 'code', 'external_pos_id']) + ); // 若回傳 null,表示尋無此倉庫代碼 if (is_null($inventoryData)) { @@ -40,9 +43,18 @@ class InventorySyncController extends Controller 'warehouse_code' => $warehouseCode, 'data' => $inventoryData->map(function ($item) { return [ + 'product_id' => $item->product_id, 'external_pos_id' => $item->external_pos_id, 'product_code' => $item->product_code, 'product_name' => $item->product_name, + 'barcode' => $item->barcode, + 'category_name' => $item->category_name ?? '未分類', + 'unit_name' => $item->unit_name ?? '個', + 'price' => (float) $item->price, + 'brand' => $item->brand, + 'specification' => $item->specification, + 'batch_number' => $item->batch_number, + 'expiry_date' => $item->expiry_date, 'quantity' => (float) $item->total_quantity, ]; }) diff --git a/app/Modules/Integration/Controllers/ProductSyncController.php b/app/Modules/Integration/Controllers/ProductSyncController.php index 4947ea2..e4ff0fe 100644 --- a/app/Modules/Integration/Controllers/ProductSyncController.php +++ b/app/Modules/Integration/Controllers/ProductSyncController.php @@ -23,8 +23,8 @@ class ProductSyncController extends Controller 'name' => 'required|string|max:255', 'price' => 'nullable|numeric|min:0|max:99999999.99', 'barcode' => 'nullable|string|max:100', - 'category' => 'nullable|string|max:100', - 'unit' => 'nullable|string|max:100', + 'category' => 'required|string|max:100', + 'unit' => 'required|string|max:100', 'brand' => 'nullable|string|max:100', 'specification' => 'nullable|string|max:255', 'cost_price' => 'nullable|numeric|min:0|max:99999999.99', @@ -41,6 +41,8 @@ class ProductSyncController extends Controller 'data' => [ 'id' => $product->id, 'external_pos_id' => $product->external_pos_id, + 'code' => $product->code, + 'barcode' => $product->barcode, ] ]); } catch (\Exception $e) { @@ -50,4 +52,63 @@ class ProductSyncController extends Controller ], 500); } } + + /** + * 搜尋商品(供外部 API 使用)。 + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function index(Request $request) + { + $request->validate([ + 'product_id' => 'nullable|integer', + 'external_pos_id' => 'nullable|string|max:255', + 'barcode' => 'nullable|string|max:100', + 'code' => 'nullable|string|max:100', + 'category' => 'nullable|string|max:100', + 'updated_after' => 'nullable|date', + 'per_page' => 'nullable|integer|min:1|max:100', + ]); + + try { + $perPage = $request->input('per_page', 50); + $products = $this->productService->searchProducts($request->all(), $perPage); + + return response()->json([ + 'status' => 'success', + 'data' => $products->getCollection()->map(function ($product) { + return [ + 'id' => $product->id, + 'code' => $product->code, + 'barcode' => $product->barcode, + 'name' => $product->name, + 'external_pos_id' => $product->external_pos_id, + 'category_name' => $product->category?->name ?? '未分類', + 'brand' => $product->brand, + 'specification' => $product->specification, + 'unit_name' => $product->baseUnit?->name ?? '個', + 'price' => (float) $product->price, + 'cost_price' => (float) $product->cost_price, + 'member_price' => (float) $product->member_price, + 'wholesale_price' => (float) $product->wholesale_price, + 'is_active' => (bool) $product->is_active, + 'updated_at' => $product->updated_at->format('Y-m-d H:i:s'), + ]; + }), + 'meta' => [ + 'current_page' => $products->currentPage(), + 'last_page' => $products->lastPage(), + 'per_page' => $products->perPage(), + 'total' => $products->total(), + ] + ]); + } catch (\Exception $e) { + Log::error('Product Search Failed', ['error' => $e->getMessage()]); + return response()->json([ + 'status' => 'error', + 'message' => 'Search failed: ' . $e->getMessage(), + ], 500); + } + } } diff --git a/app/Modules/Integration/Requests/SyncOrderRequest.php b/app/Modules/Integration/Requests/SyncOrderRequest.php index 3e5e5c3..f694ce6 100644 --- a/app/Modules/Integration/Requests/SyncOrderRequest.php +++ b/app/Modules/Integration/Requests/SyncOrderRequest.php @@ -27,7 +27,8 @@ class SyncOrderRequest extends FormRequest 'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other', 'sold_at' => 'nullable|date', 'items' => 'required|array|min:1', - 'items.*.pos_product_id' => 'required|string', + 'items.*.product_id' => 'required|integer', + 'items.*.batch_number' => 'nullable|string', 'items.*.qty' => 'required|numeric|min:0.0001', 'items.*.price' => 'required|numeric|min:0', ]; diff --git a/app/Modules/Integration/Routes/api.php b/app/Modules/Integration/Routes/api.php index f0ee7d7..f90dd32 100644 --- a/app/Modules/Integration/Routes/api.php +++ b/app/Modules/Integration/Routes/api.php @@ -9,6 +9,7 @@ use App\Modules\Integration\Controllers\InventorySyncController; Route::prefix('api/v1/integration') ->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum']) ->group(function () { + Route::get('products', [ProductSyncController::class, 'index']); Route::post('products/upsert', [ProductSyncController::class, 'upsert']); Route::post('orders', [OrderSyncController::class, 'store']); Route::post('vending/orders', [VendingOrderSyncController::class, 'store']); diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index 8e3af22..acaee86 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -21,9 +21,12 @@ interface InventoryServiceInterface * @param string|null $reason * @param bool $force * @param string|null $slot + * @param string|null $referenceType + * @param int|string|null $referenceId + * @param string|null $batchNumber * @return void */ - public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void; + public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void; /** * Get all active warehouses. @@ -162,9 +165,10 @@ interface InventoryServiceInterface * Get inventory summary (group by product) for a specific warehouse code * * @param string $code + * @param array $filters * @return \Illuminate\Support\Collection|null */ - public function getPosInventoryByWarehouseCode(string $code); + public function getPosInventoryByWarehouseCode(string $code, array $filters = []); /** * 處理批量入庫邏輯 (含批號產生與現有批號累加)。 diff --git a/app/Modules/Inventory/Contracts/ProductServiceInterface.php b/app/Modules/Inventory/Contracts/ProductServiceInterface.php index 7f00167..ffa3a37 100644 --- a/app/Modules/Inventory/Contracts/ProductServiceInterface.php +++ b/app/Modules/Inventory/Contracts/ProductServiceInterface.php @@ -31,6 +31,14 @@ interface ProductServiceInterface */ public function findByExternalPosIds(array $externalPosIds); + /** + * 透過多個 ERP 內部 ID 查找產品。 + * + * @param array $ids + * @return \Illuminate\Database\Eloquent\Collection + */ + public function findByIds(array $ids); + /** * 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。 * @@ -78,4 +86,13 @@ interface ProductServiceInterface * @return \App\Modules\Inventory\Models\Product|null */ public function findByBarcodeOrCode(?string $barcode, ?string $code); + + /** + * 搜尋商品(供外部 API 使用)。 + * + * @param array $filters + * @param int $perPage + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function searchProducts(array $filters, int $perPage = 50); } diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 2993fb6..4d749a1 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -87,42 +87,58 @@ class InventoryService implements InventoryServiceInterface return $stock >= $quantity; } - public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void + public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void { - DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId) { - $query = Inventory::where('product_id', $productId) - ->where('warehouse_id', $warehouseId) - ->where('quantity', '>', 0); - - if ($slot) { - $query->where('location', $slot); - } + DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId, $batchNumber) { + $defaultBatch = 'NO-BATCH'; + $targetBatch = $batchNumber ?? $defaultBatch; + $remainingToDecrease = $quantity; - $inventories = $query->lockForUpdate() + // 1. 優先嘗試扣除指定批號(或預設的 NO-BATCH) + $inventories = Inventory::where('product_id', $productId) + ->where('warehouse_id', $warehouseId) + ->where('batch_number', $targetBatch) + ->where('quantity', '>', 0) + ->when($slot, fn($q) => $q->where('location', $slot)) + ->lockForUpdate() ->orderBy('arrival_date', 'asc') ->get(); - $remainingToDecrease = $quantity; - foreach ($inventories as $inventory) { if ($remainingToDecrease <= 0) break; - $decreaseAmount = min($inventory->quantity, $remainingToDecrease); $this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId); $remainingToDecrease -= $decreaseAmount; } + // 2. 如果還有剩餘且剛才不是扣 NO-BATCH,則嘗試從 NO-BATCH 補位 + if ($remainingToDecrease > 0 && $targetBatch !== $defaultBatch) { + $fallbackInventories = Inventory::where('product_id', $productId) + ->where('warehouse_id', $warehouseId) + ->where('batch_number', $defaultBatch) + ->where('quantity', '>', 0) + ->when($slot, fn($q) => $q->where('location', $slot)) + ->lockForUpdate() + ->orderBy('arrival_date', 'asc') + ->get(); + + foreach ($fallbackInventories as $inventory) { + if ($remainingToDecrease <= 0) break; + $decreaseAmount = min($inventory->quantity, $remainingToDecrease); + $this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId); + $remainingToDecrease -= $decreaseAmount; + } + } + + // 3. 處理最終仍不足的情況 if ($remainingToDecrease > 0) { if ($force) { - // Find any existing inventory record in this warehouse/slot to subtract from, or create one - $query = Inventory::where('product_id', $productId) - ->where('warehouse_id', $warehouseId); - - if ($slot) { - $query->where('location', $slot); - } - - $inventory = $query->first(); + // 強制模式下,若指定批號或 NO-BATCH 均不足,統一在 NO-BATCH 建立/扣除負庫存 + $inventory = Inventory::where('product_id', $productId) + ->where('warehouse_id', $warehouseId) + ->where('batch_number', $defaultBatch) + ->when($slot, fn($q) => $q->where('location', $slot)) + ->first(); if (!$inventory) { $inventory = Inventory::create([ @@ -132,7 +148,7 @@ class InventoryService implements InventoryServiceInterface 'quantity' => 0, 'unit_cost' => 0, 'total_value' => 0, - 'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(), + 'batch_number' => $defaultBatch, 'arrival_date' => now(), 'origin_country' => 'TW', 'quality_status' => 'normal', @@ -141,7 +157,10 @@ class InventoryService implements InventoryServiceInterface $this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId); } else { - throw new \Exception("庫存不足,無法扣除所有請求的數量。"); + $context = ($targetBatch !== $defaultBatch) + ? "批號 {$targetBatch} 或 {$defaultBatch}" + : "{$defaultBatch}"; + throw new \Exception("庫存不足,無法扣除所有請求的數量 ({$context})。"); } } }); @@ -658,7 +677,7 @@ class InventoryService implements InventoryServiceInterface * @param string $code * @return \Illuminate\Support\Collection|null */ - public function getPosInventoryByWarehouseCode(string $code) + public function getPosInventoryByWarehouseCode(string $code, array $filters = []) { $warehouse = Warehouse::where('code', $code)->first(); @@ -666,19 +685,60 @@ class InventoryService implements InventoryServiceInterface return null; } - // 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity - return DB::table('inventories') + $query = DB::table('inventories') ->join('products', 'inventories.product_id', '=', 'products.id') + ->leftJoin('categories', 'products.category_id', '=', 'categories.id') + ->leftJoin('units', 'products.base_unit_id', '=', 'units.id') ->where('inventories.warehouse_id', $warehouse->id) ->whereNull('inventories.deleted_at') ->whereNull('products.deleted_at') ->select( + 'products.id as product_id', 'products.external_pos_id', 'products.code as product_code', 'products.name as product_name', + 'products.barcode', + 'categories.name as category_name', + 'units.name as unit_name', + 'products.price', + 'products.brand', + 'products.specification', + 'inventories.batch_number', + 'inventories.expiry_date', DB::raw('SUM(inventories.quantity) as total_quantity') + ); + + // 加入條件篩選 + if (!empty($filters['product_id'])) { + $query->where('products.id', $filters['product_id']); + } + + if (!empty($filters['external_pos_id'])) { + $query->where('products.external_pos_id', $filters['external_pos_id']); + } + + if (!empty($filters['barcode'])) { + $query->where('products.barcode', $filters['barcode']); + } + + if (!empty($filters['code'])) { + $query->where('products.code', $filters['code']); + } + + return $query->groupBy( + 'inventories.product_id', + 'products.external_pos_id', + 'products.code', + 'products.name', + 'products.barcode', + 'categories.name', + 'units.name', + 'products.price', + 'products.brand', + 'products.specification', + 'inventories.batch_number', + 'inventories.expiry_date' ) - ->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name') ->get(); } diff --git a/app/Modules/Inventory/Services/ProductService.php b/app/Modules/Inventory/Services/ProductService.php index e1b2466..f3f8888 100644 --- a/app/Modules/Inventory/Services/ProductService.php +++ b/app/Modules/Inventory/Services/ProductService.php @@ -38,9 +38,22 @@ class ProductService implements ProductServiceInterface // Map allowed fields $product->name = $data['name']; - $product->barcode = $data['barcode'] ?? $product->barcode; $product->price = $data['price'] ?? 0; + // Handle Barcode + if (!empty($data['barcode'])) { + $product->barcode = $data['barcode']; + } elseif (empty($product->barcode)) { + $product->barcode = $this->generateRandomBarcode(); + } + + // Handle Code (SKU) + if (!empty($data['code'])) { + $product->code = $data['code']; + } elseif (empty($product->code)) { + $product->code = $this->generateRandomCode(); + } + // Map newly added extended fields if (isset($data['brand'])) $product->brand = $data['brand']; if (isset($data['specification'])) $product->specification = $data['specification']; @@ -48,11 +61,6 @@ class ProductService implements ProductServiceInterface if (isset($data['member_price'])) $product->member_price = $data['member_price']; if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_price']; - // Generate Code if missing (use code or external_id) - if (empty($product->code)) { - $product->code = $data['code'] ?? $product->external_pos_id; - } - // Handle Category — 每次同步都更新(若有傳入) if (!empty($data['category']) || empty($product->category_id)) { $categoryName = $data['category'] ?? '未分類'; @@ -100,6 +108,17 @@ class ProductService implements ProductServiceInterface return Product::whereIn('external_pos_id', $externalPosIds)->get(); } + /** + * 透過多個 ERP 內部 ID 查找產品。 + * + * @param array $ids + * @return \Illuminate\Database\Eloquent\Collection + */ + public function findByIds(array $ids) + { + return Product::whereIn('id', $ids)->get(); + } + /** * 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。 * @@ -171,11 +190,58 @@ class ProductService implements ProductServiceInterface { $product = null; if (!empty($barcode)) { - $product = Product::where('barcode', $barcode)->first(); + $product = Product::query()->where('barcode', $barcode)->first(); } if (!$product && !empty($code)) { - $product = Product::where('code', $code)->first(); + $product = Product::query()->where('code', $code)->first(); } return $product; } + + /** + * 搜尋商品(供外部 API 使用)。 + * + * @param array $filters + * @param int $perPage + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function searchProducts(array $filters, int $perPage = 50) + { + /** @var \Illuminate\Database\Eloquent\Builder $query */ + $query = Product::query() + ->with(['category', 'baseUnit']) + ->where('is_active', true); + + // 1. 精準過濾 (ID, 條碼, 代碼, 外部 ID) + if (!empty($filters['product_id'])) { + $query->where('id', $filters['product_id']); + } + if (!empty($filters['barcode'])) { + $query->where('barcode', $filters['barcode']); + } + if (!empty($filters['code'])) { + $query->where('code', $filters['code']); + } + if (!empty($filters['external_pos_id'])) { + $query->where('external_pos_id', $filters['external_pos_id']); + } + + // 3. 分類過濾 + if (!empty($filters['category'])) { + $categoryName = $filters['category']; + $query->whereHas('category', function ($q) use ($categoryName) { + $q->where('name', $categoryName); + }); + } + + // 4. 增量同步 (Updated After) + if (!empty($filters['updated_after'])) { + $query->where('updated_at', '>=', $filters['updated_after']); + } + + // 4. 排序 (預設按更新時間降冪) + $query->orderBy('updated_at', 'desc'); + + return $query->paginate($perPage); + } } diff --git a/database/seeders/LocalTenantSeeder.php b/database/seeders/LocalTenantSeeder.php new file mode 100644 index 0000000..bf49cb4 --- /dev/null +++ b/database/seeders/LocalTenantSeeder.php @@ -0,0 +1,29 @@ + 'demo'], + ['tenancy_db_name' => 'tenant_demo'] + ); + + // 建立域名對應 localhost + $tenant->domains()->firstOrCreate( + ['domain' => 'localhost'], + ['tenant_id' => 'demo'] + ); + + $this->command->info('Local tenant "demo" with domain "localhost" created/restored.'); + } +} diff --git a/docs/integration/products_api.md b/docs/integration/products_api.md new file mode 100644 index 0000000..d81dbe8 --- /dev/null +++ b/docs/integration/products_api.md @@ -0,0 +1,64 @@ +# 商品查詢 API (External Product Fetch) + +本 API 供外部系統(如 POS)從 Star ERP 獲取商品資料。支援分頁、關鍵字搜尋與增量同步。 + +## 1. 介面詳情 +- **Endpoint**: `GET /api/v1/integration/products` +- **Authentication**: `Bearer Token` (Sanctum) +- **Tenant Isolation**: 需透過 `X-Tenant-Domain` Header 或網域識別租戶。 + +## 2. 請求參數 (Query Parameters) + +| 參數名 | 類型 | 必填 | 說明 | +| :--- | :--- | :--- | :--- | +| `keyword` | `string` | 否 | 關鍵字搜尋(符合名稱、代碼、條碼或外部編號)。 | +| `category` | `string` | 否 | 分類名稱精準過濾(例如:`飲品`)。 | +| `updated_after` | `datetime` | 否 | 增量同步機制。僅回傳該時間點之後有異動的商品(格式:`Y-m-d H:i:s`)。 | +| `per_page` | `integer` | 否 | 每頁筆數(預設 50,最大 100)。 | +| `page` | `integer` | 否 | 分頁頁碼(預設 1)。 | + +## 3. 回應結構 (JSON) + +### 成功回應 (200 OK) +```json +{ + "status": "success", + "data": [ + { + "id": 12, + "code": "COKE-001", + "barcode": "4710001", + "name": "可口可樂 600ml", + "external_pos_id": "POS-P-001", + "category_name": "飲品", + "brand": "可口可樂", + "specification": "600ml", + "unit_name": "瓶", + "price": 25.0, + "is_active": true, + "updated_at": "2026-03-19 09:30:00" + } + ], + "meta": { + "current_page": 1, + "last_page": 5, + "per_page": 50, + "total": 240 + } +} +``` + +## 4. 同步策略建議 + +### 初次同步 (Initial Sync) +1. 第一次串接時,不帶 `updated_after`。 +2. 根據 `meta.last_page` 遍歷所有分頁,將全量商品存入 POS。 + +### 增量同步 (Incremental Sync) +1. 記錄上一次同步成功的時間戳記。 +2. 定期調用 API 並帶入 `updated_after={timestamp}`。 +3. 僅處理回傳的商品資料進行更新或新增。 + +## 5. 常見錯誤 +- `401 Unauthorized`: Token 無效或已過期。 +- `422 Unprocessable Entity`: 參數驗證失敗(例如 `updated_after` 格式錯誤)。 diff --git a/resources/markdown/manual/api-integration.md b/resources/markdown/manual/api-integration.md index 5788147..448235f 100644 --- a/resources/markdown/manual/api-integration.md +++ b/resources/markdown/manual/api-integration.md @@ -14,7 +14,61 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS --- -## 1. 產品資料同步 (Upsert Product) +## 1. 商品資料讀取 (Product Retrieval) + +此 API 用於讓外部系統(如 POS)依據關鍵字、分類或最後更新時間,從 ERP 中批量抓取商品資料。支援分頁與增量同步。 + +- **Endpoint**: `/products` +- **Method**: `GET` + +### Query Parameters + +| 參數名稱 | 類型 | 必填 | 說明 | +| :--- | :--- | :---: | :--- | +| `product_id` | Integer| 否 | 依 ERP 商品 ID (`products.id`) 精準篩選 | +| `external_pos_id` | String | 否 | 依外部 POS 端的唯一識別碼 (`external_pos_id`) 精準篩選 | +| `barcode` | String | 否 | 依商品條碼 (Barcode) 精準篩選 | +| `code` | String | 否 | 依商品代碼 (Code) 精準篩選 | +| `category` | String | 否 | 分類名稱精準過濾 | +| `updated_after` | String | 否 | 增量同步機制。僅回傳該時間點之後有異動的商品 (格式: `YYYY-MM-DD HH:mm:ss`) | +| `per_page` | Integer| 否 | 每頁筆數 (預設 50, 最大 100) | +| `page` | Integer| 否 | 分頁頁碼 (預設 1) | + +### Response + +**Success (HTTP 200)** +僅開放公開價格 `price`,隱藏敏感成本與會員價。 +```json +{ + "status": "success", + "data": [ + { + "id": 12, + "code": "PROD-001", + "barcode": "4710001", + "name": "可口可樂 600ml", + "external_pos_id": "POS-P-001", + "category_name": "飲品", + "brand": "可口可樂", + "specification": "600ml", + "unit_name": "瓶", + "price": 25.0, + "is_active": true, + "updated_at": "2026-03-19 09:30:00" + } + ], + "meta": { + "current_page": 1, + "last_page": 5, + "per_page": 50, + "total": 240 + } +} +``` + +--- + +## 2. 商品資料同步 (Upsert Product) 此 API 用於將第三方系統(如 POS)的產品資料單向同步至 ERP。採用 Upsert 邏輯:若 `external_pos_id` 存在則更新資料,不存在則新增產品。 @@ -23,14 +77,15 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS ### Request Body (JSON) -| 欄位名稱 | 類型 | 必填 | 說明 | +| 參數名稱 | 類型 | 必填 | 說明 | | :--- | :--- | :---: | :--- | | `external_pos_id` | String | **是** | 在 POS 系統中的唯一商品 ID (Primary Key) | | `name` | String | **是** | 商品名稱 (最大 255 字元) | +| `category` | String | **是** | 商品分類名稱。若系統中不存在則自動建立 (最大 100 字元) | +| `unit` | String | **是** | 商品單位 (例如:個、杯、件)。若不存在則自動建立 (最大 100 字元) | +| `code` | String | 否 | 商品代碼。若未提供將由 ERP 自動產生 (最大 100 字元) | | `price` | Decimal | 否 | 商品售價 (預設 0) | -| `barcode` | String | 否 | 商品條碼 (最大 100 字元) | -| `category` | String | 否 | 商品分類名稱。若系統中不存在,會自動建立 (最大 100 字元) | -| `unit` | String | 否 | 商品單位 (例如:個、杯、件)。若不存在會自動建立 (最大 100 字元) | +| `barcode` | String | 否 | 商品條碼 (最大 100 字元)。若未提供將由 ERP 自動產生 | | `brand` | String | 否 | 商品品牌名稱 (最大 100 字元) | | `specification` | String | 否 | 商品規格描述 (最大 255 字元) | | `cost_price` | Decimal | 否 | 成本價 (預設 0) | @@ -44,10 +99,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS { "external_pos_id": "POS-PROD-9001", "name": "特級冷壓初榨橄欖油 500ml", - "price": 380.00, - "barcode": "4711234567890", "category": "調味料", "unit": "瓶", + "price": 380.00, + "barcode": "4711234567890", "brand": "健康王", "specification": "500ml / 玻璃瓶裝", "cost_price": 250.00, @@ -58,22 +113,34 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS } ``` +> [!TIP] +> **自動編碼與分類機制**: +> - **必填項**:`category` 與 `unit` 為必填。若 ERP 中無對應名稱,將會依據傳入值自動建立。 +> - **自動編碼**:若未提供 `code` (商品代碼),將由 ERP 自動產生 8 位隨機代碼。 +> - **自動條碼**:若未提供 `barcode` (條碼),將由 ERP 自動產生 13 位隨機數字條碼。 +> - 建議在同步後儲存回傳的 `id`、`code` 與 `barcode`,以利後續精確對接。 + ### Response -**Success (HTTP 200)** +回傳 ERP 端的完整商品主檔資訊,供外部系統回存 ID 或代碼。 + +### 回傳範例 (Success) +- **Status Code**: `200 OK` ```json { "message": "Product synced successfully", "data": { - "id": 15, - "external_pos_id": "POS-ITEM-001" + "id": 1, + "external_pos_id": "POS-P-999", + "code": "A1B2C3D4", + "barcode": "4710009990001" } } ``` --- -## 2. 門市庫存查詢 (Query Inventory) +## 3. 門市庫存查詢 (Query Inventory) 此 API 用於讓外部系統(如 POS)依據特定的「倉庫代碼」,查詢該倉庫目前所有商品的庫存餘額。 **注意**:此 API 會回傳該倉庫內的所有商品數量,不論該商品是否已綁定外部 POS ID。 @@ -87,25 +154,53 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS | :--- | :--- | :---: | :--- | | `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`,測試可使用預設建立之 `api-test-01`) | +### Query Parameters (選填) + +| 參數名稱 | 類型 | 說明 | +| :--- | :--- | :--- | +| `product_id` | String | 依 ERP 商品 ID (`products.id`) 篩選。 | +| `external_pos_id` | String | 依外部 POS 端的唯一識別碼 (`external_pos_id`) 篩選。 | +| `barcode` | String | 依商品條碼 (Barcode) 篩選商品。 | +| `code` | String | 依商品代碼 (Code) 篩選商品。 | + +若不帶任何參數,將回傳該倉庫下所有商品的庫存餘額。 + ### Response **Success (HTTP 200)** -回傳該倉庫內所有的商品目前庫存總數。若商品未建置 `external_pos_id`,該欄位將顯示為 `null`。 +回傳該倉庫內所有的商品目前庫存總數及詳細資訊。若商品未建置 `external_pos_id`,該欄位將顯示為 `null`。 ```json { "status": "success", "warehouse_code": "api-test-01", "data": [ { - "external_pos_id": "POS-ITEM-001", + "product_id": 1, + "external_pos_id": "PROD-001", "product_code": "PROD-A001", "product_name": "特級冷壓初榨橄欖油 500ml", + "barcode": "4710123456789", + "category_name": "調味料", + "unit_name": "瓶", + "price": 450.00, + "brand": "奧利塔", + "specification": "500ml/瓶", + "batch_number": "PROD-A001-TW-20231026-01", + "expiry_date": "2025-10-26", "quantity": 15 }, { "external_pos_id": null, "product_code": "MAT-001", "product_name": "未包裝干貝醬原料", + "barcode": null, + "category_name": "原料", + "unit_name": "kg", + "price": 0.00, + "brand": null, + "specification": null, + "batch_number": "MAT-001-TW-20231020-01", + "expiry_date": "2024-04-20", "quantity": 2.5 } ] @@ -123,10 +218,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS --- -## 3. 訂單資料寫入與扣庫 (Create Order) +## 4. 訂單資料寫入與扣庫 (Create Order) 此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。 -**重要提醒**:寫入訂單的同時,ERP 會自動且**強制**扣除對應倉庫的庫存(允許扣至負數,以利後續盤點或補單)。 +**重要提醒**:寫入訂單時,ERP 會無條件扣除庫存。若指定的「批號」庫存不足,系統會自動轉向 `NO-BATCH` 庫存項目扣除;若最終仍不足,則會在 `NO-BATCH` 產生負數庫存。 - **Endpoint**: `/orders` - **Method**: `POST` @@ -145,27 +240,26 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS | 欄位名稱 | 型態 | 必填 | 說明 | | :--- | :--- | :---: | :--- | -| `pos_product_id` | String | **是** | 對應產品的 `external_pos_id`。**注意:商品必須先透過產品同步 API 建立至 ERP 中。** | +| `product_id` | Integer | **是** | **ERP 內部的商品 ID (`id`)**。請優先使用商品同步 API 取得此 ID | +| `batch_number` | String | 否 | **商品批號**。若提供,將優先扣除該批號庫存;若該批號無剩餘或找不到,將自動 fallback 至 `NO-BATCH` 扣除 | | `qty` | Number | **是** | 銷售數量 (必須 > 0) | | `price` | Number | **是** | 銷售單價 | +**注意**:請確保傳入正確的 `product_id` 以便 ERP 準確識別商品與扣除庫存。 + **請求範例:** ```json { - "external_order_id": "ORD-20231026-0001", + "external_order_id": "ORD-20240320-0001", "warehouse_code": "api-test-01", "payment_method": "credit_card", - "sold_at": "2023-10-26 14:30:00", + "sold_at": "2024-03-20 14:30:00", "items": [ { - "pos_product_id": "POS-ITEM-001", + "product_id": 15, + "batch_number": "BATCH-2024-A1", "qty": 2, - "price": 450 - }, - { - "pos_product_id": "POS-ITEM-005", - "qty": 1, - "price": 120 + "price": 500 } ] } @@ -181,17 +275,9 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS } ``` -**Error: Product Not Found (HTTP 400)** -若 `items` 內傳入了未曾同步過的 `pos_product_id`,會導致寫入失敗。 -```json -{ - "message": "Sync failed: Product not found for POS ID: POS-ITEM-999. Please sync product first." -} -``` - #### 錯誤回應 (HTTP 422 Unprocessable Entity - 驗證失敗) -當傳入資料格式有誤、商品編號不存在,或是該訂單正在處理中被系統鎖定時返回。 +當傳入資料格式有誤、商品 ID 於系統中不存在(如 `items` 內傳入了無法辨識的商品 ID),或是倉庫代碼無效時返回。 - **`message`** (字串): 錯誤摘要。 - **`errors`** (物件): 具體的錯誤明細列表,能一次性回報多個商品不存在或其它欄位錯誤。 @@ -201,10 +287,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS "message": "Validation failed", "errors": { "items": [ - "The following products are not found: POS-999, POS-888. Please sync products first." + "The following product IDs are not found: 15, 22. Please ensure these products exist in the system." ], - "external_order_id": [ - "The order ORD-01 is currently being processed by another transaction. Please try again later." + "warehouse_code": [ + "Warehouse with code STORE-999 not found." ] } } diff --git a/tests/Feature/Integration/InventoryQueryApiTest.php b/tests/Feature/Integration/InventoryQueryApiTest.php new file mode 100644 index 0000000..e99db01 --- /dev/null +++ b/tests/Feature/Integration/InventoryQueryApiTest.php @@ -0,0 +1,211 @@ +domain = 'inventory-test-' . Str::random(8) . '.erp.local'; + $tenantId = 'test_tenant_inv_' . Str::random(8); + + tenancy()->central(function () use ($tenantId) { + $this->tenant = Tenant::create([ + 'id' => $tenantId, + 'name' => 'Inventory Test Tenant', + ]); + $this->tenant->domains()->create(['domain' => $this->domain]); + }); + + tenancy()->initialize($this->tenant); + Artisan::call('tenants:migrate'); + + $this->user = User::factory()->create(['name' => 'Inventory Admin']); + + // 建立測試資料 + $cat = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']); + $unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']); + + $p1 = Product::create([ + 'name' => '可口可樂', + 'code' => 'COKE-001', + 'barcode' => '4710001', + 'external_pos_id' => 'POS-COKE', + 'price' => 25, + 'category_id' => $cat->id, + 'base_unit_id' => $unit->id, + 'is_active' => true, + ]); + + $p2 = Product::create([ + 'name' => '百事可樂', + 'code' => 'PEPSI-001', + 'barcode' => '4710002', + 'external_pos_id' => 'POS-PEPSI', + 'price' => 23, + 'category_id' => $cat->id, + 'base_unit_id' => $unit->id, + 'is_active' => true, + ]); + + $this->warehouse = Warehouse::create([ + 'name' => '台北門市倉', + 'code' => 'WH-TP-01', + 'type' => 'retail', + ]); + + // 建立庫存 + Inventory::create([ + 'warehouse_id' => $this->warehouse->id, + 'product_id' => $p1->id, + 'quantity' => 100, + 'unit_cost' => 15, + 'total_value' => 1500, + 'batch_number' => 'BATCH-001', + 'arrival_date' => now(), + ]); + + Inventory::create([ + 'warehouse_id' => $this->warehouse->id, + 'product_id' => $p2->id, + 'quantity' => 50, + 'unit_cost' => 12, + 'total_value' => 600, + 'batch_number' => 'BATCH-002', + 'arrival_date' => now(), + ]); + + tenancy()->end(); + } + + protected function tearDown(): void + { + if ($this->tenant) { + $this->tenant->delete(); + } + parent::tearDown(); + } + + public function test_can_query_all_inventory_for_warehouse() + { + Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}"); + + $response->assertStatus(200) + ->assertJsonPath('status', 'success') + ->assertJsonCount(2, 'data'); + } + + public function test_can_filter_inventory_by_product_id() + { + Sanctum::actingAs($this->user, ['*']); + + // 先找出可樂的 ERP ID + $productId = Product::where('code', 'COKE-001')->value('id'); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id={$productId}"); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.product_code', 'COKE-001') + ->assertJsonPath('data.0.quantity', 100); + } + + public function test_can_filter_inventory_by_external_pos_id() + { + Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?external_pos_id=POS-COKE"); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.external_pos_id', 'POS-COKE') + ->assertJsonPath('data.0.quantity', 100); + } + + public function test_can_filter_inventory_by_barcode() + { + Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?barcode=4710002"); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.product_code', 'PEPSI-001') + ->assertJsonPath('data.0.quantity', 50); + } + + public function test_can_filter_inventory_by_code() + { + Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?code=COKE-001"); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.product_code', 'COKE-001'); + } + + public function test_returns_empty_when_no_match() + { + Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id=NON-EXISTENT"); + + $response->assertStatus(200) + ->assertJsonCount(0, 'data'); + } + + public function test_returns_404_when_warehouse_not_found() + { + Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/inventory/INVALID-WH"); + + $response->assertStatus(404); + } +} diff --git a/tests/Feature/Integration/PosApiTest.php b/tests/Feature/Integration/PosApiTest.php index bbac412..9eeb168 100644 --- a/tests/Feature/Integration/PosApiTest.php +++ b/tests/Feature/Integration/PosApiTest.php @@ -7,7 +7,10 @@ use App\Modules\Core\Models\Tenant; use App\Modules\Core\Models\User; use App\Modules\Inventory\Models\Product; use App\Modules\Integration\Models\SalesOrder; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; +use Laravel\Sanctum\Sanctum; use Stancl\Tenancy\Facades\Tenancy; class PosApiTest extends TestCase @@ -26,9 +29,9 @@ class PosApiTest extends TestCase { parent::setUp(); - \Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait + Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait - $this->domain = 'test-' . \Illuminate\Support\Str::random(8) . '.erp.local'; + $this->domain = 'test-' . Str::random(8) . '.erp.local'; $tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8); // Ensure we are in central context @@ -45,7 +48,7 @@ class PosApiTest extends TestCase // Initialize to create User and Token tenancy()->initialize($this->tenant); - \Artisan::call('tenants:migrate'); + Artisan::call('tenants:migrate'); $this->user = User::factory()->create([ 'email' => 'admin@test.local', @@ -83,26 +86,33 @@ class PosApiTest extends TestCase { \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); - $payload = [ - 'external_pos_id' => 'EXT-NEW-002', - 'name' => 'New Product', - 'price' => 200, - 'sku' => 'SKU-NEW', + $productData = [ + 'external_pos_id' => 'POS-P-999', + 'name' => '新款可口可樂', + 'price' => 29.0, + 'barcode' => '471000999', + 'category' => '飲品', + 'unit' => '瓶', ]; $response = $this->withHeaders([ 'X-Tenant-Domain' => $this->domain, 'Accept' => 'application/json', - ])->postJson('/api/v1/integration/products/upsert', $payload); + ])->postJson('/api/v1/integration/products/upsert', $productData); $response->assertStatus(200) - ->assertJsonPath('message', 'Product synced successfully'); + ->assertJsonPath('data.external_pos_id', 'POS-P-999') + ->assertJsonStructure([ + 'data' => [ + 'id', 'external_pos_id', 'code', 'barcode' + ] + ]); // Verify in Tenant DB tenancy()->initialize($this->tenant); $this->assertDatabaseHas('products', [ - 'external_pos_id' => 'EXT-NEW-002', - 'name' => 'New Product', + 'external_pos_id' => 'POS-P-999', + 'name' => '新款可口可樂', ]); tenancy()->end(); } @@ -115,6 +125,8 @@ class PosApiTest extends TestCase 'external_pos_id' => 'EXT-001', 'name' => 'Updated Name', 'price' => 150, + 'category' => '飲品', + 'unit' => '瓶', ]; $response = $this->withHeaders([ @@ -147,7 +159,7 @@ class PosApiTest extends TestCase 'product_id' => $product->id, 'warehouse_id' => $warehouse->id, 'quantity' => 100, - 'batch_number' => 'BATCH-TEST-001', + 'batch_number' => 'NO-BATCH', // 改為系統預設值 'arrival_date' => now()->toDateString(), 'origin_country' => 'TW', ]); @@ -163,7 +175,7 @@ class PosApiTest extends TestCase 'sold_at' => now()->toIso8601String(), 'items' => [ [ - 'pos_product_id' => 'EXT-001', + 'product_id' => $product->id, 'qty' => 5, 'price' => 100 ] @@ -208,6 +220,245 @@ class PosApiTest extends TestCase 'type' => '出庫', ]); + } + + public function test_order_creation_with_batch_number_deduction() + { + tenancy()->initialize($this->tenant); + $product = Product::where('code', 'P-001')->first(); + $warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']); + + // Scenario: Two records for the same product, one with batch, one without + \App\Modules\Inventory\Models\Inventory::create([ + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'quantity' => 10, + 'batch_number' => 'BATCH-A', + 'arrival_date' => now()->toDateString(), + ]); + + \App\Modules\Inventory\Models\Inventory::create([ + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'quantity' => 5, + 'batch_number' => 'NO-BATCH', + 'arrival_date' => now()->toDateString(), + ]); + + tenancy()->end(); + + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + // 1. Test deducting from SPECIFIC batch + $payloadA = [ + 'external_order_id' => 'ORD-BATCH-A', + 'warehouse_code' => 'MAIN', + 'items' => [ + [ + 'product_id' => $product->id, + 'batch_number' => 'BATCH-A', + 'qty' => 3, + 'price' => 100 + ] + ] + ]; + + $this->withHeaders(['X-Tenant-Domain' => $this->domain]) + ->postJson('/api/v1/integration/orders', $payloadA) + ->assertStatus(201); + + // 2. Test deducting from NULL batch (default) + $payloadNull = [ + 'external_order_id' => 'ORD-BATCH-NULL', + 'warehouse_code' => 'MAIN', + 'items' => [ + [ + 'product_id' => $product->id, + 'batch_number' => null, // 測試不傳入批號時應自動轉向 NO-BATCH + 'qty' => 2, + 'price' => 100 + ] + ] + ]; + + $this->withHeaders(['X-Tenant-Domain' => $this->domain]) + ->postJson('/api/v1/integration/orders', $payloadNull) + ->assertStatus(201); + + tenancy()->initialize($this->tenant); + // Verify BATCH-A: 10 - 3 = 7 + $this->assertDatabaseHas('inventories', [ + 'product_id' => $product->id, + 'batch_number' => 'BATCH-A', + 'quantity' => 7, + ]); + + // Verify NO-BATCH batch: 5 - 2 = 3 + $this->assertDatabaseHas('inventories', [ + 'product_id' => $product->id, + 'batch_number' => 'NO-BATCH', + 'quantity' => 3, + ]); + tenancy()->end(); + } + + public function test_upsert_auto_generates_code_and_barcode_if_missing() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $productData = [ + 'external_pos_id' => 'POS-AUTO-GEN-01', + 'name' => '自動編號商品', + 'price' => 50.0, + 'category' => '測試分類', + 'unit' => '個', + ]; + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->postJson('/api/v1/integration/products/upsert', $productData); + + $response->assertStatus(200); + + $data = $response->json('data'); + $this->assertNotEmpty($data['code']); + $this->assertNotEmpty($data['barcode']); + $this->assertEquals(8, strlen($data['code'])); + $this->assertEquals(13, strlen($data['barcode'])); + + // Ensure they are stored in DB + tenancy()->initialize($this->tenant); + $this->assertDatabaseHas('products', [ + 'external_pos_id' => 'POS-AUTO-GEN-01', + 'code' => $data['code'], + 'barcode' => $data['barcode'], + ]); + tenancy()->end(); + } + + public function test_inventory_query_returns_detailed_info() + { + tenancy()->initialize($this->tenant); + + // 1. 建立具有完整資訊的商品 + $category = \App\Modules\Inventory\Models\Category::create(['name' => '測試大類']); + $unit = \App\Modules\Inventory\Models\Unit::create(['name' => '測試單位']); + + $product = Product::create([ + 'external_pos_id' => 'DETAIL-001', + 'code' => 'DET-001', + 'barcode' => '1234567890123', + 'name' => '詳盡商品', + 'category_id' => $category->id, + 'base_unit_id' => $unit->id, + 'price' => 123.45, + 'brand' => '品牌A', + 'specification' => '規格B', + 'is_active' => true, + ]); + + $warehouse = \App\Modules\Inventory\Models\Warehouse::create(['name' => '詳盡倉庫', 'code' => 'DETAIL-WH']); + + // 2. 增加庫存 + \App\Modules\Inventory\Models\Inventory::create([ + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'quantity' => 10.5, + 'batch_number' => 'BATCH-DETAIL-01', + 'arrival_date' => now(), + 'expiry_date' => now()->addYear(), + 'origin_country' => 'TW', + ]); + + tenancy()->end(); + + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + // 3. 查詢該倉庫庫存 + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/inventory/DETAIL-WH'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'warehouse_code', + 'data' => [ + '*' => [ + 'product_id', + 'external_pos_id', + 'product_code', + 'product_name', + 'barcode', + 'category_name', + 'unit_name', + 'price', + 'brand', + 'specification', + 'batch_number', + 'expiry_date', + 'quantity' + ] + ] + ]); + + $data = $response->json('data.0'); + $this->assertEquals($product->id, $data['product_id']); + $this->assertEquals('DETAIL-001', $data['external_pos_id']); + $this->assertEquals('DET-001', $data['product_code']); + $this->assertEquals('1234567890123', $data['barcode']); + $this->assertEquals('測試大類', $data['category_name']); + $this->assertEquals('測試單位', $data['unit_name']); + $this->assertEquals(123.45, (float)$data['price']); + $this->assertEquals('品牌A', $data['brand']); + $this->assertEquals('規格B', $data['specification']); + $this->assertEquals('BATCH-DETAIL-01', $data['batch_number']); + $this->assertNotEmpty($data['expiry_date']); + $this->assertEquals(10.5, $data['quantity']); + } + + public function test_order_creation_with_insufficient_stock_creates_negative_no_batch() + { + tenancy()->initialize($this->tenant); + $product = Product::where('code', 'P-001')->first(); + $warehouse = \App\Modules\Inventory\Models\Warehouse::where('code', 'MAIN')->first(); + + // 清空該商品的現有庫存以利測試負數 + \App\Modules\Inventory\Models\Inventory::where('product_id', $product->id)->delete(); + + tenancy()->end(); + + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $payload = [ + 'external_order_id' => 'ORD-NEGATIVE-TEST', + 'warehouse_code' => 'MAIN', + 'items' => [ + [ + 'product_id' => $product->id, + 'batch_number' => 'ANY-BATCH-NAME', + 'qty' => 10, + 'price' => 100 + ] + ] + ]; + + $this->withHeaders(['X-Tenant-Domain' => $this->domain]) + ->postJson('/api/v1/integration/orders', $payload) + ->assertStatus(201); + + tenancy()->initialize($this->tenant); + + // 驗證應該在 NO-BATCH 產生 -10 的庫存 + $this->assertDatabaseHas('inventories', [ + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'batch_number' => 'NO-BATCH', + 'quantity' => -10, + ]); + tenancy()->end(); } } diff --git a/tests/Feature/Integration/ProductSearchApiTest.php b/tests/Feature/Integration/ProductSearchApiTest.php new file mode 100644 index 0000000..d9dcab0 --- /dev/null +++ b/tests/Feature/Integration/ProductSearchApiTest.php @@ -0,0 +1,194 @@ +domain = 'search-test-' . Str::random(8) . '.erp.local'; + $tenantId = 'test_tenant_s_' . Str::random(8); + + tenancy()->central(function () use ($tenantId) { + $this->tenant = Tenant::create([ + 'id' => $tenantId, + 'name' => 'Search Test Tenant', + ]); + $this->tenant->domains()->create(['domain' => $this->domain]); + }); + + tenancy()->initialize($this->tenant); + Artisan::call('tenants:migrate'); + + $this->user = User::factory()->create(['name' => 'Search Admin']); + + // 建立測試資料 + $cat1 = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']); + $cat2 = Category::firstOrCreate(['name' => '食品'], ['code' => 'CAT-FOOD']); + $unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']); + + Product::create([ + 'name' => '可口可樂', + 'code' => 'COKE-001', + 'barcode' => '4710001', + 'price' => 25, + 'category_id' => $cat1->id, + 'base_unit_id' => $unit->id, + 'is_active' => true, + ]); + + $pepsi = Product::create([ + 'name' => '百事可樂', + 'code' => 'PEPSI-001', + 'barcode' => '4710002', + 'price' => 23, + 'category_id' => $cat1->id, + 'base_unit_id' => $unit->id, + 'is_active' => true, + ]); + DB::table('products')->where('id', $pepsi->id)->update(['updated_at' => now()->subDay()]); + + Product::create([ + 'name' => '漢堡', + 'code' => 'BURGER-001', + 'barcode' => '4710003', + 'price' => 50, + 'category_id' => $cat2->id, + 'base_unit_id' => $unit->id, + 'is_active' => true, + ]); + + Product::create([ + 'name' => '停用商品', + 'code' => 'INACTIVE-001', + 'is_active' => false, + 'category_id' => $cat1->id, + 'base_unit_id' => $unit->id, + ]); + + tenancy()->end(); + } + + protected function tearDown(): void + { + if ($this->tenant) { + $this->tenant->delete(); + } + parent::tearDown(); + } + + public function test_can_search_all_active_products() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/products'); + + $response->assertStatus(200) + ->assertJsonPath('status', 'success') + ->assertJsonCount(3, 'data'); // 3 active products + } + + public function test_can_filter_by_category() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/products?category=食品'); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', '漢堡'); + } + + public function test_can_filter_by_updated_after() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + // 搜尋今天更新的商品 (百事可樂是昨天更新的,應該被過濾掉) + $timeStr = now()->startOfDay()->format('Y-m-d H:i:s'); + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/products?updated_after={$timeStr}"); + + $response->assertStatus(200) + ->assertJsonCount(2, 'data'); // 可口可樂, 漢堡 (百事可樂是昨天) + } + + public function test_pagination_works() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/products?per_page=2'); + + $response->assertStatus(200) + ->assertJsonCount(2, 'data') + ->assertJsonPath('meta.per_page', 2) + ->assertJsonPath('meta.total', 3); + } + + public function test_can_filter_by_precision_params() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + // 1. By Barcode + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/products?barcode=4710001'); + $response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '可口可樂'); + + // 2. By Code + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/products?code=PEPSI-001'); + $response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '百事可樂'); + + // 3. By product_id (ERP ID) + $productId = Product::where('code', 'BURGER-001')->value('id'); + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson("/api/v1/integration/products?product_id={$productId}"); + $response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '漢堡'); + } + + public function test_is_protected_by_auth() + { + // 不帶 Token + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->getJson('/api/v1/integration/products'); + + $response->assertStatus(401); + } +}