diff --git a/app/Http/Controllers/Admin/BasicSettings/DiscordNotificationController.php b/app/Http/Controllers/Admin/BasicSettings/DiscordNotificationController.php new file mode 100644 index 0000000..213eae1 --- /dev/null +++ b/app/Http/Controllers/Admin/BasicSettings/DiscordNotificationController.php @@ -0,0 +1,203 @@ +user(); + $activeTab = $request->input('tab', 'company'); + + // ── Tab 1: 公司全域設定 ── + $companyQuery = Company::query(); + if ($user->isSystemAdmin()) { + if ($searchCompany = $request->input('search_company')) { + $companyQuery->where(function ($q) use ($searchCompany) { + $q->where('name', 'like', "%{$searchCompany}%") + ->orWhere('code', 'like', "%{$searchCompany}%"); + }); + } + } else { + $companyQuery->where('id', $user->company_id); + } + $companies = $companyQuery->paginate($request->input('company_per_page', 10), ['*'], 'company_page') + ->withQueryString(); + + // ── Tab 2: 機台個別設定 ── + $machineQuery = \App\Models\Machine\Machine::query()->with(['company', 'machineModel']); + if ($user->isSystemAdmin()) { + if ($filterCompanyId = trim($request->input('filter_company_id', ''))) { + $machineQuery->where('company_id', $filterCompanyId); + } + if ($searchMachine = $request->input('search_machine')) { + $machineQuery->where(function ($q) use ($searchMachine) { + $q->where('name', 'like', "%{$searchMachine}%") + ->orWhere('serial_no', 'like', "%{$searchMachine}%"); + }); + } + } else { + $machineQuery->where('company_id', $user->company_id); + if ($searchMachine = $request->input('search_machine')) { + $machineQuery->where(function ($q) use ($searchMachine) { + $q->where('name', 'like', "%{$searchMachine}%") + ->orWhere('serial_no', 'like', "%{$searchMachine}%"); + }); + } + } + $machines = $machineQuery->paginate($request->input('machine_per_page', 10), ['*'], 'machine_page') + ->withQueryString(); + + // ── 輔助資料 ── + // 所有公司列表 (僅 System Admin 用於 Tab 2 篩選下拉) + $allCompanies = []; + if ($user->isSystemAdmin()) { + $allCompanies = Company::select('id', 'name')->get(); + } + + // 用於前端 Modal「拉式複製」的同公司機台 settings 列表 + $copyableQuery = \App\Models\Machine\Machine::select('id', 'name', 'serial_no', 'company_id', 'settings'); + if (!$user->isSystemAdmin()) { + $copyableQuery->where('company_id', $user->company_id); + } + $copyableMachines = $copyableQuery->get(); + + return view('admin.basic-settings.discord-notifications.index', [ + 'companies' => $companies, + 'machines' => $machines, + 'allCompanies' => $allCompanies, + 'copyableMachines' => $copyableMachines, + 'activeTab' => $activeTab, + ]); + } + + /** + * 更新公司 Discord 告警設定 + */ + public function updateCompany(Request $request, $id) + { + $user = auth()->user(); + if (!$user->isSystemAdmin() && (int)$id !== (int)$user->company_id) { + abort(403, __('Unauthorized action.')); + } + + $company = Company::findOrFail($id); + + $request->validate([ + 'discord_webhook_url' => 'nullable|url|max:500', + 'discord_notify_enabled' => 'boolean', + 'discord_notify_types' => 'array', + 'discord_notify_types.*' => 'string|in:submachine,status,temperature', + 'discord_notify_locale' => 'required|string|in:zh_TW,en,ja', + ]); + + $settings = $company->settings ?? []; + $settings['discord_webhook_url'] = $request->input('discord_webhook_url'); + $settings['discord_notify_enabled'] = (bool) $request->input('discord_notify_enabled', false); + $settings['discord_notify_types'] = $request->input('discord_notify_types', []); + $settings['locale'] = $request->input('discord_notify_locale', 'zh_TW'); + + $company->settings = $settings; + $company->save(); + + return redirect()->route('admin.basic-settings.discord-notifications.index', ['tab' => 'company']) + ->with('success', __('Company Discord notification settings updated successfully.')); + } + + /** + * 更新機台 Discord 與溫度告警設定 + */ + public function updateMachine(Request $request, $id) + { + $user = auth()->user(); + $machine = \App\Models\Machine\Machine::findOrFail($id); + + if (!$user->isSystemAdmin() && (int)$machine->company_id !== (int)$user->company_id) { + abort(403, __('Unauthorized action.')); + } + + $request->validate([ + 'discord_webhook_url' => 'nullable|url|max:500', + 'notify_types_mode' => 'required|string|in:inherit,custom', + 'discord_notify_types' => 'nullable|array', + 'discord_notify_types.*' => 'string|in:submachine,status,temperature', + 'temp_upper_limit' => 'nullable|numeric|min:-50|max:100', + 'temp_lower_limit' => 'nullable|numeric|min:-50|max:100', + ]); + + $settings = $machine->settings ?? []; + $settings['discord_webhook_url'] = $request->input('discord_webhook_url'); + + $notifyTypesMode = $request->input('notify_types_mode', 'inherit'); + $notifyTypes = $request->input('discord_notify_types', []); + $hasTemperature = in_array('temperature', $notifyTypes, true); + + if ($notifyTypesMode === 'custom') { + $settings['discord_notify_types'] = array_values($notifyTypes); + if ($hasTemperature) { + $settings['temp_alert_enabled'] = 'enabled'; + $settings['temp_upper_limit'] = $request->filled('temp_upper_limit') ? (float)$request->temp_upper_limit : null; + $settings['temp_lower_limit'] = $request->filled('temp_lower_limit') ? (float)$request->temp_lower_limit : null; + } else { + $settings['temp_alert_enabled'] = 'disabled'; + $settings['temp_upper_limit'] = null; + $settings['temp_lower_limit'] = null; + } + } else { + unset($settings['discord_notify_types']); + $settings['temp_alert_enabled'] = 'inherit'; + $settings['temp_upper_limit'] = null; + $settings['temp_lower_limit'] = null; + } + + $machine->settings = $settings; + $machine->save(); + + return redirect()->route('admin.basic-settings.discord-notifications.index', ['tab' => 'machine']) + ->with('success', __('Machine Discord notification settings updated successfully.')); + } + + /** + * 測試發送 Discord Webhook + */ + public function test(Request $request): JsonResponse + { + $request->validate([ + 'discord_webhook_url' => 'required|url|max:500', + ]); + + $webhookUrl = $request->input('discord_webhook_url'); + + try { + $service = app(DiscordWebhookService::class); + $success = $service->sendTestAlert($webhookUrl); + + if ($success) { + return response()->json([ + 'success' => true, + 'message' => __('Test notification sent successfully.'), + ]); + } else { + return response()->json([ + 'success' => false, + 'message' => __('Failed to send test notification. Please verify the Webhook URL.'), + ], 400); + } + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => __('An error occurred: ') . $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineModelController.php b/app/Http/Controllers/Admin/BasicSettings/MachineModelController.php index 59f1861..2b46771 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineModelController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineModelController.php @@ -22,13 +22,22 @@ class MachineModelController extends AdminController { $validated = $request->validate([ 'name' => 'required|string|max:255', + 'temp_upper_limit' => 'nullable|integer|min:-50|max:100', + 'temp_lower_limit' => 'nullable|integer|min:-50|max:100', ]); - MachineModel::create(array_merge($validated, [ + $settings = [ + 'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null, + 'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null, + ]; + + MachineModel::create([ + 'name' => $validated['name'], + 'settings' => $settings, 'company_id' => auth()->user()->company_id, 'creator_id' => auth()->id(), 'updater_id' => auth()->id(), - ])); + ]); return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models']) ->with('success', __('Machine model created successfully.')); @@ -47,11 +56,20 @@ class MachineModelController extends AdminController { $validated = $request->validate([ 'name' => 'required|string|max:255', + 'temp_upper_limit' => 'nullable|integer|min:-50|max:100', + 'temp_lower_limit' => 'nullable|integer|min:-50|max:100', ]); - $machine_model->update(array_merge($validated, [ + $settings = [ + 'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null, + 'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null, + ]; + + $machine_model->update([ + 'name' => $validated['name'], + 'settings' => $settings, 'updater_id' => auth()->id(), - ])); + ]); return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models']) ->with('success', __('Machine model updated successfully.')); diff --git a/app/Http/Controllers/Admin/SpecialPermissionController.php b/app/Http/Controllers/Admin/SpecialPermissionController.php index 8183569..1b304df 100644 --- a/app/Http/Controllers/Admin/SpecialPermissionController.php +++ b/app/Http/Controllers/Admin/SpecialPermissionController.php @@ -24,13 +24,4 @@ class SpecialPermissionController extends Controller 'description' => 'APP版本控制與更新', ]); } - - // Discord通知設定 - public function discordNotifications() - { - return view('admin.placeholder', [ - 'title' => 'Discord通知設定', - 'description' => 'Discord通知整合設定', - ]); - } } diff --git a/app/Jobs/Machine/ProcessHeartbeat.php b/app/Jobs/Machine/ProcessHeartbeat.php index 1ff0ca0..b4fac1e 100644 --- a/app/Jobs/Machine/ProcessHeartbeat.php +++ b/app/Jobs/Machine/ProcessHeartbeat.php @@ -81,7 +81,7 @@ class ProcessHeartbeat implements ShouldQueue $newState['firmware_version'] = $fv; } - // 2. 溫度更新 (只要有變動就紀錄日誌) + // 2. 溫度更新 (只要有變動就紀錄日誌,並進行溫度監控告警判定) if (isset($this->payload['temperature'])) { $temp = (int) $this->payload['temperature']; Log::debug("ProcessHeartbeat: Temperature reported for {$this->serialNo}: {$temp}"); @@ -95,13 +95,14 @@ class ProcessHeartbeat implements ShouldQueue $oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->temperature; if ($temp !== (int)$oldTemp) { - Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering log."); + Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering ordinary log."); \App\Jobs\Machine\ProcessStateLog::dispatch( $machine->id, $machine->company_id, "Temperature reported: :temp°C", 'info', - ['temp' => $temp] + ['temp' => $temp], + 'status' // 普通狀態日誌,不發送 Discord ); } @@ -110,6 +111,128 @@ class ProcessHeartbeat implements ShouldQueue 'value' => $temp, 'at' => now()->toDateTimeString() ], 604800); + + // === 溫度自適應告警判定 === + $tempAlertEnabled = false; + $machineSettings = $machine->settings ?? []; + $company = $machine->company; + $companySettings = $company ? ($company->settings ?? []) : []; + + // 1. 決定是否啟用溫度監控 + $enabledSetting = $machineSettings['temp_alert_enabled'] ?? 'inherit'; + if ($enabledSetting === 'enabled') { + $tempAlertEnabled = true; + } elseif ($enabledSetting === 'disabled') { + $tempAlertEnabled = false; + } else { + // inherit 或是沒設定,繼承公司設定 + $companyEnabled = $companySettings['discord_notify_enabled'] ?? false; + $companyTypes = $companySettings['discord_notify_types'] ?? []; + if ($companyEnabled && in_array('temperature', $companyTypes)) { + $tempAlertEnabled = true; + } + } + + if ($tempAlertEnabled) { + $upperLimit = null; + $lowerLimit = null; + + // 優先度 1: 機台自訂 + if (isset($machineSettings['temp_upper_limit']) && $machineSettings['temp_upper_limit'] !== '') { + $upperLimit = (int)$machineSettings['temp_upper_limit']; + } + if (isset($machineSettings['temp_lower_limit']) && $machineSettings['temp_lower_limit'] !== '') { + $lowerLimit = (int)$machineSettings['temp_lower_limit']; + } + + // 優先度 2: 型號預設 + $model = $machine->machineModel; + if ($model) { + $modelSettings = $model->settings ?? []; + if ($upperLimit === null && isset($modelSettings['temp_upper_limit']) && $modelSettings['temp_upper_limit'] !== '') { + $upperLimit = (int)$modelSettings['temp_upper_limit']; + } + if ($lowerLimit === null && isset($modelSettings['temp_lower_limit']) && $modelSettings['temp_lower_limit'] !== '') { + $lowerLimit = (int)$modelSettings['temp_lower_limit']; + } + } + + // 優先度 3: 公司預設 + if ($upperLimit === null && isset($companySettings['temp_upper_limit']) && $companySettings['temp_upper_limit'] !== '') { + $upperLimit = (int)$companySettings['temp_upper_limit']; + } + if ($lowerLimit === null && isset($companySettings['temp_lower_limit']) && $companySettings['temp_lower_limit'] !== '') { + $lowerLimit = (int)$companySettings['temp_lower_limit']; + } + + // 全域預設值 fallback + if ($upperLimit === null) { + $upperLimit = 40; + } + if ($lowerLimit === null) { + $lowerLimit = 0; + } + + $activeKey = "machine:{$this->serialNo}:temp_alert_active"; + $cooldownKey = "machine:{$this->serialNo}:temp_alert_cooldown"; + $activeStatus = \Illuminate\Support\Facades\Cache::get($activeKey); + + if ($temp > $upperLimit) { + // 若從低溫直接跳到高溫,清除低溫告警冷卻快取,但不發送恢復通知 + if ($activeStatus === 'low') { + \Illuminate\Support\Facades\Cache::forget($cooldownKey); + } + + $hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey); + if ($activeStatus !== 'high' || !$hasCooldown) { + \App\Jobs\Machine\ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + "Temperature too high: :temp°C", + 'error', + ['temp' => $temp, 'limit' => $upperLimit], + 'temperature' + ); + + \Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600); + \Illuminate\Support\Facades\Cache::put($activeKey, 'high', 86400); + } + } elseif ($temp < $lowerLimit) { + // 若從高溫直接跳到低溫,清除高溫告警冷卻快取,但不發送恢復通知 + if ($activeStatus === 'high') { + \Illuminate\Support\Facades\Cache::forget($cooldownKey); + } + + $hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey); + if ($activeStatus !== 'low' || !$hasCooldown) { + \App\Jobs\Machine\ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + "Temperature too low: :temp°C", + 'error', + ['temp' => $temp, 'limit' => $lowerLimit], + 'temperature' + ); + + \Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600); + \Illuminate\Support\Facades\Cache::put($activeKey, 'low', 86400); + } + } else { + // 溫度正常 + if ($activeStatus) { + \App\Jobs\Machine\ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + "Temperature restored: :temp°C", + 'info', + ['temp' => $temp], + 'temperature' + ); + \Illuminate\Support\Facades\Cache::forget($activeKey); + \Illuminate\Support\Facades\Cache::forget($cooldownKey); + } + } + } } // 更新快取 diff --git a/app/Jobs/Notification/SendDiscordNotificationJob.php b/app/Jobs/Notification/SendDiscordNotificationJob.php new file mode 100644 index 0000000..88c8afe --- /dev/null +++ b/app/Jobs/Notification/SendDiscordNotificationJob.php @@ -0,0 +1,84 @@ +logId = $logId; + } + + /** + * 執行任務 + */ + public function handle(DiscordWebhookService $service): void + { + $log = MachineLog::find($this->logId); + + if (!$log) { + Log::warning("SendDiscordNotificationJob: MachineLog [{$this->logId}] not found. Skipping."); + return; + } + + $machine = $log->machine; + if (!$machine || !$machine->company) { + return; + } + + $company = $machine->company; + $companySettings = $company->settings ?? []; + + // 驗證是否啟用 Discord 通知 + if (!($companySettings['discord_notify_enabled'] ?? false)) { + return; + } + + // 優先使用機台專屬 Webhook URL,其次為全域設定 + $machineSettings = $machine->settings ?? []; + $webhookUrl = $machineSettings['discord_webhook_url'] ?? $companySettings['discord_webhook_url'] ?? null; + if (!$webhookUrl) { + return; + } + + // 僅針對類型進行過濾,不再進行重要級別 (Levels) 的篩選 + $allowedTypes = $companySettings['discord_notify_types'] ?? []; + + if (!in_array($log->type, $allowedTypes)) { + Log::info("SendDiscordNotificationJob: Log [{$this->logId}] filtered by type settings.", [ + 'log_type' => $log->type, + 'allowed_types' => $allowedTypes + ]); + return; + } + + // 發送告警通知 + $success = $service->sendMachineLogAlert($webhookUrl, $log); + + if (!$success) { + // 拋出 Exception 以便 Laravel Queue 機制自動重試 + throw new \RuntimeException("Failed to deliver Discord notification for Log ID: {$log->id}"); + } + } +} diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index e5ac174..fcdf0c7 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -90,6 +90,7 @@ class Machine extends Model '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']; @@ -312,6 +313,7 @@ class Machine extends Model 'shopping_cart_enabled' => 'boolean', 'cash_module_enabled' => 'boolean', 'images' => 'array', + 'settings' => 'array', ]; /** diff --git a/app/Models/Machine/MachineLog.php b/app/Models/Machine/MachineLog.php index f700f39..019bd29 100644 --- a/app/Models/Machine/MachineLog.php +++ b/app/Models/Machine/MachineLog.php @@ -11,6 +11,65 @@ class MachineLog extends Model const UPDATED_AT = null; + protected static function booted() + { + static::created(function (MachineLog $log) { + // 1. 取得機台與公司,若無公司則直接 return + $machine = $log->machine; + if (!$machine || !$machine->company) { + return; + } + + $company = $machine->company; + $companySettings = $company->settings ?? []; + + // 必須啟用 Discord 通知功能 + if (!($companySettings['discord_notify_enabled'] ?? false)) { + return; + } + + // 優先讀取機台專屬 Webhook,無則繼承公司全域 Webhook + $machineSettings = $machine->settings ?? []; + $webhookUrl = $machineSettings['discord_webhook_url'] ?? $companySettings['discord_webhook_url'] ?? null; + if (empty($webhookUrl)) { + return; + } + + // 2. 判定是否符合發送條件 + $shouldNotify = false; + + if ($log->type === 'temperature') { + $shouldNotify = true; + } elseif ($log->type === 'status') { + if ($log->message === 'Connection lost (LWT)') { + $shouldNotify = true; + } elseif ($log->message === 'Connection restored') { + // 尋找此機台前一筆狀態日誌 (ID 比當前小,type 為 status,倒序第一筆) + $previousStatus = self::withoutGlobalScopes() + ->where('machine_id', $log->machine_id) + ->where('type', 'status') + ->where('id', '<', $log->id) + ->orderBy('id', 'desc') + ->first(); + + if ($previousStatus && $previousStatus->message === 'Connection lost (LWT)' && $previousStatus->is_resolved) { + // 只有當前一筆是真實 LWT 斷線,且已被消警 (is_resolved = true) 時,才發送恢復通知 + $shouldNotify = true; + } + } + } elseif ($log->type === 'submachine') { + $shouldNotify = true; + } elseif ($log->level === 'error') { + $shouldNotify = true; + } + + // 3. 若符合發送條件,調度非同步任務 + if ($shouldNotify) { + \App\Jobs\Notification\SendDiscordNotificationJob::dispatch($log->id); + } + }); + } + protected $fillable = [ 'company_id', 'machine_id', diff --git a/app/Models/Machine/MachineModel.php b/app/Models/Machine/MachineModel.php index cdde5b3..8203893 100644 --- a/app/Models/Machine/MachineModel.php +++ b/app/Models/Machine/MachineModel.php @@ -13,6 +13,11 @@ class MachineModel extends Model 'company_id', 'creator_id', 'updater_id', + 'settings', + ]; + + protected $casts = [ + 'settings' => 'array', ]; public function machines() diff --git a/app/Services/Notification/DiscordWebhookService.php b/app/Services/Notification/DiscordWebhookService.php new file mode 100644 index 0000000..959bb50 --- /dev/null +++ b/app/Services/Notification/DiscordWebhookService.php @@ -0,0 +1,256 @@ + '出貨成功', + '0403' => '貨道卡貨 (重大異常)', + '0202' => '貨道缺貨', + '0412' => '昇降機上升異常', + '0415' => '取貨門異常', + '5402' => '取貨門未關 (警告)', + '5403' => '昇降系統故障', + ]; + + /** + * 發送測試通知 + */ + public function sendTestAlert(string $url): bool + { + $payload = [ + 'username' => 'Star Cloud Alert Bot', + 'embeds' => [ + [ + 'title' => '🎉 Star Cloud 測試通知', + 'description' => "這是一則來自 Star Cloud 後台的 Discord Webhook 連線測試通知。\n如果您收到這則訊息,代表您的 Webhook 設定已順利生效並正常運作!", + 'color' => 561586, // cyan-600 (0x0891B2) + 'fields' => [ + [ + 'name' => '測試時間 (台北時間)', + 'value' => now()->timezone('Asia/Taipei')->format('Y-m-d H:i:s'), + 'inline' => true, + ], + [ + 'name' => '發送人員', + 'value' => auth()->user() ? auth()->user()->name : 'System', + 'inline' => true, + ], + ], + 'footer' => [ + 'text' => 'Star Cloud Notification Subsystem', + ], + 'timestamp' => now()->toISOString(), + ] + ] + ]; + + return $this->postToWebhook($url, $payload); + } + + /** + * 發送機台日誌告警 + */ + public function sendMachineLogAlert(string $url, MachineLog $log): bool + { + $machine = $log->machine; + if (!$machine) { + return false; + } + + // 1. 智慧語系切換與還原 + $company = $machine->company; + $companyLocale = $company ? ($company->settings['locale'] ?? 'zh_TW') : 'zh_TW'; + $originalLocale = app()->getLocale(); + app()->setLocale($companyLocale); + + try { + $title = ''; + $description = ''; + $color = 561586; // 預設藍色 (cyan-600) + $fields = []; + + // 根據日誌類別與內容動態建構卡片 + if ($log->type === 'temperature') { + $temp = $log->context['temp'] ?? 'N/A'; + $limit = $log->context['limit'] ?? 'N/A'; + + if (str_contains($log->message, 'too high')) { + $title = '🔴 ' . $this->cleanTranslation(__('Temperature Alert (High)'), $companyLocale); + $description = $this->cleanTranslation(__('Current temperature (:temp°C) is above the upper limit (:limit°C).', ['temp' => $temp, 'limit' => $limit]), $companyLocale); + $color = 14753096; // rose-600 + + $fields[] = [ + 'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale), + 'value' => "`{$temp}°C`", + 'inline' => true, + ]; + $fields[] = [ + 'name' => $this->cleanTranslation(__('Temperature Limit'), $companyLocale), + 'value' => "`{$limit}°C`", + 'inline' => true, + ]; + } elseif (str_contains($log->message, 'too low')) { + $title = '🔴 ' . $this->cleanTranslation(__('Temperature Alert (Low)'), $companyLocale); + $description = $this->cleanTranslation(__('Current temperature (:temp°C) is below the lower limit (:limit°C).', ['temp' => $temp, 'limit' => $limit]), $companyLocale); + $color = 561586; // cyan-600 + + $fields[] = [ + 'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale), + 'value' => "`{$temp}°C`", + 'inline' => true, + ]; + $fields[] = [ + 'name' => $this->cleanTranslation(__('Temperature Limit'), $companyLocale), + 'value' => "`{$limit}°C`", + 'inline' => true, + ]; + } else { + $title = '🟢 ' . $this->cleanTranslation(__('Temperature Restored'), $companyLocale); + $description = $this->cleanTranslation(__('Current temperature has returned to normal range (:temp°C).', ['temp' => $temp]), $companyLocale); + $color = 366185; // emerald-600 + + $fields[] = [ + 'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale), + 'value' => "`{$temp}°C`", + 'inline' => true, + ]; + } + } elseif ($log->type === 'status') { + if ($log->message === 'Connection lost (LWT)') { + $title = '🔴 ' . $this->cleanTranslation(__('Machine Connectivity Lost'), $companyLocale); + $description = $this->cleanTranslation(__('System detected that the machine disconnected unexpectedly.'), $companyLocale); + $color = 14753096; // rose-600 (0xE11D48) + } elseif ($log->message === 'Connection restored') { + $title = '🟢 ' . $this->cleanTranslation(__('Machine Connectivity Restored'), $companyLocale); + $description = $this->cleanTranslation(__('Machine has reconnected to the server and resumed normal heartbeat.'), $companyLocale); + $color = 366185; // emerald-600 (0x059669) + } else { + $title = 'ℹ️ ' . $this->cleanTranslation(__('Machine Status Updated'), $companyLocale); + $description = $this->cleanTranslation(__($log->message), $companyLocale); + $color = 14251782; // warning/amber + } + } elseif ($log->type === 'submachine') { + // 解析 B013 Payload,可能包含 tid (貨道) 與 error_code + $data = is_array($log->context) ? $log->context : json_decode($log->context ?? '', true); + $errorCode = $data['error_code'] ?? $data['raw_code'] ?? 'Unknown'; + $slotNo = $data['tid'] ?? 'N/A'; + + // 智慧異常對譯:查詢 MachineService::ERROR_CODE_MAP + $mapping = \App\Services\Machine\MachineService::ERROR_CODE_MAP[$errorCode] ?? null; + if ($mapping) { + $translatedError = __($mapping['label']); + } else { + $translatedError = $data['translated_label'] ?? $errorCode; + $translatedError = __($translatedError); + } + + $title = '⚠️ ' . $this->cleanTranslation(__('Submachine Exception'), $companyLocale); + $description = $this->cleanTranslation(__('Vending machine reported a submachine hardware error.'), $companyLocale); + $color = ($log->level === 'error') ? 14753096 : 14251782; + + $fields[] = [ + 'name' => __('Error Code'), + 'value' => "`{$errorCode}`", + 'inline' => true, + ]; + $fields[] = [ + 'name' => __('Error Details'), + 'value' => $this->cleanTranslation($translatedError, $companyLocale), + 'inline' => true, + ]; + $fields[] = [ + 'name' => __('Affected Slot'), + 'value' => "`{$slotNo}`", + 'inline' => true, + ]; + } else { + // 其他類型的日誌 + $title = '📢 ' . $this->cleanTranslation(__('Machine Status Updated'), $companyLocale); + $description = $this->cleanTranslation(__($log->message), $companyLocale); + $color = ($log->level === 'error') ? 14753096 : (($log->level === 'warning') ? 14251782 : 561586); + } + + // 加入機台基本欄位資訊 + $fields[] = [ + 'name' => __('Machine Name'), + 'value' => $machine->name ?? __('Unnamed Machine'), + 'inline' => true, + ]; + $fields[] = [ + 'name' => __('Serial No'), + 'value' => "`{$machine->serial_no}`", + 'inline' => true, + ]; + + // 所屬公司欄位依使用者要求完全移除 + + if ($machine->location) { + $fields[] = [ + 'name' => __('Location'), + 'value' => $machine->location, + 'inline' => false, + ]; + } + + $payload = [ + 'username' => 'Star Cloud Alert Bot', + 'embeds' => [ + [ + 'title' => $title, + 'description' => $description, + 'color' => $color, + 'fields' => $fields, + 'footer' => [ + 'text' => 'Star Cloud Telemetry Subsystem', + ], + 'timestamp' => $log->created_at ? $log->created_at->toISOString() : now()->toISOString(), + ] + ] + ]; + + return $this->postToWebhook($url, $payload); + } finally { + // 強制還原語系,防止系統語系污染 + app()->setLocale($originalLocale); + } + } + + /** + * 清理翻譯字串,移除末尾或中間的英文技術括號說明(如 "(K-PDT)"、"(LWT)" 等) + */ + protected function cleanTranslation(string $text, string $locale): string + { + if ($locale === 'en') { + return $text; + } + // 匹配半角及全角括號,內含英數字、連字號、空格等,例如 " (K-PDT)" 或 " (LWT)" + return preg_replace('/\s*[\(\(][A-Za-z0-9\-_\s]+[\)\)]/u', '', $text); + } + + /** + * HTTP POST 請求 Discord Webhook + */ + protected function postToWebhook(string $url, array $payload): bool + { + try { + $response = Http::timeout(5)->post($url, $payload); + + if ($response->successful()) { + return true; + } + + Log::error('Discord Webhook Failed: ' . $response->body()); + return false; + } catch (\Exception $e) { + Log::error('Discord Webhook Exception: ' . $e->getMessage()); + return false; + } + } +} diff --git a/app/Traits/TenantScoped.php b/app/Traits/TenantScoped.php index 8f77bcf..d934890 100644 --- a/app/Traits/TenantScoped.php +++ b/app/Traits/TenantScoped.php @@ -17,8 +17,8 @@ trait TenantScoped return; } - // check if running in console/migration - if (app()->runningInConsole()) { + // check if running in console/migration (but not during unit tests) + if (app()->runningInConsole() && !app()->runningUnitTests()) { return; } diff --git a/database/migrations/2026_05_20_170630_add_settings_to_machine_models_table.php b/database/migrations/2026_05_20_170630_add_settings_to_machine_models_table.php new file mode 100644 index 0000000..9eae159 --- /dev/null +++ b/database/migrations/2026_05_20_170630_add_settings_to_machine_models_table.php @@ -0,0 +1,28 @@ +json('settings')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('machine_models', function (Blueprint $table) { + $table->dropColumn('settings'); + }); + } +}; diff --git a/database/migrations/2026_05_20_170631_add_settings_to_machines_table.php b/database/migrations/2026_05_20_170631_add_settings_to_machines_table.php new file mode 100644 index 0000000..5709ece --- /dev/null +++ b/database/migrations/2026_05_20_170631_add_settings_to_machines_table.php @@ -0,0 +1,28 @@ +json('settings')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('machines', function (Blueprint $table) { + $table->dropColumn('settings'); + }); + } +}; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index b5b04ba..7d9788f 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -63,6 +63,7 @@ class RoleSeeder extends Seeder 'menu.basic', 'menu.basic.machines', 'menu.basic.payment-configs', + 'menu.basic.discord-notifications', 'menu.permissions', 'menu.permissions.companies', 'menu.permissions.accounts', @@ -123,6 +124,7 @@ class RoleSeeder extends Seeder 'menu.special-permission.clear-stock', 'menu.special-permission.apk-versions', 'menu.special-permission.discord-notifications', + 'menu.basic.discord-notifications', ]); } } \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index 81c41b3..64c203b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2036,5 +2036,106 @@ "Total Net Profit": "Total Net Profit", "Gross Margin": "Gross Margin", "No product data matching search criteria": "No product data matching search criteria", - "Analyze Solely": "Analyze Solely" -} \ No newline at end of file + "Analyze Solely": "Analyze Solely", + "Configure Discord Webhook alerts for hardware exceptions and connectivity logs": "Configure Discord Webhook alerts for hardware exceptions and connectivity logs", + "Search company name or code...": "Search company name or code...", + "Company Info": "Company Info", + "Webhook URL": "Webhook URL", + "Push Status": "Push Status", + "Configured Alert Types": "Configured Alert Types", + "Not Configured": "Not Configured", + "Edit Discord Alert Settings": "Edit Discord Alert Settings", + "General Settings": "General Settings", + "Enable and paste Webhook URL": "Enable and paste Webhook URL", + "Discord Webhook URL": "Discord Webhook URL", + "Test Connection": "Test Connection", + "Sending...": "Sending...", + "Test notification sent successfully.": "Test notification sent successfully.", + "Failed to send test notification. Please verify the Webhook URL.": "Failed to send test notification. Please verify the Webhook URL.", + "An error occurred: ": "An error occurred: ", + "Discord notification settings updated successfully.": "Discord notification settings updated successfully.", + "Alert Types": "Alert Types", + "Submachine Exception (B013)": "Submachine Exception (B013)", + "Triggers when vending machines report motor blockages, coin/bill acceptor malfunctions, or sensor failures.": "Triggers when vending machines report motor blockages, coin/bill acceptor malfunctions, or sensor failures.", + "Machine Connectivity Logs (LWT)": "Machine Connectivity Logs (LWT)", + "Triggers on unexpected connection losses (LWT) and auto-resolves with restoration reports. Regular reboots are intelligently filtered.": "Triggers on unexpected connection losses (LWT) and auto-resolves with restoration reports. Regular reboots are intelligently filtered.", + "Alert Severity Levels": "Alert Severity Levels", + "Device": "Device", + "Critical failures requiring immediate hands-on attention.": "Critical failures requiring immediate hands-on attention.", + "Device connection drops or minor warnings.": "Device connection drops or minor warnings.", + "Connection recovery alerts and normal logs.": "Connection recovery alerts and normal logs.", + "Please enter Webhook URL first.": "Please enter Webhook URL first.", + "Failed to send test notification.": "Failed to send test notification.", + "An error occurred during testing.": "An error occurred during testing.", + "Search": "Search", + "Reset": "Reset", + "Notification Language": "Notification Language", + "Machine Connectivity Lost": "Connection Lost", + "System detected that the machine disconnected unexpectedly.": "System detected that the machine disconnected unexpectedly.", + "Machine Connectivity Restored": "Connection Restored", + "Machine has reconnected to the server and resumed normal heartbeat.": "Machine has reconnected to the server and resumed normal heartbeat.", + "Submachine Exception": "Submachine Exception", + "Vending machine reported a submachine hardware error.": "Vending machine reported a submachine hardware error.", + "Error Details": "Error Details", + "Error Code": "Error Code", + "Affected Slot": "Affected Slot", + "Installation Location": "Installation Location", + "Temperature Alert": "Temperature Alert", + "Temperature Alert (High)": "Temperature Alert (High)", + "Temperature Alert (Low)": "Temperature Alert (Low)", + "Temperature Restored": "Temperature Restored", + "Current temperature (:temp°C) is above the upper limit (:limit°C).": "Current temperature (:temp°C) is above the upper limit (:limit°C).", + "Current temperature (:temp°C) is below the lower limit (:limit°C).": "Current temperature (:temp°C) is below the lower limit (:limit°C).", + "Current temperature has returned to normal range (:temp°C).": "Current temperature has returned to normal range (:temp°C).", + "Temperature Limit": "Temperature Limit", + "Current Temperature": "Current Temperature", + "Submachine Alert": "Submachine Alert", + "Machine Connection Alert": "Machine Connection Alert", + + "Company Settings": "Company Settings", + "Alert Switch": "Alert Switch", + "Connectivity Logs": "Connectivity Logs", + "Device Exception": "Device Exception", + "Copy Settings From Another Machine": "Copy Settings From Another Machine", + "Custom": "Custom", + "Custom Enabled": "Custom Enabled", + "Custom Disabled (Mute Alert)": "Custom Disabled (Mute Alert)", + "Custom Upper Limit (°C)": "Custom Upper Limit (°C)", + "Custom Lower Limit (°C)": "Custom Lower Limit (°C)", + "Define custom temperature limits or inherit from model": "Define custom temperature limits or inherit from model", + "Edit Company Alert Settings": "Edit Company Alert Settings", + "Edit Machine Alert Settings": "Edit Machine Alert Settings", + "Inherit": "Inherit", + "Inherit Company": "Inherit Company", + "Inherit Model / System default": "Inherit Model / System Default", + "Leave empty to inherit company settings": "Leave empty to inherit company settings", + "Machine Specific": "Machine Specific", + "Machine-Specific Webhook": "Machine-Specific Webhook", + "Model Default": "Model Default", + "No Model": "No Model", + "Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.", + "Select a machine to copy settings from...": "Select a machine to copy settings from...", + "Settings loaded from": "Settings loaded from", + "Source of temperature limit settings": "Source of temperature limit settings", + "Temp Alert Range": "Temp Alert Range", + "Temp Alert State": "Temp Alert State", + "Temperature Alert Settings": "Temperature Alert Settings", + "Webhook Status": "Webhook Status", + "Company Discord notification settings updated successfully.": "Company Discord notification settings updated successfully.", + "Machine Discord notification settings updated successfully.": "Machine Discord notification settings updated successfully.", + "Search machine name or SN...": "Search machine name or SN...", + + "Default Temp Upper Limit (°C)": "Default Temp Upper Limit (°C)", + "Default Temp Lower Limit (°C)": "Default Temp Lower Limit (°C)", + "Default Temp Limits": "Default Temp Limits", + "Default Temp Alert Upper Limit (°C)": "Default Temp Alert Upper Limit (°C)", + "Default Temp Alert Lower Limit (°C)": "Default Temp Alert Lower Limit (°C)", + "Default Temp Alert Limits": "Default Temp Alert Limits", + "Alert Types Source": "Alert Types Source", + "Inherit Company Alert Types": "Inherit Company Alert Types", + "Custom Alert Types": "Custom Alert Types", + "Effective Alert Types": "Effective Alert Types", + "Machine Alert Settings": "Machine Alert Settings", + "Configure alert types and temperature thresholds": "Configure alert types and temperature thresholds", + "Machine model saved successfully.": "Machine model saved successfully." +} diff --git a/lang/ja.json b/lang/ja.json index 0504f1c..986fcbf 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -2036,5 +2036,109 @@ "Total Net Profit": "純利益総額", "Gross Margin": "粗利益率", "No product data matching search criteria": "検索条件に一致する商品データはありません", - "Analyze Solely": "個別分析" -} \ No newline at end of file + "Analyze Solely": "個別分析", + "Configure Discord Webhook alerts for hardware exceptions and connectivity logs": "ハードウェア異常と接続ログ用のDiscord Webhookアラートを設定します", + "Search company name or code...": "会社名またはコードで検索...", + "Company Info": "会社情報", + "Webhook URL": "Webhook URL", + "Push Status": "アラート状態", + "Configured Alert Types": "設定済みの告警タイプ", + "Not Configured": "未設定", + "Edit Discord Alert Settings": "Discordアラート設定の編集", + "General Settings": "基本設定", + "Enable and paste Webhook URL": "Webhook URLを有効にして貼り付けます", + "Discord Webhook URL": "Discord Webhook URL", + "Test Connection": "接続テスト", + "Sending...": "送信中...", + "Test notification sent successfully.": "テスト通知が正常に送信されました。", + "Failed to send test notification. Please verify the Webhook URL.": "テスト通知の送信に失敗しました。Webhook URLを確認してください。", + "An error occurred: ": "エラーが発生しました:", + "Discord notification settings updated successfully.": "Discordアラート設定が正常に更新されました。", + "Alert Types": "アラートタイプ", + "Submachine Exception (B013)": "下位機/商品ラック異常 (B013)", + "Triggers when vending machines report motor blockages, coin/bill acceptor malfunctions, or sensor failures.": "自動販売機がモーターの詰まり、紙幣機/硬貨機の故障、またはセンサーの異常を報告したときにトリガーされます。", + "Machine Connectivity Logs (LWT)": "機台接続ログ (LWT)", + "Triggers on unexpected connection losses (LWT) and auto-resolves with restoration reports. Regular reboots are intelligently filtered.": "予期しない接続切断 (LWT) 時にアラートをトリガーし、接続復旧時に復旧通知を自動的に送信します。日常的な再起動はスマートにフィルタリングされます。", + "Alert Severity Levels": "アラート深刻度", + "Device": "機台", + "Critical failures requiring immediate hands-on attention.": "即座に対処が必要な重大な障害。", + "Device connection drops or minor warnings.": "機台の接続切断または軽微な警告。", + "Connection recovery alerts and normal logs.": "接続復旧通知および通常ログ。", + "Please enter Webhook URL first.": "最初にWebhook URLを入力してください。", + "Failed to send test notification.": "テスト通知の送信に失敗しました。", + "An error occurred during testing.": "テスト中にエラーが発生しました。", + "Search": "検索", + "Reset": "リセット", + "error": "異常", + "warning": "警告", + "info": "一般", + "Notification Language": "通知送信言語", + "Machine Connectivity Lost": "マシン接続切断", + "System detected that the machine disconnected unexpectedly.": "システムがマシンの予期せぬ切断を検出しました。", + "Machine Connectivity Restored": "マシン接続復旧", + "Machine has reconnected to the server and resumed normal heartbeat.": "マシンがサーバーに再接続し、通常のハートビートを再開しました。", + "Submachine Exception": "下位機ハードウェア異常", + "Vending machine reported a submachine hardware error.": "マシンが下位機のハードウェア動作エラーを報告しました。", + "Error Details": "異常詳細", + "Error Code": "異常コード", + "Affected Slot": "影響を受ける貨道", + "Installation Location": "設置場所", + "Temperature Alert": "温度アラート", + "Temperature Alert (High)": "温度過昇アラート", + "Temperature Alert (Low)": "温度低下アラート", + "Temperature Restored": "温度正常復帰", + "Current temperature (:temp°C) is above the upper limit (:limit°C).": "現在温度 (:temp°C) が設定上限 (:limit°C) を超えています。", + "Current temperature (:temp°C) is below the lower limit (:limit°C).": "現在温度 (:temp°C) が設定下限 (:limit°C) を下回っています。", + "Current temperature has returned to normal range (:temp°C).": "現在温度は正常範囲 (:temp°C) に戻りました。", + "Temperature Limit": "温度しきい値", + "Current Temperature": "現在温度", + "Submachine Alert": "下位機アラート", + "Machine Connection Alert": "マシン接続アラート", + + "Company Settings": "会社グローバル設定", + "Alert Switch": "アラートスイッチ", + "Connectivity Logs": "接続状態ログ", + "Device Exception": "下位機異常", + "Copy Settings From Another Machine": "他の機器の設定をコピー", + "Custom": "カスタム", + "Custom Enabled": "カスタム有効", + "Custom Disabled (Mute Alert)": "カスタム無効(アラート消音)", + "Custom Upper Limit (°C)": "カスタム上限温度 (°C)", + "Custom Lower Limit (°C)": "カスタム下限温度 (°C)", + "Define custom temperature limits or inherit from model": "カスタム温度制限を設定するか、機器モデルから継承します", + "Edit Company Alert Settings": "会社アラート設定を編集", + "Edit Machine Alert Settings": "機器アラート設定を編集", + "Inherit": "継承", + "Inherit Company": "会社設定を継承", + "Inherit Model / System default": "モデル / システムデフォルトを継承", + "Leave empty to inherit company settings": "空白のままにすると会社のグローバル設定を継承します", + "Machine Specific": "機器専用", + "Machine-Specific Webhook": "機器専用 Webhook", + "Model Default": "モデルデフォルト", + "No Model": "モデル未設定", + "Only machines under the same company can be cloned for security.": "セキュリティのため、同じ会社の機器設定のみコピーできます。", + "Select a machine to copy settings from...": "設定のコピー元機器を選択...", + "Settings loaded from": "以下の機器から設定を読み込みました:", + "Source of temperature limit settings": "温度制限設定のソース", + "Temp Alert Range": "アラート温度範囲", + "Temp Alert State": "温度アラート状態", + "Temperature Alert Settings": "温度アラート設定", + "Webhook Status": "Webhook ステータス", + "Company Discord notification settings updated successfully.": "会社の Discord 通知設定が正常に更新されました。", + "Machine Discord notification settings updated successfully.": "機器の Discord 通知設定が正常に更新されました。", + "Search machine name or SN...": "機器名またはシリアル番号で検索...", + + "Default Temp Upper Limit (°C)": "デフォルト温度上限 (°C)", + "Default Temp Lower Limit (°C)": "デフォルト温度下限 (°C)", + "Default Temp Limits": "デフォルト温度制御範囲", + "Default Temp Alert Upper Limit (°C)": "デフォルト温度アラート上限 (°C)", + "Default Temp Alert Lower Limit (°C)": "デフォルト温度アラート下限 (°C)", + "Default Temp Alert Limits": "デフォルト温度アラート範囲", + "Alert Types Source": "アラート種別ソース", + "Inherit Company Alert Types": "会社のアラート種別を継承", + "Custom Alert Types": "カスタムアラート種別", + "Effective Alert Types": "有効なアラート種別", + "Machine Alert Settings": "機台アラート設定", + "Configure alert types and temperature thresholds": "アラート種別と温度しきい値を設定", + "Machine model saved successfully.": "機器モデルが正常に追加されました。" +} diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 33a8035..3170218 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -2089,5 +2089,112 @@ "Order Number": "訂單編號", "Product Items": "商品品項", "Original Amount": "應付金額", - "Discount Amount": "折扣金額" -} \ No newline at end of file + "Discount Amount": "折扣金額", + "Configure Discord Webhook alerts for hardware exceptions and connectivity logs": "配置各公司的 Discord Webhook 以接收硬體異常與連線狀態告警", + "Search company name or code...": "搜尋公司名稱或代碼...", + "Company Info": "公司資訊", + "Webhook URL": "Webhook 網址", + "Push Status": "告警狀態", + "Configured Alert Types": "已設定的告警類型", + "Not Configured": "尚未設定", + "Edit Discord Alert Settings": "編輯 Discord 告警設定", + "General Settings": "基本設定", + "Enable Alerts": "啟用告警", + "Alert Types": "告警類別", + "Alert Severity Levels": "告警重要級別", + "Submachine Exception (B013)": "下位機/貨道異常 (B013)", + "Triggers when vending machines report motor blockages, coin/bill acceptor malfunctions, or sensor failures.": "當販賣機回報馬達堵塞、紙鈔機/硬幣器故障或感應器異常時觸發。", + "Machine Connectivity Logs (LWT)": "機台連線狀態日誌 (LWT)", + "Triggers on unexpected connection losses (LWT) and auto-resolves with restoration reports. Regular reboots are intelligently filtered.": "當機台無預警斷線 (LWT) 時觸發告警,並在恢復連線時發送復原通知。日常重開機會自動過濾。", + "Device": "機台", + "error": "異常", + "warning": "警告", + "info": "一般", + "Critical failures requiring immediate hands-on attention.": "需要立即處理的關鍵故障。", + "Device connection drops or minor warnings.": "機台連線中斷或輕微警報。", + "Connection recovery alerts and normal logs.": "連線恢復提示與一般日誌。", + "Please enter Webhook URL first.": "請先輸入 Webhook 網址。", + "Failed to send test notification.": "發送測試通知失敗。", + "An error occurred during testing.": "測試發送時發生錯誤。", + "Sending...": "發送中...", + "Test Connection": "測試連線", + "Test notification sent successfully.": "測試通知發送成功。", + "Failed to send test notification. Please verify the Webhook URL.": "發送測試通知失敗,請確認 Webhook 網址。", + "Discord notification settings updated successfully.": "Discord 通知設定更新成功。", + "Enable and paste Webhook URL": "啟用並貼上 Webhook 網址", + "Discord Webhook URL": "Discord Webhook 網址", + "An error occurred: ": "發生錯誤:", + "Search": "搜尋", + "Reset": "重設", + "Notification Language": "通知發送語系", + "Machine Connectivity Lost": "機台連線中斷", + "System detected that the machine disconnected unexpectedly.": "系統偵測到機台已非預期中斷連線。", + "Machine Connectivity Restored": "機台連線恢復", + "Machine has reconnected to the server and resumed normal heartbeat.": "機台已重新連線至伺服器並恢復正常心跳。", + "Submachine Exception": "下位機硬體異常", + "Vending machine reported a submachine hardware error.": "機台回報下位機硬體運作錯誤。", + "Error Details": "異常詳情", + "Error Code": "異常代碼", + "Affected Slot": "受影響貨道", + "Installation Location": "安裝位置", + "Temperature Alert": "溫度告警", + "Temperature Alert (High)": "溫度過高告警", + "Temperature Alert (Low)": "溫度過低告警", + "Temperature Restored": "溫度恢復正常", + "Current temperature (:temp°C) is above the upper limit (:limit°C).": "當前溫度 (:temp°C) 已高於設定上限 (:limit°C)。", + "Current temperature (:temp°C) is below the lower limit (:limit°C).": "當前溫度 (:temp°C) 已低於設定下限 (:limit°C)。", + "Current temperature has returned to normal range (:temp°C).": "當前溫度已恢復至正常範圍 (:temp°C)。", + "Temperature Limit": "溫度限制", + "Current Temperature": "當前溫度", + "Submachine Alert": "下位機告警", + "Machine Connection Alert": "機台連線告警", + + "_discord_section": "=== Discord 告警設定 ===" , + "Company Settings": "公司全域設定", + "Alert Switch": "告警開關", + "Connectivity Logs": "連線狀態日誌", + "Device Exception": "下位機異常", + "Copy Settings From Another Machine": "複製其他機台設定", + "Custom": "自訂", + "Custom Enabled": "自訂啟用", + "Custom Disabled (Mute Alert)": "自訂停用(靜音告警)", + "Custom Upper Limit (°C)": "自訂上限溫度 (°C)", + "Custom Lower Limit (°C)": "自訂下限溫度 (°C)", + "Define custom temperature limits or inherit from model": "設定自訂溫度限制,或繼承自機台型號", + "Edit Company Alert Settings": "編輯公司告警設定", + "Edit Machine Alert Settings": "編輯機台告警設定", + "Inherit": "繼承", + "Inherit Company": "繼承公司設定", + "Inherit Model / System default": "繼承型號 / 系統預設", + "Leave empty to inherit company settings": "留空則繼承公司全域設定", + "Machine Specific": "機台專屬", + "Machine-Specific Webhook": "機台專屬 Webhook", + "Model Default": "型號預設", + "No Model": "未設定型號", + "Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。", + "Select a machine to copy settings from...": "選擇要複製設定的來源機台...", + "Settings loaded from": "已從以下機台載入設定:", + "Source of temperature limit settings": "溫度限制設定來源", + "Temp Alert Range": "告警溫度範圍", + "Temp Alert State": "溫度告警狀態", + "Temperature Alert Settings": "溫度告警設定", + "Webhook Status": "Webhook 狀態", + "Company Discord notification settings updated successfully.": "公司 Discord 通知設定已成功更新。", + "Machine Discord notification settings updated successfully.": "機台 Discord 通知設定已成功更新。", + "Search machine name or SN...": "搜尋機台名稱或序號...", + + "_model_temp_section": "=== 型號溫度設定 ===", + "Default Temp Upper Limit (°C)": "預設溫度上限 (°C)", + "Default Temp Lower Limit (°C)": "預設溫度下限 (°C)", + "Default Temp Limits": "預設溫控範圍", + "Default Temp Alert Upper Limit (°C)": "預設溫度告警上限 (°C)", + "Default Temp Alert Lower Limit (°C)": "預設溫度告警下限 (°C)", + "Default Temp Alert Limits": "預設溫度告警範圍", + "Alert Types Source": "告警類型來源", + "Inherit Company Alert Types": "繼承公司告警類型", + "Custom Alert Types": "自訂告警類型", + "Effective Alert Types": "目前生效的告警類型", + "Machine Alert Settings": "機台告警設定", + "Configure alert types and temperature thresholds": "設定告警類型與溫度門檻", + "Machine model saved successfully.": "機台型號已成功新增。" +} diff --git a/resources/views/admin/basic-settings/discord-notifications/index.blade.php b/resources/views/admin/basic-settings/discord-notifications/index.blade.php new file mode 100644 index 0000000..f1e54b0 --- /dev/null +++ b/resources/views/admin/basic-settings/discord-notifications/index.blade.php @@ -0,0 +1,1281 @@ +@extends('layouts.admin') + +@section('content') +
+ + + + + + + + + + +
+ + +
+ + +
+ +
+ + @if(auth()->user()->isSystemAdmin()) +
+ +
+ + + + +
+
+ + +
+
+ @endif + + {{-- 桌面版表格 --}} + + + {{-- 行動版/平版 Card 列表 --}} +
+ @forelse($companies as $company) + @php + $settings = $company->settings ?? []; + $isEnabled = (bool)($settings['discord_notify_enabled'] ?? false); + $webhook = $settings['discord_webhook_url'] ?? ''; + $types = $settings['discord_notify_types'] ?? []; + @endphp +
+
+
+
+ +
+
+

{{ $company->name }}

+

{{ $company->code }}

+
+
+ +
+ +
+
+

{{ __('Webhook URL') }}

+ @if($webhook) +

{{ $webhook }}

+ @else +

{{ __('Not Configured') }}

+ @endif +
+
+

{{ __('Configured Alert Types') }}

+
+ @if(empty($types)) + + @endif + + @foreach($types as $type) + @php + $label = match($type) { + 'submachine' => __('Device Exception'), + 'status' => __('Connectivity Logs'), + 'temperature' => __('Temperature Alert'), + default => $type + }; + @endphp + + {{ $label }} + + @endforeach +
+
+
+ +
+ +
+
+ @empty + + @endforelse +
+ + {{-- 分頁 --}} +
+ {{ $companies->appends(array_merge(request()->query(), ['tab' => 'company']))->links('vendor.pagination.luxury', [ + 'ajax_navigate_event' => 'ajax:navigate:discord', + 'per_page_param' => 'company_per_page', + ]) }} +
+
+ + +
+ +
+ + +
+ + + +
+ + + + +
+ + + @if(auth()->user()->isSystemAdmin()) +
+ + @foreach($allCompanies as $c) + + @endforeach + +
+ @endif + +
+ + +
+
+ + {{-- 桌面版表格 --}} + + + {{-- 行動版/平版 Card 列表 --}} +
+ @forelse($machines as $machine) + @php + $mSettings = $machine->settings ?? []; + $mWebhook = $mSettings['discord_webhook_url'] ?? null; + $cSettings = $machine->company->settings ?? []; + $cWebhook = $cSettings['discord_webhook_url'] ?? null; + $cNotifyTypes = $cSettings['discord_notify_types'] ?? []; + $mNotifyTypes = array_key_exists('discord_notify_types', $mSettings) && is_array($mSettings['discord_notify_types']) ? $mSettings['discord_notify_types'] : null; + $effectiveNotifyTypes = $mNotifyTypes ?? $cNotifyTypes; + $isTypeInherited = $mNotifyTypes === null; + $hasTemperatureNotify = in_array('temperature', $effectiveNotifyTypes, true); + + $mTempAlert = $mSettings['temp_alert_enabled'] ?? 'inherit'; + + // 繼承鏈 + $mUpper = $mSettings['temp_upper_limit'] ?? null; + $mLower = $mSettings['temp_lower_limit'] ?? null; + + $modelSettings = $machine->machineModel->settings ?? []; + $modelUpper = $modelSettings['temp_upper_limit'] ?? null; + $modelLower = $modelSettings['temp_lower_limit'] ?? null; + + $sysUpper = 40.0; + $sysLower = 0.0; + + $finalUpper = $sysUpper; + $finalLower = $sysLower; + $tempSource = __('System Default'); + + if ($mTempAlert === 'enabled' && $hasTemperatureNotify && ($mUpper !== null || $mLower !== null)) { + $finalUpper = $mUpper !== null ? $mUpper : $sysUpper; + $finalLower = $mLower !== null ? $mLower : $sysLower; + $tempSource = __('Custom'); + } elseif ($hasTemperatureNotify && ($modelUpper !== null || $modelLower !== null)) { + $finalUpper = $modelUpper !== null ? $modelUpper : $sysUpper; + $finalLower = $modelLower !== null ? $modelLower : $sysLower; + $tempSource = __('Model Default'); + } + @endphp +
+
+
+
+ +
+
+

{{ $machine->name }}

+

{{ $machine->serial_no }}

+
+
+ {{-- Temperature status is implied by configured alert types; show only range below. --}} +
+ +
+
+

{{ __('Model') }}

+

{{ $machine->machineModel->name ?? __('No Model') }}

+
+ @if(auth()->user()->isSystemAdmin()) +
+

{{ __('Company') }}

+

{{ $machine->company->name ?? __('No Company') }}

+
+ @endif +
+

{{ __('Webhook URL') }}

+ @if($mWebhook) +

{{ $mWebhook }}

+ {{ __('Machine Specific') }} + @elseif($cWebhook) +

{{ $cWebhook }}

+ {{ __('Inherit Company') }} + @else +

{{ __('Not Configured') }}

+ @endif +
+
+

{{ __('Configured Alert Types') }}

+ @if($isTypeInherited) + {{ __('Inherit Company') }} + @endif +
+ @foreach($effectiveNotifyTypes as $type) + @php + $label = match($type) { + 'submachine' => __('Device Exception'), + 'status' => __('Connectivity Logs'), + 'temperature' => __('Temperature Alert'), + default => $type + }; + @endphp + {{ $label }} + @endforeach + @if(empty($effectiveNotifyTypes)) +

{{ __('Not Configured') }}

+ @endif +
+
+ @if($mTempAlert !== 'disabled' && $hasTemperatureNotify) +
+

{{ __('Temp Alert Range') }}

+
+ + {{ number_format($finalLower, 0) }}°C ~ {{ number_format($finalUpper, 0) }}°C + + + ({{ $tempSource }}) + +
+
+ @endif +
+ +
+ +
+
+ @empty + + @endforelse +
+ + {{-- 分頁 --}} +
+ {{ $machines->appends(array_merge(request()->query(), ['tab' => 'machine']))->links('vendor.pagination.luxury', [ + 'ajax_navigate_event' => 'ajax:navigate:discord', + 'per_page_param' => 'machine_per_page', + ]) }} +
+
+ +
+
+ + + + + + + + + + + + +
+@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/admin/basic-settings/machines/index.blade.php b/resources/views/admin/basic-settings/machines/index.blade.php index 107959d..b3f3514 100644 --- a/resources/views/admin/basic-settings/machines/index.blade.php +++ b/resources/views/admin/basic-settings/machines/index.blade.php @@ -69,8 +69,22 @@ currentMachine: null, showCreateModelModal: false, showEditModelModal: false, - currentModel: { name: '' }, + newModel: { name: '', temp_upper_limit: '', temp_lower_limit: '' }, + currentModel: { name: '', temp_upper_limit: '', temp_lower_limit: '' }, modelActionUrl: '', + adjustModelTemp(modelObj, field, delta, fallbackVal) { + let current = modelObj[field]; + if (current === undefined || current === null || current === '') { + current = parseInt(fallbackVal); + } else { + current = parseInt(current); + } + if (isNaN(current)) current = 0; + let newValue = current + delta; + if (newValue < -50) newValue = -50; + if (newValue > 100) newValue = 100; + modelObj[field] = newValue.toString(); + }, selectedFileCount: 0, selectedFiles: [null, null, null], deletedPhotos: [false, false, false], @@ -654,7 +668,7 @@ {{ __('Add Machine') }} - +
+ +
+ + + +
+ +
+ +
+ +
+ +
+
+
+ +
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
diff --git a/resources/views/admin/basic-settings/machines/partials/tab-models.blade.php b/resources/views/admin/basic-settings/machines/partials/tab-models.blade.php index 791b4f4..375be03 100644 --- a/resources/views/admin/basic-settings/machines/partials/tab-models.blade.php +++ b/resources/views/admin/basic-settings/machines/partials/tab-models.blade.php @@ -49,6 +49,8 @@ {{ __('Model Name') }} + + {{ __('Default Temp Alert Limits') }} {{ __('Machine Count') }} @@ -71,6 +73,21 @@ {{ $model->name }}
+ + @if(isset($model->settings['temp_upper_limit']) || isset($model->settings['temp_lower_limit'])) +
+ + {{ isset($model->settings['temp_lower_limit']) ? (int)round($model->settings['temp_lower_limit']) : '-' }}°C + + ~ + + {{ isset($model->settings['temp_upper_limit']) ? (int)round($model->settings['temp_upper_limit']) : '-' }}°C + +
+ @else + - + @endif + {{ $model->machines_count ?? 0 }} {{ __('Items') }} @@ -83,7 +100,7 @@
-
{{ $model->machines_count ?? 0 }} {{ __('Items') }} @@ -141,7 +166,7 @@ {{-- Action Buttons --}}
-
-@endcan @endif @if(auth()->user()->isSystemAdmin()) diff --git a/routes/web.php b/routes/web.php index a2cc55a..cd0107c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -231,7 +231,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix Route::prefix('special-permission')->name('special-permission.')->group(function () { Route::get('/clear-stock', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'clearStock'])->name('clear-stock'); Route::get('/apk-versions', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'apkVersions'])->name('apk-versions'); - Route::get('/discord-notifications', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'discordNotifications'])->name('discord-notifications'); }); // 14. 基本設定 @@ -261,6 +260,17 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix // QR Code 生成 Route::get('qr-code', [App\Http\Controllers\Admin\QrCodeController::class, 'generate'])->name('qr-code'); + + // Discord 告警通知設定 + Route::prefix('discord-notifications') + ->name('discord-notifications.') + ->middleware('can:menu.basic.discord-notifications') + ->group(function () { + Route::get('/', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'index'])->name('index'); + Route::put('/company/{id}', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'updateCompany'])->name('update-company'); + Route::put('/machine/{id}', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'updateMachine'])->name('update-machine'); + Route::post('/test', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'test'])->name('test'); + }); }); // 15. 權限設定 diff --git a/tests/Feature/Admin/DiscordNotificationSecurityTest.php b/tests/Feature/Admin/DiscordNotificationSecurityTest.php new file mode 100644 index 0000000..3ea2977 --- /dev/null +++ b/tests/Feature/Admin/DiscordNotificationSecurityTest.php @@ -0,0 +1,131 @@ +artisan('db:seed', ['--class' => 'RoleSeeder']); + + // 2. 建立測試公司 + $this->company = Company::create([ + 'name' => 'Test Company', + 'code' => 'TEST', + 'status' => 1, + ]); + + // 3. 建立 Super Admin 帳號 + $this->superAdmin = User::create([ + 'name' => 'Super Admin User', + 'username' => 'superadmin_test', + 'email' => 'superadmin@example.com', + 'password' => bcrypt('password'), + 'company_id' => null, + ]); + $this->superAdmin->assignRole('super-admin'); + + // 4. 建立租戶管理員帳號 + $this->tenantAdmin = User::create([ + 'name' => 'Tenant Admin User', + 'username' => 'tenantadmin_test', + 'email' => 'tenantadmin@example.com', + 'password' => bcrypt('password'), + 'company_id' => $this->company->id, + ]); + + $tenantAdminTemplate = Role::where('name', '客戶管理員角色模板')->first(); + $tenantRole = Role::create([ + 'name' => '管理員', + 'company_id' => $this->company->id, + 'guard_name' => 'web', + 'is_system' => false, + ]); + + // 確保 tenantAdminTemplate 包含我們新加的 menu.basic.discord-notifications + $tenantRole->syncPermissions($tenantAdminTemplate->permissions); + $this->tenantAdmin->assignRole($tenantRole); + + // 5. 建立普通無此權限之帳號 + $this->regularUser = User::create([ + 'name' => 'Regular User', + 'username' => 'regular_user', + 'email' => 'regular@example.com', + 'password' => bcrypt('password'), + 'company_id' => $this->company->id, + ]); + + $emptyRole = Role::create([ + 'name' => '普通人員', + 'company_id' => $this->company->id, + 'guard_name' => 'web', + 'is_system' => false, + ]); + // 給予基本會員權限而非 Discord 設定權限 + $emptyRole->syncPermissions(['menu.members']); + $this->regularUser->assignRole($emptyRole); + } + + /** + * 測試超級管理員可以訪問 Discord Webhook 設定 + */ + public function test_super_admin_can_access_discord_notifications() + { + $this->actingAs($this->superAdmin); + + $response = $this->get(route('admin.basic-settings.discord-notifications.index')); + $response->assertStatus(200); + + $response = $this->post(route('admin.basic-settings.discord-notifications.test'), [ + 'webhook_url' => 'https://discord.com/api/webhooks/test', + ]); + // 發送測試可能會失敗 (如 Mock Discord 伺服器),但應通過 403 路由認證 + $this->assertNotEquals(403, $response->status()); + } + + /** + * 測試租戶管理員可以訪問 Discord Webhook 設定 + */ + public function test_tenant_admin_can_access_discord_notifications() + { + $this->actingAs($this->tenantAdmin); + + $response = $this->get(route('admin.basic-settings.discord-notifications.index')); + $response->assertStatus(200); + } + + /** + * 測試無權限之普通用戶會被阻擋且返回 403 錯誤 + */ + public function test_regular_user_cannot_access_discord_notifications() + { + $this->actingAs($this->regularUser); + + $response = $this->get(route('admin.basic-settings.discord-notifications.index')); + $response->assertStatus(403); + + $response = $this->put(route('admin.basic-settings.discord-notifications.update-company', ['id' => $this->company->id]), [ + 'discord_webhook_url' => 'https://discord.com/api/webhooks/test', + 'discord_notify_enabled' => true, + 'discord_notify_locale' => 'zh_TW' + ]); + $response->assertStatus(403); + } +} diff --git a/tests/Feature/TenantIsolationTest.php b/tests/Feature/TenantIsolationTest.php index 19f0dd8..037ea01 100644 --- a/tests/Feature/TenantIsolationTest.php +++ b/tests/Feature/TenantIsolationTest.php @@ -19,8 +19,8 @@ class TenantIsolationTest extends TestCase $companyB = Company::create(['name' => 'Company B', 'code' => 'COB']); // 建立各自的機台 - Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id]); - Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id]); + Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id, 'serial_no' => 'SN001']); + Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id, 'serial_no' => 'SN002']); // 建立租戶 A 的使用者並登入 $userA = User::factory()->create(['company_id' => $companyA->id]); @@ -41,8 +41,8 @@ class TenantIsolationTest extends TestCase $companyB = Company::create(['name' => 'Company B', 'code' => 'COB']); // 建立各自的機台 - Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id]); - Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id]); + Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id, 'serial_no' => 'SN001']); + Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id, 'serial_no' => 'SN002']); // 建立系統管理員 (company_id = null) 並登入 $admin = User::factory()->create(['company_id' => null]); @@ -61,7 +61,7 @@ class TenantIsolationTest extends TestCase $this->actingAs($userA); // 建立新機台(不指定 company_id) - $machine = Machine::create(['name' => 'New Machine']); + $machine = Machine::create(['name' => 'New Machine', 'serial_no' => 'SN003']); $this->assertEquals($companyA->id, $machine->company_id); } diff --git a/tests/test_discord.php b/tests/test_discord.php new file mode 100644 index 0000000..280e426 --- /dev/null +++ b/tests/test_discord.php @@ -0,0 +1,102 @@ +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +use App\Models\Machine\Machine; +use App\Models\Machine\MachineLog; +use App\Models\System\Company; +use App\Services\Notification\DiscordWebhookService; +use Illuminate\Support\Facades\Http; + +// 1. Fake HTTP 請求以攔截 payload +Http::fake(function ($request) { + $payload = json_decode($request->body(), true); + + echo "========================================================\n"; + echo "📡 Discord Webhook URL: " . $request->url() . "\n"; + echo "📦 Username: " . ($payload['username'] ?? 'N/A') . "\n"; + + if (!empty($payload['embeds'])) { + foreach ($payload['embeds'] as $i => $embed) { + echo "--- [Embed " . ($i + 1) . "] ---\n"; + echo "🔴 Title: " . ($embed['title'] ?? 'N/A') . "\n"; + echo "📝 Description: " . ($embed['description'] ?? 'N/A') . "\n"; + echo "🎨 Color: " . ($embed['color'] ?? 'N/A') . "\n"; + echo "📊 Fields:\n"; + if (!empty($embed['fields'])) { + foreach ($embed['fields'] as $field) { + echo " • " . $field['name'] . ": " . $field['value'] . " (Inline: " . ($field['inline'] ? 'Yes' : 'No') . ")\n"; + } + } else { + echo " (No Fields)\n"; + } + } + } + echo "========================================================\n\n"; + + return Http::response(['status' => 'ok'], 200); +}); + +// 2. 準備假數據 (用真實 Model) +$company = Company::firstOrNew(['code' => 'TEST_COMP']); +$company->name = '星科技 (Star Tech)'; +$company->settings = ['locale' => 'zh_TW']; // 測試 Locale 變更 +$company->save(); + +$machine = Machine::firstOrNew(['serial_no' => 'M1103']); +$machine->name = '機台MQTT測試1'; +$machine->location = '台中車站1樓'; +$machine->company_id = $company->id; +$machine->save(); + +$service = app(DiscordWebhookService::class); +$webhookUrl = 'https://discord.com/api/webhooks/mock-url-123'; + +$locales = ['zh_TW', 'en', 'ja']; + +foreach ($locales as $loc) { + echo "💡 [測試語系: {$loc}]\n"; + $company->settings = array_merge($company->settings ?? [], ['locale' => $loc]); + $company->save(); + + // (A) 連線中斷 LWT + $logLwt = new MachineLog([ + 'machine_id' => $machine->id, + 'company_id' => $company->id, + 'type' => 'status', + 'level' => 'warning', + 'message' => 'Connection lost (LWT)', + ]); + $service->sendMachineLogAlert($webhookUrl, $logLwt); + + // (B) 連線恢復 + $logRestored = new MachineLog([ + 'machine_id' => $machine->id, + 'company_id' => $company->id, + 'type' => 'status', + 'level' => 'info', + 'message' => 'Connection restored', + ]); + $service->sendMachineLogAlert($webhookUrl, $logRestored); + + // (C) B013 0205 貨道阻塞異常 + $logB013 = new MachineLog([ + 'machine_id' => $machine->id, + 'company_id' => $company->id, + 'type' => 'submachine', + 'level' => 'error', + 'message' => 'Slot 1: Slot sensor blocked (Code: 0205)', + 'context' => [ + 'error_code' => '0205', + 'tid' => '1', + 'translated_label' => 'Slot sensor blocked', + ] + ]); + $service->sendMachineLogAlert($webhookUrl, $logB013); +} + +echo "🎉 測試完成!\n"; diff --git a/tests/test_temp_chain.php b/tests/test_temp_chain.php new file mode 100644 index 0000000..b34b022 --- /dev/null +++ b/tests/test_temp_chain.php @@ -0,0 +1,163 @@ +make(Illuminate\Contracts\Console\Kernel::class); +$kernel->bootstrap(); + +use App\Models\Machine\Machine; +use App\Models\Machine\MachineModel; +use App\Models\System\Company; + +// 輔助函數:模擬 ProcessHeartbeat 中獲取最終溫度限制的邏輯 +function getTempLimits($machine) { + $mSettings = $machine->settings ?? []; + $mTempAlert = $mSettings['temp_alert_enabled'] ?? 'inherit'; + + // 1. 機台個別設定 + $mUpper = $mSettings['temp_upper_limit'] ?? null; + $mLower = $mSettings['temp_lower_limit'] ?? null; + + // 2. 型號設定 + $modelSettings = $machine->machineModel->settings ?? []; + $modelUpper = $modelSettings['temp_upper_limit'] ?? null; + $modelLower = $modelSettings['temp_lower_limit'] ?? null; + + // 3. 系統安全預設 + $sysUpper = 40.0; + $sysLower = 0.0; + + $finalUpper = $sysUpper; + $finalLower = $sysLower; + $source = 'System Default'; + $isEnabled = true; + + if ($mTempAlert === 'disabled') { + $isEnabled = false; + $source = 'Muted'; + } elseif ($mTempAlert === 'enabled' && ($mUpper !== null || $mLower !== null)) { + $finalUpper = $mUpper !== null ? $mUpper : $sysUpper; + $finalLower = $mLower !== null ? $mLower : $sysLower; + $source = 'Custom Machine Settings'; + } elseif ($modelUpper !== null || $modelLower !== null) { + $finalUpper = $modelUpper !== null ? $modelUpper : $sysUpper; + $finalLower = $modelLower !== null ? $modelLower : $sysLower; + $source = 'Model Settings'; + } else { + $source = 'System Default (No custom/model limits)'; + } + + return [ + 'enabled' => $isEnabled, + 'upper' => $finalUpper, + 'lower' => $finalLower, + 'source' => $source + ]; +} + +echo "🧪 溫度告警「三層繼承鏈」邏輯單元測試\n\n"; + +// 準備資料 +$oldMachine = Machine::where('serial_no', 'T1001')->first(); +if ($oldMachine) { + $oldMachine->delete(); +} + +$oldModel = MachineModel::where('name', '溫控測試型號')->first(); +if ($oldModel) { + $oldModel->delete(); +} + +$oldCompany = Company::withTrashed()->where('code', 'TEMP_CHAIN_COMP')->first(); +if ($oldCompany) { + $oldCompany->forceDelete(); +} + +$company = new Company(); +$company->code = 'TEMP_CHAIN_COMP'; +$company->name = '溫控繼承公司'; +$company->save(); + +$model = new MachineModel(); +$model->name = '溫控測試型號'; +$model->company_id = $company->id; +$model->save(); + +$machine = new Machine(); +$machine->serial_no = 'T1001'; +$machine->name = '溫控繼承機台'; +$machine->company_id = $company->id; +$machine->machine_model_id = $model->id; +$machine->settings = null; // 初始化空 +$machine->save(); + +// --- 測試 1: 預設狀態 (機台 inherit,型號無設定) --- +$res = getTempLimits($machine); +echo "1. 預設狀態測試 (機台: inherit, 型號: 無設定)\n"; +echo " -> 告警狀態: " . ($res['enabled'] ? '✅ 啟用' : '❌ 禁用') . "\n"; +echo " -> 溫度限制: {$res['lower']}°C ~ {$res['upper']}°C\n"; +echo " -> 設定來源: {$res['source']}\n"; +assert($res['enabled'] === true, '預設應啟用'); +assert($res['upper'] === 40.0, '上限應為 40'); +assert($res['lower'] === 0.0, '下限應為 0'); +echo " 🎉 測試 1 通過!\n\n"; + +// --- 測試 2: 型號有設定 (機台 inherit,型號設定 5°C ~ 35°C) --- +$model->settings = [ + 'temp_upper_limit' => 35.0, + 'temp_lower_limit' => 5.0 +]; +$model->save(); + +// 重新載入關係 +$machine->load('machineModel'); +$res = getTempLimits($machine); +echo "2. 型號有設定測試 (機台: inherit, 型號: 5°C ~ 35°C)\n"; +echo " -> 告警狀態: " . ($res['enabled'] ? '✅ 啟用' : '❌ 禁用') . "\n"; +echo " -> 溫度限制: {$res['lower']}°C ~ {$res['upper']}°C\n"; +echo " -> 設定來源: {$res['source']}\n"; +assert($res['enabled'] === true, '應啟用'); +assert($res['upper'] === 35.0, '上限應為 35.0'); +assert($res['lower'] === 5.0, '下限應為 5.0'); +echo " 🎉 測試 2 通過!\n\n"; + +// --- 測試 3: 機台自訂設定 (機台: enabled 且設為 10°C ~ 30°C) --- +$machine->settings = [ + 'temp_alert_enabled' => 'enabled', + 'temp_upper_limit' => 30.0, + 'temp_lower_limit' => 10.0 +]; +$machine->save(); + +$res = getTempLimits($machine); +echo "3. 機台自訂設定測試 (機台: enabled 10°C ~ 30°C, 型號: 5°C ~ 35°C)\n"; +echo " -> 告警狀態: " . ($res['enabled'] ? '✅ 啟用' : '❌ 禁用') . "\n"; +echo " -> 溫度限制: {$res['lower']}°C ~ {$res['upper']}°C\n"; +echo " -> 設定來源: {$res['source']}\n"; +assert($res['enabled'] === true, '應啟用'); +assert($res['upper'] === 30.0, '上限應為 30.0'); +assert($res['lower'] === 10.0, '下限應為 10.0'); +echo " 🎉 測試 3 通過!\n\n"; + +// --- 測試 4: 機台禁用告警 (機台: disabled) --- +$machine->settings = [ + 'temp_alert_enabled' => 'disabled', + 'temp_upper_limit' => 30.0, // 即使有數字也應禁用 + 'temp_lower_limit' => 10.0 +]; +$machine->save(); + +$res = getTempLimits($machine); +echo "4. 機台禁用告警測試 (機台: disabled)\n"; +echo " -> 告警狀態: " . ($res['enabled'] ? '✅ 啟用' : '❌ 禁用') . "\n"; +echo " -> 設定來源: {$res['source']}\n"; +assert($res['enabled'] === false, '應禁用'); +echo " 🎉 測試 4 通過!\n\n"; + +// 清理測試數據 +$machine->delete(); +$model->delete(); +$company->forceDelete(); + +echo "🏆 所有「三層繼承鏈」測試圓滿成功!\n";