get('machine'); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key // === 狀態異動觸發 (Redis 快取免查 DB) === $cacheKey = "machine:{$machine->serial_no}:state"; $oldState = Cache::get($cacheKey); $currentPage = $data['current_page'] ?? null; $doorStatus = $data['door_status'] ?? null; $firmwareVersion = $data['firmware_version'] ?? null; $model = $data['model'] ?? null; if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) { // 更新目前狀態到 Redis (保存 1 天) $newState = $oldState ?? []; if ($currentPage !== null) $newState['current_page'] = $currentPage; if ($doorStatus !== null) $newState['door_status'] = $doorStatus; if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion; if ($model !== null) $newState['model'] = $model; Cache::put($cacheKey, $newState, 86400); // 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩) if ($oldState !== null) { // 1. 判斷頁面是否變更 if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) { // 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯 ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info'); } // 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟) if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) { $doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed"; $doorLevel = 'info'; // 不論開關門皆為 info,避免觸發異常狀態 ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel); } // 3. 判斷韌體版本是否變更 if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) { $oldVersion = $oldState['firmware_version'] ?? 'Unknown'; // 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串 $versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]); ProcessStateLog::dispatch( $machine->id, $machine->company_id, $versionMessage, 'info', ['old' => $oldVersion, 'new' => $firmwareVersion] ); } // 4. 判斷型號是否變更 if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) { $oldModel = $oldState['model'] ?? 'Unknown'; $modelMessage = __("Model changed to :model", ['model' => $model]); ProcessStateLog::dispatch( $machine->id, $machine->company_id, $modelMessage, 'info', ['old' => $oldModel, 'new' => $model] ); } } } // 異步處理狀態更新 ProcessHeartbeat::dispatch($machine->serial_no, $data); // 取出待處理指令 $command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id) ->pending() ->first(); $status = '49'; // 預設 49 (OK / No Command) $message = 'OK'; if ($command) { switch ($command->command_type) { case 'reboot': $status = '51'; $message = 'reboot'; break; case 'reboot_card': $status = '60'; $message = 'reboot card machine'; break; case 'checkout': $status = '61'; $message = 'checkout'; break; case 'lock': $status = '71'; $message = 'lock'; break; case 'unlock': $status = '70'; $message = 'unlock'; break; case 'change': $status = '82'; $message = $command->payload['amount'] ?? '0'; break; case 'dispense': $status = '85'; $message = $command->payload['slot_no'] ?? ''; break; case 'reload_stock': $status = '49'; $message = 'reload B017'; break; } // 標記為已發送 (sent) $command->update(['status' => 'sent', 'executed_at' => now()]); } return response()->json([ 'success' => true, 'code' => 200, 'message' => $message, 'status' => $status ], 202); // 202 Accepted } /** * B018: Record Machine Restock/Setup Report (Asynchronous) */ public function recordRestock(Request $request) { $machine = $request->get('machine'); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件 $data['serial_no'] = $machine->serial_no; \App\Jobs\Machine\ProcessRestockReport::dispatch($data); return response()->json([ 'success' => true, 'code' => 200, 'message' => 'Restock report accepted', 'status' => '49' ], 202); } /** * B017: Get Slot Info & Stock (Synchronous) */ public function getSlots(Request $request) { $machine = $request->get('machine'); $slots = $machine->slots()->with('product')->get(); // 自動轉 Success: 若機台來撈 B017,代表之前的 reload_stock 指令已成功被機台響應 \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id) ->where('command_type', 'reload_stock') ->where('status', 'sent') ->update(['status' => 'success', 'executed_at' => now()]); return response()->json([ 'success' => true, 'code' => 200, 'data' => $slots->map(function ($slot) { return [ 'slot_no' => $slot->slot_no, 'product_id' => $slot->product_id, 'stock' => $slot->stock, 'capacity' => $slot->capacity, 'price' => $slot->price, 'status' => $slot->status, 'expiry_date' => $slot->expiry_date, 'batch_no' => $slot->batch_no, ]; }) ]); } /** * B710: Sync Timer status (Asynchronous) */ public function syncTimer(Request $request) { $machine = $request->get('machine'); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件 ProcessTimerStatus::dispatch($machine->serial_no, $data); return response()->json(['success' => true], 202); } /** * B220: Sync Coin Inventory (Asynchronous) */ public function syncCoinInventory(Request $request) { $machine = $request->get('machine'); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件 ProcessCoinInventory::dispatch($machine->serial_no, $data); return response()->json(['success' => true], 202); } /** * B650: Verify Member Code/Barcode (Synchronous) */ public function verifyMember(Request $request) { $validator = Validator::make($request->all(), [ 'code' => 'required|string', ]); if ($validator->fails()) { return response()->json(['success' => false, 'message' => 'Invalid code'], 400); } $code = $request->input('code'); // 搜尋會員 (barcode 或特定驗證碼) $member = \App\Models\Member\Member::where('barcode', $code) ->orWhere('id', $code) // 暫時支援 ID ->first(); if (!$member) { return response()->json([ 'success' => false, 'code' => 404, 'message' => 'Member not found' ], 404); } return response()->json([ 'success' => true, 'code' => 200, 'data' => [ 'member_id' => $member->id, 'name' => $member->name, 'points' => $member->points, 'wallet_balance' => $member->wallet_balance ?? 0, ] ]); } /** * 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->playing(); } ]) ->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 ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($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(); $account = $payload['account'] ?? null; // 1. 驗證帳號是否存在 (驗證執行補貨的人員身分) $user = User::where('username', $account) ->orWhere('email', $account) ->first(); if (!$user) { return response()->json([ 'success' => false, 'code' => 403, 'message' => 'Unauthorized: Account not found', 'status' => '' ], 403); } // 2. 階層式權限驗證 (遵循 RBAC 多租戶規範) $isAuthorized = false; if ($user->isSystemAdmin()) { // [系統層]:系統管理員可異動所有機台 $isAuthorized = true; } elseif ($user->is_admin) { // [公司層]:公司管理員需驗證此機台是否隸屬於該公司 $isAuthorized = ($machine->company_id === $user->company_id); } else { // [人員層]:一般人員需檢查 machine_user 授權表 $isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists(); } if (!$isAuthorized) { return response()->json([ 'success' => false, 'code' => 403, 'message' => 'Unauthorized: Account not authorized for this machine', 'status' => '' ], 403); } // 3. 映射舊版機台回傳格式 (Map legacy machine format) // 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array) $legacyData = $payload['data'] ?? []; if (Arr::isAssoc($legacyData)) { $legacyData = [$legacyData]; } $mappedSlots = array_map(function ($item) { return [ 'slot_no' => $item['tid'] ?? null, 'product_id' => $item['t060v00'] ?? null, 'stock' => $item['num'] ?? 0, 'type' => $item['type'] ?? null, ]; }, $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' ]); } /** * B012_new: Download Product Catalog (Synchronous) */ public function getProducts(Request $request) { $machine = $request->get('machine'); $products = \App\Models\Product\Product::where('company_id', $machine->company_id) ->with(['translations']) ->active() ->get() ->map(function ($product) { // 提取多語系名稱 (Extract translations) $nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? ''; $nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? ''; return [ 't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性 't060v01' => $product->name, 't060v01_en' => $nameEn, 't060v01_jp' => $nameJp, 't060v03' => $product->spec ?? '', 't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '', 't060v09' => (float) $product->price, 't060v11' => (int) ($product->track_limit ?? 10), 't060v30' => (float) ($product->member_price ?? $product->price), 't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫 't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼) 'spring_limit' => (int) ($product->spring_limit ?? 10), 'track_limit' => (int) ($product->track_limit ?? 10), 't063v03' => (float) $product->price, ]; }); return response()->json([ 'success' => true, 'code' => 200, 'data' => $products ]); } /** * B013: Report Machine Hardware Error/Status (Asynchronous) */ public function reportError(Request $request) { $machine = $request->get('machine'); $data = $request->only(['tid', 'error_code']); // 異步分派處理 (Dispatch to queue) ProcessMachineError::dispatch($machine->serial_no, $data); return response()->json([ 'success' => true, 'code' => 200, 'message' => 'Error report accepted', ], 202); // 202 Accepted } /** * B014: Download Machine Settings & Config (Synchronous, Requires User Auth) * 用於機台引導階段,同步金流、發票與機台專屬 API Token。 */ public function getSettings(Request $request) { $serialNo = $request->input('machine'); $user = $request->user(); // 1. 查找機台 (忽略全局範圍以進行認領) $machine = Machine::withoutGlobalScopes() ->with(['paymentConfig', 'company']) ->where('serial_no', $serialNo) ->first(); if (!$machine) { return response()->json([ 'success' => false, 'code' => 404, 'message' => 'Machine not found' ], 404); } // 2. 權限加強驗證 (RBAC) $isAuthorized = false; if ($user->isSystemAdmin()) { $isAuthorized = true; } elseif ($machine->company_id === $user->company_id) { // 公司管理員或已授權員工才能存取 if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) { $isAuthorized = true; } } if (!$isAuthorized) { return response()->json([ 'success' => false, 'code' => 403, 'message' => 'Forbidden: You do not have permission to configure this machine' ], 403); } // 3. 獲取關聯設定 $paymentSettings = $machine->paymentConfig->settings ?? []; $companySettings = $machine->company->settings ?? []; // 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構) $data = [ 't050v01' => $machine->serial_no, 'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token // 玉山支付 't050v41' => $paymentSettings['esun_store_id'] ?? '', 't050v42' => $paymentSettings['esun_term_id'] ?? '', 't050v43' => $paymentSettings['esun_hash'] ?? '', // 電子發票 (綠界) 't050v34' => $companySettings['invoice_merchant_id'] ?? '', 't050v35' => $companySettings['invoice_hash_key'] ?? '', 't050v36' => $companySettings['invoice_hash_iv'] ?? '', 't050v38' => $companySettings['invoice_email'] ?? '', // 趨勢支付 (TrendPay/Greenpay) 'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '', 'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '', 'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '', // 各類行動支付特店 ID 'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '', 'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '', 'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '', 'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '', 'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '', ]; return response()->json([ 'success' => true, 'code' => 200, 'data' => [$data] // App 預期的是包含單一物件的陣列 ]); } }