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:
commit
95ed1494e2
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,13 +22,22 @@ class MachineModelController extends AdminController
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'temp_upper_limit' => 'nullable|integer|min:-50|max:100',
|
||||
'temp_lower_limit' => 'nullable|integer|min:-50|max:100',
|
||||
]);
|
||||
|
||||
MachineModel::create(array_merge($validated, [
|
||||
$settings = [
|
||||
'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null,
|
||||
'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null,
|
||||
];
|
||||
|
||||
MachineModel::create([
|
||||
'name' => $validated['name'],
|
||||
'settings' => $settings,
|
||||
'company_id' => auth()->user()->company_id,
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model created successfully.'));
|
||||
@ -47,11 +56,20 @@ class MachineModelController extends AdminController
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'temp_upper_limit' => 'nullable|integer|min:-50|max:100',
|
||||
'temp_lower_limit' => 'nullable|integer|min:-50|max:100',
|
||||
]);
|
||||
|
||||
$machine_model->update(array_merge($validated, [
|
||||
$settings = [
|
||||
'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null,
|
||||
'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null,
|
||||
];
|
||||
|
||||
$machine_model->update([
|
||||
'name' => $validated['name'],
|
||||
'settings' => $settings,
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
|
||||
->with('success', __('Machine model updated successfully.'));
|
||||
|
||||
@ -24,13 +24,4 @@ class SpecialPermissionController extends Controller
|
||||
'description' => 'APP版本控制與更新',
|
||||
]);
|
||||
}
|
||||
|
||||
// Discord通知設定
|
||||
public function discordNotifications()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'Discord通知設定',
|
||||
'description' => 'Discord通知整合設定',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ class ProcessHeartbeat implements ShouldQueue
|
||||
$newState['firmware_version'] = $fv;
|
||||
}
|
||||
|
||||
// 2. 溫度更新 (只要有變動就紀錄日誌)
|
||||
// 2. 溫度更新 (只要有變動就紀錄日誌,並進行溫度監控告警判定)
|
||||
if (isset($this->payload['temperature'])) {
|
||||
$temp = (int) $this->payload['temperature'];
|
||||
Log::debug("ProcessHeartbeat: Temperature reported for {$this->serialNo}: {$temp}");
|
||||
@ -95,13 +95,14 @@ class ProcessHeartbeat implements ShouldQueue
|
||||
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->temperature;
|
||||
|
||||
if ($temp !== (int)$oldTemp) {
|
||||
Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering log.");
|
||||
Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering ordinary log.");
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature reported: :temp°C",
|
||||
'info',
|
||||
['temp' => $temp]
|
||||
['temp' => $temp],
|
||||
'status' // 普通狀態日誌,不發送 Discord
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,6 +111,128 @@ class ProcessHeartbeat implements ShouldQueue
|
||||
'value' => $temp,
|
||||
'at' => now()->toDateTimeString()
|
||||
], 604800);
|
||||
|
||||
// === 溫度自適應告警判定 ===
|
||||
$tempAlertEnabled = false;
|
||||
$machineSettings = $machine->settings ?? [];
|
||||
$company = $machine->company;
|
||||
$companySettings = $company ? ($company->settings ?? []) : [];
|
||||
|
||||
// 1. 決定是否啟用溫度監控
|
||||
$enabledSetting = $machineSettings['temp_alert_enabled'] ?? 'inherit';
|
||||
if ($enabledSetting === 'enabled') {
|
||||
$tempAlertEnabled = true;
|
||||
} elseif ($enabledSetting === 'disabled') {
|
||||
$tempAlertEnabled = false;
|
||||
} else {
|
||||
// inherit 或是沒設定,繼承公司設定
|
||||
$companyEnabled = $companySettings['discord_notify_enabled'] ?? false;
|
||||
$companyTypes = $companySettings['discord_notify_types'] ?? [];
|
||||
if ($companyEnabled && in_array('temperature', $companyTypes)) {
|
||||
$tempAlertEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tempAlertEnabled) {
|
||||
$upperLimit = null;
|
||||
$lowerLimit = null;
|
||||
|
||||
// 優先度 1: 機台自訂
|
||||
if (isset($machineSettings['temp_upper_limit']) && $machineSettings['temp_upper_limit'] !== '') {
|
||||
$upperLimit = (int)$machineSettings['temp_upper_limit'];
|
||||
}
|
||||
if (isset($machineSettings['temp_lower_limit']) && $machineSettings['temp_lower_limit'] !== '') {
|
||||
$lowerLimit = (int)$machineSettings['temp_lower_limit'];
|
||||
}
|
||||
|
||||
// 優先度 2: 型號預設
|
||||
$model = $machine->machineModel;
|
||||
if ($model) {
|
||||
$modelSettings = $model->settings ?? [];
|
||||
if ($upperLimit === null && isset($modelSettings['temp_upper_limit']) && $modelSettings['temp_upper_limit'] !== '') {
|
||||
$upperLimit = (int)$modelSettings['temp_upper_limit'];
|
||||
}
|
||||
if ($lowerLimit === null && isset($modelSettings['temp_lower_limit']) && $modelSettings['temp_lower_limit'] !== '') {
|
||||
$lowerLimit = (int)$modelSettings['temp_lower_limit'];
|
||||
}
|
||||
}
|
||||
|
||||
// 優先度 3: 公司預設
|
||||
if ($upperLimit === null && isset($companySettings['temp_upper_limit']) && $companySettings['temp_upper_limit'] !== '') {
|
||||
$upperLimit = (int)$companySettings['temp_upper_limit'];
|
||||
}
|
||||
if ($lowerLimit === null && isset($companySettings['temp_lower_limit']) && $companySettings['temp_lower_limit'] !== '') {
|
||||
$lowerLimit = (int)$companySettings['temp_lower_limit'];
|
||||
}
|
||||
|
||||
// 全域預設值 fallback
|
||||
if ($upperLimit === null) {
|
||||
$upperLimit = 40;
|
||||
}
|
||||
if ($lowerLimit === null) {
|
||||
$lowerLimit = 0;
|
||||
}
|
||||
|
||||
$activeKey = "machine:{$this->serialNo}:temp_alert_active";
|
||||
$cooldownKey = "machine:{$this->serialNo}:temp_alert_cooldown";
|
||||
$activeStatus = \Illuminate\Support\Facades\Cache::get($activeKey);
|
||||
|
||||
if ($temp > $upperLimit) {
|
||||
// 若從低溫直接跳到高溫,清除低溫告警冷卻快取,但不發送恢復通知
|
||||
if ($activeStatus === 'low') {
|
||||
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
|
||||
}
|
||||
|
||||
$hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey);
|
||||
if ($activeStatus !== 'high' || !$hasCooldown) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature too high: :temp°C",
|
||||
'error',
|
||||
['temp' => $temp, 'limit' => $upperLimit],
|
||||
'temperature'
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600);
|
||||
\Illuminate\Support\Facades\Cache::put($activeKey, 'high', 86400);
|
||||
}
|
||||
} elseif ($temp < $lowerLimit) {
|
||||
// 若從高溫直接跳到低溫,清除高溫告警冷卻快取,但不發送恢復通知
|
||||
if ($activeStatus === 'high') {
|
||||
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
|
||||
}
|
||||
|
||||
$hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey);
|
||||
if ($activeStatus !== 'low' || !$hasCooldown) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature too low: :temp°C",
|
||||
'error',
|
||||
['temp' => $temp, 'limit' => $lowerLimit],
|
||||
'temperature'
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600);
|
||||
\Illuminate\Support\Facades\Cache::put($activeKey, 'low', 86400);
|
||||
}
|
||||
} else {
|
||||
// 溫度正常
|
||||
if ($activeStatus) {
|
||||
\App\Jobs\Machine\ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Temperature restored: :temp°C",
|
||||
'info',
|
||||
['temp' => $temp],
|
||||
'temperature'
|
||||
);
|
||||
\Illuminate\Support\Facades\Cache::forget($activeKey);
|
||||
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新快取
|
||||
|
||||
84
app/Jobs/Notification/SendDiscordNotificationJob.php
Normal file
84
app/Jobs/Notification/SendDiscordNotificationJob.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -13,6 +13,11 @@ class MachineModel extends Model
|
||||
'company_id',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
public function machines()
|
||||
|
||||
256
app/Services/Notification/DiscordWebhookService.php
Normal file
256
app/Services/Notification/DiscordWebhookService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
103
lang/en.json
103
lang/en.json
@ -2036,5 +2036,106 @@
|
||||
"Total Net Profit": "Total Net Profit",
|
||||
"Gross Margin": "Gross Margin",
|
||||
"No product data matching search criteria": "No product data matching search criteria",
|
||||
"Analyze Solely": "Analyze Solely"
|
||||
"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."
|
||||
}
|
||||
106
lang/ja.json
106
lang/ja.json
@ -2036,5 +2036,109 @@
|
||||
"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": "マシン接続アラート",
|
||||
|
||||
"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.": "機器モデルが正常に追加されました。"
|
||||
}
|
||||
109
lang/zh_TW.json
109
lang/zh_TW.json
@ -2089,5 +2089,112 @@
|
||||
"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": "機台連線告警",
|
||||
|
||||
"_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
@ -69,8 +69,22 @@
|
||||
currentMachine: null,
|
||||
showCreateModelModal: false,
|
||||
showEditModelModal: false,
|
||||
currentModel: { name: '' },
|
||||
newModel: { name: '', temp_upper_limit: '', temp_lower_limit: '' },
|
||||
currentModel: { name: '', temp_upper_limit: '', temp_lower_limit: '' },
|
||||
modelActionUrl: '',
|
||||
adjustModelTemp(modelObj, field, delta, fallbackVal) {
|
||||
let current = modelObj[field];
|
||||
if (current === undefined || current === null || current === '') {
|
||||
current = parseInt(fallbackVal);
|
||||
} else {
|
||||
current = parseInt(current);
|
||||
}
|
||||
if (isNaN(current)) current = 0;
|
||||
let newValue = current + delta;
|
||||
if (newValue < -50) newValue = -50;
|
||||
if (newValue > 100) newValue = 100;
|
||||
modelObj[field] = newValue.toString();
|
||||
},
|
||||
selectedFileCount: 0,
|
||||
selectedFiles: [null, null, null],
|
||||
deletedPhotos: [false, false, false],
|
||||
@ -654,7 +668,7 @@
|
||||
</svg>
|
||||
<span class="font-black text-sm sm:text-base tracking-tight">{{ __('Add Machine') }}</span>
|
||||
</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">
|
||||
<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" />
|
||||
@ -883,10 +897,52 @@
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('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') }}">
|
||||
</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
|
||||
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">{{
|
||||
@ -936,6 +992,49 @@
|
||||
<input type="text" name="name" x-model="currentModel.name" required
|
||||
class="luxury-input w-full" placeholder="{{ __('Enter model name') }}">
|
||||
</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
|
||||
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">
|
||||
|
||||
@ -49,6 +49,8 @@
|
||||
<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">
|
||||
{{ __('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">
|
||||
{{ __('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">
|
||||
@ -71,6 +73,21 @@
|
||||
{{ $model->name }}</div>
|
||||
</div>
|
||||
</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">
|
||||
<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') }}
|
||||
@ -83,7 +100,7 @@
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<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"
|
||||
title="{{ __('Edit') }}">
|
||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -108,7 +125,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<x-empty-state mode="table" :colspan="4" :message="__('No data available')" />
|
||||
<x-empty-state mode="table" :colspan="5" :message="__('No data available')" />
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
@ -130,9 +147,17 @@
|
||||
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight">
|
||||
{{ $model->name }}
|
||||
</h3>
|
||||
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">
|
||||
{{ $model->updated_at->format('Y/m/d H:i') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-x-2 mt-1">
|
||||
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
{{ $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>
|
||||
<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') }}
|
||||
@ -141,7 +166,7 @@
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
@ -87,6 +87,7 @@
|
||||
'machines' => __('Machine Settings'),
|
||||
'payment-configs' => __('Payment Config'),
|
||||
'machine-models' => __('Machine Models'),
|
||||
'discord-notifications' => __('Discord Notifications'),
|
||||
default => null,
|
||||
},
|
||||
'permission' => match($segments[2] ?? '') {
|
||||
|
||||
@ -448,8 +448,7 @@
|
||||
@endcan
|
||||
--}}
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
@can('menu.basic')
|
||||
@if(auth()->user()->isSystemAdmin() || auth()->user()->isTenant())
|
||||
{{-- 14.5. 基本設定 --}}
|
||||
<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">
|
||||
@ -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>
|
||||
</a></li>
|
||||
@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>
|
||||
</div>
|
||||
</li>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
|
||||
@ -231,7 +231,6 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
||||
Route::prefix('special-permission')->name('special-permission.')->group(function () {
|
||||
Route::get('/clear-stock', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'clearStock'])->name('clear-stock');
|
||||
Route::get('/apk-versions', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'apkVersions'])->name('apk-versions');
|
||||
Route::get('/discord-notifications', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'discordNotifications'])->name('discord-notifications');
|
||||
});
|
||||
|
||||
// 14. 基本設定
|
||||
@ -261,6 +260,17 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
||||
|
||||
// QR Code 生成
|
||||
Route::get('qr-code', [App\Http\Controllers\Admin\QrCodeController::class, 'generate'])->name('qr-code');
|
||||
|
||||
// Discord 告警通知設定
|
||||
Route::prefix('discord-notifications')
|
||||
->name('discord-notifications.')
|
||||
->middleware('can:menu.basic.discord-notifications')
|
||||
->group(function () {
|
||||
Route::get('/', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'index'])->name('index');
|
||||
Route::put('/company/{id}', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'updateCompany'])->name('update-company');
|
||||
Route::put('/machine/{id}', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'updateMachine'])->name('update-machine');
|
||||
Route::post('/test', [App\Http\Controllers\Admin\BasicSettings\DiscordNotificationController::class, 'test'])->name('test');
|
||||
});
|
||||
});
|
||||
|
||||
// 15. 權限設定
|
||||
|
||||
131
tests/Feature/Admin/DiscordNotificationSecurityTest.php
Normal file
131
tests/Feature/Admin/DiscordNotificationSecurityTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -19,8 +19,8 @@ class TenantIsolationTest extends TestCase
|
||||
$companyB = Company::create(['name' => 'Company B', 'code' => 'COB']);
|
||||
|
||||
// 建立各自的機台
|
||||
Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id]);
|
||||
Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id]);
|
||||
Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id, 'serial_no' => 'SN001']);
|
||||
Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id, 'serial_no' => 'SN002']);
|
||||
|
||||
// 建立租戶 A 的使用者並登入
|
||||
$userA = User::factory()->create(['company_id' => $companyA->id]);
|
||||
@ -41,8 +41,8 @@ class TenantIsolationTest extends TestCase
|
||||
$companyB = Company::create(['name' => 'Company B', 'code' => 'COB']);
|
||||
|
||||
// 建立各自的機台
|
||||
Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id]);
|
||||
Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id]);
|
||||
Machine::create(['name' => 'Machine A', 'company_id' => $companyA->id, 'serial_no' => 'SN001']);
|
||||
Machine::create(['name' => 'Machine B', 'company_id' => $companyB->id, 'serial_no' => 'SN002']);
|
||||
|
||||
// 建立系統管理員 (company_id = null) 並登入
|
||||
$admin = User::factory()->create(['company_id' => null]);
|
||||
@ -61,7 +61,7 @@ class TenantIsolationTest extends TestCase
|
||||
$this->actingAs($userA);
|
||||
|
||||
// 建立新機台(不指定 company_id)
|
||||
$machine = Machine::create(['name' => 'New Machine']);
|
||||
$machine = Machine::create(['name' => 'New Machine', 'serial_no' => 'SN003']);
|
||||
|
||||
$this->assertEquals($companyA->id, $machine->company_id);
|
||||
}
|
||||
|
||||
102
tests/test_discord.php
Normal file
102
tests/test_discord.php
Normal 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
163
tests/test_temp_chain.php
Normal 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";
|
||||
Loading…
Reference in New Issue
Block a user