Compare commits

...

2 Commits

Author SHA1 Message Date
904132e460 feat(integration): 擴充產品同步 API 欄位與驗證強化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m14s
1. ProductSync API 新增防護機制,為既有欄位加上字串長度與金額上限限制
2. 開放並接收 ERP Product Model 實用欄位(品牌、規格、成本價、會員價、批發價)
3. 更新 ProductService 寫入邏輯以支援新增的可選欄位
4. 同步更新 api-integration.md 手冊,加入新欄位說明與 JSON 範例
2026-02-23 11:02:25 +08:00
a05acd96dc feat(integration): 完善外部 API 對接邏輯與安全性
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 排版
2026-02-23 10:10:03 +08:00
13 changed files with 333 additions and 42 deletions

View File

@@ -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
);
}

View File

@@ -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;
}
@@ -19,12 +19,17 @@ class ProductSyncController extends Controller
public function upsert(Request $request)
{
$request->validate([
'external_pos_id' => 'required|string',
'name' => 'required|string',
'price' => 'nullable|numeric',
'barcode' => 'nullable|string',
'category' => 'nullable|string',
'unit' => 'nullable|string',
'external_pos_id' => 'required|string|max:255',
'name' => 'required|string|max:255',
'price' => 'nullable|numeric|min:0|max:99999999.99',
'barcode' => 'nullable|string|max:100',
'category' => 'nullable|string|max:100',
'unit' => 'nullable|string|max:100',
'brand' => 'nullable|string|max:100',
'specification' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
'member_price' => 'nullable|numeric|min:0|max:99999999.99',
'wholesale_price' => 'nullable|numeric|min:0|max:99999999.99',
'updated_at' => 'nullable|date',
]);
@@ -40,7 +45,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);
}
}
}

View File

@@ -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()

View File

@@ -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']);

View File

@@ -131,4 +131,12 @@ interface InventoryServiceInterface
* @return array
*/
public function getDashboardStats(): array;
/**
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
*
* @param string $warehouseName
* @return object
*/
public function findOrCreateWarehouseByName(string $warehouseName);
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Modules\Inventory\Contracts;
/**
* 產品服務介面 供跨模組使用(如 Integration 模組)。
*/
interface ProductServiceInterface
{
/**
* 透過外部 POS ID 進行產品新增或更新Upsert
*
* @param array $data
* @return object
*/
public function upsertFromPos(array $data);
/**
* 透過外部 POS ID 查找產品。
*
* @param string $externalPosId
* @return object|null
*/
public function findByExternalPosId(string $externalPosId);
}

View File

@@ -4,13 +4,16 @@ namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Inventory\Services\ProductService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
$this->app->bind(ProductServiceInterface::class, ProductService::class);
}
public function boot(): void

View File

@@ -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,
]
);
}
}

View File

@@ -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.
@@ -40,13 +41,20 @@ class ProductService
$product->barcode = $data['barcode'] ?? $product->barcode;
$product->price = $data['price'] ?? 0;
// Map newly added extended fields
if (isset($data['brand'])) $product->brand = $data['brand'];
if (isset($data['specification'])) $product->specification = $data['specification'];
if (isset($data['cost_price'])) $product->cost_price = $data['cost_price'];
if (isset($data['member_price'])) $product->member_price = $data['member_price'];
if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_price'];
// Generate Code if missing (use code or external_id)
if (empty($product->code)) {
$product->code = $data['code'] ?? $product->external_pos_id;
}
// Handle Category (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 +63,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 +77,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();
}
}

View File

@@ -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

View File

@@ -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; }
`}} />
<div className="max-w-4xl mx-auto p-4 md:p-10 lg:p-12">
<article id="manual-article" className="prose prose-slate max-w-none">

View File

@@ -0,0 +1,173 @@
# 第三方系統 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 | **是** | 在 POS 系統中的唯一商品 ID (Primary Key) |
| `name` | String | **是** | 商品名稱 (最大 255 字元) |
| `price` | Decimal | 否 | 商品售價 (預設 0) |
| `barcode` | String | 否 | 商品條碼 (最大 100 字元) |
| `category` | String | 否 | 商品分類名稱。若系統中不存在,會自動建立 (最大 100 字元) |
| `unit` | String | 否 | 商品單位 (例如:個、杯、件)。若不存在會自動建立 (最大 100 字元) |
| `brand` | String | 否 | 商品品牌名稱 (最大 100 字元) |
| `specification` | String | 否 | 商品規格描述 (最大 255 字元) |
| `cost_price` | Decimal | 否 | 成本價 (預設 0) |
| `member_price` | Decimal | 否 | 會員價 (預設 0) |
| `wholesale_price` | Decimal | 否 | 批發價 (預設 0) |
| `is_active` | Boolean| 否 | 是否上架/啟用 (預設 true) |
| `updated_at` | String | 否 | POS 端的最後更新時間 (格式: YYYY-MM-DD HH:mm:ss) |
**請求範例:**
```json
{
"external_pos_id": "POS-PROD-9001",
"name": "特級冷壓初榨橄欖油 500ml",
"price": 380.00,
"barcode": "4711234567890",
"category": "調味料",
"unit": "瓶",
"brand": "健康王",
"specification": "500ml / 玻璃瓶裝",
"cost_price": 250.00,
"member_price": 350.00,
"wholesale_price": 300.00,
"is_active": true,
"updated_at": "2024-03-15 14:30:00"
}
```
### 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訂單寫入後庫存將變為負數提醒門市人員需進行調撥補貨。

View File

@@ -27,6 +27,10 @@
{
"title": "常見問題 (FAQ)",
"slug": "faq"
},
{
"title": "外部系統 API 對接",
"slug": "api-integration"
}
]
}