['label' => 'Dispensing in progress', 'level' => 'info'], '0402' => ['label' => 'Dispense successful', 'level' => 'info'], '0403' => ['label' => 'Slot jammed', 'level' => 'error'], '0404' => ['label' => 'Motor not stopped', 'level' => 'warning'], '0406' => ['label' => 'Slot not found', 'level' => 'error'], '0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'], '0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'], '0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'], '040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'], '0410' => ['label' => 'Elevator rising', 'level' => 'info'], '0411' => ['label' => 'Elevator descending', 'level' => 'info'], '0412' => ['label' => 'Elevator rise error', 'level' => 'error'], '0413' => ['label' => 'Elevator descent error', 'level' => 'error'], '0414' => ['label' => 'Pickup door closed', 'level' => 'info'], '0415' => ['label' => 'Pickup door error', 'level' => 'error'], '0416' => ['label' => 'Delivery door opened', 'level' => 'info'], '0417' => ['label' => 'Delivery door open error', 'level' => 'error'], '0418' => ['label' => 'Delivering product', 'level' => 'info'], '0419' => ['label' => 'Delivery door closed', 'level' => 'info'], '0420' => ['label' => 'Delivery door close error', 'level' => 'error'], '0421' => ['label' => 'Hopper empty', 'level' => 'warning'], '0422' => ['label' => 'Hopper overheated', 'level' => 'warning'], '0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'], '0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'], '0426' => ['label' => 'Microwave door opened', 'level' => 'info'], '0427' => ['label' => 'Microwave door error', 'level' => 'error'], '04FF' => ['label' => 'Dispense stopped', 'level' => 'info'], // 貨道狀態類 (Prefix: 02 - SLOT_STATUS) '0201' => ['label' => 'Slot normal', 'level' => 'info'], '0202' => ['label' => 'Product empty', 'level' => 'warning'], '0203' => ['label' => 'Slot empty', 'level' => 'warning'], '0206' => ['label' => 'Slot not closed', 'level' => 'warning'], '0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'], '0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'], '0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'], '0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'], // 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS) '5400' => ['label' => 'Machine normal', 'level' => 'info'], '5401' => ['label' => 'Elevator sensor error', 'level' => 'error'], '5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'], '5403' => ['label' => 'Elevator failure', 'level' => 'error'], ]; /** * Update machine heartbeat and status. * * @param string $serialNo * @param array $data * @return Machine */ public function updateHeartbeat(string $serialNo, array $data): Machine { return DB::transaction(function () use ($serialNo, $data) { $machine = Machine::where('serial_no', $serialNo)->firstOrFail(); // 採用現代化語意命名 (Modern semantic naming) $temperature = $data['temperature'] ?? $machine->temperature; $currentPage = $data['current_page'] ?? $machine->current_page; $doorStatus = $data['door_status'] ?? $machine->door_status; $firmwareVersion = $data['firmware_version'] ?? $machine->firmware_version; $model = $data['model'] ?? $machine->model; $updateData = [ 'temperature' => $temperature, 'current_page' => $currentPage, 'door_status' => $doorStatus, 'firmware_version' => $firmwareVersion, 'model' => $model, 'last_heartbeat_at' => now(), ]; $machine->update($updateData); // Record log if provided if (!empty($data['log'])) { $machine->logs()->create([ 'company_id' => $machine->company_id, 'type' => 'status', 'level' => $data['log_level'] ?? 'info', 'message' => $data['log'], 'context' => $data['log_payload'] ?? null, ]); } return $machine; }); } /** * Sync machine API token to Redis for MQTT authentication. * * @param Machine $machine */ public function syncMqttAuth(Machine $machine): void { if (empty($machine->api_token)) { return; } // MQTT 連線認證:Username = serial_no, Password = hash(api_token) // 遵循 framework.md 4.5 規範 $redisKey = "machine_auth:{$machine->serial_no}"; // 這裡採用 SHA256 雜湊,與 EMQX 設定對應 $hashedToken = hash('sha256', $machine->api_token); \Illuminate\Support\Facades\Redis::hSet($redisKey, 'password', $hashedToken); \Illuminate\Support\Facades\Log::info("MQTT Auth synced to Redis", [ 'serial_no' => $machine->serial_no, 'key' => $redisKey ]); } /** * 統一寫入機台庫存異動紀錄 * * @param MachineSlot $slot 貨道 Model(已含 machine_id, product_id, stock) * @param int $delta 異動數量(正=增加,負=減少) * @param string $type 異動類型(MachineStockMovement::TYPE_*) * @param mixed $reference 關聯單據 Model(nullable) * @param string|null $note 備註說明 */ public function recordStockMovement( MachineSlot $slot, int $delta, string $type, mixed $reference = null, ?string $note = null, array $context = [] ): MachineStockMovement { $before = $slot->stock - $delta; // 因為已更新 slot,反推異動前的值 return MachineStockMovement::create([ 'company_id' => $slot->machine->company_id ?? null, 'machine_id' => $slot->machine_id, 'product_id' => $slot->product_id, 'slot_no' => $slot->slot_no, 'type' => $type, 'quantity' => $delta, 'before_qty' => $before, 'after_qty' => $slot->stock, 'reference_type' => $reference ? get_class($reference) : null, 'reference_id' => $reference?->id, 'note' => $note, 'context' => $context, 'created_by' => Auth::id(), ]); } /** * Sync machine slots based on replenishment report. * * @param Machine $machine * @param array $slotsData */ 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(); // 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode) $products = \App\Models\Product\Product::whereIn('id', $productCodes) ->orWhereIn('barcode', $productCodes) ->get(); foreach ($slotsData as $slotData) { $slotNo = $slotData['slot_no'] ?? null; if (!$slotNo) continue; $existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); // 查找對應的實體 ID (支援 ID 與 Barcode 比對) $productCode = $slotData['product_id'] ?? null; $actualProductId = null; if ($productCode) { $actualProductId = $products->first(function ($p) use ($productCode) { return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode; })?->id; } // 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type) // 若未提供 type,預設為 '1' (履帶/Track) $slotType = $slotData['type'] ?? $existingSlot->type ?? '1'; if ($actualProductId) { $product = $products->find($actualProductId); if ($product) { // 1: 履帶, 2: 彈簧 $calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit; $slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null; } } $newStock = (int) ($slotData['stock'] ?? 0); $updateData = [ 'product_id' => $actualProductId, 'type' => $slotType, 'stock' => $newStock, 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10), 'is_active' => true, ]; if ($existingSlot) { $oldStock = (int) $existingSlot->stock; $oldProductId = $existingSlot->product_id; // 檢查是否有實質變動 (Check for actual changes) $isStockChanged = ($oldStock !== $newStock); $isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況 if ($isStockChanged || $isProductChanged) { $existingSlot->update($updateData); $existingSlot->refresh(); $delta = $newStock - $oldStock; $note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction"; $this->recordStockMovement( $existingSlot, $delta, MachineStockMovement::TYPE_ADJUSTMENT, null, $note, ['old' => $oldStock, 'new' => $newStock, 'old_product_id' => $oldProductId, 'new_product_id' => $actualProductId] ); } } else { $newSlot = $machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo])); // 新建貨道若有初始庫存,也紀錄流水帳 if ($newStock > 0) { $this->recordStockMovement( $newSlot, $newStock, MachineStockMovement::TYPE_ADJUSTMENT, null, "movement.note.initial_sync_report", ['old' => 0, 'new' => $newStock] ); } } } }); } /** * Update machine slot stock, expiry, and batch. * * @param Machine $machine * @param array $data * @param int|null $userId * @return void */ public function updateSlot(Machine $machine, array $data, ?int $userId = null): void { DB::transaction(function () use ($machine, $data, $userId) { $slotNo = $data['slot_no']; $stock = $data['stock'] ?? null; $expiryDate = $data['expiry_date'] ?? null; $batchNo = $data['batch_no'] ?? null; $slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail(); // 1. 併行檢查:確保同一個貨道在同一時間只有一個指令在執行中 (Pending) // 包含 reload_stock 與 dispense 兩種會異動庫存的類型 $isPending = RemoteCommand::where('machine_id', $machine->id) ->whereIn('command_type', ['reload_stock', 'dispense']) ->where('status', 'pending') ->whereJsonContains('payload->slot_no', (string)$slotNo) ->exists(); if ($isPending) { throw new \Exception(__('This slot has a pending update. Please wait for the previous command to complete.')); } // 紀錄舊數據以供回滾使用 $oldData = [ 'stock' => $slot->stock, 'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null, 'batch_no' => $slot->batch_no, ]; // 2. 執行樂觀更新 (Optimistic Update) $updateData = [ 'expiry_date' => $expiryDate, 'batch_no' => $batchNo, ]; if ($stock !== null) $updateData['stock'] = (int)$stock; $slot->update($updateData); // 3. 若庫存數值有異動,記錄 adjustment 流水帳 if ($stock !== null && (int)$stock !== $oldData['stock']) { $delta = (int)$stock - $oldData['stock']; $slot->refresh(); $this->recordStockMovement( $slot, $delta, MachineStockMovement::TYPE_ADJUSTMENT, null, "movement.note.manual_adjustment", ['old' => $oldData['stock'], 'new' => $stock] ); } // 3. 建立遠端指令紀錄 $command = RemoteCommand::create([ 'machine_id' => $machine->id, 'user_id' => $userId, 'command_type' => 'reload_stock', 'status' => 'pending', 'payload' => [ 'slot_no' => (string)$slotNo, 'old' => $oldData, 'new' => [ 'stock' => $stock !== null ? (int)$stock : $oldData['stock'], 'expiry_date' => $expiryDate ?: null, 'batch_no' => $batchNo ?: null, ] ] ]); // 4. 推播 MQTT $mqttPayload = [ 'slot_no' => $command->payload['slot_no'], 'stock' => $command->payload['new']['stock'], 'expiry_date' => $command->payload['new']['expiry_date'], 'batch_no' => $command->payload['new']['batch_no'] ?? null, ]; app(\App\Services\Machine\MqttService::class)->pushCommand( $machine->serial_no, 'update_inventory', $mqttPayload, (string) $command->id ); }); } /** * 遠端出貨指令下發 (含樂觀扣庫存與鎖定) */ public function dispatchDispense(Machine $machine, string $slotNo, ?int $userId = null): RemoteCommand { return DB::transaction(function () use ($machine, $slotNo, $userId) { $slot = $machine->slots()->where('slot_no', $slotNo)->lockForUpdate()->firstOrFail(); // 併行檢查 $isPending = RemoteCommand::where('machine_id', $machine->id) ->whereIn('command_type', ['reload_stock', 'dispense']) ->where('status', 'pending') ->whereJsonContains('payload->slot_no', (string)$slotNo) ->exists(); if ($isPending) { throw new \Exception(__('This slot has a pending command. Please wait.')); } if ($slot->stock <= 0) { throw new \Exception(__('Out of stock.')); } $oldStock = $slot->stock; $newStock = $oldStock - 1; // 1. 執行樂觀扣除 $slot->update(['stock' => $newStock]); // 2. 建立指令紀錄 $command = RemoteCommand::create([ 'machine_id' => $machine->id, 'user_id' => $userId, 'command_type' => 'dispense', 'status' => 'pending', 'payload' => [ 'slot_no' => (string)$slotNo, 'old_stock' => $oldStock, 'new_stock' => $newStock, ] ]); // 3. 寫入庫存異動流水帳(remote_dispense,樂觀扣除) $slot->refresh(); $this->recordStockMovement( $slot, -1, MachineStockMovement::TYPE_REMOTE_DISPENSE, $command, "遠端出貨指令 (B055),指令 ID: {$command->id}" ); // 4. 推播 MQTT app(\App\Services\Machine\MqttService::class)->pushCommand( $machine->serial_no, 'dispense', $command->payload, (string) $command->id ); return $command; }); } /** * B013: Record machine hardware error/status log with auto-translation. * * @param Machine $machine * @param array $data * @return MachineLog */ public function recordErrorLog(Machine $machine, array $data): MachineLog { $errorCode = $data['error_code'] ?? '0000'; $mapping = self::ERROR_CODE_MAP[$errorCode] ?? ['label' => 'Unknown Status', 'level' => 'error']; $slotNo = $data['tid'] ?? null; $label = $mapping['label']; // 儲存原始英文格式作為 DB 備用,前端顯示會優先使用 model accessor 的動態翻譯內容 $message = $slotNo ? "Slot {$slotNo}: {$label} (Code: {$errorCode})" : "{$label} (Code: {$errorCode})"; return $machine->logs()->create([ 'company_id' => $machine->company_id, 'type' => 'submachine', 'level' => $mapping['level'], 'message' => $message, 'context' => array_merge($data, [ 'translated_label' => $label, 'raw_code' => $errorCode ]), ]); } /** * Update machine slot stock (single slot). * Legacy support for recordLog (Existing code). */ public function recordLog(int $machineId, array $data): MachineLog { $machine = Machine::findOrFail($machineId); return $machine->logs()->create([ 'level' => $data['level'] ?? 'info', 'message' => $data['message'], 'context' => $data['context'] ?? null, ]); } /** * 取得艦隊整體統計(批次查詢,無 N+1) */ public function getFleetStats(string $date): array { $start = Carbon::parse($date)->startOfDay(); $end = Carbon::parse($date)->endOfDay(); $machines = Machine::all(); // TenantScoped 已過濾 $machineIds = $machines->pluck('id')->toArray(); $totalMachines = $machines->count(); $onlineCount = Machine::online()->count(); // 今日成功訂單:批次查詢,非逐台 $ordersData = Order::whereIn('machine_id', $machineIds) ->where('payment_status', 1) ->whereBetween('created_at', [$start, $end]) ->selectRaw('COUNT(*) as total_count, COALESCE(SUM(pay_amount), 0) as total_revenue') ->first(); // 今日異常次數 $alertCount = MachineLog::whereIn('machine_id', $machineIds) ->where('level', 'error') ->whereBetween('created_at', [$start, $end]) ->count(); return [ 'onlineCount' => $onlineCount, 'totalMachines' => $totalMachines, 'totalOrders' => (int)($ordersData->total_count ?? 0), 'totalRevenue' => (float)($ordersData->total_revenue ?? 0), 'alertCount' => $alertCount, // OEE / avgUptime 已移除,online/offline 直接從 status 讀取 ]; } /** * 取得單台機台詳細統計(接入真實 orders 資料) */ public function getUtilizationStats(Machine $machine, string $date): array { $start = Carbon::parse($date)->startOfDay(); $end = Carbon::parse($date)->endOfDay(); // 1. 今日成功訂單:筆數 + 金額 $ordersAgg = $machine->orders() ->where('payment_status', 1) ->whereBetween('created_at', [$start, $end]) ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(pay_amount), 0) as rev') ->first(); $salesCount = (int)($ordersAgg->cnt ?? 0); $revenue = (float)($ordersAgg->rev ?? 0); // 2. 今日異常次數(dispense_records 失敗 或 machine_logs error) $errorCount = $machine->logs() ->where('level', 'error') ->whereBetween('created_at', [$start, $end]) ->count(); // 3. 逐小時銷售量(Bar Chart 用) $hourlySales = $machine->orders() ->where('payment_status', 1) ->whereBetween('created_at', [$start, $end]) ->selectRaw('HOUR(created_at) as hour, COUNT(*) as count') ->groupBy('hour') ->pluck('count', 'hour'); $labels = []; $values = []; for ($h = 0; $h < 24; $h++) { $labels[] = sprintf('%02d:00', $h); $values[] = (int)($hourlySales[$h] ?? 0); } // Flatten 格式,前端直接 spread 使用 return [ 'output_count' => $salesCount, 'revenue' => round($revenue, 0), 'error_count' => $errorCount, 'chart_data' => [ 'labels' => $labels, 'values' => $values, ], ]; } }