star-cloud/app/Models/Machine/Machine.php
sky121113 8f2dd1375e [FEAT] 實作機台報表分析功能與商品報表篩選列優化
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 為基準完美對齊、字母排序且還原斜線。
2026-06-05 16:21:05 +08:00

444 lines
13 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() && !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);
}
}