1. 實作 AnalysisController@machineReports 後端邏輯,支援租戶隔離與帳號機台授權過濾。 2. 建立 machine-reports.blade.php 視圖,加入極簡奢華風統計卡片、ApexCharts 趨勢圖與自訂多選支付工具篩選器,並停用圖表縮放優化滾輪滾動體驗。 3. 優化 product-reports.blade.php,新增機台下拉選單過濾,並啟用寬度優化與響應式自動換行,且同步停用圖表縮放。 4. 在 Machine model 全域 Scope 中排除單元測試阻擋,並更新 RoleSeeder 播種機台報表權限。 5. 新增 AnalysisReportsTest 測試,並更新 AnalysisPermissionTest 權限測試,且在 Sail 中驗證為 100% 通過。 6. 同步更新繁中、英文、日文翻譯 JSON 檔案,以 zh_TW 為基準完美對齊、字母排序且還原斜線。
444 lines
13 KiB
PHP
444 lines
13 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;
|
||
|
||
protected static function booted()
|
||
{
|
||
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
|
||
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
|
||
$user = auth()->user();
|
||
// 如果是在 Console(且非單元測試)、或是系統管理員,則不限制 (可看所有機台)
|
||
if ((app()->runningInConsole() && !app()->runningUnitTests()) || !$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}");
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($machine->wasChanged([
|
||
'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',
|
||
])) {
|
||
app(\App\Services\Machine\MachineService::class)->syncMachineSlotMaxStock($machine);
|
||
}
|
||
});
|
||
}
|
||
|
||
protected $fillable = [
|
||
'company_id',
|
||
'name',
|
||
'serial_no',
|
||
'model',
|
||
'location',
|
||
'address',
|
||
'latitude',
|
||
'longitude',
|
||
'status',
|
||
'current_page',
|
||
'door_status',
|
||
'temperature',
|
||
'ambient_temperature',
|
||
'ambient_temp_setting',
|
||
'ambient_temp_monitoring_enabled',
|
||
'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',
|
||
'ambient_temperature' => 'integer',
|
||
'ambient_temp_setting' => 'integer',
|
||
'ambient_temp_monitoring_enabled' => 'boolean',
|
||
'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);
|
||
}
|
||
}
|