user(); // 如果是在 Console、或是系統管理員,則不限制 (可看所有機台) if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) { return; } // 一般租戶帳號:限制只能看自己擁有的機台 $builder->whereExists(function ($query) use ($user) { $query->select(\Illuminate\Support\Facades\DB::raw(1)) ->from('machine_user') ->whereColumn('machine_user.machine_id', 'machines.id') ->where('machine_user.user_id', $user->id); }); }); // 當 api_token 或 serial_no 發生變動時,自動同步至 Redis (供 MQTT 認證使用) static::saved(function ($machine) { if ($machine->wasChanged('api_token') || $machine->wasChanged('serial_no') || $machine->wasRecentlyCreated) { app(\App\Services\Machine\MachineService::class)->syncMqttAuth($machine); // 如果是修改序號,則刪除舊的 Redis Key 避免殘留 if ($machine->wasChanged('serial_no') && !$machine->wasRecentlyCreated) { $oldSerial = $machine->getOriginal('serial_no'); if ($oldSerial) { \Illuminate\Support\Facades\Redis::del("machine_auth:{$oldSerial}"); } } } }); } protected $fillable = [ 'company_id', 'name', 'serial_no', 'model', 'location', 'address', 'latitude', 'longitude', 'status', 'current_page', 'door_status', 'temperature', 'firmware_version', 'api_token', 'last_heartbeat_at', 'card_reader_seconds', 'card_reader_checkout_time_1', 'card_reader_checkout_time_2', 'heating_start_time', 'heating_end_time', 'payment_buffer_seconds', 'card_reader_no', 'key_no', 'invoice_status', 'welcome_gift_enabled', 'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30', 'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60', 'member_system_enabled', 'payment_config_id', 'machine_model_id', 'images', 'creator_id', 'updater_id', 'tax_invoice_enabled', 'card_terminal_enabled', 'scan_pay_esun_enabled', 'scan_pay_linepay_enabled', 'shopping_cart_enabled', 'cash_module_enabled', 'settings', ]; protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count', 'has_login_warning']; /** * 動態計算機台當前狀態 (僅限連線與系統層級) * 優先讀取資料庫 status 欄位 * 若在線,則進一步檢查過去 15 分鐘是否為異常 */ public function getCalculatedStatusAttribute(): string { // 判定離線 if ($this->status === 'offline') { return 'offline'; } // 判定異常 (僅檢查 type = 'status' 的未解決日誌) $hasRecentErrors = $this->logs() ->where('type', 'status') ->where('level', 'error') ->where('is_resolved', false) ->exists(); if ($hasRecentErrors) { return 'error'; } // 判定警告 (僅檢查 type = 'status' 的未解決日誌) // 優化:當機台在線時,自動忽略「斷線 (LWT)」警告,因為狀態已恢復 $hasRecentWarnings = $this->logs() ->where('type', 'status') ->where('level', 'warning') ->where('is_resolved', false) ->where('message', '!=', 'Connection lost (LWT)') ->exists(); if ($hasRecentWarnings) { return 'warning'; } return $this->status ?? 'online'; } /** * 判定機台目前是否有未解決的登入失敗警告 (需達 3 次未解決警告才亮起) */ public function getHasLoginWarningAttribute(): bool { return $this->logs() ->where('type', 'login') ->where('level', 'warning') ->where('is_resolved', false) ->count() >= 3; } /** * 下位機/硬體健康狀態 (基於 type = 'submachine' 日誌) */ public function getHardwareStatusAttribute(): string { // 判定異常 $hasErrors = $this->logs() ->where('type', 'submachine') ->where('level', 'error') ->where('is_resolved', false) ->exists(); if ($hasErrors) { return 'error'; } // 判定警告 $hasWarnings = $this->logs() ->where('type', 'submachine') ->where('level', 'warning') ->where('is_resolved', false) ->exists(); if ($hasWarnings) { return 'warning'; } return 'normal'; } /** * 取得最新一筆未解決的系統狀態日誌時間 */ public function getLatestStatusLogTimeAttribute() { $query = $this->logs() ->where('type', 'status') ->whereIn('level', ['error', 'warning']) ->where('is_resolved', false); // 優化:若在線,不顯示已恢復的斷線時間 if ($this->status === 'online') { $query->where('message', '!=', 'Connection lost (LWT)'); } return $query->latest()->first()?->created_at; } /** * 取得最新一筆未解決的下位機硬體日誌時間 */ public function getLatestHardwareLogTimeAttribute() { return $this->logs() ->where('type', 'submachine') ->whereIn('level', ['error', 'warning']) ->where('is_resolved', false) ->latest() ->first()?->created_at; } /** * Scope: 判定在線 (讀取 status 欄位) */ public function scopeOnline($query) { return $query->where('status', 'online'); } /** * Scope: 判定離線 (讀取 status 欄位) */ public function scopeOffline($query) { return $query->where('status', 'offline'); } public function scopeHasError($query) { return $query->whereHas('logs', function ($q) { $q->where('is_resolved', false) ->where('level', 'error'); }); } /** * Scope: 判定是否有任何待處理的異常 (排除掉單純的連線中斷) * 只有當登入失敗次數達 3 次以上時,才列入待處理告警。 */ public function scopeHasAnyAlert($query) { return $query->where(function($outer) { // 1. 有任何非登入類型的待處理異常 $outer->whereHas('logs', function ($q) { $q->where(function($sub) { $sub->where('is_resolved', false) ->orWhereNull('is_resolved'); }) ->whereIn('level', ['error', 'warning']) ->where('type', '!=', 'login') ->where('message', '!=', 'Connection lost (LWT)'); }) // 2. 或者有至少 3 筆未解決的登入異常 ->orWhereHas('logs', function ($q) { $q->where('is_resolved', false) ->where('type', 'login') ->where('level', 'warning'); }, '>=', 3); }); } public function scopeHasStatusError($query) { return $query->whereHas('logs', function ($q) { $q->where('is_resolved', false) ->where('type', 'status') ->where('level', 'error'); }); } public function scopeHasHardwareError($query) { return $query->whereHas('logs', function ($q) { $q->where('is_resolved', false) ->where('type', 'submachine') ->where('level', 'error'); }); } /** * Scope: 判定運行中 (在線且無近期系統異常) */ public function scopeRunning($query) { return $query->online()->whereDoesntHave('logs', function ($q) { $q->where('is_resolved', false) ->where('type', 'status') ->whereIn('level', ['error', 'warning']); }); } /** * Scope: 判定異常在線 (在線且有近期異常) */ public function scopeErrorOnline($query) { return $query->online()->hasError(); } protected $casts = [ 'last_heartbeat_at' => 'datetime', 'temperature' => 'integer', 'welcome_gift_enabled' => 'boolean', 'is_spring_slot_1_10' => 'boolean', 'is_spring_slot_11_20' => 'boolean', 'is_spring_slot_21_30' => 'boolean', 'is_spring_slot_31_40' => 'boolean', 'is_spring_slot_41_50' => 'boolean', 'is_spring_slot_51_60' => 'boolean', 'member_system_enabled' => 'boolean', 'tax_invoice_enabled' => 'boolean', 'card_terminal_enabled' => 'boolean', 'scan_pay_esun_enabled' => 'boolean', 'scan_pay_linepay_enabled' => 'boolean', 'shopping_cart_enabled' => 'boolean', 'cash_module_enabled' => 'boolean', 'images' => 'array', 'settings' => 'array', ]; /** * Get machine images absolute URLs */ public function getImageUrlsAttribute(): array { if (empty($this->images)) { return []; } return array_map(fn($path) => \Illuminate\Support\Facades\Storage::disk('public')->url($path), $this->images); } public function logs() { return $this->hasMany(MachineLog::class); } public function slots() { return $this->hasMany(MachineSlot::class); } public function commands() { return $this->hasMany(RemoteCommand::class); } public function machineModel() { return $this->belongsTo(MachineModel::class); } public function paymentConfig() { return $this->belongsTo(\App\Models\System\PaymentConfig::class); } public function creator() { return $this->belongsTo(\App\Models\System\User::class, 'creator_id'); } public function updater() { return $this->belongsTo(\App\Models\System\User::class, 'updater_id'); } public const PAGE_STATUSES = [ '0' => 'Standby', '1' => 'Home Page', '2' => 'Vending Page', '3' => 'Admin Page', '4' => 'Replenishment Page', '5' => 'Tutorial Page', '6' => 'Purchasing', '7' => 'Locked Page', '60' => 'Dispense Success', '61' => 'Slot Test', '62' => 'Payment Selection', '63' => 'Waiting for Payment', '64' => 'Dispensing', '65' => 'Receipt Printing', '66' => 'Pass Code', '67' => 'Pickup Code', '68' => 'Message Display', '69' => 'Cancel Purchase', '610' => 'Purchase Finished', '611' => 'Welcome Gift', '612' => 'Dispense Failed', ]; public function getCurrentPageLabelAttribute(): string { $code = (string) $this->current_page; $label = self::PAGE_STATUSES[$code] ?? $code; return __($label); } public function getIsUnstableAttribute(): bool { // 抖動偵測:1 小時內斷線次數 >= 3 return $this->recent_disconnection_count >= 3; } public function getRecentDisconnectionCountAttribute(): int { return $this->logs() ->where('type', 'status') ->where('message', 'Connection lost (LWT)') ->where('created_at', '>=', now()->subHour()) ->count(); } public function users() { return $this->belongsToMany(\App\Models\System\User::class); } public function orders() { return $this->hasMany(\App\Models\Transaction\Order::class); } public function stockMovements() { return $this->hasMany(MachineStockMovement::class); } }