From 2f30a78118003e4f5cb6d0fe4399a770417c2411 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 23 Feb 2026 13:27:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(integration):=20=E5=AF=A6=E4=BD=9C?= =?UTF-8?q?=E4=B8=A6=E6=B8=AC=E8=A9=A6=20POS=20=E8=88=87=E8=B2=A9=E8=B3=A3?= =?UTF-8?q?=E6=A9=9F=E8=A8=82=E5=96=AE=E5=90=8C=E6=AD=A5=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要變更: - 實作 POS 與販賣機訂單同步邏輯,支援多租戶與 Sanctum 驗證。 - 修正多租戶識別中間件與 Sanctum 驗證順序問題。 - 切換快取驅動至 Redis 以支援 Tenancy 標籤功能。 - 新增商品同步 API (Upsert) 及相關單元測試。 - 新增手動測試腳本 tests/manual/test_integration_api.sh。 - 前端新增銷售訂單來源篩選與欄位顯示。 --- .../Integration/Actions/SyncOrderAction.php | 156 ++++++++++ .../Actions/SyncVendingOrderAction.php | 152 +++++++++ .../Controllers/OrderSyncController.php | 138 +++------ .../Controllers/SalesOrderController.php | 52 ++++ .../VendingOrderSyncController.php | 56 ++++ .../IntegrationServiceProvider.php | 1 + app/Modules/Integration/Models/SalesOrder.php | 2 + .../Integration/Requests/SyncOrderRequest.php | 36 +++ .../Requests/SyncVendingOrderRequest.php | 37 +++ app/Modules/Integration/Routes/api.php | 2 + app/Modules/Integration/Routes/web.php | 11 + .../Contracts/ProductServiceInterface.php | 16 + .../Inventory/Services/ProductService.php | 22 ++ bootstrap/app.php | 3 + ...2_23_120000_create_notifications_table.php | 31 ++ ...2_23_114909_add_source_to_sales_orders.php | 31 ++ database/seeders/PermissionSeeder.php | 4 + resources/js/Layouts/AuthenticatedLayout.tsx | 7 + .../Pages/Integration/SalesOrders/Index.tsx | 292 ++++++++++++++++++ .../js/Pages/Integration/SalesOrders/Show.tsx | 215 +++++++++++++ resources/markdown/manual/api-integration.md | 31 ++ tests/Feature/Integration/ProductSyncTest.php | 151 +++++++++ tests/manual/test_integration_api.sh | 83 +++++ 23 files changed, 1429 insertions(+), 100 deletions(-) create mode 100644 app/Modules/Integration/Actions/SyncOrderAction.php create mode 100644 app/Modules/Integration/Actions/SyncVendingOrderAction.php create mode 100644 app/Modules/Integration/Controllers/SalesOrderController.php create mode 100644 app/Modules/Integration/Controllers/VendingOrderSyncController.php create mode 100644 app/Modules/Integration/Requests/SyncOrderRequest.php create mode 100644 app/Modules/Integration/Requests/SyncVendingOrderRequest.php create mode 100644 app/Modules/Integration/Routes/web.php create mode 100644 database/migrations/2026_02_23_120000_create_notifications_table.php create mode 100644 database/migrations/tenant/2026_02_23_114909_add_source_to_sales_orders.php create mode 100644 resources/js/Pages/Integration/SalesOrders/Index.tsx create mode 100644 resources/js/Pages/Integration/SalesOrders/Show.tsx create mode 100644 tests/Feature/Integration/ProductSyncTest.php create mode 100755 tests/manual/test_integration_api.sh diff --git a/app/Modules/Integration/Actions/SyncOrderAction.php b/app/Modules/Integration/Actions/SyncOrderAction.php new file mode 100644 index 0000000..fe668a9 --- /dev/null +++ b/app/Modules/Integration/Actions/SyncOrderAction.php @@ -0,0 +1,156 @@ +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(); + } + } +} diff --git a/app/Modules/Integration/Actions/SyncVendingOrderAction.php b/app/Modules/Integration/Actions/SyncVendingOrderAction.php new file mode 100644 index 0000000..4963287 --- /dev/null +++ b/app/Modules/Integration/Actions/SyncVendingOrderAction.php @@ -0,0 +1,152 @@ +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(); + } + } +} diff --git a/app/Modules/Integration/Controllers/OrderSyncController.php b/app/Modules/Integration/Controllers/OrderSyncController.php index 1dd09eb..0436fc9 100644 --- a/app/Modules/Integration/Controllers/OrderSyncController.php +++ b/app/Modules/Integration/Controllers/OrderSyncController.php @@ -3,120 +3,58 @@ namespace App\Modules\Integration\Controllers; use App\Http\Controllers\Controller; -use Illuminate\Http\Request; -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 App\Modules\Integration\Requests\SyncOrderRequest; +use App\Modules\Integration\Actions\SyncOrderAction; +use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; class OrderSyncController extends Controller { - protected $inventoryService; - protected $productService; + protected $syncOrderAction; - public function __construct( - InventoryServiceInterface $inventoryService, - ProductServiceInterface $productService - ) { - $this->inventoryService = $inventoryService; - $this->productService = $productService; + public function __construct(SyncOrderAction $syncOrderAction) + { + $this->syncOrderAction = $syncOrderAction; } - public function store(Request $request) + /** + * 接收並同步外部交易訂單 + * + * @param SyncOrderRequest $request + * @return JsonResponse + */ + public function store(SyncOrderRequest $request): JsonResponse { - // 冪等性處理:若訂單已存在,回傳已建立的訂單資訊 - $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', - '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', - ]); - try { - return DB::transaction(function () use ($request) { - // 1. 建立訂單 - $order = SalesOrder::create([ - '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(), - ]); + // 所有驗證皆已透過 SyncOrderRequest 自動處理 + // 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動) + $result = $this->syncOrderAction->execute($request->validated()); - // 2. 查找或建立倉庫 - $warehouseId = $request->warehouse_id; + $statusCode = ($result['status'] === 'exists') ? 200 : 201; - if (empty($warehouseId)) { - $warehouseName = $request->warehouse ?: '銷售倉庫'; - $warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName); - $warehouseId = $warehouse->id; - } + return response()->json([ + 'message' => $result['message'], + 'order_id' => $result['order_id'] ?? null, + ], $statusCode); - $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([ - 'message' => 'Order synced and stock deducted successfully', - 'order_id' => $order->id, - ], 201); - }); + } catch (\Illuminate\Validation\ValidationException $e) { + // 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時) + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $e->errors() + ], 422); } 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); } } } diff --git a/app/Modules/Integration/Controllers/SalesOrderController.php b/app/Modules/Integration/Controllers/SalesOrderController.php new file mode 100644 index 0000000..3eccaa1 --- /dev/null +++ b/app/Modules/Integration/Controllers/SalesOrderController.php @@ -0,0 +1,52 @@ +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, + ]); + } +} diff --git a/app/Modules/Integration/Controllers/VendingOrderSyncController.php b/app/Modules/Integration/Controllers/VendingOrderSyncController.php new file mode 100644 index 0000000..2b85378 --- /dev/null +++ b/app/Modules/Integration/Controllers/VendingOrderSyncController.php @@ -0,0 +1,56 @@ +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); + } + } +} diff --git a/app/Modules/Integration/IntegrationServiceProvider.php b/app/Modules/Integration/IntegrationServiceProvider.php index 659fdbb..3aa3cbc 100644 --- a/app/Modules/Integration/IntegrationServiceProvider.php +++ b/app/Modules/Integration/IntegrationServiceProvider.php @@ -14,6 +14,7 @@ class IntegrationServiceProvider extends ServiceProvider public function boot() { $this->loadRoutesFrom(__DIR__ . '/Routes/api.php'); + $this->loadRoutesFrom(__DIR__ . '/Routes/web.php'); $this->loadMigrationsFrom(__DIR__ . '/Database/Migrations'); // 註冊 Middleware 別名 diff --git a/app/Modules/Integration/Models/SalesOrder.php b/app/Modules/Integration/Models/SalesOrder.php index 450e560..1a67e96 100644 --- a/app/Modules/Integration/Models/SalesOrder.php +++ b/app/Modules/Integration/Models/SalesOrder.php @@ -16,6 +16,8 @@ class SalesOrder extends Model 'total_amount', 'sold_at', 'raw_payload', + 'source', + 'source_label', ]; protected $casts = [ diff --git a/app/Modules/Integration/Requests/SyncOrderRequest.php b/app/Modules/Integration/Requests/SyncOrderRequest.php new file mode 100644 index 0000000..5912fe4 --- /dev/null +++ b/app/Modules/Integration/Requests/SyncOrderRequest.php @@ -0,0 +1,36 @@ +|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', + ]; + } +} diff --git a/app/Modules/Integration/Requests/SyncVendingOrderRequest.php b/app/Modules/Integration/Requests/SyncVendingOrderRequest.php new file mode 100644 index 0000000..8cb7561 --- /dev/null +++ b/app/Modules/Integration/Requests/SyncVendingOrderRequest.php @@ -0,0 +1,37 @@ +|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', + ]; + } +} diff --git a/app/Modules/Integration/Routes/api.php b/app/Modules/Integration/Routes/api.php index 3479f0b..bf7a190 100644 --- a/app/Modules/Integration/Routes/api.php +++ b/app/Modules/Integration/Routes/api.php @@ -3,10 +3,12 @@ use Illuminate\Support\Facades\Route; use App\Modules\Integration\Controllers\ProductSyncController; use App\Modules\Integration\Controllers\OrderSyncController; +use App\Modules\Integration\Controllers\VendingOrderSyncController; Route::prefix('api/v1/integration') ->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum']) ->group(function () { Route::post('products/upsert', [ProductSyncController::class, 'upsert']); Route::post('orders', [OrderSyncController::class, 'store']); + Route::post('vending/orders', [VendingOrderSyncController::class, 'store']); }); diff --git a/app/Modules/Integration/Routes/web.php b/app/Modules/Integration/Routes/web.php new file mode 100644 index 0000000..6139c1f --- /dev/null +++ b/app/Modules/Integration/Routes/web.php @@ -0,0 +1,11 @@ +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'); + }); +}); diff --git a/app/Modules/Inventory/Contracts/ProductServiceInterface.php b/app/Modules/Inventory/Contracts/ProductServiceInterface.php index ca5314b..a4cd9ff 100644 --- a/app/Modules/Inventory/Contracts/ProductServiceInterface.php +++ b/app/Modules/Inventory/Contracts/ProductServiceInterface.php @@ -22,4 +22,20 @@ interface ProductServiceInterface * @return object|null */ 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); } diff --git a/app/Modules/Inventory/Services/ProductService.php b/app/Modules/Inventory/Services/ProductService.php index cddd0f5..5b11e82 100644 --- a/app/Modules/Inventory/Services/ProductService.php +++ b/app/Modules/Inventory/Services/ProductService.php @@ -88,4 +88,26 @@ class ProductService implements ProductServiceInterface { 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(); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 819f2d6..f2799a5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,9 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(prepend: [ \App\Http\Middleware\UniversalTenancy::class, ]); + $middleware->api(prepend: [ + \App\Http\Middleware\UniversalTenancy::class, + ]); $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, ]); diff --git a/database/migrations/2026_02_23_120000_create_notifications_table.php b/database/migrations/2026_02_23_120000_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_02_23_120000_create_notifications_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/tenant/2026_02_23_114909_add_source_to_sales_orders.php b/database/migrations/tenant/2026_02_23_114909_add_source_to_sales_orders.php new file mode 100644 index 0000000..b1d4dcc --- /dev/null +++ b/database/migrations/tenant/2026_02_23_114909_add_source_to_sales_orders.php @@ -0,0 +1,31 @@ +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']); + }); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 5ecfb78..5004632 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -139,6 +139,9 @@ class PermissionSeeder extends Seeder 'store_requisitions.delete' => '刪除', 'store_requisitions.approve' => '核準', 'store_requisitions.cancel' => '取消', + + // 銷售訂單管理 (API) + 'sales_orders.view' => '檢視', ]; foreach ($permissions as $name => $displayName) { @@ -222,6 +225,7 @@ class PermissionSeeder extends Seeder 'utility_fees.view', 'inventory_report.view', 'accounting.view', + 'sales_orders.view', ]); // 將現有使用者設為 super-admin(如果存在的話) diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 91f0c2a..7082da8 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -190,6 +190,13 @@ export default function AuthenticatedLayout({ route: "/sales/imports", permission: "sales_imports.view", }, + { + id: "sales-order-list", + label: "銷售訂單管理", + icon: , + route: "/integration/sales-orders", + permission: "sales_orders.view", + }, ], }, { diff --git a/resources/js/Pages/Integration/SalesOrders/Index.tsx b/resources/js/Pages/Integration/SalesOrders/Index.tsx new file mode 100644 index 0000000..c1dc593 --- /dev/null +++ b/resources/js/Pages/Integration/SalesOrders/Index.tsx @@ -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(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 ( + + + +
+
+
+

+ + 銷售訂單管理 +

+

+ 檢視從 POS 或販賣機同步進來的銷售訂單紀錄 +

+
+
+ + {/* 篩選列 */} +
+
+ + 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="篩選來源" + /> +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="搜尋外部訂單號 (External Order ID)..." + className="h-9" + /> + +
+
+
+ + {/* 表格 */} +
+ + + + # + 外部訂單號 + 來源 + 狀態 + 付款方式 + 總金額 + 銷售時間 + 操作 + + + + {orders.data.length === 0 ? ( + + + 無符合條件的資料 + + + ) : ( + orders.data.map((order, index) => ( + + + {startIndex + index} + + + {order.external_order_id} + + + + {order.source_label || getSourceLabel(order.source)} + + + + + {getStatusLabel(order.status)} + + + + {order.payment_method || "—"} + + + ${formatNumber(parseFloat(order.total_amount))} + + + {formatDate(order.sold_at)} + + +
+ + + + + +
+
+
+ )) + )} +
+
+
+ + {/* 分頁 */} +
+
+
+ 每頁顯示 + + +
+ + 共 {orders.total} 筆紀錄 + +
+ +
+
+
+ ); +} diff --git a/resources/js/Pages/Integration/SalesOrders/Show.tsx b/resources/js/Pages/Integration/SalesOrders/Show.tsx new file mode 100644 index 0000000..9db184d --- /dev/null +++ b/resources/js/Pages/Integration/SalesOrders/Show.tsx @@ -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 ( + + + +
+ {/* Header */} +
+ + + + +
+
+

+ + 查看銷售訂單 +

+

外部單號:{order.external_order_id}

+
+ + {getStatusLabel(order.status)} + +
+
+ +
+ {/* 左側:基本資訊與明細 */} +
+ {/* 基本資訊卡片 */} +
+

+ + 基本資訊 +

+
+
+ 外部訂單編號 +
+ {order.external_order_id} + +
+
+
+ 銷售時間 +
+ + {formatDate(order.sold_at)} +
+
+
+ 付款方式 +
+ + {order.payment_method || "—"} +
+
+
+ 同步時間 + {formatDate(order.created_at as any)} +
+
+ 訂單來源 + {getSourceDisplay(order.source, order.source_label)} +
+
+
+ + {/* 項目清單卡片 */} +
+
+

銷售項目清單

+
+
+
+ + + + # + 商品名稱 + 數量 + 單價 + 小計 + + + + {order.items.map((item, index) => ( + + {index + 1} + {item.product_name} + {formatNumber(parseFloat(item.quantity))} + ${formatNumber(parseFloat(item.price))} + ${formatNumber(parseFloat(item.total))} + + ))} + +
+
+
+
+
+ 訂單總金額 + + ${formatNumber(parseFloat(order.total_amount))} + +
+
+
+
+
+
+ + {/* 右側:原始資料 (Raw Payload) */} +
+
+

+ + API 原始資料 +

+

+ 此區塊顯示同步時接收到的完整原始 JSON 內容,可用於排查資料問題。 +

+
+
+                                    {JSON.stringify(order.raw_payload, null, 2)}
+                                
+
+
+
+
+
+
+ ); +} diff --git a/resources/markdown/manual/api-integration.md b/resources/markdown/manual/api-integration.md index 9f61a1f..dfab930 100644 --- a/resources/markdown/manual/api-integration.md +++ b/resources/markdown/manual/api-integration.md @@ -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) diff --git a/tests/Feature/Integration/ProductSyncTest.php b/tests/Feature/Integration/ProductSyncTest.php new file mode 100644 index 0000000..5391648 --- /dev/null +++ b/tests/Feature/Integration/ProductSyncTest.php @@ -0,0 +1,151 @@ +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']); + } +} diff --git a/tests/manual/test_integration_api.sh b/tests/manual/test_integration_api.sh new file mode 100755 index 0000000..a60f2d7 --- /dev/null +++ b/tests/manual/test_integration_api.sh @@ -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."