1. 修正客戶管理頁面 H1 標題與麵包屑導覽路徑。 2. 在機台編輯頁面實作分頁功能(基本、營運、機台系統設定)。 3. 實作極簡奢華風的機台系統設定 UI,按功能模組(電子發票、刷卡、支付、購物車等)進行卡片式分組。 4. 將系統設定資料從 Company JSON 遷移至 Machine 資料表獨立欄位,並更新關聯控制器與模型邏輯。 5. 清理 CompanyController 與視圖中的冗餘設定邏輯。 6. 完成現有資料遷移,確保機台正確繼承客戶層級之設定。 7. 優化倉儲管理「現有庫存」矩陣視圖與導覽功能。
278 lines
8.1 KiB
PHP
278 lines
8.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);
|
||
});
|
||
});
|
||
|
||
// 當 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'];
|
||
|
||
/**
|
||
* 動態計算機台當前狀態
|
||
* 優先讀取資料庫 status 欄位
|
||
* 若在線,則進一步檢查過去 15 分鐘是否為異常
|
||
*/
|
||
public function getCalculatedStatusAttribute(): string
|
||
{
|
||
// 判定離線
|
||
if ($this->status === 'offline') {
|
||
return 'offline';
|
||
}
|
||
|
||
// 判定異常 (檢查過去 15 分鐘內是否有 error 日誌)
|
||
$hasRecentErrors = $this->logs()
|
||
->where('level', 'error')
|
||
->where('created_at', '>=', now()->subMinutes(15))
|
||
->exists();
|
||
|
||
if ($hasRecentErrors) {
|
||
return 'error';
|
||
}
|
||
|
||
return 'online';
|
||
}
|
||
|
||
/**
|
||
* Scope: 判定在線 (讀取 status 欄位)
|
||
*/
|
||
public function scopeOnline($query)
|
||
{
|
||
return $query->where('status', 'online');
|
||
}
|
||
|
||
/**
|
||
* Scope: 判定離線 (讀取 status 欄位)
|
||
*/
|
||
public function scopeOffline($query)
|
||
{
|
||
return $query->where('status', 'offline');
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
'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' => '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);
|
||
}
|
||
}
|