diff --git a/Dockerfile b/Dockerfile index 1ea21e8..a0779bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,9 +117,13 @@ RUN apk update && apk add --no-cache \ arm64) architecture="arm64" ;; \ *) echo "Unsupported architecture"; exit 1 ;; \ esac \ - && curl -O https://download.microsoft.com/download/0b3d5518-b4a7-4a2b-afc7-7ee9e967f93c/msodbcsql18_18.6.2.1-1_${architecture}.apk \ - && ACCEPT_EULA=Y apk add --allow-untrusted msodbcsql18_18.6.2.1-1_${architecture}.apk \ - && rm msodbcsql18_18.6.2.1-1_${architecture}.apk \ + && echo "Downloading ODBC Driver for architecture: ${architecture}" \ + && curl -fkSLo msodbcsql.apk "https://download.microsoft.com/download/0b3d5518-b4a7-4a2b-afc7-7ee9e967f93c/msodbcsql18_18.6.2.1-1_${architecture}.apk" \ + && curl -fkSLo msodbcsql.sig "https://download.microsoft.com/download/0b3d5518-b4a7-4a2b-afc7-7ee9e967f93c/msodbcsql18_18.6.2.1-1_${architecture}.sig" \ + && curl https://packages.microsoft.com/keys/microsoft.asc | gpg --import - \ + && gpg --verify msodbcsql.sig msodbcsql.apk \ + && ACCEPT_EULA=Y apk add --allow-untrusted msodbcsql.apk \ + && rm msodbcsql.apk msodbcsql.sig \ && pecl install sqlsrv pdo_sqlsrv \ && docker-php-ext-enable sqlsrv pdo_sqlsrv \ && apk del autoconf make g++ \ @@ -150,7 +154,7 @@ RUN chown -R application:application /var/www/html && \ RUN php artisan storage:link HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost/health || exit 1 + CMD curl -f http://localhost/up || exit 1 EXPOSE 80 diff --git a/app/Console/Commands/AutofillDutyReports.php b/app/Console/Commands/AutofillDutyReports.php new file mode 100644 index 0000000..6480dc7 --- /dev/null +++ b/app/Console/Commands/AutofillDutyReports.php @@ -0,0 +1,99 @@ +option('date') ?: now($tz)->format('Y-m-d'), $tz); + + // Пользователь для аудита + $userId = $this->option('user') ?: env('DUTY_AUTOFILL_USER_ID'); + $user = $userId ? User::find($userId) : User::orderBy('id')->first(); + if (! $user) { + $this->error('Не найден пользователь для аудита (укажите --user или DUTY_AUTOFILL_USER_ID).'); + return CommandAlias::FAILURE; + } + + // Смена: вчера 09:00 → сегодня 09:00 (для date = сегодня) + $dateRange = $this->dateRangeService->createDateRangeForDate($date, $user); + $periodStart = $dateRange->startDateRaw; + $periodEnd = $dateRange->endDateRaw; + + $departments = Department::orderBy('department_id')->get(); + + $this->info("Смена: {$periodStart} → {$periodEnd} ({$tz})"); + $this->info("Аудит: пользователь #{$user->id}"); + if ($this->option('dry-run')) { + $this->warn('Режим DRY RUN — без записи'); + } + + $created = 0; $skipped = 0; $errors = 0; + + foreach ($departments as $dept) { + $exists = ReportDuty::where('rf_department_id', $dept->department_id) + ->where('period_start', $periodStart) + ->where('period_end', $periodEnd) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + if ($this->option('dry-run')) { + $this->line(" нет отчёта: #{$dept->department_id} ".($dept->name_short ?? '')); + $created++; + continue; + } + + try { + $report = $this->reportService->saveReport($dateRange, $user->id, 0, $dept->department_id); + $stats = $this->reportService->saveSnapshot($dateRange, $report, $dept->rf_mis_department_id, $user->id); + $this->reportService->saveMetrics($stats, $report); + $created++; + } catch (Throwable $e) { + $errors++; + Log::error('DutyAutofill', [ + 'department_id' => $dept->department_id, + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'error' => $e->getMessage(), + ]); + $this->error("[#{$dept->department_id}] {$e->getMessage()}"); + } + } + + $this->newLine(); + $this->info("Готово. Создано: {$created} | Уже было: {$skipped} | Ошибок: {$errors}"); + + return $errors > 0 ? CommandAlias::FAILURE : CommandAlias::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/Syncio/SyncioWebhookController.php b/app/Http/Controllers/Api/Syncio/SyncioWebhookController.php index 00e8992..e79791c 100644 --- a/app/Http/Controllers/Api/Syncio/SyncioWebhookController.php +++ b/app/Http/Controllers/Api/Syncio/SyncioWebhookController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\Syncio; use App\Http\Controllers\Controller; use App\Jobs\RefreshMaterializedViews; +use App\Models\ReplicationLog; +use App\Models\ReplicationSchedule; use Illuminate\Http\Request; class SyncioWebhookController extends Controller @@ -20,6 +22,24 @@ class SyncioWebhookController extends Controller // $data['tables']['success'] кол-во успешных // $data['errors'] массив ошибок + $scheduleId = $data['schedule_id'] ?? ($data['schedule']['id'] ?? null); + + // Обрабатываем только расписания, заведённые в таблице (связанные с проектом) + if (! ReplicationSchedule::active()->where('schedule_id', $scheduleId)->exists()) { + return response()->noContent(); // 204, но игнорируем чужое расписание + } + + // Сохраняем информацию о репликации из вебхука + ReplicationLog::create([ + 'status' => $data['status'] ?? null, + 'schedule_id' => $scheduleId, + 'tables_success' => (int) ($data['tables']['success'] ?? 0), + 'tables_failed' => (int) ($data['tables']['failed'] ?? 0), + 'errors' => $data['errors'] ?? null, + 'payload' => $data, + 'received_at' => now(), + ]); + // При успешной репликации обновляем материализованные представления if (($data['status'] ?? null) === 'success') { RefreshMaterializedViews::dispatch(); diff --git a/app/Http/Controllers/Web/Admin/ReplicationController.php b/app/Http/Controllers/Web/Admin/ReplicationController.php new file mode 100644 index 0000000..363cfac --- /dev/null +++ b/app/Http/Controllers/Web/Admin/ReplicationController.php @@ -0,0 +1,126 @@ +user()->isAdmin(), 403); + + $logs = ReplicationLog::query() + ->latest('id') + ->limit(50) + ->get() + ->map(fn (ReplicationLog $l) => [ + 'id' => $l->id, + 'status' => $l->status, + 'schedule_id' => $l->schedule_id, + 'tables_success' => $l->tables_success, + 'tables_failed' => $l->tables_failed, + 'errors' => $l->errors, + 'received_at' => optional($l->received_at)->toIso8601String(), + ]); + + $schedules = ReplicationSchedule::orderBy('name')->get()->map(fn (ReplicationSchedule $s) => [ + 'id' => $s->id, + 'schedule_id' => $s->schedule_id, + 'name' => $s->name, + 'is_active' => $s->is_active, + ]); + + return Inertia::render('Admin/Replication/Index', [ + 'logs' => $logs, + 'schedules' => $schedules, + 'configured' => (bool) config('services.syncio.base_url'), + ]); + } + + public function storeSchedule(Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $data = $request->validate([ + 'schedule_id' => ['required', 'string', 'max:255', 'unique:replication_schedules,schedule_id'], + 'name' => ['nullable', 'string', 'max:255'], + ]); + + ReplicationSchedule::create([ + 'schedule_id' => $data['schedule_id'], + 'name' => $data['name'] ?? null, + 'is_active' => true, + ]); + + return back()->with('success', 'Расписание добавлено.'); + } + + public function updateSchedule(Request $request, ReplicationSchedule $schedule) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $data = $request->validate([ + 'name' => ['nullable', 'string', 'max:255'], + 'is_active' => ['required', 'boolean'], + ]); + + $schedule->update($data); + + return back()->with('success', 'Расписание обновлено.'); + } + + public function destroySchedule(ReplicationSchedule $schedule) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $schedule->delete(); + + return back()->with('success', 'Расписание удалено.'); + } + + public function run(Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $data = $request->validate([ + 'schedule_id' => ['required', 'string', 'max:255'], + ]); + + $baseUrl = rtrim((string) config('services.syncio.base_url'), '/'); + if ($baseUrl === '') { + return back()->with('error', 'Не задан SYNCIO_BASE_URL — запуск репликации недоступен.'); + } + + $scheduleId = $data['schedule_id']; + $url = "{$baseUrl}/migrations/schedules/{$scheduleId}/run"; + + try { + $response = Http::acceptJson() + ->when( + config('services.syncio.token'), + fn ($http) => $http->withToken(config('services.syncio.token')) + ) + ->timeout(30) + ->post($url); + + if ($response->failed()) { + Log::warning('Syncio run failed', ['status' => $response->status(), 'body' => $response->body()]); + + return back()->with('error', "Репликатор вернул ошибку ({$response->status()})."); + } + + return back()->with('success', 'Репликация запущена. Результат придёт по вебхуку.'); + } catch (\Throwable $e) { + Log::error('Syncio run exception', ['message' => $e->getMessage()]); + + return back()->with('error', 'Не удалось связаться с репликатором: '.$e->getMessage()); + } + } +} diff --git a/app/Models/ReplicationLog.php b/app/Models/ReplicationLog.php new file mode 100644 index 0000000..aa56cdc --- /dev/null +++ b/app/Models/ReplicationLog.php @@ -0,0 +1,24 @@ + 'array', + 'payload' => 'array', + 'received_at' => 'datetime', + ]; +} diff --git a/app/Models/ReplicationSchedule.php b/app/Models/ReplicationSchedule.php new file mode 100644 index 0000000..10511f1 --- /dev/null +++ b/app/Models/ReplicationSchedule.php @@ -0,0 +1,23 @@ + 'boolean', + ]; + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/config/services.php b/config/services.php index 9967d34..5692b1e 100644 --- a/config/services.php +++ b/config/services.php @@ -36,7 +36,10 @@ return [ ], 'syncio' => [ - 'secret' => env('SYNCIO_SECRET'), - ] + 'secret' => env('SYNCIO_SECRET'), + 'base_url' => env('SYNCIO_BASE_URL'), // напр. https://host/syncio-api + 'token' => env('SYNCIO_TOKEN'), // Bearer-токен для запуска репликации + // Разрешённые расписания и шорткаты хранятся в таблице replication_schedules + ], ]; diff --git a/database/migrations/2026_06_18_120000_create_replication_logs_table.php b/database/migrations/2026_06_18_120000_create_replication_logs_table.php new file mode 100644 index 0000000..f0e124b --- /dev/null +++ b/database/migrations/2026_06_18_120000_create_replication_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('status')->nullable(); // success | partial_success | failed + $table->string('schedule_id')->nullable(); // какое расписание запускалось (если известно) + $table->unsignedInteger('tables_success')->default(0); + $table->unsignedInteger('tables_failed')->default(0); + $table->json('errors')->nullable(); // массив ошибок из вебхука + $table->json('payload')->nullable(); // сырой payload вебхука + $table->timestamp('received_at')->nullable(); // когда пришёл вебхук + $table->timestamps(); + + $table->index('status'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('replication_logs'); + } +}; diff --git a/database/migrations/2026_06_18_130000_create_replication_schedules_table.php b/database/migrations/2026_06_18_130000_create_replication_schedules_table.php new file mode 100644 index 0000000..bdc2132 --- /dev/null +++ b/database/migrations/2026_06_18_130000_create_replication_schedules_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('schedule_id')->unique(); // ID расписания в репликаторе (syncio) + $table->string('name')->nullable(); // человекочитаемое имя (для кнопки) + $table->boolean('is_active')->default(true); // участвует в фильтре вебхука и шорткатах + $table->timestamps(); + }); + + // Расписания, связанные с проектом + DB::table('replication_schedules')->insert([ + [ + 'schedule_id' => '4969c65e-6824-45c8-b221-8cfca5c29m12', + 'name' => 'Основная (m12)', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'schedule_id' => '4969c65e-6824-45c8-b221-8cfca5c29m06', + 'name' => 'Основная (m06)', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('replication_schedules'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 6d674c2..3b9e4ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,28 @@ services: networks: - aokb-onboard-network + #Scheduler (одиночный — планировщик Laravel) + scheduler: + image: registry.brusoff.su/aokb-onboard:2026.06-dev + build: . + container_name: aokb_onboard_scheduler + restart: unless-stopped + working_dir: /var/www/html + command: php artisan schedule:work + environment: + - REDIS_HOST=redis + - REDIS_PORT=6379 + - QUEUE_CONNECTION=redis + depends_on: + - redis + - app + volumes: + - ./.env:/var/www/html/.env + - ./docker/php.ini:/usr/local/etc/php/conf.d/app.ini + - ./storage/logs:/var/www/storage/logs + networks: + - aokb-onboard-network + redis: image: redis:8-alpine container_name: aokb_onboard_redis diff --git a/package-lock.json b/package-lock.json index b8d978c..b203672 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1082,7 +1082,8 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/language-server": { "version": "0.14.29", @@ -1897,8 +1898,7 @@ "version": "5.13.0", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.13.0.tgz", "integrity": "sha512-PJuXT6zdiCbv0IkX5cqkKFVIIh+9v3kqP9zsOHEGpIWi7DfTgzvfOKc8icw6G3/ulR3V1alDDUtOVH0zWCWGEQ==", - "license": "SEE LICENSE IN LICENSE", - "peer": true + "license": "SEE LICENSE IN LICENSE" }, "node_modules/async-validator": { "version": "4.2.5", @@ -2129,7 +2129,6 @@ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -2205,7 +2204,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2259,7 +2257,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -2280,6 +2277,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -2354,6 +2352,7 @@ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", "integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==", "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -2367,6 +2366,7 @@ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -3143,7 +3143,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/muggle-string": { "version": "0.4.1", @@ -3261,7 +3262,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -3347,7 +3347,6 @@ "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz", "integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==", "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -3576,6 +3575,7 @@ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -3788,7 +3788,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4146,7 +4145,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -4259,6 +4257,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -4279,6 +4278,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/resources/js/Pages/Admin/Index.vue b/resources/js/Pages/Admin/Index.vue index 4b9e27c..c0e04cf 100644 --- a/resources/js/Pages/Admin/Index.vue +++ b/resources/js/Pages/Admin/Index.vue @@ -3,7 +3,7 @@ import AppLayout from "../../Layouts/AppLayout.vue" import { useAuthStore } from "../../Stores/auth.js" import { NEl, NFlex, NText, NTag, NAvatar } from 'naive-ui' import ActionTile from "../../Components/ActionTile.vue" -import { TbUsers, TbChartBar, TbLayoutDashboard } from "vue-icons-plus/tb" +import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh } from "vue-icons-plus/tb" import { Link } from "@inertiajs/vue3" import { computed } from "vue" import { useThemeVars } from "naive-ui" @@ -23,7 +23,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)