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); }); }); } protected $fillable = [ 'company_id', 'name', 'serial_no', 'model', 'location', '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', ]; protected $appends = ['image_urls', 'calculated_status']; /** * 動態計算機台當前狀態 * 1. 離線 (offline):超過 30 秒未收到心跳 * 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌 * 3. 在線 (online):正常在線 */ public function getCalculatedStatusAttribute(): string { // 判定離線 if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) { return 'offline'; } // 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌) $hasRecentErrors = $this->logs() ->whereIn('level', ['error', 'warning']) ->where('created_at', '>=', now()->subMinutes(15)) ->exists(); if ($hasRecentErrors) { return 'error'; } return 'online'; } /** * Scope: 判定在線 (30 秒內有心跳) */ public function scopeOnline($query) { return $query->where('last_heartbeat_at', '>=', now()->subSeconds(30)); } /** * Scope: 判定離線 (超過 30 秒未收到心跳或從未收到心跳) */ public function scopeOffline($query) { return $query->where(function ($q) { $q->whereNull('last_heartbeat_at') ->orWhere('last_heartbeat_at', '<', now()->subSeconds(30)); }); } /** * Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌) */ public function scopeHasError($query) { return $query->whereExists(function ($q) { $q->select(\Illuminate\Support\Facades\DB::raw(1)) ->from('machine_logs') ->whereColumn('machine_logs.machine_id', 'machines.id') ->whereIn('level', ['error', 'warning']) ->where('created_at', '>=', now()->subMinutes(15)); }); } /** * Scope: 判定運行中 (在線且無近期異常) */ public function scopeRunning($query) { return $query->online()->whereNotExists(function ($q) { $q->select(\Illuminate\Support\Facades\DB::raw(1)) ->from('machine_logs') ->whereColumn('machine_logs.machine_id', 'machines.id') ->whereIn('level', ['error', 'warning']) ->where('created_at', '>=', now()->subMinutes(15)); }); } /** * Scope: 判定異常在線 (在線且有近期異常) */ public function scopeErrorOnline($query) { return $query->online()->hasError(); } protected $casts = [ 'last_heartbeat_at' => 'datetime', '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', 'images' => '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' => 'Offline', '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 users() { return $this->belongsToMany(\App\Models\System\User::class); } }