2026.06.8
This commit is contained in:
99
app/Console/Commands/AutofillDutyReports.php
Normal file
99
app/Console/Commands/AutofillDutyReports.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
126
app/Http/Controllers/Web/Admin/ReplicationController.php
Normal file
126
app/Http/Controllers/Web/Admin/ReplicationController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/Models/ReplicationLog.php
Normal file
24
app/Models/ReplicationLog.php
Normal 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',
|
||||
];
|
||||
}
|
||||
23
app/Models/ReplicationSchedule.php
Normal file
23
app/Models/ReplicationSchedule.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user