Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f96d2870c3 | |||
| 96440f6b50 | |||
| 8f6b8d55cc | |||
| 60f5f00a9e | |||
| 0b4aeacb55 |
@@ -58,42 +58,36 @@ 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) {
|
||||
// 1. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'cash',
|
||||
'total_amount' => 0,
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => $data['source'] ?? 'pos',
|
||||
'source_label' => $data['source_label'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. 查找倉庫
|
||||
$result = DB::transaction(function () use ($data, $items, $resolvedProducts) {
|
||||
// 1. 查找倉庫(提前至建立訂單前,以便判定來源)
|
||||
$warehouseCode = $data['warehouse_code'];
|
||||
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||
|
||||
@@ -102,17 +96,36 @@ class SyncOrderAction
|
||||
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||
]);
|
||||
}
|
||||
$warehouseId = $warehouses->first()->id;
|
||||
$warehouse = $warehouses->first();
|
||||
$warehouseId = $warehouse->id;
|
||||
|
||||
// 2. 自動判定來源:若是販賣機倉庫則標記為 vending,其餘為 pos
|
||||
$source = ($warehouse->type === \App\Enums\WarehouseType::VENDING) ? 'vending' : 'pos';
|
||||
|
||||
// 3. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'name' => $data['name'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'cash',
|
||||
'total_amount' => $data['total_amount'],
|
||||
'total_qty' => $data['total_qty'],
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => $source,
|
||||
'source_label' => $data['source_label'] ?? null,
|
||||
]);
|
||||
|
||||
$totalAmount = 0;
|
||||
|
||||
// 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 +147,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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ class SalesOrderController extends Controller
|
||||
|
||||
// 搜尋篩選 (外部訂單號)
|
||||
if ($request->filled('search')) {
|
||||
$query->where('external_order_id', 'like', '%' . $request->search . '%');
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('external_order_id', 'like', '%' . $request->search . '%')
|
||||
->orWhere('name', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// 來源篩選
|
||||
@@ -26,6 +29,11 @@ class SalesOrderController extends Controller
|
||||
$query->where('source', $request->source);
|
||||
}
|
||||
|
||||
// 付款方式篩選
|
||||
if ($request->filled('payment_method')) {
|
||||
$query->where('payment_method', $request->payment_method);
|
||||
}
|
||||
|
||||
// 排序
|
||||
$query->orderBy('sold_at', 'desc');
|
||||
|
||||
@@ -40,7 +48,7 @@ class SalesOrderController extends Controller
|
||||
|
||||
return Inertia::render('Integration/SalesOrders/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'per_page', 'source']),
|
||||
'filters' => $request->only(['search', 'per_page', 'source', 'status', 'payment_method']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ class SalesOrder extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'external_order_id',
|
||||
'name',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total_amount',
|
||||
'total_qty',
|
||||
'sold_at',
|
||||
'raw_payload',
|
||||
'source',
|
||||
@@ -24,6 +26,7 @@ class SalesOrder extends Model
|
||||
'sold_at' => 'datetime',
|
||||
'raw_payload' => 'array',
|
||||
'total_amount' => 'decimal:4',
|
||||
'total_qty' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function items(): HasMany
|
||||
|
||||
@@ -23,11 +23,15 @@ class SyncOrderRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'external_order_id' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
'warehouse_code' => 'required|string',
|
||||
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
'total_qty' => 'required|numeric|min:0',
|
||||
'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',
|
||||
];
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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 = []);
|
||||
|
||||
/**
|
||||
* 處理批量入庫邏輯 (含批號產生與現有批號累加)。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 使用)。
|
||||
*
|
||||
@@ -178,4 +197,54 @@ class ProductService implements ProductServiceInterface
|
||||
}
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜尋商品(供外部 API 使用)。
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
*/
|
||||
public function searchProducts(array $filters, int $perPage = 50)
|
||||
{
|
||||
$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. 分類過濾 (優先使用 ID,若傳入字串則按名稱)
|
||||
if (!empty($filters['category'])) {
|
||||
$categoryVal = $filters['category'];
|
||||
if (is_numeric($categoryVal)) {
|
||||
$query->where('category_id', $categoryVal);
|
||||
} else {
|
||||
$query->whereHas('category', function ($q) use ($categoryVal) {
|
||||
$q->where('name', $categoryVal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('sales_orders', function (Blueprint $table) {
|
||||
$table->decimal('total_qty', 12, 4)->default(0)->after('total_amount');
|
||||
$table->string('name')->after('external_order_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sales_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['total_qty', 'name']);
|
||||
});
|
||||
}
|
||||
};
|
||||
29
database/seeders/LocalTenantSeeder.php
Normal file
29
database/seeders/LocalTenantSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
|
||||
class LocalTenantSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// 建立預設租戶 demo
|
||||
$tenant = Tenant::firstOrCreate(
|
||||
['id' => '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.');
|
||||
}
|
||||
}
|
||||
64
docs/integration/products_api.md
Normal file
64
docs/integration/products_api.md
Normal file
@@ -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` 格式錯誤)。
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -26,9 +28,11 @@ import { Can } from "@/Components/Permission/Can";
|
||||
interface SalesOrder {
|
||||
id: number;
|
||||
external_order_id: string;
|
||||
name: string | null;
|
||||
status: string;
|
||||
payment_method: string;
|
||||
total_amount: string;
|
||||
total_qty: string;
|
||||
sold_at: string;
|
||||
created_at: string;
|
||||
source: string;
|
||||
@@ -54,6 +58,8 @@ interface Props {
|
||||
search?: string;
|
||||
per_page?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
payment_method?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +71,14 @@ const sourceOptions = [
|
||||
{ label: "手動匯入", value: "manual_import" },
|
||||
];
|
||||
|
||||
const paymentMethodOptions = [
|
||||
{ label: "全部付款方式", value: "" },
|
||||
{ label: "現金", value: "cash" },
|
||||
{ label: "信用卡", value: "credit_card" },
|
||||
{ label: "Line Pay", value: "line_pay" },
|
||||
{ label: "悠遊卡", value: "easycard" },
|
||||
];
|
||||
|
||||
const getSourceLabel = (source: string): string => {
|
||||
switch (source) {
|
||||
case 'pos': return 'POS';
|
||||
@@ -105,12 +119,32 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
const handleSearch = () => {
|
||||
router.get(
|
||||
route("integration.sales-orders.index"),
|
||||
{ ...filters, search, page: 1 },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: any) => {
|
||||
router.get(route("integration.sales-orders.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearch(term);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: term,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
[key]: value || undefined,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
@@ -153,38 +187,40 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
{/* 篩選列 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SearchableSelect
|
||||
value={filters.source || ""}
|
||||
onValueChange={(v) =>
|
||||
router.get(
|
||||
route("integration.sales-orders.index"),
|
||||
{ ...filters, source: v || undefined, page: 1 },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
)
|
||||
}
|
||||
options={sourceOptions}
|
||||
className="w-[160px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選來源"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px] relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜尋外部訂單號 (External Order ID)..."
|
||||
className="h-9"
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="搜尋訂單名稱或外部訂單號 (External Order ID)..."
|
||||
className="pl-10 pr-10 h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-9"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SearchableSelect
|
||||
value={filters.source || ""}
|
||||
onValueChange={(val) => handleFilterChange("source", val)}
|
||||
options={sourceOptions}
|
||||
className="w-[140px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選來源"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={filters.payment_method || ""}
|
||||
onValueChange={(val) => handleFilterChange("payment_method", val)}
|
||||
options={paymentMethodOptions}
|
||||
className="w-[160px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選付款方式"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,9 +231,11 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>外部訂單號</TableHead>
|
||||
<TableHead>名稱</TableHead>
|
||||
<TableHead className="text-center">來源</TableHead>
|
||||
<TableHead className="text-center">狀態</TableHead>
|
||||
<TableHead>付款方式</TableHead>
|
||||
<TableHead className="text-right">總數量</TableHead>
|
||||
<TableHead className="text-right">總金額</TableHead>
|
||||
<TableHead>銷售時間</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
@@ -206,7 +244,7 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
<TableBody>
|
||||
{orders.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
|
||||
<TableCell colSpan={10} className="text-center py-8 text-gray-500">
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -219,6 +257,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
<TableCell className="font-mono text-sm">
|
||||
{order.external_order_id}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate" title={order.name || ""}>
|
||||
{order.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusBadge variant={getSourceVariant(order.source)}>
|
||||
{order.source_label || getSourceLabel(order.source)}
|
||||
@@ -233,6 +274,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
{order.payment_method || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatNumber(parseFloat(order.total_qty))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary-main">
|
||||
${formatNumber(parseFloat(order.total_amount))}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
|
||||
@@ -28,7 +28,9 @@ interface SalesOrder {
|
||||
status: string;
|
||||
payment_method: string;
|
||||
total_amount: string;
|
||||
total_qty: string;
|
||||
sold_at: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
raw_payload: any;
|
||||
items: SalesOrderItem[];
|
||||
@@ -103,6 +105,7 @@ export default function SalesOrderShow({ order }: Props) {
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 font-medium flex flex-wrap items-center gap-2">
|
||||
銷售時間: {formatDate(order.sold_at)} <span className="mx-1">|</span>
|
||||
名稱: {order.name || "—"} <span className="mx-1">|</span>
|
||||
付款方式: {order.payment_method || "—"} <span className="mx-1">|</span>
|
||||
訂單來源: {getSourceDisplay(order.source, order.source_label)} <span className="mx-1">|</span>
|
||||
同步時間: {formatDate(order.created_at as any)}
|
||||
@@ -146,6 +149,13 @@ export default function SalesOrderShow({ order }: Props) {
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="w-full max-w-sm bg-primary-lightest/30 px-6 py-4 rounded-xl border border-primary-light/20 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">訂單總數量</span>
|
||||
<span className="text-lg font-bold text-gray-700">
|
||||
{formatNumber(parseFloat(order.total_qty))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-primary-light/10 my-1"></div>
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">訂單總金額</span>
|
||||
<span className="text-2xl font-black text-primary-main">
|
||||
|
||||
@@ -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`
|
||||
@@ -136,8 +231,11 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
|
||||
| `name` | String | **是** | 訂單名稱或客戶名稱 (最多 255 字元) |
|
||||
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`api-test-01` 測試倉)。若找不到對應倉庫將直接拒絕請求 |
|
||||
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
|
||||
| `total_amount` | Number | **是** | 整筆訂單的總交易金額 (例如:500) |
|
||||
| `total_qty` | Number | **是** | 整筆訂單的商品總數量 (例如:5) |
|
||||
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
||||
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
|
||||
|
||||
@@ -145,27 +243,29 @@ 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",
|
||||
"warehouse_code": "api-test-01",
|
||||
"external_order_id": "ORD-20231024-001",
|
||||
"name": "陳小明-干貝醬訂購",
|
||||
"warehouse_code": "STORE-01",
|
||||
"payment_method": "credit_card",
|
||||
"sold_at": "2023-10-26 14:30:00",
|
||||
"total_amount": 1050,
|
||||
"total_qty": 3,
|
||||
"sold_at": "2023-10-24 15: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 +281,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 +293,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."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
211
tests/Feature/Integration/InventoryQueryApiTest.php
Normal file
211
tests/Feature/Integration/InventoryQueryApiTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class InventoryQueryApiTest extends TestCase
|
||||
{
|
||||
protected $tenant;
|
||||
protected $user;
|
||||
protected $domain;
|
||||
protected $warehouse;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -159,11 +171,15 @@ class PosApiTest extends TestCase
|
||||
|
||||
$payload = [
|
||||
'external_order_id' => 'ORD-001',
|
||||
'name' => '測試訂單一號',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 500, // 5 * 100
|
||||
'total_qty' => 5,
|
||||
'sold_at' => now()->toIso8601String(),
|
||||
'items' => [
|
||||
[
|
||||
'pos_product_id' => 'EXT-001',
|
||||
'product_id' => $product->id,
|
||||
'qty' => 5,
|
||||
'price' => 100
|
||||
]
|
||||
@@ -208,6 +224,314 @@ 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',
|
||||
'name' => '測試訂單A',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 300, // 3 * 100
|
||||
'total_qty' => 3,
|
||||
'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',
|
||||
'name' => '無批號測試',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 200, // 2 * 100
|
||||
'total_qty' => 2,
|
||||
'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::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
|
||||
|
||||
// 清空該商品的現有庫存以利測試負數
|
||||
\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',
|
||||
'name' => '負數庫存測試',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 1000,
|
||||
'total_qty' => 10,
|
||||
'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();
|
||||
}
|
||||
|
||||
public function test_order_source_automation_based_on_warehouse_type()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
$product = Product::where('code', 'P-001')->first();
|
||||
|
||||
// 1. Create a VENDING warehouse
|
||||
$vendingWarehouse = \App\Modules\Inventory\Models\Warehouse::create([
|
||||
'name' => 'Vending Machine 01',
|
||||
'code' => 'VEND-01',
|
||||
'type' => \App\Enums\WarehouseType::VENDING,
|
||||
]);
|
||||
|
||||
// 2. Create a STANDARD warehouse
|
||||
$standardWarehouse = \App\Modules\Inventory\Models\Warehouse::create([
|
||||
'name' => 'General Warehouse',
|
||||
'code' => 'ST-01',
|
||||
'type' => \App\Enums\WarehouseType::STANDARD,
|
||||
]);
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// Case A: Vending Warehouse -> Source should be 'vending'
|
||||
$payloadVending = [
|
||||
'external_order_id' => 'ORD-VEND',
|
||||
'name' => 'Vending Order',
|
||||
'warehouse_code' => 'VEND-01',
|
||||
'total_amount' => 100,
|
||||
'total_qty' => 1,
|
||||
'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadVending)
|
||||
->assertStatus(201);
|
||||
|
||||
// Case B: Standard Warehouse -> Source should be 'pos'
|
||||
$payloadPos = [
|
||||
'external_order_id' => 'ORD-POS',
|
||||
'name' => 'POS Order',
|
||||
'warehouse_code' => 'ST-01',
|
||||
'total_amount' => 100,
|
||||
'total_qty' => 1,
|
||||
'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadPos)
|
||||
->assertStatus(201);
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
$this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-VEND', 'source' => 'vending']);
|
||||
$this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-POS', 'source' => 'pos']);
|
||||
tenancy()->end();
|
||||
}
|
||||
}
|
||||
|
||||
194
tests/Feature/Integration/ProductSearchApiTest.php
Normal file
194
tests/Feature/Integration/ProductSearchApiTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductSearchApiTest extends TestCase
|
||||
{
|
||||
protected $tenant;
|
||||
protected $user;
|
||||
protected $domain;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user