All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m18s
1. 重構機台在線狀態判定機制:移除資料庫 status 欄位,改由 Model 根據心跳時間動態計算。 2. 修正儀表板 (Dashboard) 與機台管理頁面的多語系顯示問題,解決換行導致翻譯失效的 Bug。 3. 修正個人檔案頁面的麵包屑 (Breadcrumbs) 導航,補齊「個人設定」層級。 4. 更新 IoT API (B010, B600) 的認證機制與日誌處理邏輯。 5. 同步更新繁中、英文、日文語言檔,確保 UI 標籤一致性。
250 lines
7.1 KiB
PHP
250 lines
7.1 KiB
PHP
<?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;
|
||
use \Illuminate\Database\Eloquent\SoftDeletes;
|
||
|
||
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);
|
||
});
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|