feat(integration): 實作並測試 POS 與販賣機訂單同步 API
主要變更: - 實作 POS 與販賣機訂單同步邏輯,支援多租戶與 Sanctum 驗證。 - 修正多租戶識別中間件與 Sanctum 驗證順序問題。 - 切換快取驅動至 Redis 以支援 Tenancy 標籤功能。 - 新增商品同步 API (Upsert) 及相關單元測試。 - 新增手動測試腳本 tests/manual/test_integration_api.sh。 - 前端新增銷售訂單來源篩選與欄位顯示。
This commit is contained in:
156
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
156
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Actions;
|
||||||
|
|
||||||
|
use App\Modules\Integration\Models\SalesOrder;
|
||||||
|
use App\Modules\Integration\Models\SalesOrderItem;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class SyncOrderAction
|
||||||
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
protected $productService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
InventoryServiceInterface $inventoryService,
|
||||||
|
ProductServiceInterface $productService
|
||||||
|
) {
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
$this->productService = $productService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 執行訂單同步
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return array 包含 orders 建立結果的資訊
|
||||||
|
* @throws ValidationException
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function execute(array $data)
|
||||||
|
{
|
||||||
|
$externalOrderId = $data['external_order_id'];
|
||||||
|
|
||||||
|
// 使用 Cache::lock 防護高併發,鎖定該訂單號 10 秒
|
||||||
|
// 此處需要 cache store 支援鎖 (如 memcached, dynamodb, redis, database, file, array)
|
||||||
|
// Laravel 預設的 file/redis 都支援。若無法取得鎖,表示有另一個相同的請求正在處理
|
||||||
|
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
|
||||||
|
|
||||||
|
if (!$lock->get()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
||||||
|
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
|
||||||
|
if ($existingOrder) {
|
||||||
|
return [
|
||||||
|
'status' => 'exists',
|
||||||
|
'message' => 'Order already exists',
|
||||||
|
'order_id' => $existingOrder->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 預檢 (Pre-flight check) N+1 優化 ---
|
||||||
|
$items = $data['items'];
|
||||||
|
$posProductIds = array_column($items, 'pos_product_id');
|
||||||
|
|
||||||
|
// 一次性查出所有相關的 Product
|
||||||
|
$products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id');
|
||||||
|
|
||||||
|
$missingIds = [];
|
||||||
|
foreach ($posProductIds as $id) {
|
||||||
|
if (!$products->has($id)) {
|
||||||
|
$missingIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($missingIds)) {
|
||||||
|
// 回報所有缺漏的 ID
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 執行寫入交易 ---
|
||||||
|
$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. 查找或建立倉庫
|
||||||
|
$warehouseId = $data['warehouse_id'] ?? null;
|
||||||
|
|
||||||
|
if (empty($warehouseId)) {
|
||||||
|
$warehouseName = $data['warehouse'] ?? '銷售倉庫';
|
||||||
|
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
||||||
|
$warehouseId = $warehouse->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalAmount = 0;
|
||||||
|
|
||||||
|
// 3. 處理訂單明細
|
||||||
|
$orderItemsData = [];
|
||||||
|
foreach ($items as $itemData) {
|
||||||
|
$product = $products->get($itemData['pos_product_id']);
|
||||||
|
|
||||||
|
$qty = $itemData['qty'];
|
||||||
|
$price = $itemData['price'];
|
||||||
|
$lineTotal = $qty * $price;
|
||||||
|
$totalAmount += $lineTotal;
|
||||||
|
|
||||||
|
$orderItemsData[] = [
|
||||||
|
'sales_order_id' => $order->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'price' => $price,
|
||||||
|
'total' => $lineTotal,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. 扣除庫存(強制模式,允許負庫存)
|
||||||
|
$this->inventoryService->decreaseStock(
|
||||||
|
$product->id,
|
||||||
|
$warehouseId,
|
||||||
|
$qty,
|
||||||
|
"POS Order: " . $order->external_order_id,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert order items
|
||||||
|
SalesOrderItem::insert($orderItemsData);
|
||||||
|
|
||||||
|
$order->update(['total_amount' => $totalAmount]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'created',
|
||||||
|
'message' => 'Order synced and stock deducted successfully',
|
||||||
|
'order_id' => $order->id,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} finally {
|
||||||
|
// 無論成功失敗,最後釋放鎖定
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
152
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Actions;
|
||||||
|
|
||||||
|
use App\Modules\Integration\Models\SalesOrder;
|
||||||
|
use App\Modules\Integration\Models\SalesOrderItem;
|
||||||
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class SyncVendingOrderAction
|
||||||
|
{
|
||||||
|
protected $inventoryService;
|
||||||
|
protected $productService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
InventoryServiceInterface $inventoryService,
|
||||||
|
ProductServiceInterface $productService
|
||||||
|
) {
|
||||||
|
$this->inventoryService = $inventoryService;
|
||||||
|
$this->productService = $productService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 執行販賣機訂單同步
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return array 包含訂單建立結果的資訊
|
||||||
|
* @throws ValidationException
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function execute(array $data)
|
||||||
|
{
|
||||||
|
$externalOrderId = $data['external_order_id'];
|
||||||
|
|
||||||
|
// 使用 Cache::lock 防護高併發
|
||||||
|
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
|
||||||
|
|
||||||
|
if (!$lock->get()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
||||||
|
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
|
||||||
|
if ($existingOrder) {
|
||||||
|
return [
|
||||||
|
'status' => 'exists',
|
||||||
|
'message' => 'Order already exists',
|
||||||
|
'order_id' => $existingOrder->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 預檢:以 ERP 商品代碼查詢 ---
|
||||||
|
$items = $data['items'];
|
||||||
|
$productCodes = array_column($items, 'product_code');
|
||||||
|
|
||||||
|
// 一次性查出所有相關的 Product(以 code 查詢)
|
||||||
|
$products = $this->productService->findByCodes($productCodes)->keyBy('code');
|
||||||
|
|
||||||
|
$missingCodes = [];
|
||||||
|
foreach ($productCodes as $code) {
|
||||||
|
if (!$products->has($code)) {
|
||||||
|
$missingCodes[] = $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($missingCodes)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => ["The following products are not found by code: " . implode(', ', $missingCodes) . ". 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'] ?? 'electronic',
|
||||||
|
'total_amount' => 0,
|
||||||
|
'sold_at' => $data['sold_at'] ?? now(),
|
||||||
|
'raw_payload' => $data,
|
||||||
|
'source' => 'vending',
|
||||||
|
'source_label' => $data['machine_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 查找或建立倉庫
|
||||||
|
$warehouseId = $data['warehouse_id'] ?? null;
|
||||||
|
|
||||||
|
if (empty($warehouseId)) {
|
||||||
|
$warehouseName = $data['warehouse'] ?? '販賣機倉庫';
|
||||||
|
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
||||||
|
$warehouseId = $warehouse->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalAmount = 0;
|
||||||
|
|
||||||
|
// 3. 處理訂單明細
|
||||||
|
$orderItemsData = [];
|
||||||
|
foreach ($items as $itemData) {
|
||||||
|
$product = $products->get($itemData['product_code']);
|
||||||
|
|
||||||
|
$qty = $itemData['qty'];
|
||||||
|
$price = $itemData['price'];
|
||||||
|
$lineTotal = $qty * $price;
|
||||||
|
$totalAmount += $lineTotal;
|
||||||
|
|
||||||
|
$orderItemsData[] = [
|
||||||
|
'sales_order_id' => $order->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'product_name' => $product->name,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'price' => $price,
|
||||||
|
'total' => $lineTotal,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. 扣除庫存(強制模式,允許負庫存)
|
||||||
|
$this->inventoryService->decreaseStock(
|
||||||
|
$product->id,
|
||||||
|
$warehouseId,
|
||||||
|
$qty,
|
||||||
|
"Vending Order: " . $order->external_order_id,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert order items
|
||||||
|
SalesOrderItem::insert($orderItemsData);
|
||||||
|
|
||||||
|
$order->update(['total_amount' => $totalAmount]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'created',
|
||||||
|
'message' => 'Vending order synced and stock deducted successfully',
|
||||||
|
'order_id' => $order->id,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,120 +3,58 @@
|
|||||||
namespace App\Modules\Integration\Controllers;
|
namespace App\Modules\Integration\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use App\Modules\Integration\Requests\SyncOrderRequest;
|
||||||
use App\Modules\Integration\Models\SalesOrder;
|
use App\Modules\Integration\Actions\SyncOrderAction;
|
||||||
use App\Modules\Integration\Models\SalesOrderItem;
|
use Illuminate\Http\JsonResponse;
|
||||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
|
||||||
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class OrderSyncController extends Controller
|
class OrderSyncController extends Controller
|
||||||
{
|
{
|
||||||
protected $inventoryService;
|
protected $syncOrderAction;
|
||||||
protected $productService;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(SyncOrderAction $syncOrderAction)
|
||||||
InventoryServiceInterface $inventoryService,
|
|
||||||
ProductServiceInterface $productService
|
|
||||||
) {
|
|
||||||
$this->inventoryService = $inventoryService;
|
|
||||||
$this->productService = $productService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request)
|
|
||||||
{
|
{
|
||||||
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
|
$this->syncOrderAction = $syncOrderAction;
|
||||||
$existingOrder = SalesOrder::where('external_order_id', $request->external_order_id)->first();
|
|
||||||
if ($existingOrder) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Order already exists',
|
|
||||||
'order_id' => $existingOrder->id,
|
|
||||||
], 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->validate([
|
/**
|
||||||
'external_order_id' => 'required|string|unique:sales_orders,external_order_id',
|
* 接收並同步外部交易訂單
|
||||||
'warehouse' => 'nullable|string',
|
*
|
||||||
'warehouse_id' => 'nullable|integer',
|
* @param SyncOrderRequest $request
|
||||||
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
* @return JsonResponse
|
||||||
'sold_at' => 'nullable|date',
|
*/
|
||||||
'items' => 'required|array|min:1',
|
public function store(SyncOrderRequest $request): JsonResponse
|
||||||
'items.*.pos_product_id' => 'required|string',
|
{
|
||||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
|
||||||
'items.*.price' => 'required|numeric|min:0',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($request) {
|
// 所有驗證皆已透過 SyncOrderRequest 自動處理
|
||||||
// 1. 建立訂單
|
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
|
||||||
$order = SalesOrder::create([
|
$result = $this->syncOrderAction->execute($request->validated());
|
||||||
'external_order_id' => $request->external_order_id,
|
|
||||||
'status' => 'completed',
|
|
||||||
'payment_method' => $request->payment_method ?? 'cash',
|
|
||||||
'total_amount' => 0,
|
|
||||||
'sold_at' => $request->sold_at ?? now(),
|
|
||||||
'raw_payload' => $request->all(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 2. 查找或建立倉庫
|
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||||
$warehouseId = $request->warehouse_id;
|
|
||||||
|
|
||||||
if (empty($warehouseId)) {
|
|
||||||
$warehouseName = $request->warehouse ?: '銷售倉庫';
|
|
||||||
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
|
|
||||||
$warehouseId = $warehouse->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalAmount = 0;
|
|
||||||
|
|
||||||
// 3. 處理訂單明細
|
|
||||||
foreach ($request->items as $itemData) {
|
|
||||||
// 透過介面查找產品
|
|
||||||
$product = $this->productService->findByExternalPosId($itemData['pos_product_id']);
|
|
||||||
|
|
||||||
if (!$product) {
|
|
||||||
throw new \Exception(
|
|
||||||
"Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$qty = $itemData['qty'];
|
|
||||||
$price = $itemData['price'];
|
|
||||||
$lineTotal = $qty * $price;
|
|
||||||
$totalAmount += $lineTotal;
|
|
||||||
|
|
||||||
// 建立訂單明細
|
|
||||||
SalesOrderItem::create([
|
|
||||||
'sales_order_id' => $order->id,
|
|
||||||
'product_id' => $product->id,
|
|
||||||
'product_name' => $product->name,
|
|
||||||
'quantity' => $qty,
|
|
||||||
'price' => $price,
|
|
||||||
'total' => $lineTotal,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 4. 扣除庫存(強制模式,允許負庫存)
|
|
||||||
$this->inventoryService->decreaseStock(
|
|
||||||
$product->id,
|
|
||||||
$warehouseId,
|
|
||||||
$qty,
|
|
||||||
"POS Order: " . $order->external_order_id,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->update(['total_amount' => $totalAmount]);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Order synced and stock deducted successfully',
|
'message' => $result['message'],
|
||||||
'order_id' => $order->id,
|
'order_id' => $result['order_id'] ?? null,
|
||||||
], 201);
|
], $statusCode);
|
||||||
});
|
|
||||||
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
|
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $e->errors()
|
||||||
|
], 422);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Order Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
// 系統層級的錯誤
|
||||||
return response()->json(['message' => 'Sync failed: ' . $e->getMessage()], 400);
|
Log::error('Order Sync Failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'payload' => $request->all()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sync failed: An unexpected error occurred.'
|
||||||
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Integration\Models\SalesOrder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SalesOrderController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示銷售訂單列表
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = SalesOrder::query();
|
||||||
|
|
||||||
|
// 搜尋篩選 (外部訂單號)
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->where('external_order_id', 'like', '%' . $request->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 來源篩選
|
||||||
|
if ($request->filled('source')) {
|
||||||
|
$query->where('source', $request->source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
$query->orderBy('sold_at', 'desc');
|
||||||
|
|
||||||
|
$orders = $query->paginate($request->input('per_page', 10))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('Integration/SalesOrders/Index', [
|
||||||
|
'orders' => $orders,
|
||||||
|
'filters' => $request->only(['search', 'per_page', 'source']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示單一銷售訂單詳情
|
||||||
|
*/
|
||||||
|
public function show(SalesOrder $salesOrder)
|
||||||
|
{
|
||||||
|
$salesOrder->load(['items']);
|
||||||
|
|
||||||
|
return Inertia::render('Integration/SalesOrders/Show', [
|
||||||
|
'order' => $salesOrder,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Integration\Requests\SyncVendingOrderRequest;
|
||||||
|
use App\Modules\Integration\Actions\SyncVendingOrderAction;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class VendingOrderSyncController extends Controller
|
||||||
|
{
|
||||||
|
protected $syncVendingOrderAction;
|
||||||
|
|
||||||
|
public function __construct(SyncVendingOrderAction $syncVendingOrderAction)
|
||||||
|
{
|
||||||
|
$this->syncVendingOrderAction = $syncVendingOrderAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收並同步販賣機交易訂單
|
||||||
|
*
|
||||||
|
* @param SyncVendingOrderRequest $request
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
public function store(SyncVendingOrderRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->syncVendingOrderAction->execute($request->validated());
|
||||||
|
|
||||||
|
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
'order_id' => $result['order_id'] ?? null,
|
||||||
|
], $statusCode);
|
||||||
|
|
||||||
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $e->errors()
|
||||||
|
], 422);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Vending Order Sync Failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'payload' => $request->all()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sync failed: An unexpected error occurred.'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ class IntegrationServiceProvider extends ServiceProvider
|
|||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
||||||
|
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
||||||
|
|
||||||
// 註冊 Middleware 別名
|
// 註冊 Middleware 別名
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class SalesOrder extends Model
|
|||||||
'total_amount',
|
'total_amount',
|
||||||
'sold_at',
|
'sold_at',
|
||||||
'raw_payload',
|
'raw_payload',
|
||||||
|
'source',
|
||||||
|
'source_label',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SyncOrderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'external_order_id' => 'required|string',
|
||||||
|
'warehouse' => 'nullable|string',
|
||||||
|
'warehouse_id' => 'nullable|integer',
|
||||||
|
'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.*.qty' => 'required|numeric|min:0.0001',
|
||||||
|
'items.*.price' => 'required|numeric|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SyncVendingOrderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 販賣機訂單同步的驗證規則
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'external_order_id' => 'required|string',
|
||||||
|
'machine_id' => 'nullable|string',
|
||||||
|
'warehouse' => 'nullable|string',
|
||||||
|
'warehouse_id' => 'nullable|integer',
|
||||||
|
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
|
||||||
|
'sold_at' => 'nullable|date',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_code' => 'required|string', // 使用 ERP 商品代碼
|
||||||
|
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||||
|
'items.*.price' => 'required|numeric|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Modules\Integration\Controllers\ProductSyncController;
|
use App\Modules\Integration\Controllers\ProductSyncController;
|
||||||
use App\Modules\Integration\Controllers\OrderSyncController;
|
use App\Modules\Integration\Controllers\OrderSyncController;
|
||||||
|
use App\Modules\Integration\Controllers\VendingOrderSyncController;
|
||||||
|
|
||||||
Route::prefix('api/v1/integration')
|
Route::prefix('api/v1/integration')
|
||||||
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
|
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
||||||
Route::post('orders', [OrderSyncController::class, 'store']);
|
Route::post('orders', [OrderSyncController::class, 'store']);
|
||||||
|
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|||||||
11
app/Modules/Integration/Routes/web.php
Normal file
11
app/Modules/Integration/Routes/web.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Modules\Integration\Controllers\SalesOrderController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'verified'])->group(function () {
|
||||||
|
Route::prefix('integration')->name('integration.')->group(function () {
|
||||||
|
Route::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index');
|
||||||
|
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,4 +22,20 @@ interface ProductServiceInterface
|
|||||||
* @return object|null
|
* @return object|null
|
||||||
*/
|
*/
|
||||||
public function findByExternalPosId(string $externalPosId);
|
public function findByExternalPosId(string $externalPosId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過多個外部 POS ID 查找產品。
|
||||||
|
*
|
||||||
|
* @param array $externalPosIds
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public function findByExternalPosIds(array $externalPosIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||||
|
*
|
||||||
|
* @param array $codes
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public function findByCodes(array $codes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,4 +88,26 @@ class ProductService implements ProductServiceInterface
|
|||||||
{
|
{
|
||||||
return Product::where('external_pos_id', $externalPosId)->first();
|
return Product::where('external_pos_id', $externalPosId)->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過多個外部 POS ID 查找產品。
|
||||||
|
*
|
||||||
|
* @param array $externalPosIds
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public function findByExternalPosIds(array $externalPosIds)
|
||||||
|
{
|
||||||
|
return Product::whereIn('external_pos_id', $externalPosIds)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||||
|
*
|
||||||
|
* @param array $codes
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public function findByCodes(array $codes)
|
||||||
|
{
|
||||||
|
return Product::whereIn('code', $codes)->get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(prepend: [
|
$middleware->web(prepend: [
|
||||||
\App\Http\Middleware\UniversalTenancy::class,
|
\App\Http\Middleware\UniversalTenancy::class,
|
||||||
]);
|
]);
|
||||||
|
$middleware->api(prepend: [
|
||||||
|
\App\Http\Middleware\UniversalTenancy::class,
|
||||||
|
]);
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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::create('notifications', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('type');
|
||||||
|
$table->morphs('notifiable');
|
||||||
|
$table->text('data');
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 為 sales_orders 新增來源標記欄位,支援多來源 API 寫入
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('sales_orders', function (Blueprint $table) {
|
||||||
|
$table->string('source')->default('pos')->after('raw_payload');
|
||||||
|
$table->string('source_label')->nullable()->after('source');
|
||||||
|
$table->index('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('sales_orders', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['source']);
|
||||||
|
$table->dropColumn(['source', 'source_label']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -139,6 +139,9 @@ class PermissionSeeder extends Seeder
|
|||||||
'store_requisitions.delete' => '刪除',
|
'store_requisitions.delete' => '刪除',
|
||||||
'store_requisitions.approve' => '核準',
|
'store_requisitions.approve' => '核準',
|
||||||
'store_requisitions.cancel' => '取消',
|
'store_requisitions.cancel' => '取消',
|
||||||
|
|
||||||
|
// 銷售訂單管理 (API)
|
||||||
|
'sales_orders.view' => '檢視',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $name => $displayName) {
|
foreach ($permissions as $name => $displayName) {
|
||||||
@@ -222,6 +225,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'utility_fees.view',
|
'utility_fees.view',
|
||||||
'inventory_report.view',
|
'inventory_report.view',
|
||||||
'accounting.view',
|
'accounting.view',
|
||||||
|
'sales_orders.view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 將現有使用者設為 super-admin(如果存在的話)
|
// 將現有使用者設為 super-admin(如果存在的話)
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/sales/imports",
|
route: "/sales/imports",
|
||||||
permission: "sales_imports.view",
|
permission: "sales_imports.view",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sales-order-list",
|
||||||
|
label: "銷售訂單管理",
|
||||||
|
icon: <ShoppingCart className="h-4 w-4" />,
|
||||||
|
route: "/integration/sales-orders",
|
||||||
|
permission: "sales_orders.view",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
292
resources/js/Pages/Integration/SalesOrders/Index.tsx
Normal file
292
resources/js/Pages/Integration/SalesOrders/Index.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Head, Link, router } from "@inertiajs/react";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
TrendingUp,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import Pagination from "@/Components/shared/Pagination";
|
||||||
|
import { formatDate } from "@/lib/date";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import { Can } from "@/Components/Permission/Can";
|
||||||
|
|
||||||
|
interface SalesOrder {
|
||||||
|
id: number;
|
||||||
|
external_order_id: string;
|
||||||
|
status: string;
|
||||||
|
payment_method: string;
|
||||||
|
total_amount: string;
|
||||||
|
sold_at: string;
|
||||||
|
created_at: string;
|
||||||
|
source: string;
|
||||||
|
source_label: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orders: {
|
||||||
|
data: SalesOrder[];
|
||||||
|
total: number;
|
||||||
|
per_page: number;
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
links: PaginationLink[];
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
search?: string;
|
||||||
|
per_page?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 來源篩選選項
|
||||||
|
const sourceOptions = [
|
||||||
|
{ label: "全部來源", value: "" },
|
||||||
|
{ label: "POS 收銀機", value: "pos" },
|
||||||
|
{ label: "販賣機", value: "vending" },
|
||||||
|
{ label: "手動匯入", value: "manual_import" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSourceLabel = (source: string): string => {
|
||||||
|
switch (source) {
|
||||||
|
case 'pos': return 'POS';
|
||||||
|
case 'vending': return '販賣機';
|
||||||
|
case 'manual_import': return '手動匯入';
|
||||||
|
default: return source;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceVariant = (source: string): StatusVariant => {
|
||||||
|
switch (source) {
|
||||||
|
case 'pos': return 'info';
|
||||||
|
case 'vending': return 'warning';
|
||||||
|
case 'manual_import': return 'neutral';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string): StatusVariant => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'pending': return 'warning';
|
||||||
|
case 'cancelled': return 'destructive';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return "已完成";
|
||||||
|
case 'pending': return "待處理";
|
||||||
|
case 'cancelled': return "已取消";
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePerPageChange = (value: string) => {
|
||||||
|
setPerPage(value);
|
||||||
|
router.get(
|
||||||
|
route("integration.sales-orders.index"),
|
||||||
|
{ ...filters, per_page: value, page: 1 },
|
||||||
|
{ preserveState: false, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIndex = (orders.current_page - 1) * orders.per_page + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "銷售管理", href: "#" },
|
||||||
|
{
|
||||||
|
label: "銷售訂單管理",
|
||||||
|
href: route("integration.sales-orders.index"),
|
||||||
|
isPage: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="銷售訂單管理" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-6 w-6 text-primary-main" />
|
||||||
|
銷售訂單管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
檢視從 POS 或販賣機同步進來的銷售訂單紀錄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 篩選列 */}
|
||||||
|
<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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
options={sourceOptions}
|
||||||
|
className="w-[160px] h-9"
|
||||||
|
showSearch={false}
|
||||||
|
placeholder="篩選來源"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="搜尋外部訂單號 (External Order ID)..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary h-9"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
|
<TableHead>外部訂單號</TableHead>
|
||||||
|
<TableHead className="text-center">來源</TableHead>
|
||||||
|
<TableHead className="text-center">狀態</TableHead>
|
||||||
|
<TableHead>付款方式</TableHead>
|
||||||
|
<TableHead className="text-right">總金額</TableHead>
|
||||||
|
<TableHead>銷售時間</TableHead>
|
||||||
|
<TableHead className="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orders.data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
|
||||||
|
無符合條件的資料
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
orders.data.map((order, index) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="text-gray-500 font-medium text-center">
|
||||||
|
{startIndex + index}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{order.external_order_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusBadge variant={getSourceVariant(order.source)}>
|
||||||
|
{order.source_label || getSourceLabel(order.source)}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusBadge variant={getStatusVariant(order.status)}>
|
||||||
|
{getStatusLabel(order.status)}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-600">
|
||||||
|
{order.payment_method || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
${formatNumber(parseFloat(order.total_amount))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{formatDate(order.sold_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Can permission="sales_orders.view">
|
||||||
|
<Link href={route("integration.sales-orders.show", order.id)}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="檢視明細"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分頁 */}
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" },
|
||||||
|
]}
|
||||||
|
className="w-[90px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
共 {orders.total} 筆紀錄
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Pagination links={orders.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
resources/js/Pages/Integration/SalesOrders/Show.tsx
Normal file
215
resources/js/Pages/Integration/SalesOrders/Show.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { ArrowLeft, TrendingUp, Package, CreditCard, Calendar, FileJson } from "lucide-react";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Head, Link } from "@inertiajs/react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||||
|
import { formatDate } from "@/lib/date";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import CopyButton from "@/Components/shared/CopyButton";
|
||||||
|
|
||||||
|
interface SalesOrderItem {
|
||||||
|
id: number;
|
||||||
|
product_name: string;
|
||||||
|
quantity: string;
|
||||||
|
price: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesOrder {
|
||||||
|
id: number;
|
||||||
|
external_order_id: string;
|
||||||
|
status: string;
|
||||||
|
payment_method: string;
|
||||||
|
total_amount: string;
|
||||||
|
sold_at: string;
|
||||||
|
created_at: string;
|
||||||
|
raw_payload: any;
|
||||||
|
items: SalesOrderItem[];
|
||||||
|
source: string;
|
||||||
|
source_label: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSourceDisplay = (source: string, sourceLabel: string | null): string => {
|
||||||
|
const base = source === 'pos' ? 'POS 收銀機'
|
||||||
|
: source === 'vending' ? '販賣機'
|
||||||
|
: source === 'manual_import' ? '手動匯入'
|
||||||
|
: source;
|
||||||
|
return sourceLabel ? `${base} (${sourceLabel})` : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: SalesOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string): StatusVariant => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'pending': return 'warning';
|
||||||
|
case 'cancelled': return 'destructive';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return "已完成";
|
||||||
|
case 'pending': return "待處理";
|
||||||
|
case 'cancelled': return "已取消";
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SalesOrderShow({ order }: Props) {
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "銷售管理", href: "#" },
|
||||||
|
{ label: "銷售訂單管理", href: route("integration.sales-orders.index") },
|
||||||
|
{ label: `訂單詳情 (#${order.external_order_id})`, href: "#", isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title={`銷售訂單詳情 - ${order.external_order_id}`} />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href={route("integration.sales-orders.index")}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 button-outlined-primary mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回銷售訂單列表
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-6 w-6 text-primary-main" />
|
||||||
|
查看銷售訂單
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">外部單號:{order.external_order_id}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge variant={getStatusVariant(order.status)}>
|
||||||
|
{getStatusLabel(order.status)}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 左側:基本資訊與明細 */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* 基本資訊卡片 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-primary-main" />
|
||||||
|
基本資訊
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">外部訂單編號</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-mono font-medium text-gray-900">{order.external_order_id}</span>
|
||||||
|
<CopyButton text={order.external_order_id} label="複製單號" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">銷售時間</span>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-gray-900">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
{formatDate(order.sold_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">付款方式</span>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-gray-900">
|
||||||
|
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||||
|
{order.payment_method || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">同步時間</span>
|
||||||
|
<span className="font-medium text-gray-900">{formatDate(order.created_at as any)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">訂單來源</span>
|
||||||
|
<span className="font-medium text-gray-900">{getSourceDisplay(order.source, order.source_label)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 項目清單卡片 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-0">銷售項目清單</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||||
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
|
<TableHead>商品名稱</TableHead>
|
||||||
|
<TableHead className="text-right">數量</TableHead>
|
||||||
|
<TableHead className="text-right">單價</TableHead>
|
||||||
|
<TableHead className="text-right">小計</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{order.items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-gray-500 text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{formatNumber(parseFloat(item.quantity))}</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600">${formatNumber(parseFloat(item.price))}</TableCell>
|
||||||
|
<TableCell className="text-right font-bold text-primary-main">${formatNumber(parseFloat(item.total))}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</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-end w-full">
|
||||||
|
<span className="text-sm text-gray-500 font-medium mb-1">訂單總金額</span>
|
||||||
|
<span className="text-2xl font-black text-primary-main">
|
||||||
|
${formatNumber(parseFloat(order.total_amount))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右側:原始資料 (Raw Payload) */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<FileJson className="h-5 w-5 text-primary-main" />
|
||||||
|
API 原始資料
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
此區塊顯示同步時接收到的完整原始 JSON 內容,可用於排查資料問題。
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-4 overflow-auto max-h-[600px]">
|
||||||
|
<pre className="text-xs text-slate-300 font-mono">
|
||||||
|
{JSON.stringify(order.raw_payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -140,6 +140,37 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 錯誤回應 (HTTP 422 Unprocessable Entity - 驗證失敗)
|
||||||
|
|
||||||
|
當傳入資料格式有誤、商品編號不存在,或是該訂單正在處理中被系統鎖定時返回。
|
||||||
|
|
||||||
|
- **`message`** (字串): 錯誤摘要。
|
||||||
|
- **`errors`** (物件): 具體的錯誤明細列表,能一次性回報多個商品不存在或其它欄位錯誤。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Validation failed",
|
||||||
|
"errors": {
|
||||||
|
"items": [
|
||||||
|
"The following products are not found: POS-999, POS-888. Please sync products first."
|
||||||
|
],
|
||||||
|
"external_order_id": [
|
||||||
|
"The order ORD-01 is currently being processed by another transaction. Please try again later."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 錯誤回應 (HTTP 500 Internal Server Error - 伺服器異常)
|
||||||
|
|
||||||
|
當系統發生預期外的錯誤(如資料庫連線失敗)時返回。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Sync failed: An unexpected error occurred."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 幂等性說明 (Idempotency)
|
## 幂等性說明 (Idempotency)
|
||||||
|
|||||||
151
tests/Feature/Integration/ProductSyncTest.php
Normal file
151
tests/Feature/Integration/ProductSyncTest.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?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 Stancl\Tenancy\Facades\Tenancy;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ProductSyncTest extends TestCase
|
||||||
|
{
|
||||||
|
protected $tenant;
|
||||||
|
protected $user;
|
||||||
|
protected $domain;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// 每次測試前重置資料庫(在測試環境)
|
||||||
|
\Artisan::call('migrate:fresh');
|
||||||
|
|
||||||
|
$this->domain = 'product-test-' . Str::random(8) . '.erp.local';
|
||||||
|
$tenantId = 'test_tenant_p_' . Str::random(8);
|
||||||
|
|
||||||
|
// 建立租戶
|
||||||
|
tenancy()->central(function () use ($tenantId) {
|
||||||
|
$this->tenant = Tenant::create([
|
||||||
|
'id' => $tenantId,
|
||||||
|
'name' => 'Product Test Tenant',
|
||||||
|
]);
|
||||||
|
$this->tenant->domains()->create(['domain' => $this->domain]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化租戶並遷移
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
\Artisan::call('tenants:migrate');
|
||||||
|
|
||||||
|
// 建立測試使用者與分類
|
||||||
|
$this->user = User::factory()->create(['name' => 'Test Admin']);
|
||||||
|
Category::create(['name' => '測試分類', 'code' => 'TEST-CAT']);
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ($this->tenant) {
|
||||||
|
$this->tenant->delete();
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試產品同步新增功能
|
||||||
|
*/
|
||||||
|
public function test_product_sync_can_create_new_product()
|
||||||
|
{
|
||||||
|
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'external_pos_id' => 'POS-NEW-999',
|
||||||
|
'name' => '全新同步商品',
|
||||||
|
'price' => 299,
|
||||||
|
'barcode' => '1234567890123',
|
||||||
|
'category' => '測試分類',
|
||||||
|
'cost_price' => 150
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Tenant-Domain' => $this->domain,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('message', 'Product synced successfully');
|
||||||
|
|
||||||
|
// 驗證租戶資料庫
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
$this->assertDatabaseHas('products', [
|
||||||
|
'external_pos_id' => 'POS-NEW-999',
|
||||||
|
'name' => '全新同步商品',
|
||||||
|
'price' => 299,
|
||||||
|
]);
|
||||||
|
tenancy()->end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試產品同步更新功能 (Upsert)
|
||||||
|
*/
|
||||||
|
public function test_product_sync_can_update_existing_product()
|
||||||
|
{
|
||||||
|
// 先建立一個既有商品
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
Product::create([
|
||||||
|
'name' => '舊商品名稱',
|
||||||
|
'code' => 'OLD-CODE',
|
||||||
|
'external_pos_id' => 'POS-EXIST-001',
|
||||||
|
'price' => 100,
|
||||||
|
'category_id' => Category::first()->id,
|
||||||
|
]);
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'external_pos_id' => 'POS-EXIST-001',
|
||||||
|
'name' => '更新後的商品名稱',
|
||||||
|
'price' => 888,
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Tenant-Domain' => $this->domain,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
$this->assertDatabaseHas('products', [
|
||||||
|
'external_pos_id' => 'POS-EXIST-001',
|
||||||
|
'name' => '更新後的商品名稱',
|
||||||
|
'price' => 888,
|
||||||
|
]);
|
||||||
|
tenancy()->end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試產品同步驗證失敗
|
||||||
|
*/
|
||||||
|
public function test_product_sync_validation_fails_without_required_fields()
|
||||||
|
{
|
||||||
|
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'external_pos_id' => '', // 缺少此欄位
|
||||||
|
'name' => '', // 缺少此欄位
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Tenant-Domain' => $this->domain,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['external_pos_id', 'name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
tests/manual/test_integration_api.sh
Executable file
83
tests/manual/test_integration_api.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Star ERP 整合 API 手動測試腳本
|
||||||
|
# 用途:手動驗證商品同步、POS 訂單、販賣機訂單 API
|
||||||
|
|
||||||
|
# --- 設定區 ---
|
||||||
|
BASE_URL=${1:-"http://localhost:8081"}
|
||||||
|
TOKEN=${2:-"YOUR_TOKEN_HERE"}
|
||||||
|
TENANT_DOMAIN="localhost"
|
||||||
|
|
||||||
|
echo "=== Star ERP Integration API Test ==="
|
||||||
|
echo "Target URL: $BASE_URL"
|
||||||
|
echo "Tenant Domain: $TENANT_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 顏色定義
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# 輔助函式:執行 curl
|
||||||
|
function call_api() {
|
||||||
|
local method=$1
|
||||||
|
local path=$2
|
||||||
|
local data=$3
|
||||||
|
local title=$4
|
||||||
|
|
||||||
|
echo -e "Testing: ${GREEN}$title${NC} ($path)"
|
||||||
|
|
||||||
|
curl -s -X "$method" "$BASE_URL$path" \
|
||||||
|
-H "X-Tenant-Domain: $TENANT_DOMAIN" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" | jq .
|
||||||
|
|
||||||
|
echo -e "-----------------------------------\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 商品同步 (Upsert)
|
||||||
|
PRODUCT_ID="TEST-AUTO-$(date +%s)"
|
||||||
|
call_api "POST" "/api/v1/integration/products/upsert" "{
|
||||||
|
\"external_pos_id\": \"$PRODUCT_ID\",
|
||||||
|
\"name\": \"自動測試商品 $(date +%H%M%S)\",
|
||||||
|
\"price\": 150,
|
||||||
|
\"barcode\": \"690123456789\",
|
||||||
|
\"category\": \"飲品\",
|
||||||
|
\"cost_price\": 80
|
||||||
|
}" "Product Upsert"
|
||||||
|
|
||||||
|
# 2. POS 訂單同步
|
||||||
|
call_api "POST" "/api/v1/integration/orders" "{
|
||||||
|
\"external_order_id\": \"POS-AUTO-$(date +%s)\",
|
||||||
|
\"status\": \"completed\",
|
||||||
|
\"payment_method\": \"cash\",
|
||||||
|
\"sold_at\": \"$(date -Iseconds)\",
|
||||||
|
\"warehouse_id\": 2,
|
||||||
|
\"items\": [
|
||||||
|
{
|
||||||
|
\"pos_product_id\": \"TEST-FINAL-VERIFY\",
|
||||||
|
\"qty\": 1,
|
||||||
|
\"price\": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}" "POS Order Sync"
|
||||||
|
|
||||||
|
# 3. 販賣機訂單同步
|
||||||
|
call_api "POST" "/api/v1/integration/vending/orders" "{
|
||||||
|
\"external_order_id\": \"VEND-AUTO-$(date +%s)\",
|
||||||
|
\"machine_id\": \"Vending-Machine-Test-01\",
|
||||||
|
\"payment_method\": \"line_pay\",
|
||||||
|
\"sold_at\": \"$(date -Iseconds)\",
|
||||||
|
\"warehouse_id\": 2,
|
||||||
|
\"items\": [
|
||||||
|
{
|
||||||
|
\"product_code\": \"FINAL-OK\",
|
||||||
|
\"qty\": 2,
|
||||||
|
\"price\": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}" "Vending Machine Order Sync"
|
||||||
|
|
||||||
|
echo "Test Completed."
|
||||||
Reference in New Issue
Block a user