Merge branch 'feat/discord-notification' into dev

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
This commit is contained in:
sky121113 2026-05-21 16:39:32 +08:00
commit 95ed1494e2
26 changed files with 2970 additions and 43 deletions

View File

@ -0,0 +1,203 @@
<?php
namespace App\Http\Controllers\Admin\BasicSettings;
use App\Http\Controllers\Admin\AdminController;
use App\Models\System\Company;
use App\Services\Notification\DiscordWebhookService;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\JsonResponse;
class DiscordNotificationController extends AdminController
{
/**
* 顯示 Discord 告警設定頁面 ( Tab 支援)
*/
public function index(Request $request): View
{
$user = auth()->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);
}
}
}

View File

@ -22,13 +22,22 @@ class MachineModelController extends AdminController
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', '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, 'company_id' => auth()->user()->company_id,
'creator_id' => auth()->id(), 'creator_id' => auth()->id(),
'updater_id' => auth()->id(), 'updater_id' => auth()->id(),
])); ]);
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models']) return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
->with('success', __('Machine model created successfully.')); ->with('success', __('Machine model created successfully.'));
@ -47,11 +56,20 @@ class MachineModelController extends AdminController
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', '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(), 'updater_id' => auth()->id(),
])); ]);
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models']) return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
->with('success', __('Machine model updated successfully.')); ->with('success', __('Machine model updated successfully.'));

View File

@ -24,13 +24,4 @@ class SpecialPermissionController extends Controller
'description' => 'APP版本控制與更新', 'description' => 'APP版本控制與更新',
]); ]);
} }
// Discord通知設定
public function discordNotifications()
{
return view('admin.placeholder', [
'title' => 'Discord通知設定',
'description' => 'Discord通知整合設定',
]);
}
} }

View File

@ -81,7 +81,7 @@ class ProcessHeartbeat implements ShouldQueue
$newState['firmware_version'] = $fv; $newState['firmware_version'] = $fv;
} }
// 2. 溫度更新 (只要有變動就紀錄日誌) // 2. 溫度更新 (只要有變動就紀錄日誌,並進行溫度監控告警判定)
if (isset($this->payload['temperature'])) { if (isset($this->payload['temperature'])) {
$temp = (int) $this->payload['temperature']; $temp = (int) $this->payload['temperature'];
Log::debug("ProcessHeartbeat: Temperature reported for {$this->serialNo}: {$temp}"); 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; $oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->temperature;
if ($temp !== (int)$oldTemp) { 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( \App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id, $machine->id,
$machine->company_id, $machine->company_id,
"Temperature reported: :temp°C", "Temperature reported: :temp°C",
'info', 'info',
['temp' => $temp] ['temp' => $temp],
'status' // 普通狀態日誌,不發送 Discord
); );
} }
@ -110,6 +111,128 @@ class ProcessHeartbeat implements ShouldQueue
'value' => $temp, 'value' => $temp,
'at' => now()->toDateTimeString() 'at' => now()->toDateTimeString()
], 604800); ], 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);
}
}
}
} }
// 更新快取 // 更新快取

View File

@ -0,0 +1,84 @@
<?php
namespace App\Jobs\Notification;
use App\Models\Machine\MachineLog;
use App\Services\Notification\DiscordWebhookService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendDiscordNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 任務最大重試次數
*/
public int $tries = 3;
protected int $logId;
/**
* 建立新任務實例
*/
public function __construct(int $logId)
{
$this->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}");
}
}
}

View File

@ -90,6 +90,7 @@ class Machine extends Model
'scan_pay_linepay_enabled', 'scan_pay_linepay_enabled',
'shopping_cart_enabled', 'shopping_cart_enabled',
'cash_module_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']; 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', 'shopping_cart_enabled' => 'boolean',
'cash_module_enabled' => 'boolean', 'cash_module_enabled' => 'boolean',
'images' => 'array', 'images' => 'array',
'settings' => 'array',
]; ];
/** /**

View File

@ -11,6 +11,65 @@ class MachineLog extends Model
const UPDATED_AT = null; 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 = [ protected $fillable = [
'company_id', 'company_id',
'machine_id', 'machine_id',

View File

@ -13,6 +13,11 @@ class MachineModel extends Model
'company_id', 'company_id',
'creator_id', 'creator_id',
'updater_id', 'updater_id',
'settings',
];
protected $casts = [
'settings' => 'array',
]; ];
public function machines() public function machines()

View File

@ -0,0 +1,256 @@
<?php
namespace App\Services\Notification;
use App\Models\Machine\MachineLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DiscordWebhookService
{
// B013 硬體代碼中文翻譯對照
protected array $errorCodes = [
'0402' => '出貨成功',
'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;
}
}
}

View File

@ -17,8 +17,8 @@ trait TenantScoped
return; return;
} }
// check if running in console/migration // check if running in console/migration (but not during unit tests)
if (app()->runningInConsole()) { if (app()->runningInConsole() && !app()->runningUnitTests()) {
return; return;
} }

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_models', function (Blueprint $table) {
$table->json('settings')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_models', function (Blueprint $table) {
$table->dropColumn('settings');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->json('settings')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->dropColumn('settings');
});
}
};

View File

@ -63,6 +63,7 @@ class RoleSeeder extends Seeder
'menu.basic', 'menu.basic',
'menu.basic.machines', 'menu.basic.machines',
'menu.basic.payment-configs', 'menu.basic.payment-configs',
'menu.basic.discord-notifications',
'menu.permissions', 'menu.permissions',
'menu.permissions.companies', 'menu.permissions.companies',
'menu.permissions.accounts', 'menu.permissions.accounts',
@ -123,6 +124,7 @@ class RoleSeeder extends Seeder
'menu.special-permission.clear-stock', 'menu.special-permission.clear-stock',
'menu.special-permission.apk-versions', 'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications', 'menu.special-permission.discord-notifications',
'menu.basic.discord-notifications',
]); ]);
} }
} }

View File

@ -2036,5 +2036,106 @@
"Total Net Profit": "Total Net Profit", "Total Net Profit": "Total Net Profit",
"Gross Margin": "Gross Margin", "Gross Margin": "Gross Margin",
"No product data matching search criteria": "No product data matching search criteria", "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",
"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."
}

View File

@ -2036,5 +2036,109 @@
"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": "個別分析",
} "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.": "機器モデルが正常に追加されました。"
}

View File

@ -2089,5 +2089,112 @@
"Order Number": "訂單編號", "Order Number": "訂單編號",
"Product Items": "商品品項", "Product Items": "商品品項",
"Original Amount": "應付金額", "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": "機台連線告警",
"_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.": "機台型號已成功新增。"
}

File diff suppressed because it is too large Load Diff

View File

@ -69,8 +69,22 @@
currentMachine: null, currentMachine: null,
showCreateModelModal: false, showCreateModelModal: false,
showEditModelModal: false, showEditModelModal: false,
currentModel: { name: '' }, newModel: { name: '', temp_upper_limit: '', temp_lower_limit: '' },
currentModel: { name: '', temp_upper_limit: '', temp_lower_limit: '' },
modelActionUrl: '', 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, selectedFileCount: 0,
selectedFiles: [null, null, null], selectedFiles: [null, null, null],
deletedPhotos: [false, false, false], deletedPhotos: [false, false, false],
@ -654,7 +668,7 @@
</svg> </svg>
<span class="font-black text-sm sm:text-base tracking-tight">{{ __('Add Machine') }}</span> <span class="font-black text-sm sm:text-base tracking-tight">{{ __('Add Machine') }}</span>
</button> </button>
<button x-show="tab === 'models'" x-cloak @click="showCreateModelModal = true" <button x-show="tab === 'models'" x-cloak @click="newModel = { name: '', temp_upper_limit: '', temp_lower_limit: '' }; showCreateModelModal = true"
class="btn-luxury-primary flex items-center gap-2 px-4 sm:px-6 py-2.5 sm:py-3 h-10 sm:h-auto rounded-xl shadow-lg shadow-cyan-500/20 transition-all duration-300"> class="btn-luxury-primary flex items-center gap-2 px-4 sm:px-6 py-2.5 sm:py-3 h-10 sm:h-auto rounded-xl shadow-lg shadow-cyan-500/20 transition-all duration-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
@ -883,10 +897,52 @@
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Model Name') }}</label> __('Model Name') }}</label>
<input type="text" name="name" required class="luxury-input w-full" <input type="text" name="name" x-model="newModel.name" required class="luxury-input w-full"
placeholder="{{ __('Enter model name') }}"> placeholder="{{ __('Enter model name') }}">
</div> </div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Default Temp Alert Upper Limit (°C)') }}
</label>
<div class="flex items-center h-12 rounded-xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all w-full">
<button type="button" @click="adjustModelTemp(newModel, 'temp_upper_limit', -1, '40')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" name="temp_upper_limit" x-model="newModel.temp_upper_limit"
placeholder="40"
class="w-full bg-transparent border-none text-center text-sm font-bold text-slate-800 dark:text-white focus:ring-0 p-0">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustModelTemp(newModel, 'temp_upper_limit', 1, '40')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Default Temp Alert Lower Limit (°C)') }}
</label>
<div class="flex items-center h-12 rounded-xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all w-full">
<button type="button" @click="adjustModelTemp(newModel, 'temp_lower_limit', -1, '0')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" name="temp_lower_limit" x-model="newModel.temp_lower_limit"
placeholder="0"
class="w-full bg-transparent border-none text-center text-sm font-bold text-slate-800 dark:text-white focus:ring-0 p-0">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustModelTemp(newModel, 'temp_lower_limit', 1, '0')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
</div>
<div <div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800"> class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{ <button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{
@ -936,6 +992,49 @@
<input type="text" name="name" x-model="currentModel.name" required <input type="text" name="name" x-model="currentModel.name" required
class="luxury-input w-full" placeholder="{{ __('Enter model name') }}"> class="luxury-input w-full" placeholder="{{ __('Enter model name') }}">
</div> </div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Default Temp Alert Upper Limit (°C)') }}
</label>
<div class="flex items-center h-12 rounded-xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all w-full">
<button type="button" @click="adjustModelTemp(currentModel, 'temp_upper_limit', -1, '40')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" name="temp_upper_limit" x-model="currentModel.temp_upper_limit"
placeholder="40"
class="w-full bg-transparent border-none text-center text-sm font-bold text-slate-800 dark:text-white focus:ring-0 p-0">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustModelTemp(currentModel, 'temp_upper_limit', 1, '40')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Default Temp Alert Lower Limit (°C)') }}
</label>
<div class="flex items-center h-12 rounded-xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all w-full">
<button type="button" @click="adjustModelTemp(currentModel, 'temp_lower_limit', -1, '0')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" name="temp_lower_limit" x-model="currentModel.temp_lower_limit"
placeholder="0"
class="w-full bg-transparent border-none text-center text-sm font-bold text-slate-800 dark:text-white focus:ring-0 p-0">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustModelTemp(currentModel, 'temp_lower_limit', 1, '0')"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
</div>
</div> </div>
<div <div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800"> class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">

View File

@ -49,6 +49,8 @@
<tr class="bg-slate-50/50 dark:bg-slate-900/10"> <tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800"> <th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
{{ __('Model Name') }}</th> {{ __('Model Name') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
{{ __('Default Temp Alert Limits') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800"> <th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
{{ __('Machine Count') }}</th> {{ __('Machine Count') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800"> <th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
@ -71,6 +73,21 @@
{{ $model->name }}</div> {{ $model->name }}</div>
</div> </div>
</td> </td>
<td class="px-6 py-6">
@if(isset($model->settings['temp_upper_limit']) || isset($model->settings['temp_lower_limit']))
<div class="flex items-center gap-1.5">
<span class="px-2 py-0.5 rounded bg-sky-50 dark:bg-sky-950/30 text-sky-600 dark:text-sky-400 text-xs font-bold font-mono">
{{ isset($model->settings['temp_lower_limit']) ? (int)round($model->settings['temp_lower_limit']) : '-' }}°C
</span>
<span class="text-slate-400 text-xs font-bold">~</span>
<span class="px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-950/30 text-rose-600 dark:text-rose-400 text-xs font-bold font-mono">
{{ isset($model->settings['temp_upper_limit']) ? (int)round($model->settings['temp_upper_limit']) : '-' }}°C
</span>
</div>
@else
<span class="text-xs text-slate-400 font-bold font-mono">-</span>
@endif
</td>
<td class="px-6 py-6"> <td class="px-6 py-6">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest"> <span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
{{ $model->machines_count ?? 0 }} {{ __('Items') }} {{ $model->machines_count ?? 0 }} {{ __('Items') }}
@ -83,7 +100,7 @@
</td> </td>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button @click="currentModel = @js($model->only(['name'])); modelActionUrl = '{{ route('admin.basic-settings.machine-models.update', $model) }}'; showEditModelModal = true" <button @click="currentModel = { name: @js($model->name), temp_upper_limit: @js(isset($model->settings['temp_upper_limit']) ? (string)(int)round($model->settings['temp_upper_limit']) : ''), temp_lower_limit: @js(isset($model->settings['temp_lower_limit']) ? (string)(int)round($model->settings['temp_lower_limit']) : '') }; modelActionUrl = '{{ route('admin.basic-settings.machine-models.update', $model) }}'; showEditModelModal = true"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 dark:hover:text-cyan-400 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 dark:hover:text-cyan-400 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all"
title="{{ __('Edit') }}"> title="{{ __('Edit') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -108,7 +125,7 @@
</td> </td>
</tr> </tr>
@empty @empty
<x-empty-state mode="table" :colspan="4" :message="__('No data available')" /> <x-empty-state mode="table" :colspan="5" :message="__('No data available')" />
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@ -130,9 +147,17 @@
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight"> <h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight">
{{ $model->name }} {{ $model->name }}
</h3> </h3>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5"> <div class="flex flex-wrap items-center gap-x-2 mt-1">
{{ $model->updated_at->format('Y/m/d H:i') }} <p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">
</p> {{ $model->updated_at->format('Y/m/d H:i') }}
</p>
@if(isset($model->settings['temp_upper_limit']) || isset($model->settings['temp_lower_limit']))
<span class="text-xs text-slate-300 dark:text-slate-700 font-bold"></span>
<span class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 font-mono">
{{ 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
</span>
@endif
</div>
</div> </div>
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest shrink-0"> <span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest shrink-0">
{{ $model->machines_count ?? 0 }} {{ __('Items') }} {{ $model->machines_count ?? 0 }} {{ __('Items') }}
@ -141,7 +166,7 @@
{{-- Action Buttons --}} {{-- Action Buttons --}}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button @click="currentModel = @js($model->only(['name'])); modelActionUrl = '{{ route('admin.basic-settings.machine-models.update', $model) }}'; showEditModelModal = true" <button @click="currentModel = { name: @js($model->name), temp_upper_limit: @js(isset($model->settings['temp_upper_limit']) ? (string)(int)round($model->settings['temp_upper_limit']) : ''), temp_lower_limit: @js(isset($model->settings['temp_lower_limit']) ? (string)(int)round($model->settings['temp_lower_limit']) : '') }; modelActionUrl = '{{ route('admin.basic-settings.machine-models.update', $model) }}'; showEditModelModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50"> class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931Z" />

View File

@ -87,6 +87,7 @@
'machines' => __('Machine Settings'), 'machines' => __('Machine Settings'),
'payment-configs' => __('Payment Config'), 'payment-configs' => __('Payment Config'),
'machine-models' => __('Machine Models'), 'machine-models' => __('Machine Models'),
'discord-notifications' => __('Discord Notifications'),
default => null, default => null,
}, },
'permission' => match($segments[2] ?? '') { 'permission' => match($segments[2] ?? '') {

View File

@ -448,8 +448,7 @@
@endcan @endcan
--}} --}}
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin() || auth()->user()->isTenant())
@can('menu.basic')
{{-- 14.5. 基本設定 --}} {{-- 14.5. 基本設定 --}}
<li x-data="{ open: localStorage.getItem('menu_basic_settings') === 'true' || {{ request()->routeIs('admin.basic-settings.*') ? 'true' : 'false' }} }"> <li x-data="{ open: localStorage.getItem('menu_basic_settings') === 'true' || {{ request()->routeIs('admin.basic-settings.*') ? 'true' : 'false' }} }">
<button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_basic_settings', open)" class="luxury-nav-item w-full text-start group"> <button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_basic_settings', open)" class="luxury-nav-item w-full text-start group">
@ -473,10 +472,15 @@
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Customer Payment Config') }}</span> <span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Customer Payment Config') }}</span>
</a></li> </a></li>
@endcan @endcan
@can('menu.basic.discord-notifications')
<li><a class="flex items-center gap-x-3 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.basic-settings.discord-notifications.*') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.basic-settings.discord-notifications.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Discord Notifications') }}</span>
</a></li>
@endcan
</ul> </ul>
</div> </div>
</li> </li>
@endcan
@endif @endif
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())

View File

@ -231,7 +231,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::prefix('special-permission')->name('special-permission.')->group(function () { 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('/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('/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. 基本設定 // 14. 基本設定
@ -261,6 +260,17 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// QR Code 生成 // QR Code 生成
Route::get('qr-code', [App\Http\Controllers\Admin\QrCodeController::class, 'generate'])->name('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. 權限設定 // 15. 權限設定

View File

@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\User;
use App\Models\System\Role;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DiscordNotificationSecurityTest extends TestCase
{
use RefreshDatabase;
protected $company;
protected $superAdmin;
protected $tenantAdmin;
protected $regularUser;
protected function setUp(): void
{
parent::setUp();
// 1. 執行 RoleSeeder 以初始化權限
$this->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);
}
}

View File

@ -19,8 +19,8 @@ class TenantIsolationTest extends TestCase
$companyB = Company::create(['name' => 'Company B', 'code' => 'COB']); $companyB = Company::create(['name' => 'Company B', 'code' => 'COB']);
// 建立各自的機台 // 建立各自的機台
Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id]); Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id, 'serial_no' => 'SN001']);
Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id]); Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id, 'serial_no' => 'SN002']);
// 建立租戶 A 的使用者並登入 // 建立租戶 A 的使用者並登入
$userA = User::factory()->create(['company_id' => $companyA->id]); $userA = User::factory()->create(['company_id' => $companyA->id]);
@ -41,8 +41,8 @@ class TenantIsolationTest extends TestCase
$companyB = Company::create(['name' => 'Company B', 'code' => 'COB']); $companyB = Company::create(['name' => 'Company B', 'code' => 'COB']);
// 建立各自的機台 // 建立各自的機台
Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id]); Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id, 'serial_no' => 'SN001']);
Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id]); Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id, 'serial_no' => 'SN002']);
// 建立系統管理員 (company_id = null) 並登入 // 建立系統管理員 (company_id = null) 並登入
$admin = User::factory()->create(['company_id' => null]); $admin = User::factory()->create(['company_id' => null]);
@ -61,7 +61,7 @@ class TenantIsolationTest extends TestCase
$this->actingAs($userA); $this->actingAs($userA);
// 建立新機台(不指定 company_id // 建立新機台(不指定 company_id
$machine = Machine::create(['name' => 'New Machine']); $machine = Machine::create(['name' => 'New Machine', 'serial_no' => 'SN003']);
$this->assertEquals($companyA->id, $machine->company_id); $this->assertEquals($companyA->id, $machine->company_id);
} }

102
tests/test_discord.php Normal file
View File

@ -0,0 +1,102 @@
<?php
// 載入 Laravel Bootstrap
require __DIR__ . '/../vendor/autoload.php';
$app = require_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->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";

163
tests/test_temp_chain.php Normal file
View File

@ -0,0 +1,163 @@
<?php
// 載入 Laravel Bootstrap
require __DIR__ . '/../vendor/autoload.php';
$app = require_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->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";