diff --git a/.agents/skills/api-technical-specs/SKILL.md b/.agents/skills/api-technical-specs/SKILL.md index d83428c..f281e75 100644 --- a/.agents/skills/api-technical-specs/SKILL.md +++ b/.agents/skills/api-technical-specs/SKILL.md @@ -88,10 +88,57 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與 --- -### 3.3 B017: 貨道與庫存同步 (規劃中) +### 3.3 B017: 貨道與庫存同步 - **URL**: `POST /api/v1/app/machine/reload_msg/B017` -- 說明:當機台收到 B010 回應 `status: 49` 時,應呼叫此 API 同步最新貨道佈局。 +- **說明**:當機台收到 B010 回應 `status: 49` 時,呼叫此此 API 獲取雲端最新的貨道佈局與庫存設定。 -### 3.4 B600: 交易數據回傳 (規劃中) +--- + +### 3.4 B005: 廣告清單同步 +用於機台端獲取目前應播放的廣告檔案 URL 清單。 + +- **URL**: `POST /api/v1/app/machine/ad/B005` +- **Request Body:** (無須額外 Body 參數,僅需傳送空 JSON `{}`) + +- **Response Body:** +| 參數 | 類型 | 說明 | 範例 | +| :--- | :--- | :--- | :--- | +| `success` | Boolean | 請求是否成功 | `true` | +| `data` | Array | 廣告物件陣列 | `[{"t070v04": "https://..."}]` | + +**data 陣列內部欄位:** +- `t070v01`: 廣告名稱 (Name) +- `t070v02`: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。 +- `t070v03`: 廣告位置 (Position/Flag) — (`3`: 待機廣告, `1`: 販賣頁, `2`: 來店禮)。 +- `t070v04`: 廣告 URL。 +- `t070v05`: 播放順位 (Sort Order)。 + +--- + +### 3.5 B009: 貨道庫存即時回報 (Supplementary Report) +當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。 + +- **URL**: `PUT /api/v1/app/products/supplementary/B009` +- **Request Body:** +| 參數 | 類型 | 必填 | 說明 | 範例 | +| :--- | :--- | :--- | :--- | :--- | +| `account` | String | 是 | 操作人員帳號 | `technician_01` | +| `vmcType` | String | 否 | VMC 韌體/機型類別 | `XinYuan` | +| `data` | Array | 貨道數據陣列 | `[{"tid":"1", "t060v00":"SKU001", "num":"10"}]` | + +- **data 陣列內部欄位:** +- `tid`: 貨道編號 (Slot No) +- `t060v00`: 商品編碼 (SKU / Product Code) +- `num`: 實體剩餘庫存數量 + +- **Response Body:** +| 參數 | 類型 | 說明 | 範例 | +| :--- | :--- | :--- | :--- | +| `success` | Boolean | 同步是否成功 | `true` | +| `status` | String | 固定回傳 `49` 代表已處理 | `49` | + +--- + +### 3.6 B600: 交易數據回傳 (規劃中) - **URL**: `POST /api/v1/app/B600` - 說明:交易完成後提交支付方式、金額、商品與出貨結果。 diff --git a/app/Http/Controllers/Admin/AdvertisementController.php b/app/Http/Controllers/Admin/AdvertisementController.php index e9ded16..9dbf9bf 100644 --- a/app/Http/Controllers/Admin/AdvertisementController.php +++ b/app/Http/Controllers/Admin/AdvertisementController.php @@ -179,6 +179,7 @@ class AdvertisementController extends AdminController { $assignments = MachineAdvertisement::where('machine_id', $machine->id) ->with('advertisement') + ->orderBy('sort_order', 'asc') ->get() ->groupBy('position'); diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index e2d79ec..2231cd2 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -194,4 +194,95 @@ class MachineController extends Controller ] ]); } + + /** + * B005: Download Machine Advertisements (Synchronous) + */ + public function getAdvertisements(Request $request) + { + $machine = $request->get('machine'); + + $advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id) + ->with(['advertisement' => function ($query) { + $query->active(); + }]) + ->get() + ->filter(fn($ma) => $ma->advertisement !== null) + ->map(function ($ma) { + // 定義顯示順序權重 (待機 > 購物 > 成功禮) + $posWeight = [ + 'standby' => 1, + 'vending' => 2, + 'visit_gift' => 3 + ]; + + // 為了相容現有機台 App 邏輯: + // App 讀取 t070v03 作為位置標籤 (flag): + // 1. HomeActivity (待機) 讀取 "3" + // 2. FontendActivity (販賣頁) 讀取 "1" + $posIdMap = [ + 'standby' => '3', + 'vending' => '1', + 'visit_gift' => '2' + ]; + + return [ + 't070v01' => $ma->advertisement->name, + 't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡 + 't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag) + 't070v04' => $ma->advertisement->url, + 't070v05' => (string) $ma->sort_order, + 'raw_pos_weight' => $posWeight[$ma->position] ?? 99, + 'raw_sort' => (int) $ma->sort_order + ]; + }) + ->sortBy([ + ['raw_pos_weight', 'asc'], + ['raw_sort', 'asc'] + ]) + ->values() + ->map(function($item) { + unset($item['raw_pos_weight'], $item['raw_sort']); + return $item; + }); + + return response()->json([ + 'success' => true, + 'code' => 200, + 'data' => $advertisements->values() + ]); + } + + /** + * B009: Report Machine Slot List / Supplementary (Synchronous) + */ + public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService) + { + $machine = $request->get('machine'); + $payload = $request->all(); + + // 映射舊版機台回傳格式 (Map legacy machine format) + // t060v00 -> product_id, num -> stock, tid -> slot_no + $legacyData = $payload['data'] ?? []; + $mappedSlots = array_map(function ($item) { + return [ + 'slot_no' => $item['tid'] ?? null, + 'product_id' => $item['t060v00'] ?? null, + 'stock' => $item['num'] ?? 0, + ]; + }, $legacyData); + + // 過濾無效資料 (Filter invalid entries) + $mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null); + + // 同步處理更新庫存 (直接更新不進隊列) + $machineService->syncSlots($machine, $mappedSlots); + + return response()->json([ + 'success' => true, + 'code' => 200, + 'message' => 'Slot report synchronized success', + 'status' => '49' + ]); + } } diff --git a/app/Services/Machine/MachineService.php b/app/Services/Machine/MachineService.php index 041160c..ffba6ae 100644 --- a/app/Services/Machine/MachineService.php +++ b/app/Services/Machine/MachineService.php @@ -64,24 +64,36 @@ class MachineService public function syncSlots(Machine $machine, array $slotsData): void { DB::transaction(function () use ($machine, $slotsData) { + // 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID) + $productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray(); + + // 批次查詢商品 (支援以 SKU 查詢,確保對應至資料庫 ID) + $products = \App\Models\Product\Product::whereIn('sku', $productCodes) + ->orWhereIn('id', $productCodes) + ->get() + ->keyBy(fn($p) => $p->sku ?: $p->id); + foreach ($slotsData as $slotData) { $slotNo = $slotData['slot_no'] ?? null; if (!$slotNo) continue; $existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); + + // 查找對應的實體 ID + $productCode = $slotData['product_id'] ?? null; + $actualProductId = null; + if ($productCode) { + $actualProductId = $products->get($productCode)?->id; + } $updateData = [ - 'product_id' => $slotData['product_id'] ?? null, + 'product_id' => $actualProductId, 'stock' => $slotData['stock'] ?? 0, - 'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10), - 'price' => $slotData['price'] ?? ($existingSlot->price ?? 0), - 'last_restocked_at' => now(), + 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10), + 'is_active' => true, ]; - // 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新 - // 這裡我們暫定只要有 report 進來,就需要重新確認效期 - $updateData['expiry_date'] = null; - + // 如果這是一次明確的補貨回報,建議更新時間並記錄 if ($existingSlot) { $existingSlot->update($updateData); } else { diff --git a/routes/api.php b/routes/api.php index b96400d..31371c1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -61,6 +61,10 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () { Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']); Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']); + // 廣告與貨道清單 (B005, B009) + Route::post('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']); + Route::put('products/supplementary/B009', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportSlotList']); + // 交易、發票與出貨 (B600, B601, B602) Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']); Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);