star-cloud/app/Models/Machine/Machine.php
sky121113 3d1ddd5b91 [FEAT] 實作機台智慧連線監控與儀表板告警同步優化
1. [Machine Model] 實作智慧抖動偵測邏輯 (Jitter Detection),判定 1 小時內斷線 3 次以上為不穩定機台。
2. [Machine Model] 優化 calculated_status,機台在線時自動忽略歷史斷線警告,並修正 scopeHasAnyAlert 排除連線類告警。
3. [ProcessStatus Job] 實作自動消警機制 (Auto-resolve),機台恢復連線時自動處理舊的斷線告警。
4. [Dashboard] 同步儀表板「待處理告警」統計,確保與機台列表狀態一致。
5. [UI] 於機台列表與儀表板同步新增琥珀色閃電圖示 () 與不穩定提示 Tooltip。
6. [i18n] 新增連線不穩定相關之繁體中文翻譯詞條。
2026-05-14 16:20:59 +08:00

403 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\TenantScoped;
class Machine extends Model
{
use HasFactory, TenantScoped;
protected static function booted()
{
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
$user = auth()->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',
];
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count'];
/**
* 動態計算機台當前狀態 (僅限連線與系統層級)
* 優先讀取資料庫 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';
}
/**
* 下位機/硬體健康狀態 (基於 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: 判定是否有任何待處理的異常 (排除掉單純的連線中斷)
* 待處理告警應專注於「功能性異常」如硬體錯誤、APP 報錯),而非連線狀態。
*/
public function scopeHasAnyAlert($query)
{
return $query->whereHas('logs', function ($q) {
$q->where(function($sub) {
$sub->where('is_resolved', false)
->orWhereNull('is_resolved');
})
->whereIn('level', ['error', 'warning'])
->where('message', '!=', 'Connection lost (LWT)');
});
}
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',
];
/**
* 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);
}
}