diff --git a/app/Http/Controllers/Admin/BasicSettings/DiscordNotificationController.php b/app/Http/Controllers/Admin/BasicSettings/DiscordNotificationController.php new file mode 100644 index 0000000..36a1805 --- /dev/null +++ b/app/Http/Controllers/Admin/BasicSettings/DiscordNotificationController.php @@ -0,0 +1,182 @@ +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 = $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', + 'temp_alert_enabled' => 'required|string|in:inherit,enabled,disabled', + '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'); + $settings['temp_alert_enabled'] = $request->input('temp_alert_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; + + $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..712127e 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|numeric|min:-50|max:100', + 'temp_lower_limit' => 'nullable|numeric|min:-50|max:100', ]); - MachineModel::create(array_merge($validated, [ + $settings = [ + 'temp_upper_limit' => $request->filled('temp_upper_limit') ? (float)$request->temp_upper_limit : null, + 'temp_lower_limit' => $request->filled('temp_lower_limit') ? (float)$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|numeric|min:-50|max:100', + 'temp_lower_limit' => 'nullable|numeric|min:-50|max:100', ]); - $machine_model->update(array_merge($validated, [ + $settings = [ + 'temp_upper_limit' => $request->filled('temp_upper_limit') ? (float)$request->temp_upper_limit : null, + 'temp_lower_limit' => $request->filled('temp_lower_limit') ? (float)$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..f196d9d 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,144 @@ 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') { + \App\Jobs\Machine\ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + "Temperature restored: :temp°C", + 'info', + ['temp' => $temp], + 'temperature' + ); + \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') { + \App\Jobs\Machine\ProcessStateLog::dispatch( + $machine->id, + $machine->company_id, + "Temperature restored: :temp°C", + 'info', + ['temp' => $temp], + 'temperature' + ); + \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..b267216 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2036,5 +2036,59 @@ "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" + "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" } \ No newline at end of file diff --git a/lang/ja.json b/lang/ja.json index 0504f1c..ab4c1fb 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -2036,5 +2036,62 @@ "Total Net Profit": "純利益総額", "Gross Margin": "粗利益率", "No product data matching search criteria": "検索条件に一致する商品データはありません", - "Analyze Solely": "個別分析" + "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": "マシン接続アラート" } \ No newline at end of file diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 33a8035..f027cba 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -2089,5 +2089,63 @@ "Order Number": "訂單編號", "Product Items": "商品品項", "Original Amount": "應付金額", - "Discount Amount": "折扣金額" + "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": "機台連線告警" } \ No newline at end of file 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..1e07a93 --- /dev/null +++ b/resources/views/admin/basic-settings/discord-notifications/index.blade.php @@ -0,0 +1,993 @@ +@extends('layouts.admin') + +@section('content') +
| {{ __('Company Info') }} | +{{ __('Webhook URL') }} | +{{ __('Push Status') }} | +{{ __('Configured Alert Types') }} | +{{ __('Actions') }} | +
|---|---|---|---|---|
|
+
+
+
+
+
+
+
+ {{ $company->name }} +{{ $company->code }} + |
+ + @if($webhook) + {{ Str::limit($webhook, 40) }} + @else + {{ __('Not Configured') }} + @endif + | +
+ |
+
+
+ @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
+
+ |
+ + + | +
{{ $company->code }}
+{{ __('Webhook URL') }}
+ @if($webhook) +{{ $webhook }}
+ @else +{{ __('Not Configured') }}
+ @endif +{{ __('Configured Alert Types') }}
+| {{ __('Machine Info') }} | +{{ __('Model') }} | + @if(auth()->user()->isSystemAdmin()) +{{ __('Company') }} | + @endif +{{ __('Webhook Status') }} | +{{ __('Temp Alert State') }} | +{{ __('Actions') }} | +
|---|---|---|---|---|---|
|
+
+
+
+
+
+
+
+ {{ $machine->name }} +{{ $machine->serial_no }} + |
+ + + {{ $machine->machineModel->name ?? __('No Model') }} + + | + @if(auth()->user()->isSystemAdmin()) ++ + {{ $machine->company->name ?? __('No Company') }} + + | + @endif ++ @if($mWebhook) + {{ Str::limit($mWebhook, 30) }} + {{ __('Machine Specific') }} + @elseif($cWebhook) + {{ Str::limit($cWebhook, 30) }} + {{ __('Inherit Company') }} + @else + {{ __('Not Configured') }} + @endif + | +
+
+
+
+ @if($mTempAlert === 'enabled')
+
+ @if($mTempAlert !== 'disabled')
+
+
+ {{ number_format($finalLower, 1) }}°C ~ {{ number_format($finalUpper, 1) }}°C
+
+
+ ({{ $tempSource }})
+
+
+ @endif
+ |
+ + + | +
{{ $machine->serial_no }}
+{{ __('Model') }}
+{{ $machine->machineModel->name ?? __('No Model') }}
+{{ __('Company') }}
+{{ $machine->company->name ?? __('No Company') }}
+{{ __('Webhook URL') }}
+ @if($mWebhook) +{{ $mWebhook }}
+ {{ __('Machine Specific') }} + @elseif($cWebhook) +{{ $cWebhook }}
+ {{ __('Inherit Company') }} + @else +{{ __('Not Configured') }}
+ @endif +{{ __('Temp Alert Range') }}
+- {{ $model->updated_at->format('Y/m/d H:i') }} -
++ {{ $model->updated_at->format('Y/m/d H:i') }} +
+ @if(isset($model->settings['temp_upper_limit']) || isset($model->settings['temp_lower_limit'])) + • + + {{ $model->settings['temp_lower_limit'] ?? '-' }}°C ~ {{ $model->settings['temp_upper_limit'] ?? '-' }}°C + + @endif +