From 0316c63d3c3001d2c3371c5b8253ce891544751d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 08:55:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:DB=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TriggerAuditLog 모델 (casts, accessors, scopes) - TriggerAuditController (목록/상세/이력/롤백 미리보기/롤백 실행) - index: 대시보드 통계 + 필터 + 목록 + 파티션 현황 - show: old/new diff 뷰 (변경 컬럼 하이라이트) - history: 레코드별 변경 타임라인 - rollback-preview: SQL 미리보기 + 확인 후 실행 - 라우트 5개 등록, 메뉴 시더 (시스템 관리 > DB 변경 추적) Co-Authored-By: Claude Opus 4.6 --- .../Controllers/TriggerAuditController.php | 228 ++++++++++++++++++ app/Models/Audit/TriggerAuditLog.php | 54 +++++ database/seeders/TriggerAuditMenuSeeder.php | 65 +++++ .../views/trigger-audit/history.blade.php | 60 +++++ resources/views/trigger-audit/index.blade.php | 191 +++++++++++++++ .../trigger-audit/rollback-preview.blade.php | 109 +++++++++ resources/views/trigger-audit/show.blade.php | 122 ++++++++++ routes/web.php | 27 ++- 8 files changed, 848 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/TriggerAuditController.php create mode 100644 app/Models/Audit/TriggerAuditLog.php create mode 100644 database/seeders/TriggerAuditMenuSeeder.php create mode 100644 resources/views/trigger-audit/history.blade.php create mode 100644 resources/views/trigger-audit/index.blade.php create mode 100644 resources/views/trigger-audit/rollback-preview.blade.php create mode 100644 resources/views/trigger-audit/show.blade.php diff --git a/app/Http/Controllers/TriggerAuditController.php b/app/Http/Controllers/TriggerAuditController.php new file mode 100644 index 00000000..47adb9e3 --- /dev/null +++ b/app/Http/Controllers/TriggerAuditController.php @@ -0,0 +1,228 @@ +orderByDesc('created_at'); + + // 필터링 + if ($request->filled('table_name')) { + $query->where('table_name', $request->table_name); + } + if ($request->filled('dml_type')) { + $query->where('dml_type', $request->dml_type); + } + if ($request->filled('tenant_id')) { + $query->where('tenant_id', (int) $request->tenant_id); + } + if ($request->filled('row_id')) { + $query->where('row_id', $request->row_id); + } + if ($request->filled('from')) { + $query->where('created_at', '>=', $request->from.' 00:00:00'); + } + if ($request->filled('to')) { + $query->where('created_at', '<=', $request->to.' 23:59:59'); + } + + $logs = $query->paginate(50)->withQueryString(); + + // 통계 + $stats = $this->getStats(); + + // 테이블 목록 (필터용) + $tables = TriggerAuditLog::selectRaw('table_name, COUNT(*) as cnt') + ->groupBy('table_name') + ->orderByDesc('cnt') + ->pluck('cnt', 'table_name') + ->toArray(); + + // 파티션 현황 + $partitions = DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [config('database.connections.mysql.database')]); + + // 트리거 수 + $triggerCount = DB::selectOne(" + SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND TRIGGER_NAME LIKE 'trg_%' + ", [config('database.connections.mysql.database')])->cnt; + + return view('trigger-audit.index', compact('logs', 'stats', 'tables', 'partitions', 'triggerCount')); + } + + /** + * 트리거 감사 로그 상세 (diff 뷰) + */ + public function show(int $id): View + { + $log = TriggerAuditLog::findOrFail($id); + + // diff 계산 + $diff = $this->calculateDiff($log); + + return view('trigger-audit.show', compact('log', 'diff')); + } + + /** + * 특정 레코드의 변경 이력 + */ + public function recordHistory(string $tableName, string $rowId): View + { + $logs = TriggerAuditLog::forRecord($tableName, $rowId) + ->orderByDesc('created_at') + ->paginate(50); + + return view('trigger-audit.history', compact('logs', 'tableName', 'rowId')); + } + + /** + * 롤백 미리보기 + */ + public function rollbackPreview(int $id): View + { + $log = TriggerAuditLog::findOrFail($id); + $sql = $this->generateRollbackSQL($log); + + return view('trigger-audit.rollback-preview', compact('log', 'sql')); + } + + /** + * 롤백 실행 + */ + public function rollbackExecute(Request $request, int $id) + { + $request->validate(['confirm' => 'required|accepted']); + + $log = TriggerAuditLog::findOrFail($id); + $sql = $this->generateRollbackSQL($log); + + DB::statement('SET @disable_audit_trigger = 1'); + + try { + DB::transaction(function () use ($sql) { + DB::statement($sql); + }); + + return redirect()->route('trigger-audit.show', $id) + ->with('success', '롤백이 성공적으로 실행되었습니다.'); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.rollback-preview', $id) + ->with('error', '롤백 실패: '.$e->getMessage()); + } finally { + DB::statement('SET @disable_audit_trigger = NULL'); + } + } + + private function getStats(): array + { + $total = TriggerAuditLog::count(); + + $byDmlType = TriggerAuditLog::selectRaw('dml_type, COUNT(*) as count') + ->groupBy('dml_type') + ->pluck('count', 'dml_type') + ->toArray(); + + $today = TriggerAuditLog::whereDate('created_at', today())->count(); + + $topTables = TriggerAuditLog::selectRaw('table_name, COUNT(*) as count') + ->groupBy('table_name') + ->orderByDesc('count') + ->limit(10) + ->pluck('count', 'table_name') + ->toArray(); + + // 저장소 크기 + $storageInfo = DB::selectOne(" + SELECT + ROUND(SUM(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) as size_mb + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + ", [config('database.connections.mysql.database')]); + + return [ + 'total' => $total, + 'today' => $today, + 'by_dml_type' => $byDmlType, + 'top_tables' => $topTables, + 'storage_mb' => $storageInfo->size_mb ?? 0, + ]; + } + + private function calculateDiff(TriggerAuditLog $log): array + { + $old = $log->old_values ?? []; + $new = $log->new_values ?? []; + $allKeys = array_unique(array_merge(array_keys($old), array_keys($new))); + sort($allKeys); + + $diff = []; + foreach ($allKeys as $key) { + $oldVal = $old[$key] ?? null; + $newVal = $new[$key] ?? null; + $changed = $oldVal !== $newVal; + + $diff[] = [ + 'column' => $key, + 'old' => $oldVal, + 'new' => $newVal, + 'changed' => $changed, + ]; + } + + return $diff; + } + + private function generateRollbackSQL(TriggerAuditLog $log): string + { + $pdo = DB::getPdo(); + + return match ($log->dml_type) { + 'INSERT' => "DELETE FROM `{$log->table_name}` WHERE `id` = ".$pdo->quote($log->row_id).' LIMIT 1', + 'UPDATE' => $this->buildRevertUpdateSQL($log, $pdo), + 'DELETE' => $this->buildReinsertSQL($log, $pdo), + }; + } + + private function buildRevertUpdateSQL(TriggerAuditLog $log, \PDO $pdo): string + { + if (empty($log->old_values)) { + return '-- old_values 없음, 롤백 불가'; + } + + $sets = collect($log->old_values) + ->map(fn ($val, $col) => "`{$col}` = ".($val === null ? 'NULL' : $pdo->quote((string) $val))) + ->implode(', '); + + return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = ".$pdo->quote($log->row_id).' LIMIT 1'; + } + + private function buildReinsertSQL(TriggerAuditLog $log, \PDO $pdo): string + { + if (empty($log->old_values)) { + return '-- old_values 없음, 롤백 불가'; + } + + $cols = collect($log->old_values)->keys()->map(fn ($c) => "`{$c}`")->implode(', '); + $vals = collect($log->old_values)->values() + ->map(fn ($v) => $v === null ? 'NULL' : $pdo->quote((string) $v)) + ->implode(', '); + + return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})"; + } +} diff --git a/app/Models/Audit/TriggerAuditLog.php b/app/Models/Audit/TriggerAuditLog.php new file mode 100644 index 00000000..8968bb7a --- /dev/null +++ b/app/Models/Audit/TriggerAuditLog.php @@ -0,0 +1,54 @@ + 'array', + 'new_values' => 'array', + 'changed_columns' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * changed_columns에서 NULL 값 제거 + */ + public function getChangedColumnsAttribute($value): ?array + { + $decoded = is_string($value) ? json_decode($value, true) : $value; + if (! is_array($decoded)) { + return null; + } + + return array_values(array_filter($decoded, fn ($v) => $v !== null)); + } + + /** + * session_info JSON → array + */ + public function getSessionInfoAttribute($value): ?array + { + if (! $value) { + return null; + } + + return is_string($value) ? json_decode($value, true) : $value; + } + + public function scopeForTable($query, string $tableName) + { + return $query->where('table_name', $tableName); + } + + public function scopeForRecord($query, string $tableName, string $rowId) + { + return $query->where('table_name', $tableName)->where('row_id', $rowId); + } +} diff --git a/database/seeders/TriggerAuditMenuSeeder.php b/database/seeders/TriggerAuditMenuSeeder.php new file mode 100644 index 00000000..4836bcf6 --- /dev/null +++ b/database/seeders/TriggerAuditMenuSeeder.php @@ -0,0 +1,65 @@ +where('name', '시스템 관리') + ->first(); + + if (! $parentMenu) { + $this->command->error('시스템 관리 메뉴를 찾을 수 없습니다.'); + + return; + } + + // 이미 존재하는지 확인 + $existingMenu = Menu::where('tenant_id', $tenantId) + ->where('name', 'DB 변경 추적') + ->where('parent_id', $parentMenu->id) + ->first(); + + if ($existingMenu) { + $this->command->info('DB 변경 추적 메뉴가 이미 존재합니다.'); + + return; + } + + // 현재 자식 메뉴 최대 sort_order 확인 + $maxSort = Menu::where('parent_id', $parentMenu->id) + ->max('sort_order') ?? 0; + + // 메뉴 생성 + $menu = Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentMenu->id, + 'name' => 'DB 변경 추적', + 'url' => '/trigger-audit', + 'icon' => 'database', + 'sort_order' => $maxSort + 1, + 'is_active' => true, + ]); + + $this->command->info("메뉴 생성 완료: {$menu->name} (sort_order: {$menu->sort_order})"); + + // 하위 메뉴 목록 출력 + $this->command->info(''); + $this->command->info('=== 시스템 관리 하위 메뉴 ==='); + $children = Menu::where('parent_id', $parentMenu->id) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + foreach ($children as $child) { + $this->command->info("{$child->sort_order}. {$child->name} ({$child->url})"); + } + } +} diff --git a/resources/views/trigger-audit/history.blade.php b/resources/views/trigger-audit/history.blade.php new file mode 100644 index 00000000..321c734a --- /dev/null +++ b/resources/views/trigger-audit/history.blade.php @@ -0,0 +1,60 @@ +@extends('layouts.app') + +@section('title', '레코드 변경 이력') + +@section('content') +
+
+

레코드 변경 이력

+

+ {{ $tableName }} / Row + {{ $rowId }} +

+
+ 목록으로 +
+ + +
+ @forelse($logs as $log) +
+
+
+ + {{ $log->dml_type }} + + {{ $log->created_at->format('Y-m-d H:i:s') }} + {{ $log->db_user }} +
+ 상세 보기 +
+ + @if($log->changed_columns) +
+ 변경 컬럼: + @foreach($log->changed_columns as $col) + {{ $col }} + @endforeach +
+ @endif +
+ @empty +
+ 이 레코드의 변경 이력이 없습니다. +
+ @endforelse +
+ + +
+ {{ $logs->links() }} +
+@endsection diff --git a/resources/views/trigger-audit/index.blade.php b/resources/views/trigger-audit/index.blade.php new file mode 100644 index 00000000..d0045971 --- /dev/null +++ b/resources/views/trigger-audit/index.blade.php @@ -0,0 +1,191 @@ +@extends('layouts.app') + +@section('title', 'DB 트리거 감사 로그') + +@section('content') + +
+

DB 트리거 감사 로그

+
+ 트리거 {{ $triggerCount }}개 활성 +
+
+ + +
+
+
전체 기록
+
{{ number_format($stats['total']) }}
+
+
+
오늘 기록
+
{{ number_format($stats['today']) }}
+
+
+
INSERT
+
{{ number_format($stats['by_dml_type']['INSERT'] ?? 0) }}
+
+
+
UPDATE
+
{{ number_format($stats['by_dml_type']['UPDATE'] ?? 0) }}
+
+
+
DELETE
+
{{ number_format($stats['by_dml_type']['DELETE'] ?? 0) }}
+
+
+ + +
+ +
+

변경 빈도 상위 테이블

+
+ @foreach($stats['top_tables'] as $table => $count) +
+ {{ $table }} + {{ number_format($count) }} +
+ @endforeach +
+
+ + +
+

파티션 현황 (저장소: {{ $stats['storage_mb'] }}MB)

+
+ + + + + + + + + @foreach($partitions as $p) + + + + + @endforeach + +
파티션행 수
{{ $p->PARTITION_NAME }}{{ number_format($p->TABLE_ROWS) }}
+
+
+
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ 초기화 +
+
+
+ + +
+ + + + + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + + + + @empty + + + + @endforelse + +
ID테이블Row IDDML변경 컬럼DB 사용자일시액션
{{ $log->id }} + {{ $log->table_name }} + + {{ $log->row_id }} + + + {{ $log->dml_type }} + + + @if($log->changed_columns) + {{ implode(', ', array_slice($log->changed_columns, 0, 3)) }} + @if(count($log->changed_columns) > 3) + +{{ count($log->changed_columns) - 3 }} + @endif + @else + - + @endif + {{ $log->db_user ?? '-' }}{{ $log->created_at->format('m/d H:i:s') }} + 상세 +
감사 로그가 없습니다.
+ + +
+ {{ $logs->links() }} +
+
+@endsection diff --git a/resources/views/trigger-audit/rollback-preview.blade.php b/resources/views/trigger-audit/rollback-preview.blade.php new file mode 100644 index 00000000..6742ea66 --- /dev/null +++ b/resources/views/trigger-audit/rollback-preview.blade.php @@ -0,0 +1,109 @@ +@extends('layouts.app') + +@section('title', '롤백 미리보기 #' . $log->id) + +@section('content') +
+
+

롤백 미리보기

+

+ 감사 로그 #{{ $log->id }} / + {{ $log->table_name }} / Row + {{ $log->row_id }} +

+
+ +
+ +@if(session('error')) +
{{ session('error') }}
+@endif + + +
+

변경 요약

+
+
+ DML 타입 +
+ + {{ $log->dml_type }} + +
+
+
+ 롤백 동작 +
+ @if($log->dml_type === 'INSERT') + DELETE (삽입된 레코드 삭제) + @elseif($log->dml_type === 'UPDATE') + UPDATE (이전 값으로 복원) + @elseif($log->dml_type === 'DELETE') + INSERT (삭제된 레코드 복원) + @endif +
+
+
+ 대상 테이블 +
{{ $log->table_name }}
+
+
+ 대상 Row ID +
{{ $log->row_id }}
+
+
+
+ + +
+

실행할 SQL

+
+
{{ $sql }}
+
+

+ * 감사 트리거를 비활성화한 상태에서 실행됩니다 (@disable_audit_trigger = 1) +

+
+ + +
+
+ +
+

주의사항

+
    +
  • 이 작업은 실제 데이터를 변경합니다. 되돌릴 수 없습니다.
  • +
  • 롤백 이후 추가 변경이 있었다면 데이터 충돌이 발생할 수 있습니다.
  • +
  • 운영 환경에서는 반드시 백업 후 실행하세요.
  • +
+
+
+
+ + +
+
+ @csrf +
+ + +
+
+
+@endsection diff --git a/resources/views/trigger-audit/show.blade.php b/resources/views/trigger-audit/show.blade.php new file mode 100644 index 00000000..f262ea64 --- /dev/null +++ b/resources/views/trigger-audit/show.blade.php @@ -0,0 +1,122 @@ +@extends('layouts.app') + +@section('title', '감사 로그 상세 #' . $log->id) + +@section('content') +
+
+

감사 로그 상세 #{{ $log->id }}

+

{{ $log->table_name }} / Row {{ $log->row_id }}

+
+ +
+ +@if(session('success')) +
{{ session('success') }}
+@endif + + +
+

기본 정보

+
+
+ DML 타입 +
+ + {{ $log->dml_type }} + +
+
+
+ 테넌트 ID +
{{ $log->tenant_id ?? '-' }}
+
+
+ DB 사용자 +
{{ $log->db_user ?? '-' }}
+
+
+ 일시 +
{{ $log->created_at->format('Y-m-d H:i:s') }}
+
+ @if($log->actor_id) +
+ Actor ID +
{{ $log->actor_id }}
+
+ @endif + @if($log->session_info) +
+ IP +
{{ $log->session_info['ip'] ?? '-' }}
+
+
+ Route +
{{ $log->session_info['route'] ?? '-' }}
+
+ @endif + @if($log->changed_columns) +
+ 변경 컬럼 +
+ @foreach($log->changed_columns as $col) + {{ $col }} + @endforeach +
+
+ @endif +
+
+ + +
+

데이터 변경 내역

+ + + + + + + + + + @foreach($diff as $d) + + + + + + @endforeach + +
컬럼이전 값 (OLD)이후 값 (NEW)
+ {{ $d['column'] }} + @if($d['changed']) * @endif + + @if($d['old'] === null) + NULL + @elseif(is_array($d['old']) || is_object($d['old'])) +
{{ json_encode($d['old'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @else + {{ Str::limit((string) $d['old'], 500) }} + @endif +
+ @if($d['new'] === null) + NULL + @elseif(is_array($d['new']) || is_object($d['new'])) +
{{ json_encode($d['new'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @else + {{ Str::limit((string) $d['new'], 500) }} + @endif +
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 3b6566a0..e94cc0c9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,13 +2,15 @@ use App\Http\Controllers\Api\BusinessCardOcrController; use App\Http\Controllers\ApiLogController; +use App\Http\Controllers\AppVersionController; use App\Http\Controllers\ArchivedRecordController; use App\Http\Controllers\AuditLogController; -use App\Http\Controllers\AppVersionController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\BoardController; use App\Http\Controllers\CategoryController; +use App\Http\Controllers\CategorySyncController; use App\Http\Controllers\CommonCodeController; +use App\Http\Controllers\CommonCodeSyncController; use App\Http\Controllers\CustomerCenterController; use App\Http\Controllers\DailyLogController; use App\Http\Controllers\DepartmentController; @@ -18,13 +20,9 @@ use App\Http\Controllers\DocumentTemplateController; use App\Http\Controllers\FcmController; use App\Http\Controllers\ItemFieldController; -use App\Http\Controllers\Lab\AIController; -use App\Http\Controllers\Lab\ManagementController; use App\Http\Controllers\Lab\StrategyController; use App\Http\Controllers\MenuController; use App\Http\Controllers\MenuSyncController; -use App\Http\Controllers\CommonCodeSyncController; -use App\Http\Controllers\CategorySyncController; use App\Http\Controllers\PermissionController; use App\Http\Controllers\PostController; use App\Http\Controllers\ProfileController; @@ -33,12 +31,13 @@ use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\Sales\SalesProductController; +use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\AiConfigController; use App\Http\Controllers\System\HolidayController; -use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\SystemAlertController; use App\Http\Controllers\TenantController; use App\Http\Controllers\TenantSettingController; +use App\Http\Controllers\TriggerAuditController; use App\Http\Controllers\UserController; use Illuminate\Support\Facades\Route; @@ -92,7 +91,7 @@ */ // GET /logout 요청 시 로그인 페이지로 리다이렉트 -Route::get('/logout', fn() => redirect()->route('login')); +Route::get('/logout', fn () => redirect()->route('login')); Route::middleware(['auth', 'hq.member', 'password.changed'])->group(function () { Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); @@ -273,6 +272,15 @@ Route::get('/{id}', [AuditLogController::class, 'show'])->name('show'); }); + // 트리거 감사 로그 (DB 레벨 변경 추적) + Route::prefix('trigger-audit')->name('trigger-audit.')->group(function () { + Route::get('/', [TriggerAuditController::class, 'index'])->name('index'); + Route::get('/{id}', [TriggerAuditController::class, 'show'])->name('show')->whereNumber('id'); + Route::get('/{id}/rollback-preview', [TriggerAuditController::class, 'rollbackPreview'])->name('rollback-preview')->whereNumber('id'); + Route::post('/{id}/rollback', [TriggerAuditController::class, 'rollbackExecute'])->name('rollback-execute')->whereNumber('id'); + Route::get('/{tableName}/{rowId}/history', [TriggerAuditController::class, 'recordHistory'])->name('history'); + }); + // 프로젝트 관리 (Blade 화면만) Route::prefix('project-management')->name('pm.')->group(function () { // 대시보드 @@ -456,6 +464,7 @@ if (request()->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('finance.account-transactions')); } + return redirect()->route('finance.account-transactions'); })->name('index'); Route::get('/accounts', [\App\Http\Controllers\Barobill\EaccountController::class, 'accounts'])->name('accounts'); @@ -486,6 +495,7 @@ if (request()->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('finance.card-transactions')); } + return redirect()->route('finance.card-transactions'); })->name('index'); Route::get('/cards', [\App\Http\Controllers\Barobill\EcardController::class, 'cards'])->name('cards'); @@ -782,6 +792,7 @@ if (request()->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('finance.journal-entries')); } + return view('finance.journal-entries'); })->name('journal-entries'); @@ -909,7 +920,7 @@ }); // 기존 sales-commission URL 리다이렉트 (호환성) - Route::get('/sales-commission', fn() => redirect()->route('finance.sales-commissions.index'))->name('sales-commission'); + Route::get('/sales-commission', fn () => redirect()->route('finance.sales-commissions.index'))->name('sales-commission'); Route::get('/consulting-fee', function () { if (request()->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('finance.consulting-fee'));