2026.06.8

This commit is contained in:
brusnitsyn
2026-06-18 17:45:41 +09:00
parent 698422e0ba
commit f163b95663
15 changed files with 695 additions and 20 deletions

View File

@@ -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

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Models\Department;
use App\Models\ReportDuty;
use App\Models\User;
use App\Services\DateRangeService;
use App\Services\DutyReportService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Console\Command\Command as CommandAlias;
use Throwable;
class AutofillDutyReports extends Command
{
protected $signature = 'duty:autofill
{--date= : Дата смены (YYYY-MM-DD). По умолчанию сегодня (смена вчера 09:00 сегодня 09:00)}
{--user= : ID пользователя для аудита. По умолчанию DUTY_AUTOFILL_USER_ID или первый пользователь}
{--dry-run : Не писать в БД, только показать, для каких отделений нет отчёта}';
protected $description = 'Создаёт суточные отчёты для отделений, у которых нет отчёта за текущую смену';
public function __construct(
protected DutyReportService $reportService,
protected DateRangeService $dateRangeService
) {
parent::__construct();
}
public function handle(): int
{
$tz = config('app.timezone', 'Europe/Moscow');
$date = Carbon::parse($this->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;
}
}

View File

@@ -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();

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Web\Admin;
use App\Http\Controllers\Controller;
use App\Models\ReplicationLog;
use App\Models\ReplicationSchedule;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
class ReplicationController extends Controller
{
public function index()
{
abort_unless(auth()->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());
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReplicationLog extends Model
{
protected $fillable = [
'status',
'schedule_id',
'tables_success',
'tables_failed',
'errors',
'payload',
'received_at',
];
protected $casts = [
'errors' => 'array',
'payload' => 'array',
'received_at' => 'datetime',
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReplicationSchedule extends Model
{
protected $fillable = [
'schedule_id',
'name',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

View File

@@ -37,6 +37,9 @@ return [
'syncio' => [
'secret' => env('SYNCIO_SECRET'),
]
'base_url' => env('SYNCIO_BASE_URL'), // напр. https://host/syncio-api
'token' => env('SYNCIO_TOKEN'), // Bearer-токен для запуска репликации
// Разрешённые расписания и шорткаты хранятся в таблице replication_schedules
],
];

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('replication_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('replication_schedules', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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

22
package-lock.json generated
View File

@@ -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"
}

View File

@@ -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)
<template>
<AppLayout>
<div class="flex flex-col justify-center items-center" style="min-height: calc(100vh - 48px);">
<NFlex vertical :size="10" class="max-w-xl w-full" style="padding: 0 16px 24px;">
<NFlex vertical :size="10" class="max-w-2xl w-full" style="padding: 0 16px 24px;">
<!-- Шапка -->
<NEl class="panel-card rounded-2xl" style="padding: 18px 22px;">
@@ -43,7 +43,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
<NAvatar
round :size="40"
style="
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
background: transparent;
color: var(--primary-color);
font-size: 15px; font-weight: 700;
"
@@ -78,6 +78,13 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
:tag="Link"
href="/admin/metrics"
/>
<ActionTile
:icon="TbRefresh"
title="Репликация"
description="Запуск синхронизации и история вебхуков"
:tag="Link"
href="/admin/replication"
/>
</div>
<!-- Назад -->

View File

@@ -0,0 +1,258 @@
<script setup>
import {
NFlex, NButton, NDataTable, NTag, NText, NEl, NIcon, NInput, NTooltip, NScrollbar, NGrid, NGi,
NSwitch, NPopconfirm,
} from 'naive-ui'
import {
TbRefresh, TbPlayerPlay, TbLayoutDashboard, TbAlertTriangle, TbBolt, TbTrash, TbPlus,
} from 'vue-icons-plus/tb'
import AppLayout from '../../../Layouts/AppLayout.vue'
import AppContainer from '../../../Components/AppContainer.vue'
import SectionCard from '../../../Components/SectionCard.vue'
import PageBanner from '../../../Components/PageBanner.vue'
import { Link, router, usePage } from '@inertiajs/vue3'
import { ref, computed, watch, h } from 'vue'
import { format } from 'date-fns'
const props = defineProps({
logs: { type: Array, default: () => [] },
schedules: { type: Array, default: () => [] },
configured: { type: Boolean, default: false },
})
const page = usePage()
const running = ref(null)
const customId = ref('')
watch(() => page.props.flash, (f) => {
if (f?.success) window.$message?.success(f.success)
if (f?.error) window.$message?.error(f.error)
}, { deep: true })
function run(scheduleId) {
const id = String(scheduleId ?? '').trim()
if (!id) return
running.value = id
router.post('/admin/replication/run', { schedule_id: id }, {
preserveScroll: true,
onFinish: () => { running.value = null },
})
}
// --- Управление расписаниями (таблица replication_schedules) ---
const newId = ref('')
const newName = ref('')
function addSchedule() {
const id = newId.value.trim()
if (!id) return
router.post('/admin/replication/schedules', { schedule_id: id, name: newName.value.trim() || null }, {
preserveScroll: true,
onSuccess: () => { newId.value = ''; newName.value = '' },
})
}
function toggleSchedule(s, value) {
router.put(`/admin/replication/schedules/${s.id}`, { name: s.name, is_active: value }, {
preserveScroll: true,
})
}
function removeSchedule(s) {
router.delete(`/admin/replication/schedules/${s.id}`, { preserveScroll: true })
}
const statusType = (s) => ({ success: 'success', partial_success: 'warning', failed: 'error' }[s] ?? 'default')
const statusLabel = (s) => ({ success: 'Успешно', partial_success: 'Частично', failed: 'Ошибка' }[s] ?? (s ?? '—'))
const fmt = (iso) => iso ? format(new Date(iso), 'dd.MM.yyyy HH:mm:ss') : '—'
const hasSchedules = computed(() => props.schedules.length > 0)
const lastLog = computed(() => props.logs[0] ?? null)
const columns = computed(() => [
{
key: 'received_at',
title: 'Время',
width: 170,
render: (row) => h(NText, { style: 'font-size: 13px;' }, () => fmt(row.received_at)),
},
{
key: 'status',
title: 'Статус',
width: 120,
render: (row) => h(NTag, { type: statusType(row.status), size: 'small', round: true, bordered: false }, () => statusLabel(row.status)),
},
{
key: 'schedule_id',
title: 'Расписание',
render: (row) => h(NText, { style: 'font-size: 13px; font-family: monospace;' }, () => row.schedule_id ?? '—'),
},
{
key: 'tables_success',
title: 'Таблиц ОК',
width: 110,
align: 'center',
render: (row) => h(NTag, { size: 'small', round: true, bordered: false, type: 'success' }, () => `${row.tables_success}`),
},
{
key: 'tables_failed',
title: 'Ошибок',
width: 100,
align: 'center',
render: (row) => h(NTag, {
size: 'small', round: true, bordered: false,
type: row.tables_failed > 0 ? 'error' : 'default',
}, () => `${row.tables_failed}`),
},
{
key: 'errors',
title: 'Детали ошибок',
width: 150,
render: (row) => {
if (!row.errors || !row.errors.length) return h(NText, { depth: 3 }, () => '—')
return h(NTooltip, { style: { maxWidth: '440px' } }, {
trigger: () => h(NTag, { type: 'error', size: 'small', round: true, bordered: false }, () => `${row.errors.length} ошиб.`),
default: () => h(NScrollbar, { style: 'max-height: 240px;' },
() => row.errors.map((e, i) => h('div', { key: i, style: 'font-size: 12px; padding: 2px 0;' },
typeof e === 'string' ? e : JSON.stringify(e)))),
})
},
},
])
</script>
<template>
<AppLayout>
<AppContainer>
<PageBanner
title="Репликация"
:icon="TbRefresh"
:breadcrumbs="[{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link }]"
>
<template #meta>
<NFlex align="center" :size="8">
<NText depth="3" style="font-size: 13px;">{{ logs.length }} записей</NText>
<template v-if="lastLog">
<NEl style="width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .3;" />
<NText depth="3" style="font-size: 13px;">последняя:</NText>
<NTag :type="statusType(lastLog.status)" size="small" round :bordered="false">
{{ statusLabel(lastLog.status) }}
</NTag>
</template>
</NFlex>
</template>
</PageBanner>
<!-- Запуск: 2 колонки ручной запуск и шорткаты -->
<NGrid cols="2" :x-gap="16">
<NGi>
<SectionCard :icon="TbPlayerPlay" title="Запуск репликации">
<div v-if="!configured" class="hint-error">
<NIcon :component="TbAlertTriangle" /> Не задан <code>SYNCIO_BASE_URL</code> запуск недоступен. Добавьте переменные окружения репликатора.
</div>
<NFlex v-else align="center" :size="8" :wrap="false">
<NInput v-model:value="customId" placeholder="schedule_id" clearable />
<NButton
type="primary"
:loading="running !== null && running === customId.trim() && customId.trim() !== ''"
:disabled="running !== null || !customId.trim()"
@click="run(customId)"
>
Запустить
</NButton>
</NFlex>
</SectionCard>
</NGi>
<NGi>
<SectionCard :icon="TbBolt" title="Шорткаты (расписания проекта)">
<NFlex vertical :size="8">
<NFlex
v-for="s in schedules" :key="s.id"
align="center" :size="8" :wrap="false"
>
<NButton
type="primary" secondary
:loading="running === s.schedule_id"
:disabled="!configured || running !== null || !s.is_active"
style="flex: 1; justify-content: flex-start;"
@click="run(s.schedule_id)"
>
<template #icon><NIcon><TbPlayerPlay /></NIcon></template>
{{ s.name || s.schedule_id }}
</NButton>
<NTooltip>
<template #trigger>
<NSwitch :value="s.is_active" size="small" @update:value="v => toggleSchedule(s, v)" />
</template>
Учитывать вебхуки этого расписания
</NTooltip>
<NPopconfirm @positive-click="removeSchedule(s)">
<template #trigger>
<NButton text type="error" title="Удалить">
<template #icon><NIcon><TbTrash /></NIcon></template>
</NButton>
</template>
Удалить расписание «{{ s.name || s.schedule_id }}»?
</NPopconfirm>
</NFlex>
<NText v-if="!hasSchedules" depth="3" style="font-size: 12px;">
Расписаний нет. Добавьте ID расписания из репликатора ниже.
</NText>
<!-- Добавление -->
<NFlex align="center" :size="8" :wrap="false" style="margin-top: 4px;">
<NInput v-model:value="newId" placeholder="schedule_id" size="small" />
<NInput v-model:value="newName" placeholder="Имя" size="small" style="max-width: 150px;" />
<NButton size="small" :disabled="!newId.trim()" @click="addSchedule">
<template #icon><NIcon><TbPlus /></NIcon></template>
</NButton>
</NFlex>
</NFlex>
</SectionCard>
</NGi>
</NGrid>
<!-- История -->
<SectionCard :icon="TbRefresh" title="История репликаций" no-padding style="margin-top: 12px;">
<template #header-extra>
<NButton text size="small" @click="router.reload({ only: ['logs'] })">
<template #icon><NIcon><TbRefresh /></NIcon></template>
Обновить
</NButton>
</template>
<NDataTable
:columns="columns"
:data="logs"
:bordered="false"
size="small"
:max-height="520"
/>
</SectionCard>
</AppContainer>
</AppLayout>
</template>
<style scoped>
.hint-error {
display: flex;
align-items: center;
gap: 6px;
color: var(--error-color);
font-size: 13px;
}
.hint-error code {
background: color-mix(in srgb, var(--error-color) 12%, transparent);
padding: 1px 5px;
border-radius: 4px;
}
:deep(.n-data-table-th) { background: transparent !important; }
:deep(.n-data-table) { background: transparent; }
:deep(.n-data-table-wrapper) { border-radius: 0; }
:deep(.n-data-table-th .n-data-table-th__title) { font-size: 12px; }
:deep(.n-data-table-td) { font-size: 13px; }
</style>

View File

@@ -2,7 +2,14 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
// Каждый день в 08:59 — досоздаём отчёты отделениям, у которых нет отчёта за текущую смену
Schedule::command('duty:autofill')
->dailyAt('08:59')
->timezone(config('app.timezone', 'Europe/Moscow'))
->withoutOverlapping();

View File

@@ -52,6 +52,14 @@ Route::prefix('admin')->middleware(['auth'])->group(function () {
Route::get('/items/{item}', [\App\Http\Controllers\Web\Admin\MetrikaController::class, 'showItem']);
Route::put('/items/{item}', [\App\Http\Controllers\Web\Admin\MetrikaController::class, 'updateItem']);
});
Route::prefix('replication')->group(function () {
Route::get('/', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'index']);
Route::post('/run', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'run']);
Route::post('/schedules', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'storeSchedule']);
Route::put('/schedules/{schedule}', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'updateSchedule']);
Route::delete('/schedules/{schedule}', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'destroySchedule']);
});
});
Route::prefix('statistic')->middleware(['auth'])->group(function () {