12 Commits

Author SHA1 Message Date
f96d2870c3 [DOCS] 修正商品資料讀取 API 說明,移除關鍵字搜尋描述 2026-03-19 15:06:20 +08:00
96440f6b50 [DOCS] 修正商品資料讀取 API 關於關鍵字搜尋的誤導說明 2026-03-19 15:05:59 +08:00
8f6b8d55cc [FEAT] 銷售訂單管理:補齊欄位、即時搜尋、篩選與來源自動判定 2026-03-19 15:03:24 +08:00
60f5f00a9e [FEAT] 銷售訂單管理:補齊欄位、即時搜尋、篩選與來源自動判定 2026-03-19 15:00:33 +08:00
0b4aeacb55 [REFACTOR] 統一訂單同步 API 錯誤回應與修正 Linter 警告 2026-03-19 14:07:32 +08:00
e3ceedc579 [STYLE] 移除冗餘的簡報生成腳本,改由全域技能處理 2026-03-13 16:23:02 +08:00
7a1fc02dfc [REFACTOR] 移除 package.json 中不使用的 pptxgenjs 套件 2026-03-13 16:19:56 +08:00
bee8ecb55b [FIX] 修正盤調單明細插入時的欄位名稱錯誤並更新簡報/圖片處理套件 2026-03-13 16:18:13 +08:00
b57a4feeab [FIX] 嚴格限制 now-push 工作流的 main 合併鏈路
- 修改 now-push.md 確保 main 只能從 demo 合併
- 明列 dev -> demo -> main 的強制合併順序
2026-03-10 15:39:37 +08:00
6ca0bafd60 [FEAT] 新增生產工單實際產量欄位與 UI 規範
- 新增 database/migrations/tenant 實際產量與耗損原因
- ProductionOrder API 狀態推進與實際產量計算
- 完工入庫新增實際產出數量原生數字輸入框 (step=1)
- Create.tsx 補上前端資料驗證與狀態保護
- 建立並更新 UI 數字輸入框設計規範
2026-03-10 15:32:52 +08:00
adf13410ba [DOCS] 更新 API 文件,補充 api-test-01 測試倉說明
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-10 11:57:34 +08:00
d52a215916 [FEAT] 優化庫存分析邏輯,增加銷售 Reference Type 追蹤並修正 InventoryService 閉包變數問題
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m20s
2026-03-10 11:15:55 +08:00
35 changed files with 9089 additions and 6903 deletions

View File

@@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9` - **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。 - **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
## 11.6 日期顯示規範 (Date Display) ## 11.6 數字輸入框規範 (Numeric Inputs)
當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。
**必須使用原生 HTML5 數字輸入與屬性**
1. 使用 `<Input type="number" />` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。
2. 針對整數需求,固定加上 `step="1"` 屬性。
3. 視需求加上 `min``max` 控制上下限。
這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。
```tsx
// ✅ 正確:依賴原生行為
<Input
type="number"
step="1"
min="0"
max={outputQuantity}
value={actualOutputQuantity}
onChange={(e) => setActualOutputQuantity(e.target.value)}
className="h-9 w-24 text-center"
/>
// ❌ 錯誤:過度設計、浪費空間與破壞一致性
<div className="flex">
<Button><Minus /></Button>
<Input type="number" />
<Button><Plus /></Button>
</div>
```
## 11.7 日期顯示規範 (Date Display)
前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。 前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。

View File

@@ -19,9 +19,17 @@ description: 將目前的變更提交並推送至指定的遠端分支 (遵守
3. **目標分支安全檢查** 3. **目標分支安全檢查**
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。 - 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main` - 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`
- **【最嚴格限制】**`main` 分支的程式碼**只能**, **必須**從 `demo` 分支合併而來。絕對禁止將 `dev` (或 `feature/*`) 直接合併進 `main`
4. **執行推送 (Push)** 4. **執行推送 (Push) 與嚴格合併鏈路**
- 通過安全檢查後,執行:`git push origin [目前分支]:[目標分支]` - **若目標為 `dev`**:直接 `git push origin [目前分支]:dev` 或 commit 後 merge 到 dev
- **若目標為 `demo`**:必須先確保變更已在 `dev` 且無衝突,然後 `git checkout demo && git merge dev && git push origin demo`
- **若目標為 `main`**
必須確保變更已經依照順序通過前置環境,嚴格執行以下流程(缺一不可):
1. `git checkout dev && git merge [目前分支] && git push origin dev`
2. `git checkout demo && git merge dev && git push origin demo`
3. `git checkout main && git merge demo && git push origin main`
*(就算遭遇衝突,也必須在對應的分支上解衝突,絕對不可略過 `demo` 直接 `dev -> main`)*
5. **後續同步** 5. **後續同步 (針對 Hotfix)**
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」評估是否需要同步`demo` `dev` 分支。 - 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」:若有從 main 開出來的 hotfix 分支直接併回 main 的例外情況(需使用者明確指示),**必須**同步將 main 分支 merge `demo` `dev` 分支,維持全環境版本一致

View File

@@ -58,42 +58,36 @@ class SyncOrderAction
]; ];
} }
// --- 預檢 (Pre-flight check) N+1 優化 --- // --- 預檢 (Pre-flight check) 僅使用 product_id ---
$items = $data['items']; $items = $data['items'];
$posProductIds = array_column($items, 'pos_product_id'); $targetErpIds = array_column($items, 'product_id');
// 一次性查出所有相關的 Product // 一次性查出所有相關的 Product
$products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id'); $productsById = $this->productService->findByIds($targetErpIds)->keyBy('id');
$resolvedProducts = [];
$missingIds = []; $missingIds = [];
foreach ($posProductIds as $id) {
if (!$products->has($id)) { foreach ($items as $index => $item) {
$missingIds[] = $id; $productId = $item['product_id'];
$product = $productsById->get($productId);
if ($product) {
$resolvedProducts[$index] = $product;
} else {
$missingIds[] = $productId;
} }
} }
if (!empty($missingIds)) { if (!empty($missingIds)) {
// 回報所有缺漏的 ID
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."] 'items' => ["The following product IDs are not found: " . implode(', ', array_unique($missingIds)) . ". Please ensure these products exist in the system."]
]); ]);
} }
// --- 執行寫入交易 --- // --- 執行寫入交易 ---
$result = DB::transaction(function () use ($data, $items, $products) { $result = DB::transaction(function () use ($data, $items, $resolvedProducts) {
// 1. 建立訂單 // 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. 查找倉庫
$warehouseCode = $data['warehouse_code']; $warehouseCode = $data['warehouse_code'];
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]); $warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
@@ -102,17 +96,36 @@ class SyncOrderAction
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."] 'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
]); ]);
} }
$warehouseId = $warehouses->first()->id; $warehouse = $warehouses->first();
$warehouseId = $warehouse->id;
// 2. 自動判定來源:若是販賣機倉庫則標記為 vending其餘為 pos
$source = ($warehouse->type === \App\Enums\WarehouseType::VENDING) ? 'vending' : 'pos';
// 3. 建立訂單
$order = SalesOrder::create([
'external_order_id' => $data['external_order_id'],
'name' => $data['name'],
'status' => 'completed',
'payment_method' => $data['payment_method'] ?? 'cash',
'total_amount' => $data['total_amount'],
'total_qty' => $data['total_qty'],
'sold_at' => $data['sold_at'] ?? now(),
'raw_payload' => $data,
'source' => $source,
'source_label' => $data['source_label'] ?? null,
]);
$totalAmount = 0; $totalAmount = 0;
// 3. 處理訂單明細 // 3. 處理訂單明細
$orderItemsData = []; $orderItemsData = [];
foreach ($items as $itemData) { foreach ($items as $index => $itemData) {
$product = $products->get($itemData['pos_product_id']); $product = $resolvedProducts[$index];
$qty = $itemData['qty']; $qty = $itemData['qty'];
$price = $itemData['price']; $price = $itemData['price'];
$batchNumber = $itemData['batch_number'] ?? null;
$lineTotal = $qty * $price; $lineTotal = $qty * $price;
$totalAmount += $lineTotal; $totalAmount += $lineTotal;
@@ -133,7 +146,11 @@ class SyncOrderAction
$warehouseId, $warehouseId,
$qty, $qty,
"POS Order: " . $order->external_order_id, "POS Order: " . $order->external_order_id,
true true,
null, // Slot (location)
\App\Modules\Integration\Models\SalesOrder::class,
$order->id,
$batchNumber
); );
} }

View File

@@ -130,7 +130,10 @@ class SyncVendingOrderAction
$warehouseId, $warehouseId,
$qty, $qty,
"Vending Order: " . $order->external_order_id, "Vending Order: " . $order->external_order_id,
true true,
null,
\App\Modules\Integration\Models\SalesOrder::class,
$order->id
); );
} }

View File

@@ -21,10 +21,13 @@ class InventorySyncController extends Controller
* @param string $warehouseCode * @param string $warehouseCode
* @return JsonResponse * @return JsonResponse
*/ */
public function show(string $warehouseCode): JsonResponse public function show(\Illuminate\Http\Request $request, string $warehouseCode): JsonResponse
{ {
// 透過 Service 調用跨模組庫存查詢功能 // 透過 Service 調用跨模組庫存查詢功能,傳入篩選條件
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode); $inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode(
$warehouseCode,
$request->only(['product_id', 'barcode', 'code', 'external_pos_id'])
);
// 若回傳 null表示尋無此倉庫代碼 // 若回傳 null表示尋無此倉庫代碼
if (is_null($inventoryData)) { if (is_null($inventoryData)) {
@@ -40,9 +43,18 @@ class InventorySyncController extends Controller
'warehouse_code' => $warehouseCode, 'warehouse_code' => $warehouseCode,
'data' => $inventoryData->map(function ($item) { 'data' => $inventoryData->map(function ($item) {
return [ return [
'product_id' => $item->product_id,
'external_pos_id' => $item->external_pos_id, 'external_pos_id' => $item->external_pos_id,
'product_code' => $item->product_code, 'product_code' => $item->product_code,
'product_name' => $item->product_name, 'product_name' => $item->product_name,
'barcode' => $item->barcode,
'category_name' => $item->category_name ?? '未分類',
'unit_name' => $item->unit_name ?? '個',
'price' => (float) $item->price,
'brand' => $item->brand,
'specification' => $item->specification,
'batch_number' => $item->batch_number,
'expiry_date' => $item->expiry_date,
'quantity' => (float) $item->total_quantity, 'quantity' => (float) $item->total_quantity,
]; ];
}) })

View File

@@ -23,8 +23,8 @@ class ProductSyncController extends Controller
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'price' => 'nullable|numeric|min:0|max:99999999.99', 'price' => 'nullable|numeric|min:0|max:99999999.99',
'barcode' => 'nullable|string|max:100', 'barcode' => 'nullable|string|max:100',
'category' => 'nullable|string|max:100', 'category' => 'required|string|max:100',
'unit' => 'nullable|string|max:100', 'unit' => 'required|string|max:100',
'brand' => 'nullable|string|max:100', 'brand' => 'nullable|string|max:100',
'specification' => 'nullable|string|max:255', 'specification' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0|max:99999999.99', 'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
@@ -41,6 +41,8 @@ class ProductSyncController extends Controller
'data' => [ 'data' => [
'id' => $product->id, 'id' => $product->id,
'external_pos_id' => $product->external_pos_id, 'external_pos_id' => $product->external_pos_id,
'code' => $product->code,
'barcode' => $product->barcode,
] ]
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -50,4 +52,63 @@ class ProductSyncController extends Controller
], 500); ], 500);
} }
} }
/**
* 搜尋商品(供外部 API 使用)。
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function index(Request $request)
{
$request->validate([
'product_id' => 'nullable|integer',
'external_pos_id' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'code' => 'nullable|string|max:100',
'category' => 'nullable|string|max:100',
'updated_after' => 'nullable|date',
'per_page' => 'nullable|integer|min:1|max:100',
]);
try {
$perPage = $request->input('per_page', 50);
$products = $this->productService->searchProducts($request->all(), $perPage);
return response()->json([
'status' => 'success',
'data' => $products->getCollection()->map(function ($product) {
return [
'id' => $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'external_pos_id' => $product->external_pos_id,
'category_name' => $product->category?->name ?? '未分類',
'brand' => $product->brand,
'specification' => $product->specification,
'unit_name' => $product->baseUnit?->name ?? '個',
'price' => (float) $product->price,
'cost_price' => (float) $product->cost_price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
'updated_at' => $product->updated_at->format('Y-m-d H:i:s'),
];
}),
'meta' => [
'current_page' => $products->currentPage(),
'last_page' => $products->lastPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
]
]);
} catch (\Exception $e) {
Log::error('Product Search Failed', ['error' => $e->getMessage()]);
return response()->json([
'status' => 'error',
'message' => 'Search failed: ' . $e->getMessage(),
], 500);
}
}
} }

View File

@@ -18,7 +18,10 @@ class SalesOrderController extends Controller
// 搜尋篩選 (外部訂單號) // 搜尋篩選 (外部訂單號)
if ($request->filled('search')) { if ($request->filled('search')) {
$query->where('external_order_id', 'like', '%' . $request->search . '%'); $query->where(function ($q) use ($request) {
$q->where('external_order_id', 'like', '%' . $request->search . '%')
->orWhere('name', 'like', '%' . $request->search . '%');
});
} }
// 來源篩選 // 來源篩選
@@ -26,6 +29,11 @@ class SalesOrderController extends Controller
$query->where('source', $request->source); $query->where('source', $request->source);
} }
// 付款方式篩選
if ($request->filled('payment_method')) {
$query->where('payment_method', $request->payment_method);
}
// 排序 // 排序
$query->orderBy('sold_at', 'desc'); $query->orderBy('sold_at', 'desc');
@@ -40,7 +48,7 @@ class SalesOrderController extends Controller
return Inertia::render('Integration/SalesOrders/Index', [ return Inertia::render('Integration/SalesOrders/Index', [
'orders' => $orders, 'orders' => $orders,
'filters' => $request->only(['search', 'per_page', 'source']), 'filters' => $request->only(['search', 'per_page', 'source', 'status', 'payment_method']),
]); ]);
} }

View File

@@ -11,9 +11,11 @@ class SalesOrder extends Model
protected $fillable = [ protected $fillable = [
'external_order_id', 'external_order_id',
'name',
'status', 'status',
'payment_method', 'payment_method',
'total_amount', 'total_amount',
'total_qty',
'sold_at', 'sold_at',
'raw_payload', 'raw_payload',
'source', 'source',
@@ -24,6 +26,7 @@ class SalesOrder extends Model
'sold_at' => 'datetime', 'sold_at' => 'datetime',
'raw_payload' => 'array', 'raw_payload' => 'array',
'total_amount' => 'decimal:4', 'total_amount' => 'decimal:4',
'total_qty' => 'decimal:4',
]; ];
public function items(): HasMany public function items(): HasMany

View File

@@ -23,11 +23,15 @@ class SyncOrderRequest extends FormRequest
{ {
return [ return [
'external_order_id' => 'required|string', 'external_order_id' => 'required|string',
'name' => 'required|string|max:255',
'warehouse_code' => 'required|string', 'warehouse_code' => 'required|string',
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other', 'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
'total_amount' => 'required|numeric|min:0',
'total_qty' => 'required|numeric|min:0',
'sold_at' => 'nullable|date', 'sold_at' => 'nullable|date',
'items' => 'required|array|min:1', 'items' => 'required|array|min:1',
'items.*.pos_product_id' => 'required|string', 'items.*.product_id' => 'required|integer',
'items.*.batch_number' => 'nullable|string',
'items.*.qty' => 'required|numeric|min:0.0001', 'items.*.qty' => 'required|numeric|min:0.0001',
'items.*.price' => 'required|numeric|min:0', 'items.*.price' => 'required|numeric|min:0',
]; ];

View File

@@ -9,6 +9,7 @@ use App\Modules\Integration\Controllers\InventorySyncController;
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::get('products', [ProductSyncController::class, 'index']);
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']); Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);

View File

@@ -21,9 +21,12 @@ interface InventoryServiceInterface
* @param string|null $reason * @param string|null $reason
* @param bool $force * @param bool $force
* @param string|null $slot * @param string|null $slot
* @param string|null $referenceType
* @param int|string|null $referenceId
* @param string|null $batchNumber
* @return void * @return void
*/ */
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void; public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void;
/** /**
* Get all active warehouses. * Get all active warehouses.
@@ -162,9 +165,10 @@ interface InventoryServiceInterface
* Get inventory summary (group by product) for a specific warehouse code * Get inventory summary (group by product) for a specific warehouse code
* *
* @param string $code * @param string $code
* @param array $filters
* @return \Illuminate\Support\Collection|null * @return \Illuminate\Support\Collection|null
*/ */
public function getPosInventoryByWarehouseCode(string $code); public function getPosInventoryByWarehouseCode(string $code, array $filters = []);
/** /**
* 處理批量入庫邏輯 (含批號產生與現有批號累加) * 處理批量入庫邏輯 (含批號產生與現有批號累加)

View File

@@ -31,6 +31,14 @@ interface ProductServiceInterface
*/ */
public function findByExternalPosIds(array $externalPosIds); public function findByExternalPosIds(array $externalPosIds);
/**
* 透過多個 ERP 內部 ID 查找產品。
*
* @param array $ids
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByIds(array $ids);
/** /**
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。 * 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
* *
@@ -78,4 +86,13 @@ interface ProductServiceInterface
* @return \App\Modules\Inventory\Models\Product|null * @return \App\Modules\Inventory\Models\Product|null
*/ */
public function findByBarcodeOrCode(?string $barcode, ?string $code); public function findByBarcodeOrCode(?string $barcode, ?string $code);
/**
* 搜尋商品(供外部 API 使用)。
*
* @param array $filters
* @param int $perPage
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function searchProducts(array $filters, int $perPage = 50);
} }

View File

@@ -49,7 +49,7 @@ class AdjustService
if (abs($item->diff_qty) < 0.0001) continue; if (abs($item->diff_qty) < 0.0001) continue;
$itemsToInsert[] = [ $itemsToInsert[] = [
'inventory_adjust_doc_id' => $adjDoc->id, 'adjust_doc_id' => $adjDoc->id,
'product_id' => $item->product_id, 'product_id' => $item->product_id,
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'qty_before' => $item->system_qty, 'qty_before' => $item->system_qty,
@@ -108,7 +108,7 @@ class AdjustService
$qtyBefore = $inventory ? $inventory->quantity : 0; $qtyBefore = $inventory ? $inventory->quantity : 0;
$itemsToInsert[] = [ $itemsToInsert[] = [
'inventory_adjust_doc_id' => $doc->id, 'adjust_doc_id' => $doc->id,
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'qty_before' => $qtyBefore, 'qty_before' => $qtyBefore,

View File

@@ -87,42 +87,58 @@ class InventoryService implements InventoryServiceInterface
return $stock >= $quantity; return $stock >= $quantity;
} }
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void
{ {
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) { DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId, $batchNumber) {
$query = Inventory::where('product_id', $productId) $defaultBatch = 'NO-BATCH';
->where('warehouse_id', $warehouseId) $targetBatch = $batchNumber ?? $defaultBatch;
->where('quantity', '>', 0); $remainingToDecrease = $quantity;
if ($slot) {
$query->where('location', $slot);
}
$inventories = $query->lockForUpdate() // 1. 優先嘗試扣除指定批號(或預設的 NO-BATCH
$inventories = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('batch_number', $targetBatch)
->where('quantity', '>', 0)
->when($slot, fn($q) => $q->where('location', $slot))
->lockForUpdate()
->orderBy('arrival_date', 'asc') ->orderBy('arrival_date', 'asc')
->get(); ->get();
$remainingToDecrease = $quantity;
foreach ($inventories as $inventory) { foreach ($inventories as $inventory) {
if ($remainingToDecrease <= 0) break; if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease); $decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason); $this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
$remainingToDecrease -= $decreaseAmount; $remainingToDecrease -= $decreaseAmount;
} }
// 2. 如果還有剩餘且剛才不是扣 NO-BATCH則嘗試從 NO-BATCH 補位
if ($remainingToDecrease > 0 && $targetBatch !== $defaultBatch) {
$fallbackInventories = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('batch_number', $defaultBatch)
->where('quantity', '>', 0)
->when($slot, fn($q) => $q->where('location', $slot))
->lockForUpdate()
->orderBy('arrival_date', 'asc')
->get();
foreach ($fallbackInventories as $inventory) {
if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
$remainingToDecrease -= $decreaseAmount;
}
}
// 3. 處理最終仍不足的情況
if ($remainingToDecrease > 0) { if ($remainingToDecrease > 0) {
if ($force) { if ($force) {
// Find any existing inventory record in this warehouse/slot to subtract from, or create one // 強制模式下,若指定批號或 NO-BATCH 均不足,統一在 NO-BATCH 建立/扣除負庫存
$query = Inventory::where('product_id', $productId) $inventory = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId); ->where('warehouse_id', $warehouseId)
->where('batch_number', $defaultBatch)
if ($slot) { ->when($slot, fn($q) => $q->where('location', $slot))
$query->where('location', $slot); ->first();
}
$inventory = $query->first();
if (!$inventory) { if (!$inventory) {
$inventory = Inventory::create([ $inventory = Inventory::create([
@@ -132,16 +148,19 @@ class InventoryService implements InventoryServiceInterface
'quantity' => 0, 'quantity' => 0,
'unit_cost' => 0, 'unit_cost' => 0,
'total_value' => 0, 'total_value' => 0,
'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(), 'batch_number' => $defaultBatch,
'arrival_date' => now(), 'arrival_date' => now(),
'origin_country' => 'TW', 'origin_country' => 'TW',
'quality_status' => 'normal', 'quality_status' => 'normal',
]); ]);
} }
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason); $this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
} else { } else {
throw new \Exception("庫存不足,無法扣除所有請求的數量。"); $context = ($targetBatch !== $defaultBatch)
? "批號 {$targetBatch}{$defaultBatch}"
: "{$defaultBatch}";
throw new \Exception("庫存不足,無法扣除所有請求的數量 ({$context})。");
} }
} }
}); });
@@ -658,7 +677,7 @@ class InventoryService implements InventoryServiceInterface
* @param string $code * @param string $code
* @return \Illuminate\Support\Collection|null * @return \Illuminate\Support\Collection|null
*/ */
public function getPosInventoryByWarehouseCode(string $code) public function getPosInventoryByWarehouseCode(string $code, array $filters = [])
{ {
$warehouse = Warehouse::where('code', $code)->first(); $warehouse = Warehouse::where('code', $code)->first();
@@ -666,19 +685,60 @@ class InventoryService implements InventoryServiceInterface
return null; return null;
} }
// 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity $query = DB::table('inventories')
return DB::table('inventories')
->join('products', 'inventories.product_id', '=', 'products.id') ->join('products', 'inventories.product_id', '=', 'products.id')
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
->leftJoin('units', 'products.base_unit_id', '=', 'units.id')
->where('inventories.warehouse_id', $warehouse->id) ->where('inventories.warehouse_id', $warehouse->id)
->whereNull('inventories.deleted_at') ->whereNull('inventories.deleted_at')
->whereNull('products.deleted_at') ->whereNull('products.deleted_at')
->select( ->select(
'products.id as product_id',
'products.external_pos_id', 'products.external_pos_id',
'products.code as product_code', 'products.code as product_code',
'products.name as product_name', 'products.name as product_name',
'products.barcode',
'categories.name as category_name',
'units.name as unit_name',
'products.price',
'products.brand',
'products.specification',
'inventories.batch_number',
'inventories.expiry_date',
DB::raw('SUM(inventories.quantity) as total_quantity') DB::raw('SUM(inventories.quantity) as total_quantity')
);
// 加入條件篩選
if (!empty($filters['product_id'])) {
$query->where('products.id', $filters['product_id']);
}
if (!empty($filters['external_pos_id'])) {
$query->where('products.external_pos_id', $filters['external_pos_id']);
}
if (!empty($filters['barcode'])) {
$query->where('products.barcode', $filters['barcode']);
}
if (!empty($filters['code'])) {
$query->where('products.code', $filters['code']);
}
return $query->groupBy(
'inventories.product_id',
'products.external_pos_id',
'products.code',
'products.name',
'products.barcode',
'categories.name',
'units.name',
'products.price',
'products.brand',
'products.specification',
'inventories.batch_number',
'inventories.expiry_date'
) )
->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name')
->get(); ->get();
} }

View File

@@ -38,9 +38,22 @@ class ProductService implements ProductServiceInterface
// Map allowed fields // Map allowed fields
$product->name = $data['name']; $product->name = $data['name'];
$product->barcode = $data['barcode'] ?? $product->barcode;
$product->price = $data['price'] ?? 0; $product->price = $data['price'] ?? 0;
// Handle Barcode
if (!empty($data['barcode'])) {
$product->barcode = $data['barcode'];
} elseif (empty($product->barcode)) {
$product->barcode = $this->generateRandomBarcode();
}
// Handle Code (SKU)
if (!empty($data['code'])) {
$product->code = $data['code'];
} elseif (empty($product->code)) {
$product->code = $this->generateRandomCode();
}
// Map newly added extended fields // Map newly added extended fields
if (isset($data['brand'])) $product->brand = $data['brand']; if (isset($data['brand'])) $product->brand = $data['brand'];
if (isset($data['specification'])) $product->specification = $data['specification']; if (isset($data['specification'])) $product->specification = $data['specification'];
@@ -48,11 +61,6 @@ class ProductService implements ProductServiceInterface
if (isset($data['member_price'])) $product->member_price = $data['member_price']; if (isset($data['member_price'])) $product->member_price = $data['member_price'];
if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_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 — 每次同步都更新(若有傳入) // Handle Category — 每次同步都更新(若有傳入)
if (!empty($data['category']) || empty($product->category_id)) { if (!empty($data['category']) || empty($product->category_id)) {
$categoryName = $data['category'] ?? '未分類'; $categoryName = $data['category'] ?? '未分類';
@@ -100,6 +108,17 @@ class ProductService implements ProductServiceInterface
return Product::whereIn('external_pos_id', $externalPosIds)->get(); return Product::whereIn('external_pos_id', $externalPosIds)->get();
} }
/**
* 透過多個 ERP 內部 ID 查找產品。
*
* @param array $ids
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByIds(array $ids)
{
return Product::whereIn('id', $ids)->get();
}
/** /**
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。 * 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
* *
@@ -178,4 +197,54 @@ class ProductService implements ProductServiceInterface
} }
return $product; return $product;
} }
/**
* 搜尋商品(供外部 API 使用)。
*
* @param array $filters
* @param int $perPage
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function searchProducts(array $filters, int $perPage = 50)
{
$query = Product::query()
->with(['category', 'baseUnit'])
->where('is_active', true);
// 1. 精準過濾 (ID, 條碼, 代碼, 外部 ID)
if (!empty($filters['product_id'])) {
$query->where('id', $filters['product_id']);
}
if (!empty($filters['barcode'])) {
$query->where('barcode', $filters['barcode']);
}
if (!empty($filters['code'])) {
$query->where('code', $filters['code']);
}
if (!empty($filters['external_pos_id'])) {
$query->where('external_pos_id', $filters['external_pos_id']);
}
// 3. 分類過濾 (優先使用 ID若傳入字串則按名稱)
if (!empty($filters['category'])) {
$categoryVal = $filters['category'];
if (is_numeric($categoryVal)) {
$query->where('category_id', $categoryVal);
} else {
$query->whereHas('category', function ($q) use ($categoryVal) {
$q->where('name', $categoryVal);
});
}
}
// 4. 增量同步 (Updated After)
if (!empty($filters['updated_after'])) {
$query->where('updated_at', '>=', $filters['updated_after']);
}
// 4. 排序 (預設按更新時間降冪)
$query->orderBy('updated_at', 'desc');
return $query->paginate($perPage);
}
} }

View File

@@ -69,6 +69,12 @@ class TurnoverService
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d')) ->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data ->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
->where(function ($q) {
$q->whereIn('inventory_transactions.reference_type', [
\App\Modules\Integration\Models\SalesOrder::class,
\App\Modules\Sales\Models\SalesImportBatch::class,
])->orWhereNull('inventory_transactions.reference_type');
})
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo) ->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
->groupBy('inventories.product_id'); ->groupBy('inventories.product_id');
@@ -87,6 +93,12 @@ class TurnoverService
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date')) ->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') ->where('inventory_transactions.type', '出庫')
->where(function ($q) {
$q->whereIn('inventory_transactions.reference_type', [
\App\Modules\Integration\Models\SalesOrder::class,
\App\Modules\Sales\Models\SalesImportBatch::class,
])->orWhereNull('inventory_transactions.reference_type');
})
->groupBy('inventories.product_id'); ->groupBy('inventories.product_id');
if ($warehouseId) { if ($warehouseId) {
@@ -199,6 +211,12 @@ class TurnoverService
// Get IDs of products sold in last 90 days // Get IDs of products sold in last 90 days
$soldProductIds = InventoryTransaction::query() $soldProductIds = InventoryTransaction::query()
->where('type', '出庫') ->where('type', '出庫')
->where(function ($q) {
$q->whereIn('reference_type', [
\App\Modules\Integration\Models\SalesOrder::class,
\App\Modules\Sales\Models\SalesImportBatch::class,
])->orWhereNull('reference_type');
})
->where('actual_time', '>=', $ninetyDaysAgo) ->where('actual_time', '>=', $ninetyDaysAgo)
->distinct() ->distinct()
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product. ->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
@@ -214,6 +232,12 @@ class TurnoverService
$soldProductIdsQuery = DB::table('inventory_transactions') $soldProductIdsQuery = DB::table('inventory_transactions')
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') ->where('inventory_transactions.type', '出庫')
->where(function ($q) {
$q->whereIn('inventory_transactions.reference_type', [
\App\Modules\Integration\Models\SalesOrder::class,
\App\Modules\Sales\Models\SalesImportBatch::class,
])->orWhereNull('inventory_transactions.reference_type');
})
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo) ->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
->select('inventories.product_id') ->select('inventories.product_id')
->distinct(); ->distinct();
@@ -236,6 +260,12 @@ class TurnoverService
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->join('products', 'inventories.product_id', '=', 'products.id') ->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventory_transactions.type', '出庫') ->where('inventory_transactions.type', '出庫')
->where(function ($q) {
$q->whereIn('inventory_transactions.reference_type', [
\App\Modules\Integration\Models\SalesOrder::class,
\App\Modules\Sales\Models\SalesImportBatch::class,
])->orWhereNull('inventory_transactions.reference_type');
})
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays)) ->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId)) ->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId)) ->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))

View File

@@ -134,15 +134,15 @@ class ProductionOrderController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$status = $request->input('status', 'draft'); $status = $request->input('status', 'draft');
$rules = [ $rules = [
'product_id' => 'required', 'product_id' => 'required',
'status' => 'nullable|in:draft,completed', 'status' => 'nullable|in:draft,pending,completed',
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', 'warehouse_id' => 'required',
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', 'output_quantity' => 'required|numeric|min:0.01',
'items' => 'nullable|array', 'items' => 'nullable|array',
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', 'items.*.inventory_id' => 'required',
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', 'items.*.quantity_used' => 'required|numeric|min:0.0001',
]; ];
$validated = $request->validate($rules); $validated = $request->validate($rules);
@@ -159,7 +159,7 @@ class ProductionOrderController extends Controller
'production_date' => $request->production_date, 'production_date' => $request->production_date,
'expiry_date' => $request->expiry_date, 'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 'status' => $status ?: ProductionOrder::STATUS_DRAFT,
'remark' => $request->remark, 'remark' => $request->remark,
]); ]);
@@ -414,6 +414,19 @@ class ProductionOrderController extends Controller
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403); return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
} }
// 送審前的資料完整性驗證
if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) {
if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) {
return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」');
}
if (!$productionOrder->warehouse_id) {
return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」');
}
if ($productionOrder->items()->count() === 0) {
return back()->with('error', '送審工單前,請至少新增一項原物料明細');
}
}
DB::transaction(function () use ($newStatus, $productionOrder, $request) { DB::transaction(function () use ($newStatus, $productionOrder, $request) {
// 使用鎖定重新獲取單據,防止併發狀態修改 // 使用鎖定重新獲取單據,防止併發狀態修改
$productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first(); $productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first();
@@ -444,6 +457,8 @@ class ProductionOrderController extends Controller
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
$actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量
$lossReason = $request->input('loss_reason'); // 耗損原因
if (!$warehouseId) { if (!$warehouseId) {
throw new \Exception('必須選擇入庫倉庫'); throw new \Exception('必須選擇入庫倉庫');
@@ -451,8 +466,14 @@ class ProductionOrderController extends Controller
if (!$batchNumber) { if (!$batchNumber) {
throw new \Exception('必須提供成品批號'); throw new \Exception('必須提供成品批號');
} }
if (!$actualOutputQuantity || $actualOutputQuantity <= 0) {
throw new \Exception('實際產出數量必須大於 0');
}
if ($actualOutputQuantity > $productionOrder->output_quantity) {
throw new \Exception('實際產出數量不可大於預計產量');
}
// --- 新增:計算原物料投入總成本 --- // --- 計算原物料投入總成本 ---
$totalCost = 0; $totalCost = 0;
$items = $productionOrder->items()->with('inventory')->get(); $items = $productionOrder->items()->with('inventory')->get();
foreach ($items as $item) { foreach ($items as $item) {
@@ -461,23 +482,25 @@ class ProductionOrderController extends Controller
} }
} }
// 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤) // 單位成本以「實際產出數量」為分母,反映真實生產效率
$unitCost = $productionOrder->output_quantity > 0 $unitCost = $actualOutputQuantity > 0
? $totalCost / $productionOrder->output_quantity ? $totalCost / $actualOutputQuantity
: 0; : 0;
// --------------------------------
// 更新單據資訊:批號、效期與自動記錄生產日期 // 更新單據資訊:批號、效期、實際產量與耗損原因
$productionOrder->output_batch_number = $batchNumber; $productionOrder->output_batch_number = $batchNumber;
$productionOrder->expiry_date = $expiryDate; $productionOrder->expiry_date = $expiryDate;
$productionOrder->production_date = now()->toDateString(); $productionOrder->production_date = now()->toDateString();
$productionOrder->warehouse_id = $warehouseId; $productionOrder->warehouse_id = $warehouseId;
$productionOrder->actual_output_quantity = $actualOutputQuantity;
$productionOrder->loss_reason = $lossReason;
// 成品入庫數量改用「實際產出數量」
$this->inventoryService->createInventoryRecord([ $this->inventoryService->createInventoryRecord([
'warehouse_id' => $warehouseId, 'warehouse_id' => $warehouseId,
'product_id' => $productionOrder->product_id, 'product_id' => $productionOrder->product_id,
'quantity' => $productionOrder->output_quantity, 'quantity' => $actualOutputQuantity,
'unit_cost' => $unitCost, // 傳入計算後的單位成本 'unit_cost' => $unitCost,
'batch_number' => $batchNumber, 'batch_number' => $batchNumber,
'box_number' => $productionOrder->output_box_count, 'box_number' => $productionOrder->output_box_count,
'arrival_date' => now()->toDateString(), 'arrival_date' => now()->toDateString(),

View File

@@ -24,6 +24,8 @@ class ProductionOrder extends Model
'product_id', 'product_id',
'warehouse_id', 'warehouse_id',
'output_quantity', 'output_quantity',
'actual_output_quantity',
'loss_reason',
'output_batch_number', 'output_batch_number',
'output_box_count', 'output_box_count',
'production_date', 'production_date',
@@ -82,6 +84,7 @@ class ProductionOrder extends Model
'production_date' => 'date', 'production_date' => 'date',
'expiry_date' => 'date', 'expiry_date' => 'date',
'output_quantity' => 'decimal:2', 'output_quantity' => 'decimal:2',
'actual_output_quantity' => 'decimal:2',
]; ];
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
@@ -91,6 +94,8 @@ class ProductionOrder extends Model
'code', 'code',
'status', 'status',
'output_quantity', 'output_quantity',
'actual_output_quantity',
'loss_reason',
'output_batch_number', 'output_batch_number',
'production_date', 'production_date',
'remark' 'remark'

View File

@@ -157,7 +157,9 @@ class SalesImportController extends Controller
$deduction['quantity'], $deduction['quantity'],
$reason, $reason,
true, // Force deduction true, // Force deduction
$deduction['slot'] // Location/Slot $deduction['slot'], // Location/Slot
\App\Modules\Sales\Models\SalesImportBatch::class,
$import->id
); );
} }

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 為生產工單新增「實際產出數量」與「耗損原因」欄位。
* 實際產出數量用於記錄完工時的真實產量(可能因耗損低於預計產量)。
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->decimal('actual_output_quantity', 10, 2)
->nullable()
->after('output_quantity')
->comment('實際產出數量(預設等於 output_quantity可於完工時調降');
$table->string('loss_reason', 255)
->nullable()
->after('actual_output_quantity')
->comment('耗損原因說明');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->dropColumn(['actual_output_quantity', 'loss_reason']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sales_orders', function (Blueprint $table) {
$table->decimal('total_qty', 12, 4)->default(0)->after('total_amount');
$table->string('name')->after('external_order_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sales_orders', function (Blueprint $table) {
$table->dropColumn(['total_qty', 'name']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Modules\Core\Models\Tenant;
class LocalTenantSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// 建立預設租戶 demo
$tenant = Tenant::firstOrCreate(
['id' => 'demo'],
['tenancy_db_name' => 'tenant_demo']
);
// 建立域名對應 localhost
$tenant->domains()->firstOrCreate(
['domain' => 'localhost'],
['tenant_id' => 'demo']
);
$this->command->info('Local tenant "demo" with domain "localhost" created/restored.');
}
}

View File

@@ -0,0 +1,64 @@
# 商品查詢 API (External Product Fetch)
本 API 供外部系統(如 POS從 Star ERP 獲取商品資料。支援分頁、關鍵字搜尋與增量同步。
## 1. 介面詳情
- **Endpoint**: `GET /api/v1/integration/products`
- **Authentication**: `Bearer Token` (Sanctum)
- **Tenant Isolation**: 需透過 `X-Tenant-Domain` Header 或網域識別租戶。
## 2. 請求參數 (Query Parameters)
| 參數名 | 類型 | 必填 | 說明 |
| :--- | :--- | :--- | :--- |
| `keyword` | `string` | 否 | 關鍵字搜尋(符合名稱、代碼、條碼或外部編號)。 |
| `category` | `string` | 否 | 分類名稱精準過濾(例如:`飲品`)。 |
| `updated_after` | `datetime` | 否 | 增量同步機制。僅回傳該時間點之後有異動的商品(格式:`Y-m-d H:i:s`)。 |
| `per_page` | `integer` | 否 | 每頁筆數(預設 50最大 100。 |
| `page` | `integer` | 否 | 分頁頁碼(預設 1。 |
## 3. 回應結構 (JSON)
### 成功回應 (200 OK)
```json
{
"status": "success",
"data": [
{
"id": 12,
"code": "COKE-001",
"barcode": "4710001",
"name": "可口可樂 600ml",
"external_pos_id": "POS-P-001",
"category_name": "飲品",
"brand": "可口可樂",
"specification": "600ml",
"unit_name": "瓶",
"price": 25.0,
"is_active": true,
"updated_at": "2026-03-19 09:30:00"
}
],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 50,
"total": 240
}
}
```
## 4. 同步策略建議
### 初次同步 (Initial Sync)
1. 第一次串接時,不帶 `updated_after`
2. 根據 `meta.last_page` 遍歷所有分頁,將全量商品存入 POS。
### 增量同步 (Incremental Sync)
1. 記錄上一次同步成功的時間戳記。
2. 定期調用 API 並帶入 `updated_after={timestamp}`
3. 僅處理回傳的商品資料進行更新或新增。
## 5. 常見錯誤
- `401 Unauthorized`: Token 無效或已過期。
- `422 Unprocessable Entity`: 參數驗證失敗(例如 `updated_after` 格式錯誤)。

13888
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,7 +54,8 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
} }
} }

View File

@@ -1,5 +1,6 @@
/** /**
* 生產工單完工入庫 - 選擇倉庫彈窗 * 生產工單完工入庫 - 選擇倉庫彈窗
* 含產出確認與耗損記錄功能
*/ */
import React from 'react'; import React from 'react';
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react"; import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
import { formatQuantity } from "@/lib/utils";
interface Warehouse { interface Warehouse {
id: number; id: number;
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
warehouseId: number; warehouseId: number;
batchNumber: string; batchNumber: string;
expiryDate: string; expiryDate: string;
actualOutputQuantity: number;
lossReason: string;
}) => void; }) => void;
warehouses: Warehouse[]; warehouses: Warehouse[];
processing?: boolean; processing?: boolean;
// 新增商品資訊以利產生批號 // 商品資訊用於產生批號
productCode?: string; productCode?: string;
productId?: number; productId?: number;
// 預計產量(用於耗損計算)
outputQuantity: number;
// 成品單位名稱
unitName?: string;
} }
export default function WarehouseSelectionModal({ export default function WarehouseSelectionModal({
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
processing = false, processing = false,
productCode, productCode,
productId, productId,
outputQuantity,
unitName = '',
}: WarehouseSelectionModalProps) { }: WarehouseSelectionModalProps) {
const [selectedId, setSelectedId] = React.useState<number | null>(null); const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [batchNumber, setBatchNumber] = React.useState<string>(""); const [batchNumber, setBatchNumber] = React.useState<string>("");
const [expiryDate, setExpiryDate] = React.useState<string>(""); const [expiryDate, setExpiryDate] = React.useState<string>("");
const [actualOutputQuantity, setActualOutputQuantity] = React.useState<string>("");
const [lossReason, setLossReason] = React.useState<string>("");
// 當開啟時,初始化實際產出數量為預計產量
React.useEffect(() => {
if (isOpen) {
setActualOutputQuantity(String(outputQuantity));
setLossReason("");
}
}, [isOpen, outputQuantity]);
// 當開啟時,嘗試產生成品批號 (若有資訊) // 當開啟時,嘗試產生成品批號 (若有資訊)
React.useEffect(() => { React.useEffect(() => {
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
} }
}, [isOpen, productCode, productId]); }, [isOpen, productCode, productId]);
// 計算耗損數量
const actualQty = parseFloat(actualOutputQuantity) || 0;
const lossQuantity = outputQuantity - actualQty;
const hasLoss = lossQuantity > 0;
const handleConfirm = () => { const handleConfirm = () => {
if (selectedId && batchNumber) { if (selectedId && batchNumber && actualQty > 0) {
onConfirm({ onConfirm({
warehouseId: selectedId, warehouseId: selectedId,
batchNumber, batchNumber,
expiryDate expiryDate,
actualOutputQuantity: actualQty,
lossReason: hasLoss ? lossReason : '',
}); });
} }
}; };
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-main"> <DialogTitle className="flex items-center gap-2 text-primary-main">
<WarehouseIcon className="h-5 w-5" /> <WarehouseIcon className="h-5 w-5" />
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-6 space-y-6"> <div className="py-6 space-y-6">
{/* 倉庫選擇 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<WarehouseIcon className="h-3 w-3" /> <WarehouseIcon className="h-3 w-3" />
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
/> />
</div> </div>
{/* 成品批號 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<Tag className="h-3 w-3" /> <Tag className="h-3 w-3" />
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
/> />
</div> </div>
{/* 成品效期 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1"> <Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<CalendarIcon className="h-3 w-3" /> <CalendarIcon className="h-3 w-3" />
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
className="h-9" className="h-9"
/> />
</div> </div>
{/* 分隔線 - 產出確認區 */}
<div className="border-t border-grey-4 pt-4">
<p className="text-xs font-bold text-grey-2 uppercase tracking-wider mb-4"></p>
{/* 預計產量(唯讀) */}
<div className="flex items-center justify-between mb-3 px-3 py-2 bg-grey-5 rounded-lg border border-grey-4">
<span className="text-sm text-grey-2"></span>
<span className="font-bold text-grey-0">
{formatQuantity(outputQuantity)} {unitName}
</span>
</div>
{/* 實際產出數量 */}
<div className="space-y-1 mb-3">
<Label className="text-xs font-medium text-grey-2">
*
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
step="1"
min="0"
max={outputQuantity}
value={actualOutputQuantity}
onChange={(e) => setActualOutputQuantity(e.target.value)}
className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`}
/>
{unitName && <span className="text-sm text-grey-2 whitespace-nowrap">{unitName}</span>}
</div>
{actualQty > outputQuantity && (
<p className="text-xs text-red-500 mt-1"></p>
)}
</div>
{/* 耗損顯示 */}
{hasLoss && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 space-y-2 animate-in fade-in duration-300">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
<span className="text-sm font-bold text-orange-700">
{formatQuantity(lossQuantity)} {unitName}
</span>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-orange-600">
()
</Label>
<Input
value={lossReason}
onChange={(e) => setLossReason(e.target.value)}
placeholder="例如:製作過程損耗、品質不合格..."
className="h-9 border-orange-200 focus:ring-orange-400"
/>
</div>
</div>
)}
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
</Button> </Button>
<Button <Button
onClick={handleConfirm} onClick={handleConfirm}
disabled={!selectedId || !batchNumber || processing} disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
className="gap-2 button-filled-primary" className="gap-2 button-filled-primary"
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />

View File

@@ -38,6 +38,8 @@ interface SearchableSelectProps {
showSearch?: boolean; showSearch?: boolean;
/** 是否可清除選取 */ /** 是否可清除選取 */
isClearable?: boolean; isClearable?: boolean;
/** 是否為無效狀態(顯示紅色邊框) */
"aria-invalid"?: boolean;
} }
export function SearchableSelect({ export function SearchableSelect({
@@ -52,6 +54,7 @@ export function SearchableSelect({
searchThreshold = 10, searchThreshold = 10,
showSearch, showSearch,
isClearable = false, isClearable = false,
"aria-invalid": ariaInvalid,
}: SearchableSelectProps) { }: SearchableSelectProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -79,12 +82,15 @@ export function SearchableSelect({
!selectedOption && "text-grey-3", !selectedOption && "text-grey-3",
// Focus state - primary border with ring // Focus state - primary border with ring
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]", "focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
// Error state
ariaInvalid && "border-destructive ring-destructive/20",
// Disabled state // Disabled state
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50", "disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
// Height // Height
"h-9", "h-9",
className className
)} )}
aria-invalid={ariaInvalid}
> >
<span className="truncate"> <span className="truncate">
{selectedOption ? selectedOption.label : placeholder} {selectedOption ? selectedOption.label : placeholder}

View File

@@ -1,10 +1,12 @@
import { useState } from "react"; import { useState, useCallback } from "react";
import { debounce } from "lodash";
import { Head, Link, router } from "@inertiajs/react"; import { Head, Link, router } from "@inertiajs/react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { import {
Search, Search,
TrendingUp, TrendingUp,
Eye, Eye,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { import {
Table, Table,
@@ -26,9 +28,11 @@ import { Can } from "@/Components/Permission/Can";
interface SalesOrder { interface SalesOrder {
id: number; id: number;
external_order_id: string; external_order_id: string;
name: string | null;
status: string; status: string;
payment_method: string; payment_method: string;
total_amount: string; total_amount: string;
total_qty: string;
sold_at: string; sold_at: string;
created_at: string; created_at: string;
source: string; source: string;
@@ -54,6 +58,8 @@ interface Props {
search?: string; search?: string;
per_page?: string; per_page?: string;
source?: string; source?: string;
status?: string;
payment_method?: string;
}; };
} }
@@ -65,6 +71,14 @@ const sourceOptions = [
{ label: "手動匯入", value: "manual_import" }, { label: "手動匯入", value: "manual_import" },
]; ];
const paymentMethodOptions = [
{ label: "全部付款方式", value: "" },
{ label: "現金", value: "cash" },
{ label: "信用卡", value: "credit_card" },
{ label: "Line Pay", value: "line_pay" },
{ label: "悠遊卡", value: "easycard" },
];
const getSourceLabel = (source: string): string => { const getSourceLabel = (source: string): string => {
switch (source) { switch (source) {
case 'pos': return 'POS'; case 'pos': return 'POS';
@@ -105,12 +119,32 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
const [search, setSearch] = useState(filters.search || ""); const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10"); const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const handleSearch = () => { const debouncedFilter = useCallback(
router.get( debounce((params: any) => {
route("integration.sales-orders.index"), router.get(route("integration.sales-orders.index"), params, {
{ ...filters, search, page: 1 }, preserveState: true,
{ preserveState: true, replace: true, preserveScroll: true } replace: true,
); preserveScroll: true,
});
}, 300),
[]
);
const handleSearchChange = (term: string) => {
setSearch(term);
debouncedFilter({
...filters,
search: term,
page: 1,
});
};
const handleFilterChange = (key: string, value: string) => {
debouncedFilter({
...filters,
[key]: value || undefined,
page: 1,
});
}; };
const handlePerPageChange = (value: string) => { const handlePerPageChange = (value: string) => {
@@ -153,38 +187,40 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
{/* 篩選列 */} {/* 篩選列 */}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-4"> <div className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<SearchableSelect <div className="flex items-center gap-2 flex-1 min-w-[300px] relative">
value={filters.source || ""} <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
onValueChange={(v) =>
router.get(
route("integration.sales-orders.index"),
{ ...filters, source: v || undefined, page: 1 },
{ preserveState: true, replace: true, preserveScroll: true }
)
}
options={sourceOptions}
className="w-[160px] h-9"
showSearch={false}
placeholder="篩選來源"
/>
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<Input <Input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} placeholder="搜尋訂單名稱或外部訂單號 (External Order ID)..."
placeholder="搜尋外部訂單號 (External Order ID)..." className="pl-10 pr-10 h-9"
className="h-9"
/> />
<Button {search && (
variant="outline" <button
size="sm" onClick={() => handleSearchChange("")}
className="button-outlined-primary h-9" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={handleSearch} >
> <Trash2 className="h-4 w-4" />
<Search className="h-4 w-4" /> </button>
</Button> )}
</div> </div>
<SearchableSelect
value={filters.source || ""}
onValueChange={(val) => handleFilterChange("source", val)}
options={sourceOptions}
className="w-[140px] h-9"
showSearch={false}
placeholder="篩選來源"
/>
<SearchableSelect
value={filters.payment_method || ""}
onValueChange={(val) => handleFilterChange("payment_method", val)}
options={paymentMethodOptions}
className="w-[160px] h-9"
showSearch={false}
placeholder="篩選付款方式"
/>
</div> </div>
</div> </div>
@@ -195,9 +231,11 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
<TableRow> <TableRow>
<TableHead className="w-[50px] text-center">#</TableHead> <TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="text-center"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="text-center"></TableHead>
@@ -206,7 +244,7 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
<TableBody> <TableBody>
{orders.data.length === 0 ? ( {orders.data.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-8 text-gray-500"> <TableCell colSpan={10} className="text-center py-8 text-gray-500">
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -219,6 +257,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
<TableCell className="font-mono text-sm"> <TableCell className="font-mono text-sm">
{order.external_order_id} {order.external_order_id}
</TableCell> </TableCell>
<TableCell className="max-w-[150px] truncate" title={order.name || ""}>
{order.name || "—"}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<StatusBadge variant={getSourceVariant(order.source)}> <StatusBadge variant={getSourceVariant(order.source)}>
{order.source_label || getSourceLabel(order.source)} {order.source_label || getSourceLabel(order.source)}
@@ -233,6 +274,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
{order.payment_method || "—"} {order.payment_method || "—"}
</TableCell> </TableCell>
<TableCell className="text-right font-medium"> <TableCell className="text-right font-medium">
{formatNumber(parseFloat(order.total_qty))}
</TableCell>
<TableCell className="text-right font-bold text-primary-main">
${formatNumber(parseFloat(order.total_amount))} ${formatNumber(parseFloat(order.total_amount))}
</TableCell> </TableCell>
<TableCell className="text-gray-500 text-sm"> <TableCell className="text-gray-500 text-sm">

View File

@@ -28,7 +28,9 @@ interface SalesOrder {
status: string; status: string;
payment_method: string; payment_method: string;
total_amount: string; total_amount: string;
total_qty: string;
sold_at: string; sold_at: string;
name: string | null;
created_at: string; created_at: string;
raw_payload: any; raw_payload: any;
items: SalesOrderItem[]; items: SalesOrderItem[];
@@ -103,6 +105,7 @@ export default function SalesOrderShow({ order }: Props) {
</div> </div>
<p className="text-sm text-gray-500 font-medium flex flex-wrap items-center gap-2"> <p className="text-sm text-gray-500 font-medium flex flex-wrap items-center gap-2">
: {formatDate(order.sold_at)} <span className="mx-1">|</span> : {formatDate(order.sold_at)} <span className="mx-1">|</span>
: {order.name || "—"} <span className="mx-1">|</span>
: {order.payment_method || "—"} <span className="mx-1">|</span> : {order.payment_method || "—"} <span className="mx-1">|</span>
: {getSourceDisplay(order.source, order.source_label)} <span className="mx-1">|</span> : {getSourceDisplay(order.source, order.source_label)} <span className="mx-1">|</span>
: {formatDate(order.created_at as any)} : {formatDate(order.created_at as any)}
@@ -146,6 +149,13 @@ export default function SalesOrderShow({ order }: Props) {
</div> </div>
<div className="mt-6 flex justify-end"> <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="w-full max-w-sm bg-primary-lightest/30 px-6 py-4 rounded-xl border border-primary-light/20 flex flex-col gap-3">
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-lg font-bold text-gray-700">
{formatNumber(parseFloat(order.total_qty))}
</span>
</div>
<div className="border-t border-primary-light/10 my-1"></div>
<div className="flex justify-between items-end w-full"> <div className="flex justify-between items-end w-full">
<span className="text-sm text-gray-500 font-medium mb-1"></span> <span className="text-sm text-gray-500 font-medium mb-1"></span>
<span className="text-2xl font-black text-primary-main"> <span className="text-2xl font-black text-primary-main">

View File

@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
const [recipes, setRecipes] = useState<any[]>([]); const [recipes, setRecipes] = useState<any[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<string>(""); const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
const { data, setData, processing, errors } = useForm({ // 提交表單
const { data, setData, processing, errors, setError, clearErrors } = useForm({
product_id: "", product_id: "",
warehouse_id: "", warehouse_id: "",
output_quantity: "", output_quantity: "",
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
remark: "", remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
}); });
@@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
// 獲取特定商品在各倉庫的庫存分佈 // 獲取特定商品在各倉庫的庫存分佈
const fetchProductInventories = async (productId: string) => { const fetchProductInventories = async (productId: string) => {
if (!productId) return; if (!productId) return;
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return; if (loadingProducts[productId]) return;
setLoadingProducts(prev => ({ ...prev, [productId]: true })); setLoadingProducts(prev => ({ ...prev, [productId]: true }));
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
item.unit_id = ""; item.unit_id = "";
item.ui_input_quantity = ""; item.ui_input_quantity = "";
item.ui_selected_unit = "base"; item.ui_selected_unit = "base";
// 清除 cache 資訊
delete item.ui_product_name; delete item.ui_product_name;
delete item.ui_batch_number; delete item.ui_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
} }
} }
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
if (field === 'ui_warehouse_id') { if (field === 'ui_warehouse_id') {
item.inventory_id = ""; item.inventory_id = "";
// 不重置數量
// item.quantity_used = "";
// item.ui_input_quantity = "";
// item.ui_selected_unit = "base";
// 清除某些 cache
delete item.ui_batch_number; delete item.ui_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
delete item.ui_expiry_date; delete item.ui_expiry_date;
} }
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) { if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || []; const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value); const inv = currentOptions.find(i => String(i.id) === value);
@@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) {
item.ui_batch_number = inv.batch_number; item.ui_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity; item.ui_available_qty = inv.quantity;
item.ui_expiry_date = inv.expiry_date || ''; item.ui_expiry_date = inv.expiry_date || '';
// 單位與轉換率
item.ui_base_unit_name = inv.unit_name || ''; item.ui_base_unit_name = inv.unit_name || '';
item.ui_base_unit_id = inv.base_unit_id; item.ui_base_unit_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id; item.ui_large_unit_id = inv.large_unit_id;
item.ui_purchase_unit_id = inv.purchase_unit_id; item.ui_purchase_unit_id = inv.purchase_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1; item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_unit_cost = inv.unit_cost || 0; item.ui_unit_cost = inv.unit_cost || 0;
// 預設單位
item.ui_selected_unit = 'base'; item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || ''); item.unit_id = String(inv.base_unit_id || '');
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
if (!item.ui_input_quantity) { if (!item.ui_input_quantity) {
item.ui_input_quantity = formatQuantity(inv.quantity); item.ui_input_quantity = formatQuantity(inv.quantity);
} }
} }
} }
// 4. 計算最終數量 (Base Quantity)
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') { if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
const inputQty = parseFloat(item.ui_input_quantity || '0'); const inputQty = parseFloat(item.ui_input_quantity || '0');
const rate = item.ui_conversion_rate || 1; const rate = item.ui_conversion_rate || 1;
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
if (item.ui_selected_unit === 'large') { item.unit_id = String(item.ui_base_unit_id || '');
item.quantity_used = String(inputQty * rate);
item.unit_id = String(item.ui_base_unit_id || '');
} else {
item.quantity_used = String(inputQty);
item.unit_id = String(item.ui_base_unit_id || '');
}
} }
updated[index] = item; updated[index] = item;
setBomItems(updated); setBomItems(updated);
}; };
// 同步 BOM items 到表單 data
useEffect(() => { useEffect(() => {
setData('items', bomItems.map(item => ({ setData('items', bomItems.map(item => ({
inventory_id: Number(item.inventory_id), inventory_id: Number(item.inventory_id),
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
}))); })));
}, [bomItems]); }, [bomItems]);
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => { const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return; if (!recipe || !recipe.items) return;
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1; const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', formatQuantity(yieldQty)); setData('output_quantity', formatQuantity(yieldQty));
const newBomItems: BomItem[] = recipe.items.map((item: any) => { const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0"); if (item.product_id) fetchProductInventories(String(item.product_id));
const calculatedQty = baseQty; // 保持精度
// 若有配方商品,預先載入庫存分佈
if (item.product_id) {
fetchProductInventories(String(item.product_id));
}
return { return {
inventory_id: "", inventory_id: "",
quantity_used: String(calculatedQty), quantity_used: String(item.quantity || "0"),
unit_id: String(item.unit_id), unit_id: String(item.unit_id),
ui_warehouse_id: "", ui_warehouse_id: "",
ui_product_id: String(item.product_id), ui_product_id: String(item.product_id),
ui_product_name: item.product_name, ui_product_name: item.product_name,
ui_batch_number: "", ui_batch_number: "",
ui_available_qty: 0, ui_available_qty: 0,
ui_input_quantity: formatQuantity(calculatedQty), ui_input_quantity: formatQuantity(item.quantity || "0"),
ui_selected_unit: 'base', ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name, ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id, ui_base_unit_id: item.unit_id,
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
}; };
}); });
setBomItems(newBomItems); setBomItems(newBomItems);
toast.success(`已自動載入配方: ${recipe.name}`);
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${formatQuantity(yieldQty)}`
});
}; };
// 當手動切換配方時
useEffect(() => { useEffect(() => {
if (!selectedRecipeId) return; if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId); const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
if (targetRecipe) { if (targetRecipe) applyRecipe(targetRecipe);
applyRecipe(targetRecipe);
}
}, [selectedRecipeId]); }, [selectedRecipeId]);
// 自動產生成品批號與載入配方
useEffect(() => { useEffect(() => {
if (!data.product_id) return; if (!data.product_id) return;
// 2. 自動載入配方列表
const fetchRecipes = async () => { const fetchRecipes = async () => {
try { try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id)); const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json(); const recipesData = await res.json();
if (Array.isArray(recipesData) && recipesData.length > 0) { if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData); setRecipes(recipesData);
// 預設選取最新的 (第一個) setSelectedRecipeId(String(recipesData[0].id));
const latest = recipesData[0];
setSelectedRecipeId(String(latest.id));
} else { } else {
// 若無配方
setRecipes([]); setRecipes([]);
setSelectedRecipeId(""); setSelectedRecipeId("");
setBomItems([]); // 清空 BOM setBomItems([]);
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch recipes", e);
setRecipes([]); setRecipes([]);
setBomItems([]); setBomItems([]);
} }
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
fetchRecipes(); fetchRecipes();
}, [data.product_id]); }, [data.product_id]);
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量 // 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
useEffect(() => { useEffect(() => {
if (bomItems.length > 0 && data.output_quantity) { const errorKeys = Object.keys(errors);
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號 if (errorKeys.length > 0) {
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾 // 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
// 但如果是剛載入inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性 setTimeout(() => {
const firstInvalid = document.querySelector('[aria-invalid="true"]');
if (firstInvalid instanceof HTMLElement) {
firstInvalid.focus();
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
} }
}, [data.output_quantity]); }, [errors]);
// 提交表單 const submit = (status: 'draft') => {
const submit = (status: 'draft' | 'completed') => { clearErrors();
// 驗證(簡單前端驗證,完整驗證在後端) let hasError = false;
if (status === 'completed') {
const missingFields = [];
if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量');
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) { // 草稿建立時也要求必填生產數量與預計入庫倉庫
toast.error("請填寫必要欄位", { if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
description: `缺漏:${missingFields.join('、')}` if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
}); if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
return; if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
// 驗證 BOM 明細
bomItems.forEach((item, index) => {
if (!item.ui_product_id) {
setError(`items.${index}.ui_product_id` as any, '請選擇商品');
hasError = true;
} else {
if (!item.inventory_id) {
setError(`items.${index}.inventory_id` as any, '請選擇批號');
hasError = true;
}
if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
setError(`items.${index}.quantity_used` as any, '請輸入數量');
hasError = true;
}
} }
});
if (hasError) {
toast.error("建立失敗,請檢查標單內紅框欄位");
return;
} }
// 轉換 BOM items 格式 const formattedItems = bomItems.map(item => ({
const formattedItems = bomItems inventory_id: parseInt(item.inventory_id),
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used)) quantity_used: parseFloat(item.quantity_used),
.map(item => ({ unit_id: item.unit_id ? parseInt(item.unit_id) : null,
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null, }));
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
}));
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), { router.post(route('production-orders.store'), {
...data, ...data,
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems, items: formattedItems,
status: status, status: status,
}, { }, {
onError: (errors) => { onError: () => {
const errorCount = Object.keys(errors).length; toast.error("建立失敗,請檢查表單");
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
} }
}); });
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
submit('completed'); submit('draft');
}; };
const getBomItemUnitCost = (item: BomItem) => { const getBomItemUnitCost = (item: BomItem) => {
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" /> <Factory className="h-6 w-6 text-primary-main" />
</h1> </h1>
@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) {
</p> </p>
</div> </div>
<Button <div className="flex items-center gap-3">
onClick={() => submit('draft')} <Button
disabled={processing} type="button"
className="gap-2 button-filled-primary" variant="default"
> onClick={() => submit('draft')}
<Save className="h-4 w-4" /> disabled={processing}
(稿) className="button-filled-primary gap-2"
</Button> >
<Save className="h-4 w-4" />
(稿)
</Button>
</div>
</div> </div>
</div> </div>
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
}))} }))}
placeholder="選擇成品" placeholder="選擇成品"
className="w-full h-9" className="w-full h-9"
aria-invalid={!!errors.product_id}
/> />
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>} {errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
@@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
onChange={(e) => setData('output_quantity', e.target.value)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
className="h-9 font-mono" className="h-9 font-mono"
aria-invalid={!!errors.output_quantity}
/> />
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>} {errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div> </div>
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
}))} }))}
placeholder="選擇倉庫" placeholder="選擇倉庫"
className="w-full h-9" className="w-full h-9"
aria-invalid={!!errors.warehouse_id}
/> />
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>} {errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
</div> </div>
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
options={productOptions} options={productOptions}
placeholder="選擇商品" placeholder="選擇商品"
className="w-full" className="w-full"
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
/> />
</TableCell> </TableCell>
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"} placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full" className="w-full"
disabled={!item.ui_warehouse_id} disabled={!item.ui_warehouse_id}
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
@@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
placeholder="0" placeholder="0"
className="h-9 text-right" className="h-9 text-right"
disabled={!item.inventory_id} disabled={!item.inventory_id}
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
/> />
</TableCell> </TableCell>

View File

@@ -56,6 +56,8 @@ interface ProductionOrder {
output_batch_number: string; output_batch_number: string;
output_box_count: string | null; output_box_count: string | null;
output_quantity: number; output_quantity: number;
actual_output_quantity: number | null;
loss_reason: string | null;
production_date: string; production_date: string;
expiry_date: string | null; expiry_date: string | null;
status: ProductionOrderStatus; status: ProductionOrderStatus;
@@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
warehouseId?: number; warehouseId?: number;
batchNumber?: string; batchNumber?: string;
expiryDate?: string; expiryDate?: string;
actualOutputQuantity?: number;
lossReason?: string;
}) => { }) => {
router.patch(route('production-orders.update-status', productionOrder.id), { router.patch(route('production-orders.update-status', productionOrder.id), {
status: newStatus, status: newStatus,
warehouse_id: extraData?.warehouseId, warehouse_id: extraData?.warehouseId,
output_batch_number: extraData?.batchNumber, output_batch_number: extraData?.batchNumber,
expiry_date: extraData?.expiryDate, expiry_date: extraData?.expiryDate,
actual_output_quantity: extraData?.actualOutputQuantity,
loss_reason: extraData?.lossReason,
}, { }, {
onSuccess: () => { onSuccess: () => {
setIsWarehouseModalOpen(false); setIsWarehouseModalOpen(false);
@@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
processing={processing} processing={processing}
productCode={productionOrder.product?.code} productCode={productionOrder.product?.code}
productId={productionOrder.product?.id} productId={productionOrder.product?.id}
outputQuantity={Number(productionOrder.output_quantity)}
unitName={productionOrder.product?.base_unit?.name}
/> />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500"> <div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
</p> </p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">/</p> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<p className="font-bold text-grey-0 text-xl"> <p className="font-bold text-grey-0 text-xl">
{formatQuantity(productionOrder.output_quantity)} {formatQuantity(productionOrder.output_quantity)}
@@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
)} )}
</div> </div>
</div> </div>
{/* 實際產量與耗損(僅完成狀態顯示) */}
{productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && (
<div className="space-y-1.5">
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="flex items-baseline gap-1.5">
<p className="font-bold text-grey-0 text-xl">
{formatQuantity(productionOrder.actual_output_quantity)}
</p>
{productionOrder.product?.base_unit?.name && (
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
)}
{Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && (
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold border border-orange-200">
{formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))}
</span>
)}
</div>
{productionOrder.loss_reason && (
<p className="text-xs text-orange-600 mt-1">{productionOrder.loss_reason}</p>
)}
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4"> <div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">

View File

@@ -14,7 +14,61 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
--- ---
## 1. 品資料同步 (Upsert Product) ## 1. 品資料讀取 (Product Retrieval)
此 API 用於讓外部系統(如 POS依據條件從 ERP 中批量抓取商品資料。支援分頁與增量同步。
- **Endpoint**: `/products`
- **Method**: `GET`
### Query Parameters
| 參數名稱 | 類型 | 必填 | 說明 |
| :--- | :--- | :---: | :--- |
| `product_id` | Integer| 否 | 依 ERP 商品 ID (`products.id`) 精準篩選 |
| `external_pos_id` | String | 否 | 依外部 POS 端的唯一識別碼 (`external_pos_id`) 精準篩選 |
| `barcode` | String | 否 | 依商品條碼 (Barcode) 精準篩選 |
| `code` | String | 否 | 依商品代碼 (Code) 精準篩選 |
| `category` | String | 否 | 分類名稱精準過濾 |
| `updated_after` | String | 否 | 增量同步機制。僅回傳該時間點之後有異動的商品 (格式: `YYYY-MM-DD HH:mm:ss`) |
| `per_page` | Integer| 否 | 每頁筆數 (預設 50, 最大 100) |
| `page` | Integer| 否 | 分頁頁碼 (預設 1) |
### Response
**Success (HTTP 200)**
僅開放公開價格 `price`,隱藏敏感成本與會員價。
```json
{
"status": "success",
"data": [
{
"id": 12,
"code": "PROD-001",
"barcode": "4710001",
"name": "可口可樂 600ml",
"external_pos_id": "POS-P-001",
"category_name": "飲品",
"brand": "可口可樂",
"specification": "600ml",
"unit_name": "瓶",
"price": 25.0,
"is_active": true,
"updated_at": "2026-03-19 09:30:00"
}
],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 50,
"total": 240
}
}
```
---
## 2. 商品資料同步 (Upsert Product)
此 API 用於將第三方系統(如 POS的產品資料單向同步至 ERP。採用 Upsert 邏輯:若 `external_pos_id` 存在則更新資料,不存在則新增產品。 此 API 用於將第三方系統(如 POS的產品資料單向同步至 ERP。採用 Upsert 邏輯:若 `external_pos_id` 存在則更新資料,不存在則新增產品。
@@ -23,14 +77,15 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
### Request Body (JSON) ### Request Body (JSON)
| 欄位名稱 | 類型 | 必填 | 說明 | | 參數名稱 | 類型 | 必填 | 說明 |
| :--- | :--- | :---: | :--- | | :--- | :--- | :---: | :--- |
| `external_pos_id` | String | **是** | 在 POS 系統中的唯一商品 ID (Primary Key) | | `external_pos_id` | String | **是** | 在 POS 系統中的唯一商品 ID (Primary Key) |
| `name` | String | **是** | 商品名稱 (最大 255 字元) | | `name` | String | **是** | 商品名稱 (最大 255 字元) |
| `category` | String | **是** | 商品分類名稱。若系統中不存在則自動建立 (最大 100 字元) |
| `unit` | String | **是** | 商品單位 (例如:個、杯、件)。若不存在則自動建立 (最大 100 字元) |
| `code` | String | 否 | 商品代碼。若未提供將由 ERP 自動產生 (最大 100 字元) |
| `price` | Decimal | 否 | 商品售價 (預設 0) | | `price` | Decimal | 否 | 商品售價 (預設 0) |
| `barcode` | String | 否 | 商品條碼 (最大 100 字元) | | `barcode` | String | 否 | 商品條碼 (最大 100 字元)。若未提供將由 ERP 自動產生 |
| `category` | String | 否 | 商品分類名稱。若系統中不存在,會自動建立 (最大 100 字元) |
| `unit` | String | 否 | 商品單位 (例如:個、杯、件)。若不存在會自動建立 (最大 100 字元) |
| `brand` | String | 否 | 商品品牌名稱 (最大 100 字元) | | `brand` | String | 否 | 商品品牌名稱 (最大 100 字元) |
| `specification` | String | 否 | 商品規格描述 (最大 255 字元) | | `specification` | String | 否 | 商品規格描述 (最大 255 字元) |
| `cost_price` | Decimal | 否 | 成本價 (預設 0) | | `cost_price` | Decimal | 否 | 成本價 (預設 0) |
@@ -44,10 +99,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
{ {
"external_pos_id": "POS-PROD-9001", "external_pos_id": "POS-PROD-9001",
"name": "特級冷壓初榨橄欖油 500ml", "name": "特級冷壓初榨橄欖油 500ml",
"price": 380.00,
"barcode": "4711234567890",
"category": "調味料", "category": "調味料",
"unit": "瓶", "unit": "瓶",
"price": 380.00,
"barcode": "4711234567890",
"brand": "健康王", "brand": "健康王",
"specification": "500ml / 玻璃瓶裝", "specification": "500ml / 玻璃瓶裝",
"cost_price": 250.00, "cost_price": 250.00,
@@ -58,22 +113,34 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
} }
``` ```
> [!TIP]
> **自動編碼與分類機制**
> - **必填項**`category``unit` 為必填。若 ERP 中無對應名稱,將會依據傳入值自動建立。
> - **自動編碼**:若未提供 `code` (商品代碼),將由 ERP 自動產生 8 位隨機代碼。
> - **自動條碼**:若未提供 `barcode` (條碼),將由 ERP 自動產生 13 位隨機數字條碼。
> - 建議在同步後儲存回傳的 `id``code``barcode`,以利後續精確對接。
### Response ### Response
**Success (HTTP 200)** 回傳 ERP 端的完整商品主檔資訊,供外部系統回存 ID 或代碼。
### 回傳範例 (Success)
- **Status Code**: `200 OK`
```json ```json
{ {
"message": "Product synced successfully", "message": "Product synced successfully",
"data": { "data": {
"id": 15, "id": 1,
"external_pos_id": "POS-ITEM-001" "external_pos_id": "POS-P-999",
"code": "A1B2C3D4",
"barcode": "4710009990001"
} }
} }
``` ```
--- ---
## 2. 門市庫存查詢 (Query Inventory) ## 3. 門市庫存查詢 (Query Inventory)
此 API 用於讓外部系統(如 POS依據特定的「倉庫代碼」查詢該倉庫目前所有商品的庫存餘額。 此 API 用於讓外部系統(如 POS依據特定的「倉庫代碼」查詢該倉庫目前所有商品的庫存餘額。
**注意**:此 API 會回傳該倉庫內的所有商品數量,不論該商品是否已綁定外部 POS ID。 **注意**:此 API 會回傳該倉庫內的所有商品數量,不論該商品是否已綁定外部 POS ID。
@@ -85,27 +152,55 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
| 參數名稱 | 類型 | 必填 | 說明 | | 參數名稱 | 類型 | 必填 | 說明 |
| :--- | :--- | :---: | :--- | | :--- | :--- | :---: | :--- |
| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`) | | `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`,測試可使用預設建立之 `api-test-01`) |
### Query Parameters (選填)
| 參數名稱 | 類型 | 說明 |
| :--- | :--- | :--- |
| `product_id` | String | 依 ERP 商品 ID (`products.id`) 篩選。 |
| `external_pos_id` | String | 依外部 POS 端的唯一識別碼 (`external_pos_id`) 篩選。 |
| `barcode` | String | 依商品條碼 (Barcode) 篩選商品。 |
| `code` | String | 依商品代碼 (Code) 篩選商品。 |
若不帶任何參數,將回傳該倉庫下所有商品的庫存餘額。
### Response ### Response
**Success (HTTP 200)** **Success (HTTP 200)**
回傳該倉庫內所有的商品目前庫存總數。若商品未建置 `external_pos_id`,該欄位將顯示為 `null` 回傳該倉庫內所有的商品目前庫存總數及詳細資訊。若商品未建置 `external_pos_id`,該欄位將顯示為 `null`
```json ```json
{ {
"status": "success", "status": "success",
"warehouse_code": "STORE-001", "warehouse_code": "api-test-01",
"data": [ "data": [
{ {
"external_pos_id": "POS-ITEM-001", "product_id": 1,
"external_pos_id": "PROD-001",
"product_code": "PROD-A001", "product_code": "PROD-A001",
"product_name": "特級冷壓初榨橄欖油 500ml", "product_name": "特級冷壓初榨橄欖油 500ml",
"barcode": "4710123456789",
"category_name": "調味料",
"unit_name": "瓶",
"price": 450.00,
"brand": "奧利塔",
"specification": "500ml/瓶",
"batch_number": "PROD-A001-TW-20231026-01",
"expiry_date": "2025-10-26",
"quantity": 15 "quantity": 15
}, },
{ {
"external_pos_id": null, "external_pos_id": null,
"product_code": "MAT-001", "product_code": "MAT-001",
"product_name": "未包裝干貝醬原料", "product_name": "未包裝干貝醬原料",
"barcode": null,
"category_name": "原料",
"unit_name": "kg",
"price": 0.00,
"brand": null,
"specification": null,
"batch_number": "MAT-001-TW-20231020-01",
"expiry_date": "2024-04-20",
"quantity": 2.5 "quantity": 2.5
} }
] ]
@@ -123,10 +218,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
--- ---
## 3. 訂單資料寫入與扣庫 (Create Order) ## 4. 訂單資料寫入與扣庫 (Create Order)
此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。 此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。
**重要提醒**:寫入訂單的同ERP 會自動且**強制**扣除對應倉庫的庫存(允許扣至負數,以利後續盤點或補單) **重要提醒**寫入訂單時ERP 會無條件扣除庫存。若指定的「批號」庫存不足,系統會自動轉向 `NO-BATCH` 庫存項目扣除;若最終仍不足,則會在 `NO-BATCH` 產生負數庫存
- **Endpoint**: `/orders` - **Endpoint**: `/orders`
- **Method**: `POST` - **Method**: `POST`
@@ -136,8 +231,11 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
| 欄位名稱 | 型態 | 必填 | 說明 | | 欄位名稱 | 型態 | 必填 | 說明 |
| :--- | :--- | :---: | :--- | | :--- | :--- | :---: | :--- |
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) | | `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`STORE-001`)。若找不到對應倉庫將直接拒絕請求 | | `name` | String | **是** | 訂單名稱或客戶名稱 (最多 255 字元) |
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`api-test-01` 測試倉)。若找不到對應倉庫將直接拒絕請求 |
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` | | `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
| `total_amount` | Number | **是** | 整筆訂單的總交易金額 (例如500) |
| `total_qty` | Number | **是** | 整筆訂單的商品總數量 (例如5) |
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) | | `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 | | `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
@@ -145,27 +243,29 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
| 欄位名稱 | 型態 | 必填 | 說明 | | 欄位名稱 | 型態 | 必填 | 說明 |
| :--- | :--- | :---: | :--- | | :--- | :--- | :---: | :--- |
| `pos_product_id` | String | **是** | 對應產品的 `external_pos_id`。**注意:商品必須先透過產品同步 API 建立至 ERP 中。** | | `product_id` | Integer | **是** | **ERP 內部的商品 ID (`id`)**。請優先使用商品同步 API 取得此 ID |
| `batch_number` | String | 否 | **商品批號**。若提供,將優先扣除該批號庫存;若該批號無剩餘或找不到,將自動 fallback 至 `NO-BATCH` 扣除 |
| `qty` | Number | **是** | 銷售數量 (必須 > 0) | | `qty` | Number | **是** | 銷售數量 (必須 > 0) |
| `price` | Number | **是** | 銷售單價 | | `price` | Number | **是** | 銷售單價 |
**注意**:請確保傳入正確的 `product_id` 以便 ERP 準確識別商品與扣除庫存。
**請求範例:** **請求範例:**
```json ```json
{ {
"external_order_id": "ORD-20231026-0001", "external_order_id": "ORD-20231024-001",
"warehouse_code": "STORE-001", "name": "陳小明-干貝醬訂購",
"warehouse_code": "STORE-01",
"payment_method": "credit_card", "payment_method": "credit_card",
"sold_at": "2023-10-26 14:30:00", "total_amount": 1050,
"total_qty": 3,
"sold_at": "2023-10-24 15:30:00",
"items": [ "items": [
{ {
"pos_product_id": "POS-ITEM-001", "product_id": 15,
"batch_number": "BATCH-2024-A1",
"qty": 2, "qty": 2,
"price": 450 "price": 500
},
{
"pos_product_id": "POS-ITEM-005",
"qty": 1,
"price": 120
} }
] ]
} }
@@ -181,17 +281,9 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
} }
``` ```
**Error: Product Not Found (HTTP 400)**
`items` 內傳入了未曾同步過的 `pos_product_id`,會導致寫入失敗。
```json
{
"message": "Sync failed: Product not found for POS ID: POS-ITEM-999. Please sync product first."
}
```
#### 錯誤回應 (HTTP 422 Unprocessable Entity - 驗證失敗) #### 錯誤回應 (HTTP 422 Unprocessable Entity - 驗證失敗)
當傳入資料格式有誤、商品編號不存在,或是該訂單正在處理中被系統鎖定時返回。 當傳入資料格式有誤、商品 ID 於系統中不存在(如 `items` 內傳入了無法辨識的商品 ID或是倉庫代碼無效時返回。
- **`message`** (字串): 錯誤摘要。 - **`message`** (字串): 錯誤摘要。
- **`errors`** (物件): 具體的錯誤明細列表,能一次性回報多個商品不存在或其它欄位錯誤。 - **`errors`** (物件): 具體的錯誤明細列表,能一次性回報多個商品不存在或其它欄位錯誤。
@@ -201,10 +293,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
"message": "Validation failed", "message": "Validation failed",
"errors": { "errors": {
"items": [ "items": [
"The following products are not found: POS-999, POS-888. Please sync products first." "The following product IDs are not found: 15, 22. Please ensure these products exist in the system."
], ],
"external_order_id": [ "warehouse_code": [
"The order ORD-01 is currently being processed by another transaction. Please try again later." "Warehouse with code STORE-999 not found."
] ]
} }
} }

View File

@@ -0,0 +1,211 @@
<?php
namespace Tests\Feature\Integration;
use Tests\TestCase;
use App\Modules\Core\Models\Tenant;
use App\Modules\Core\Models\User;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
class InventoryQueryApiTest extends TestCase
{
protected $tenant;
protected $user;
protected $domain;
protected $warehouse;
protected function setUp(): void
{
parent::setUp();
Artisan::call('migrate:fresh');
$this->domain = 'inventory-test-' . Str::random(8) . '.erp.local';
$tenantId = 'test_tenant_inv_' . Str::random(8);
tenancy()->central(function () use ($tenantId) {
$this->tenant = Tenant::create([
'id' => $tenantId,
'name' => 'Inventory Test Tenant',
]);
$this->tenant->domains()->create(['domain' => $this->domain]);
});
tenancy()->initialize($this->tenant);
Artisan::call('tenants:migrate');
$this->user = User::factory()->create(['name' => 'Inventory Admin']);
// 建立測試資料
$cat = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
$p1 = Product::create([
'name' => '可口可樂',
'code' => 'COKE-001',
'barcode' => '4710001',
'external_pos_id' => 'POS-COKE',
'price' => 25,
'category_id' => $cat->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
$p2 = Product::create([
'name' => '百事可樂',
'code' => 'PEPSI-001',
'barcode' => '4710002',
'external_pos_id' => 'POS-PEPSI',
'price' => 23,
'category_id' => $cat->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
$this->warehouse = Warehouse::create([
'name' => '台北門市倉',
'code' => 'WH-TP-01',
'type' => 'retail',
]);
// 建立庫存
Inventory::create([
'warehouse_id' => $this->warehouse->id,
'product_id' => $p1->id,
'quantity' => 100,
'unit_cost' => 15,
'total_value' => 1500,
'batch_number' => 'BATCH-001',
'arrival_date' => now(),
]);
Inventory::create([
'warehouse_id' => $this->warehouse->id,
'product_id' => $p2->id,
'quantity' => 50,
'unit_cost' => 12,
'total_value' => 600,
'batch_number' => 'BATCH-002',
'arrival_date' => now(),
]);
tenancy()->end();
}
protected function tearDown(): void
{
if ($this->tenant) {
$this->tenant->delete();
}
parent::tearDown();
}
public function test_can_query_all_inventory_for_warehouse()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}");
$response->assertStatus(200)
->assertJsonPath('status', 'success')
->assertJsonCount(2, 'data');
}
public function test_can_filter_inventory_by_product_id()
{
Sanctum::actingAs($this->user, ['*']);
// 先找出可樂的 ERP ID
$productId = Product::where('code', 'COKE-001')->value('id');
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id={$productId}");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.product_code', 'COKE-001')
->assertJsonPath('data.0.quantity', 100);
}
public function test_can_filter_inventory_by_external_pos_id()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?external_pos_id=POS-COKE");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.external_pos_id', 'POS-COKE')
->assertJsonPath('data.0.quantity', 100);
}
public function test_can_filter_inventory_by_barcode()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?barcode=4710002");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.product_code', 'PEPSI-001')
->assertJsonPath('data.0.quantity', 50);
}
public function test_can_filter_inventory_by_code()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?code=COKE-001");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.product_code', 'COKE-001');
}
public function test_returns_empty_when_no_match()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id=NON-EXISTENT");
$response->assertStatus(200)
->assertJsonCount(0, 'data');
}
public function test_returns_404_when_warehouse_not_found()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/INVALID-WH");
$response->assertStatus(404);
}
}

View File

@@ -7,7 +7,10 @@ use App\Modules\Core\Models\Tenant;
use App\Modules\Core\Models\User; use App\Modules\Core\Models\User;
use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Models\Product;
use App\Modules\Integration\Models\SalesOrder; use App\Modules\Integration\Models\SalesOrder;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Stancl\Tenancy\Facades\Tenancy; use Stancl\Tenancy\Facades\Tenancy;
class PosApiTest extends TestCase class PosApiTest extends TestCase
@@ -26,9 +29,9 @@ class PosApiTest extends TestCase
{ {
parent::setUp(); parent::setUp();
\Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
$this->domain = 'test-' . \Illuminate\Support\Str::random(8) . '.erp.local'; $this->domain = 'test-' . Str::random(8) . '.erp.local';
$tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8); $tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8);
// Ensure we are in central context // Ensure we are in central context
@@ -45,7 +48,7 @@ class PosApiTest extends TestCase
// Initialize to create User and Token // Initialize to create User and Token
tenancy()->initialize($this->tenant); tenancy()->initialize($this->tenant);
\Artisan::call('tenants:migrate'); Artisan::call('tenants:migrate');
$this->user = User::factory()->create([ $this->user = User::factory()->create([
'email' => 'admin@test.local', 'email' => 'admin@test.local',
@@ -83,26 +86,33 @@ class PosApiTest extends TestCase
{ {
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$payload = [ $productData = [
'external_pos_id' => 'EXT-NEW-002', 'external_pos_id' => 'POS-P-999',
'name' => 'New Product', 'name' => '新款可口可樂',
'price' => 200, 'price' => 29.0,
'sku' => 'SKU-NEW', 'barcode' => '471000999',
'category' => '飲品',
'unit' => '瓶',
]; ];
$response = $this->withHeaders([ $response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain, 'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json', 'Accept' => 'application/json',
])->postJson('/api/v1/integration/products/upsert', $payload); ])->postJson('/api/v1/integration/products/upsert', $productData);
$response->assertStatus(200) $response->assertStatus(200)
->assertJsonPath('message', 'Product synced successfully'); ->assertJsonPath('data.external_pos_id', 'POS-P-999')
->assertJsonStructure([
'data' => [
'id', 'external_pos_id', 'code', 'barcode'
]
]);
// Verify in Tenant DB // Verify in Tenant DB
tenancy()->initialize($this->tenant); tenancy()->initialize($this->tenant);
$this->assertDatabaseHas('products', [ $this->assertDatabaseHas('products', [
'external_pos_id' => 'EXT-NEW-002', 'external_pos_id' => 'POS-P-999',
'name' => 'New Product', 'name' => '新款可口可樂',
]); ]);
tenancy()->end(); tenancy()->end();
} }
@@ -115,6 +125,8 @@ class PosApiTest extends TestCase
'external_pos_id' => 'EXT-001', 'external_pos_id' => 'EXT-001',
'name' => 'Updated Name', 'name' => 'Updated Name',
'price' => 150, 'price' => 150,
'category' => '飲品',
'unit' => '瓶',
]; ];
$response = $this->withHeaders([ $response = $this->withHeaders([
@@ -147,7 +159,7 @@ class PosApiTest extends TestCase
'product_id' => $product->id, 'product_id' => $product->id,
'warehouse_id' => $warehouse->id, 'warehouse_id' => $warehouse->id,
'quantity' => 100, 'quantity' => 100,
'batch_number' => 'BATCH-TEST-001', 'batch_number' => 'NO-BATCH', // 改為系統預設值
'arrival_date' => now()->toDateString(), 'arrival_date' => now()->toDateString(),
'origin_country' => 'TW', 'origin_country' => 'TW',
]); ]);
@@ -159,11 +171,15 @@ class PosApiTest extends TestCase
$payload = [ $payload = [
'external_order_id' => 'ORD-001', 'external_order_id' => 'ORD-001',
'name' => '測試訂單一號',
'warehouse_code' => 'MAIN', 'warehouse_code' => 'MAIN',
'payment_method' => 'cash',
'total_amount' => 500, // 5 * 100
'total_qty' => 5,
'sold_at' => now()->toIso8601String(), 'sold_at' => now()->toIso8601String(),
'items' => [ 'items' => [
[ [
'pos_product_id' => 'EXT-001', 'product_id' => $product->id,
'qty' => 5, 'qty' => 5,
'price' => 100 'price' => 100
] ]
@@ -175,6 +191,9 @@ class PosApiTest extends TestCase
'Accept' => 'application/json', 'Accept' => 'application/json',
])->postJson('/api/v1/integration/orders', $payload); ])->postJson('/api/v1/integration/orders', $payload);
$response->assertStatus(201)
->assertJsonPath('message', 'Order synced and stock deducted successfully');
$response->assertStatus(201) $response->assertStatus(201)
->assertJsonPath('message', 'Order synced and stock deducted successfully'); ->assertJsonPath('message', 'Order synced and stock deducted successfully');
@@ -196,7 +215,323 @@ class PosApiTest extends TestCase
'warehouse_id' => $warehouseId, 'warehouse_id' => $warehouseId,
'quantity' => 95, 'quantity' => 95,
]); ]);
$order = \App\Modules\Integration\Models\SalesOrder::where('external_order_id', 'ORD-001')->first();
$this->assertDatabaseHas('inventory_transactions', [
'reference_type' => \App\Modules\Integration\Models\SalesOrder::class,
'reference_id' => $order->id,
'quantity' => -5,
'type' => '出庫',
]);
}
public function test_order_creation_with_batch_number_deduction()
{
tenancy()->initialize($this->tenant);
$product = Product::where('code', 'P-001')->first();
$warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
// Scenario: Two records for the same product, one with batch, one without
\App\Modules\Inventory\Models\Inventory::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 10,
'batch_number' => 'BATCH-A',
'arrival_date' => now()->toDateString(),
]);
\App\Modules\Inventory\Models\Inventory::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 5,
'batch_number' => 'NO-BATCH',
'arrival_date' => now()->toDateString(),
]);
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 1. Test deducting from SPECIFIC batch
$payloadA = [
'external_order_id' => 'ORD-BATCH-A',
'name' => '測試訂單A',
'warehouse_code' => 'MAIN',
'payment_method' => 'cash',
'total_amount' => 300, // 3 * 100
'total_qty' => 3,
'items' => [
[
'product_id' => $product->id,
'batch_number' => 'BATCH-A',
'qty' => 3,
'price' => 100
]
]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payloadA)
->assertStatus(201);
// 2. Test deducting from NULL batch (default)
$payloadNull = [
'external_order_id' => 'ORD-BATCH-NULL',
'name' => '無批號測試',
'warehouse_code' => 'MAIN',
'payment_method' => 'cash',
'total_amount' => 200, // 2 * 100
'total_qty' => 2,
'items' => [
[
'product_id' => $product->id,
'batch_number' => null, // 測試不傳入批號時應自動轉向 NO-BATCH
'qty' => 2,
'price' => 100
]
]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payloadNull)
->assertStatus(201);
tenancy()->initialize($this->tenant);
// Verify BATCH-A: 10 - 3 = 7
$this->assertDatabaseHas('inventories', [
'product_id' => $product->id,
'batch_number' => 'BATCH-A',
'quantity' => 7,
]);
// Verify NO-BATCH batch: 5 - 2 = 3
$this->assertDatabaseHas('inventories', [
'product_id' => $product->id,
'batch_number' => 'NO-BATCH',
'quantity' => 3,
]);
tenancy()->end();
}
public function test_upsert_auto_generates_code_and_barcode_if_missing()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$productData = [
'external_pos_id' => 'POS-AUTO-GEN-01',
'name' => '自動編號商品',
'price' => 50.0,
'category' => '測試分類',
'unit' => '個',
];
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->postJson('/api/v1/integration/products/upsert', $productData);
$response->assertStatus(200);
$data = $response->json('data');
$this->assertNotEmpty($data['code']);
$this->assertNotEmpty($data['barcode']);
$this->assertEquals(8, strlen($data['code']));
$this->assertEquals(13, strlen($data['barcode']));
// Ensure they are stored in DB
tenancy()->initialize($this->tenant);
$this->assertDatabaseHas('products', [
'external_pos_id' => 'POS-AUTO-GEN-01',
'code' => $data['code'],
'barcode' => $data['barcode'],
]);
tenancy()->end();
}
public function test_inventory_query_returns_detailed_info()
{
tenancy()->initialize($this->tenant);
// 1. 建立具有完整資訊的商品
$category = \App\Modules\Inventory\Models\Category::create(['name' => '測試大類']);
$unit = \App\Modules\Inventory\Models\Unit::create(['name' => '測試單位']);
$product = Product::create([
'external_pos_id' => 'DETAIL-001',
'code' => 'DET-001',
'barcode' => '1234567890123',
'name' => '詳盡商品',
'category_id' => $category->id,
'base_unit_id' => $unit->id,
'price' => 123.45,
'brand' => '品牌A',
'specification' => '規格B',
'is_active' => true,
]);
$warehouse = \App\Modules\Inventory\Models\Warehouse::create(['name' => '詳盡倉庫', 'code' => 'DETAIL-WH']);
// 2. 增加庫存
\App\Modules\Inventory\Models\Inventory::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 10.5,
'batch_number' => 'BATCH-DETAIL-01',
'arrival_date' => now(),
'expiry_date' => now()->addYear(),
'origin_country' => 'TW',
]);
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 3. 查詢該倉庫庫存
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/inventory/DETAIL-WH');
$response->assertStatus(200)
->assertJsonStructure([
'status',
'warehouse_code',
'data' => [
'*' => [
'product_id',
'external_pos_id',
'product_code',
'product_name',
'barcode',
'category_name',
'unit_name',
'price',
'brand',
'specification',
'batch_number',
'expiry_date',
'quantity'
]
]
]);
$data = $response->json('data.0');
$this->assertEquals($product->id, $data['product_id']);
$this->assertEquals('DETAIL-001', $data['external_pos_id']);
$this->assertEquals('DET-001', $data['product_code']);
$this->assertEquals('1234567890123', $data['barcode']);
$this->assertEquals('測試大類', $data['category_name']);
$this->assertEquals('測試單位', $data['unit_name']);
$this->assertEquals(123.45, (float)$data['price']);
$this->assertEquals('品牌A', $data['brand']);
$this->assertEquals('規格B', $data['specification']);
$this->assertEquals('BATCH-DETAIL-01', $data['batch_number']);
$this->assertNotEmpty($data['expiry_date']);
$this->assertEquals(10.5, $data['quantity']);
}
public function test_order_creation_with_insufficient_stock_creates_negative_no_batch()
{
tenancy()->initialize($this->tenant);
$product = Product::where('code', 'P-001')->first();
// 確保倉庫存在
$warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
// 清空該商品的現有庫存以利測試負數
\App\Modules\Inventory\Models\Inventory::where('product_id', $product->id)->delete();
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$payload = [
'external_order_id' => 'ORD-NEGATIVE-TEST',
'name' => '負數庫存測試',
'warehouse_code' => 'MAIN',
'payment_method' => 'cash',
'total_amount' => 1000,
'total_qty' => 10,
'items' => [
[
'product_id' => $product->id,
'batch_number' => 'ANY-BATCH-NAME',
'qty' => 10,
'price' => 100
]
]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payload)
->assertStatus(201);
tenancy()->initialize($this->tenant);
// 驗證應該在 NO-BATCH 產生 -10 的庫存
$this->assertDatabaseHas('inventories', [
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'batch_number' => 'NO-BATCH',
'quantity' => -10,
]);
tenancy()->end(); tenancy()->end();
} }
public function test_order_source_automation_based_on_warehouse_type()
{
tenancy()->initialize($this->tenant);
$product = Product::where('code', 'P-001')->first();
// 1. Create a VENDING warehouse
$vendingWarehouse = \App\Modules\Inventory\Models\Warehouse::create([
'name' => 'Vending Machine 01',
'code' => 'VEND-01',
'type' => \App\Enums\WarehouseType::VENDING,
]);
// 2. Create a STANDARD warehouse
$standardWarehouse = \App\Modules\Inventory\Models\Warehouse::create([
'name' => 'General Warehouse',
'code' => 'ST-01',
'type' => \App\Enums\WarehouseType::STANDARD,
]);
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// Case A: Vending Warehouse -> Source should be 'vending'
$payloadVending = [
'external_order_id' => 'ORD-VEND',
'name' => 'Vending Order',
'warehouse_code' => 'VEND-01',
'total_amount' => 100,
'total_qty' => 1,
'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payloadVending)
->assertStatus(201);
// Case B: Standard Warehouse -> Source should be 'pos'
$payloadPos = [
'external_order_id' => 'ORD-POS',
'name' => 'POS Order',
'warehouse_code' => 'ST-01',
'total_amount' => 100,
'total_qty' => 1,
'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payloadPos)
->assertStatus(201);
tenancy()->initialize($this->tenant);
$this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-VEND', 'source' => 'vending']);
$this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-POS', 'source' => 'pos']);
tenancy()->end();
}
} }

View File

@@ -0,0 +1,194 @@
<?php
namespace Tests\Feature\Integration;
use Tests\TestCase;
use App\Modules\Core\Models\Tenant;
use App\Modules\Core\Models\User;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Facades\Tenancy;
use Illuminate\Support\Str;
class ProductSearchApiTest extends TestCase
{
protected $tenant;
protected $user;
protected $domain;
protected function setUp(): void
{
parent::setUp();
Artisan::call('migrate:fresh');
$this->domain = 'search-test-' . Str::random(8) . '.erp.local';
$tenantId = 'test_tenant_s_' . Str::random(8);
tenancy()->central(function () use ($tenantId) {
$this->tenant = Tenant::create([
'id' => $tenantId,
'name' => 'Search Test Tenant',
]);
$this->tenant->domains()->create(['domain' => $this->domain]);
});
tenancy()->initialize($this->tenant);
Artisan::call('tenants:migrate');
$this->user = User::factory()->create(['name' => 'Search Admin']);
// 建立測試資料
$cat1 = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
$cat2 = Category::firstOrCreate(['name' => '食品'], ['code' => 'CAT-FOOD']);
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
Product::create([
'name' => '可口可樂',
'code' => 'COKE-001',
'barcode' => '4710001',
'price' => 25,
'category_id' => $cat1->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
$pepsi = Product::create([
'name' => '百事可樂',
'code' => 'PEPSI-001',
'barcode' => '4710002',
'price' => 23,
'category_id' => $cat1->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
DB::table('products')->where('id', $pepsi->id)->update(['updated_at' => now()->subDay()]);
Product::create([
'name' => '漢堡',
'code' => 'BURGER-001',
'barcode' => '4710003',
'price' => 50,
'category_id' => $cat2->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
Product::create([
'name' => '停用商品',
'code' => 'INACTIVE-001',
'is_active' => false,
'category_id' => $cat1->id,
'base_unit_id' => $unit->id,
]);
tenancy()->end();
}
protected function tearDown(): void
{
if ($this->tenant) {
$this->tenant->delete();
}
parent::tearDown();
}
public function test_can_search_all_active_products()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products');
$response->assertStatus(200)
->assertJsonPath('status', 'success')
->assertJsonCount(3, 'data'); // 3 active products
}
public function test_can_filter_by_category()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?category=食品');
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', '漢堡');
}
public function test_can_filter_by_updated_after()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 搜尋今天更新的商品 (百事可樂是昨天更新的,應該被過濾掉)
$timeStr = now()->startOfDay()->format('Y-m-d H:i:s');
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/products?updated_after={$timeStr}");
$response->assertStatus(200)
->assertJsonCount(2, 'data'); // 可口可樂, 漢堡 (百事可樂是昨天)
}
public function test_pagination_works()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?per_page=2');
$response->assertStatus(200)
->assertJsonCount(2, 'data')
->assertJsonPath('meta.per_page', 2)
->assertJsonPath('meta.total', 3);
}
public function test_can_filter_by_precision_params()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 1. By Barcode
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?barcode=4710001');
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '可口可樂');
// 2. By Code
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?code=PEPSI-001');
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '百事可樂');
// 3. By product_id (ERP ID)
$productId = Product::where('code', 'BURGER-001')->value('id');
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/products?product_id={$productId}");
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '漢堡');
}
public function test_is_protected_by_auth()
{
// 不帶 Token
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products');
$response->assertStatus(401);
}
}