From a05acd96dca7df0b6999a4212ceea28244344852 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 23 Feb 2026 10:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(integration):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=A4=96=E9=83=A8=20API=20=E5=B0=8D=E6=8E=A5=E9=82=8F=E8=BC=AF?= =?UTF-8?q?=E8=88=87=E5=AE=89=E5=85=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 API Rate Limiting (每分鐘 60 次) 2. 實作 ProductServiceInterface 與 findOrCreateWarehouseByName 解決跨模組耦合問題 3. 強化 OrderSync API 驗證 (price 欄位限制最小 0、payment_method 加上允許白名單) 4. 實作 OrderSync API 冪等性處理,重複訂單直接回傳現有資訊 5. 修正 ProductSync API 同步邏輯,每次同步皆會更新產品分類與單位 6. 完善 integration API 對接手冊內容與 UI 排版 --- .../Controllers/OrderSyncController.php | 61 ++++--- .../Controllers/ProductSyncController.php | 10 +- .../IntegrationServiceProvider.php | 10 +- app/Modules/Integration/Routes/api.php | 2 +- .../Contracts/InventoryServiceInterface.php | 8 + .../Contracts/ProductServiceInterface.php | 25 +++ .../Inventory/InventoryServiceProvider.php | 3 + .../Inventory/Services/InventoryService.php | 19 ++- .../Inventory/Services/ProductService.php | 22 ++- docker/8.5/supervisord.conf | 13 ++ resources/js/Pages/System/Manual/Index.tsx | 3 +- resources/markdown/manual/api-integration.md | 160 ++++++++++++++++++ resources/markdown/manual/toc.json | 4 + 13 files changed, 303 insertions(+), 37 deletions(-) create mode 100644 app/Modules/Inventory/Contracts/ProductServiceInterface.php create mode 100644 resources/markdown/manual/api-integration.md diff --git a/app/Modules/Integration/Controllers/OrderSyncController.php b/app/Modules/Integration/Controllers/OrderSyncController.php index dfa4f89..1dd09eb 100644 --- a/app/Modules/Integration/Controllers/OrderSyncController.php +++ b/app/Modules/Integration/Controllers/OrderSyncController.php @@ -6,66 +6,79 @@ 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\Services\InventoryService; -use App\Modules\Inventory\Models\Product; -use App\Modules\Inventory\Models\Warehouse; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use App\Modules\Inventory\Contracts\ProductServiceInterface; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class OrderSyncController extends Controller { protected $inventoryService; + protected $productService; - public function __construct(InventoryService $inventoryService) - { + public function __construct( + InventoryServiceInterface $inventoryService, + ProductServiceInterface $productService + ) { $this->inventoryService = $inventoryService; + $this->productService = $productService; } public function store(Request $request) { + // 冪等性處理:若訂單已存在,回傳已建立的訂單資訊 + $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|exists:warehouses,id', - 'items' => 'required|array', + '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', + 'items.*.price' => 'required|numeric|min:0', ]); try { return DB::transaction(function () use ($request) { - // 1. Create Order + // 1. 建立訂單 $order = SalesOrder::create([ 'external_order_id' => $request->external_order_id, 'status' => 'completed', 'payment_method' => $request->payment_method ?? 'cash', - 'total_amount' => 0, // Will calculate + 'total_amount' => 0, 'sold_at' => $request->sold_at ?? now(), 'raw_payload' => $request->all(), ]); - // Find Warehouse (Default to "銷售倉庫") + // 2. 查找或建立倉庫 $warehouseId = $request->warehouse_id; - + if (empty($warehouseId)) { $warehouseName = $request->warehouse ?: '銷售倉庫'; - $warehouse = Warehouse::firstOrCreate(['name' => $warehouseName], [ - 'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))), - 'type' => 'system_sales', - 'is_active' => true, - ]); + $warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName); $warehouseId = $warehouse->id; } $totalAmount = 0; + // 3. 處理訂單明細 foreach ($request->items as $itemData) { - // Find product by external ID (Strict Check) - $product = Product::where('external_pos_id', $itemData['pos_product_id'])->first(); + // 透過介面查找產品 + $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."); + throw new \Exception( + "Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first." + ); } $qty = $itemData['qty']; @@ -73,23 +86,23 @@ class OrderSyncController extends Controller $lineTotal = $qty * $price; $totalAmount += $lineTotal; - // 2. Create Order Item + // 建立訂單明細 SalesOrderItem::create([ 'sales_order_id' => $order->id, 'product_id' => $product->id, - 'product_name' => $product->name, // Snapshot name + 'product_name' => $product->name, 'quantity' => $qty, 'price' => $price, 'total' => $lineTotal, ]); - // 3. Deduct Stock (Force negative allowed for POS orders) + // 4. 扣除庫存(強制模式,允許負庫存) $this->inventoryService->decreaseStock( $product->id, $warehouseId, $qty, "POS Order: " . $order->external_order_id, - true // Force = true + true ); } diff --git a/app/Modules/Integration/Controllers/ProductSyncController.php b/app/Modules/Integration/Controllers/ProductSyncController.php index 8cd1bf6..f1dcf4c 100644 --- a/app/Modules/Integration/Controllers/ProductSyncController.php +++ b/app/Modules/Integration/Controllers/ProductSyncController.php @@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use App\Modules\Inventory\Services\ProductService; +use App\Modules\Inventory\Contracts\ProductServiceInterface; use Illuminate\Support\Facades\Log; class ProductSyncController extends Controller { protected $productService; - public function __construct(ProductService $productService) + public function __construct(ProductServiceInterface $productService) { $this->productService = $productService; } @@ -21,7 +21,7 @@ class ProductSyncController extends Controller $request->validate([ 'external_pos_id' => 'required|string', 'name' => 'required|string', - 'price' => 'nullable|numeric', + 'price' => 'nullable|numeric|min:0', 'barcode' => 'nullable|string', 'category' => 'nullable|string', 'unit' => 'nullable|string', @@ -40,7 +40,9 @@ class ProductSyncController extends Controller ]); } catch (\Exception $e) { Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]); - return response()->json(['message' => 'Sync failed'], 500); + return response()->json([ + 'message' => 'Sync failed: ' . $e->getMessage(), + ], 500); } } } diff --git a/app/Modules/Integration/IntegrationServiceProvider.php b/app/Modules/Integration/IntegrationServiceProvider.php index 1ea7da9..659fdbb 100644 --- a/app/Modules/Integration/IntegrationServiceProvider.php +++ b/app/Modules/Integration/IntegrationServiceProvider.php @@ -4,6 +4,9 @@ namespace App\Modules\Integration; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use App\Modules\Integration\Middleware\TenantIdentificationMiddleware; class IntegrationServiceProvider extends ServiceProvider @@ -13,8 +16,13 @@ class IntegrationServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/Routes/api.php'); $this->loadMigrationsFrom(__DIR__ . '/Database/Migrations'); - // Register Middleware Alias + // 註冊 Middleware 別名 Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class); + + // 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別) + RateLimiter::for('integration', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); } public function register() diff --git a/app/Modules/Integration/Routes/api.php b/app/Modules/Integration/Routes/api.php index 571c93c..3479f0b 100644 --- a/app/Modules/Integration/Routes/api.php +++ b/app/Modules/Integration/Routes/api.php @@ -5,7 +5,7 @@ use App\Modules\Integration\Controllers\ProductSyncController; use App\Modules\Integration\Controllers\OrderSyncController; Route::prefix('api/v1/integration') - ->middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant + ->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum']) ->group(function () { Route::post('products/upsert', [ProductSyncController::class, 'upsert']); Route::post('orders', [OrderSyncController::class, 'store']); diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index f71053d..ee4590b 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -131,4 +131,12 @@ interface InventoryServiceInterface * @return array */ public function getDashboardStats(): array; + + /** + * 依倉庫名稱查找或建立倉庫(供外部整合用)。 + * + * @param string $warehouseName + * @return object + */ + public function findOrCreateWarehouseByName(string $warehouseName); } \ No newline at end of file diff --git a/app/Modules/Inventory/Contracts/ProductServiceInterface.php b/app/Modules/Inventory/Contracts/ProductServiceInterface.php new file mode 100644 index 0000000..ca5314b --- /dev/null +++ b/app/Modules/Inventory/Contracts/ProductServiceInterface.php @@ -0,0 +1,25 @@ +app->bind(InventoryServiceInterface::class, InventoryService::class); + $this->app->bind(ProductServiceInterface::class, ProductService::class); } public function boot(): void diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 7823085..f0b081f 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -590,5 +590,22 @@ class InventoryService implements InventoryServiceInterface 'abnormalItems' => $abnormalItems, ]; } -} + /** + * 依倉庫名稱查找或建立倉庫(供外部整合用)。 + * + * @param string $warehouseName + * @return Warehouse + */ + public function findOrCreateWarehouseByName(string $warehouseName) + { + return Warehouse::firstOrCreate( + ['name' => $warehouseName], + [ + 'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))), + 'type' => 'system_sales', + 'is_active' => true, + ] + ); + } +} diff --git a/app/Modules/Inventory/Services/ProductService.php b/app/Modules/Inventory/Services/ProductService.php index 3df7832..3b79b92 100644 --- a/app/Modules/Inventory/Services/ProductService.php +++ b/app/Modules/Inventory/Services/ProductService.php @@ -2,13 +2,14 @@ namespace App\Modules\Inventory\Services; +use App\Modules\Inventory\Contracts\ProductServiceInterface; use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Models\Category; use App\Modules\Inventory\Models\Unit; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -class ProductService +class ProductService implements ProductServiceInterface { /** * Upsert product from external POS source. @@ -45,8 +46,8 @@ class ProductService $product->code = $data['code'] ?? $product->external_pos_id; } - // Handle Category (Default: 未分類) - if (empty($product->category_id)) { + // Handle Category — 每次同步都更新(若有傳入) + if (!empty($data['category']) || empty($product->category_id)) { $categoryName = $data['category'] ?? '未分類'; $category = Category::firstOrCreate( ['name' => $categoryName], @@ -55,8 +56,8 @@ class ProductService $product->category_id = $category->id; } - // Handle Base Unit (Default: 個) - if (empty($product->base_unit_id)) { + // Handle Base Unit — 每次同步都更新(若有傳入) + if (!empty($data['unit']) || empty($product->base_unit_id)) { $unitName = $data['unit'] ?? '個'; $unit = Unit::firstOrCreate(['name' => $unitName]); $product->base_unit_id = $unit->id; @@ -69,4 +70,15 @@ class ProductService return $product; }); } + + /** + * 透過外部 POS ID 查找產品。 + * + * @param string $externalPosId + * @return Product|null + */ + public function findByExternalPosId(string $externalPosId) + { + return Product::where('external_pos_id', $externalPosId)->first(); + } } diff --git a/docker/8.5/supervisord.conf b/docker/8.5/supervisord.conf index 656da8a..61faa9b 100644 --- a/docker/8.5/supervisord.conf +++ b/docker/8.5/supervisord.conf @@ -12,3 +12,16 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:npm] +command=/usr/bin/npm run dev +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true diff --git a/resources/js/Pages/System/Manual/Index.tsx b/resources/js/Pages/System/Manual/Index.tsx index 7601e69..cc0b77b 100644 --- a/resources/js/Pages/System/Manual/Index.tsx +++ b/resources/js/Pages/System/Manual/Index.tsx @@ -130,7 +130,8 @@ export default function ManualIndex({ toc, currentSlug, content }: Props) { #manual-article blockquote { margin: 0.75rem 0 !important; padding: 0.25rem 1rem !important; border-left: 4px solid var(--primary-main); background: #f8fafc; border-radius: 0 4px 4px 0; } #manual-article img { margin: 1rem 0 !important; border-radius: 8px; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); } #manual-article code { padding: 0.1rem 0.3rem; background: #e6f7f3; color: #018a6a; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, monospace; } - #manual-article pre { margin: 0.75rem 0 !important; padding: 0.75rem !important; background: #1e293b; color: #f8fafc; border-radius: 8px; overflow-x: auto; } + #manual-article pre { margin: 0.75rem 0 !important; padding: 1rem !important; background: #f8fafc; color: #334155; border: 1px solid #e2e8f0; border-radius: 8px; overflow-x: auto; box-shadow: inset 0 1px 2px rgba(0,0,0,0.02); } + #manual-article pre code { background: transparent; padding: 0; color: inherit; font-size: 13.5px; } `}} />
diff --git a/resources/markdown/manual/api-integration.md b/resources/markdown/manual/api-integration.md new file mode 100644 index 0000000..e7228b5 --- /dev/null +++ b/resources/markdown/manual/api-integration.md @@ -0,0 +1,160 @@ +# 第三方系統 API 對接手冊 + +Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS 機或其他第三方系統串接。 +所有的整合 API 均受到 Laravel Sanctum Token 與多租戶 (Multi-tenant) Middleware 保護。 + +## 基礎連線資訊 + +- **API Base URL**: `https://[租戶網域]/api/v1/integration` (依實際部署網址為準,單機開發為 `http://localhost/api/v1/integration`) +- **Headers 要求**: + - `Accept: application/json` + - `Content-Type: application/json` + - `Authorization: Bearer {YOUR_API_TOKEN}` (由 ERP 系統管理員核發的 Sanctum Token) +- **速率限制**:每位使用者每分鐘最多 60 次請求。超過時會回傳 `429 Too Many Requests`。 + +--- + +## 1. 產品資料同步 (Upsert Product) + +此 API 用於將第三方系統(如 POS)的產品資料單向同步至 ERP。採用 Upsert 邏輯:若 `external_pos_id` 存在則更新資料,不存在則新增產品。 + +- **Endpoint**: `/products/upsert` +- **Method**: `POST` + +### Request Body (JSON) + +| 欄位名稱 | 型態 | 必填 | 說明 | +| :--- | :--- | :---: | :--- | +| `external_pos_id` | String | **是** | 第三方系統中的唯一產品 ID,ERP 會依此 ID 判斷是否為同一商品 | +| `name` | String | **是** | 產品名稱 | +| `price` | Number | 否 | 產品售價 | +| `barcode` | String | 否 | 產品條碼 | +| `category` | String | 否 | 產品分類名稱 | +| `unit` | String | 否 | 單位 (例如: 個, 瓶, 箱) | +| `updated_at` | String(Date) | 否 | 第三方系統的最後更新時間 (格式: YYYY-MM-DD HH:mm:ss) | + +**請求範例:** +```json +{ + "external_pos_id": "POS-ITEM-001", + "name": "特級冷壓初榨橄欖油", + "price": 450, + "barcode": "4711234567890", + "category": "調味料", + "unit": "瓶" +} +``` + +### Response + +**Success (HTTP 200)** +```json +{ + "message": "Product synced successfully", + "data": { + "id": 15, + "external_pos_id": "POS-ITEM-001" + } +} +``` + +--- + +## 2. 訂單資料寫入與扣庫 (Create Order) + +此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。 +**重要提醒**:寫入訂單的同時,ERP 會自動且**強制**扣除對應倉庫的庫存(允許扣至負數,以利後續盤點或補單)。 + +- **Endpoint**: `/orders` +- **Method**: `POST` + +### Request Body (JSON) + +| 欄位名稱 | 型態 | 必填 | 說明 | +| :--- | :--- | :---: | :--- | +| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) | +| `warehouse_id` | Integer | 否 | 指定出貨倉庫的系統 ID (若已知) | +| `warehouse` | String | 否 | 指定出貨倉庫名稱。若 `warehouse_id` 與此欄皆未傳,系統將預設寫入並建立「銷售倉庫」 | +| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` | +| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) | +| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 | + +#### `items` 陣列欄位說明: + +| 欄位名稱 | 型態 | 必填 | 說明 | +| :--- | :--- | :---: | :--- | +| `pos_product_id` | String | **是** | 對應產品的 `external_pos_id`。**注意:商品必須先透過產品同步 API 建立至 ERP 中。** | +| `qty` | Number | **是** | 銷售數量 (必須 > 0) | +| `price` | Number | **是** | 銷售單價 | + +**請求範例:** +```json +{ + "external_order_id": "ORD-20231026-0001", + "warehouse": "台北大安門市", + "payment_method": "credit_card", + "sold_at": "2023-10-26 14:30:00", + "items": [ + { + "pos_product_id": "POS-ITEM-001", + "qty": 2, + "price": 450 + }, + { + "pos_product_id": "POS-ITEM-005", + "qty": 1, + "price": 120 + } + ] +} +``` + +### Response + +**Success (HTTP 201)** +```json +{ + "message": "Order synced and stock deducted successfully", + "order_id": 42 +} +``` + +**Error: Product Not Found (HTTP 400)** +若 `items` 內傳入了未曾同步過的 `pos_product_id`,會導致寫入失敗。 +```json +{ + "message": "Sync failed: Product not found for POS ID: POS-ITEM-999. Please sync product first." +} +``` + +--- + +## 幂等性說明 (Idempotency) + +訂單 API 支援幂等性處理:若傳入已存在的 `external_order_id`,系統不會報錯,而是回傳該訂單的資訊: + +```json +{ + "message": "Order already exists", + "order_id": 42 +} +``` + +這讓第三方系統在網路問題導致重送時,不會產生重複訂單或重複扣庫。 + +--- + +## 常見問題與除錯 (FAQ) + +1. **收到 `401 Unauthorized` 錯誤?** + - 請檢查請求標頭 (Header) 是否有正確攜帶 `Authorization: Bearer {Token}`。 + - 確認該 Token 尚未過期或被撤銷。 + +2. **收到 `422 Unprocessable Entity` 錯誤?** + - 代表傳送的 JSON 欄位不符合格式要求,例如必填欄位遺漏、數量為負數、或 `payment_method` 不在允許的值中。Laravel 會在回應的 `errors` 物件中詳細說明哪個欄位驗證失敗。 + +3. **收到 `429 Too Many Requests` 錯誤?** + - 代表已超過速率限制(每分鐘 60 次),請稍後再試。 + +4. **庫存扣除邏輯** + - POS 訂單寫入視為「已發生之事實」,系統會無條件扣除庫存。若該產品在指定倉庫原先庫存為 0,訂單寫入後庫存將變為負數,提醒門市人員需進行調撥補貨。 diff --git a/resources/markdown/manual/toc.json b/resources/markdown/manual/toc.json index a5527c7..998ee93 100644 --- a/resources/markdown/manual/toc.json +++ b/resources/markdown/manual/toc.json @@ -27,6 +27,10 @@ { "title": "常見問題 (FAQ)", "slug": "faq" + }, + { + "title": "外部系統 API 對接", + "slug": "api-integration" } ] }