Files
portal/app/routes/console.php
T
Дмитрий d228a501dc feat(autopodbor): джоба зовёт распорядителя после завершения + крон autopodbor:study-tick
Задача 5: RunAutopodborStudyJob::handle() оборачивает существующее тело в
try/finally — после ЛЮБОГО завершения прогона (done/empty/failed, включая
проброшенное исключение) распорядитель AutopodborStudyScheduler::tick()
берёт следующего кандидата с освободившейся дорожки шага 2.

Задача 6: artisan autopodbor:study-tick — крон-страховка раз в минуту на
случай, если дорожка освободилась не через завершение джобы. Без
withoutOverlapping() — требует таблицу cache_locks, которой нет в схеме
(см. комментарий в routes/console.php); overlap уже защищён внутренним
Cache::lock в AutopodborStudyScheduler::tick().
2026-07-08 18:15:23 +03:00

224 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Jobs\External\RefreshExternalBalancesJob;
use App\Jobs\SendNewLeadsDigestJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\FlushDeferredOnlineSyncJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Services\SchedulerHeartbeatTracker;
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');
// Hole #6: heartbeat-трекинг всех cron-задач.
// SchedulerHeartbeatTracker::recordRun() оборачивает каждую задачу через
// before/after/onFailure хуки Laravel Scheduler — минимально инвазивный подход.
/** @var SchedulerHeartbeatTracker $hb */
$hb = app(SchedulerHeartbeatTracker::class);
// Spec §6.1: ежедневный сброс projects.delivered_today=0 в 00:00 МСК.
// delivered_in_month НЕ трогаем — это месячный счётчик, отдельный cron Plan 4.
//
// NB: без `withoutOverlapping()` — операция идемпотентна (UPDATE WHERE delivered_today <> 0)
// и завершается за < 1 сек на любом ожидаемом объёме, overlap физически невозможен.
// Кроме того, `withoutOverlapping` требует таблицу `cache_locks`, которой в нашей
// schema.sql нет (Laravel-default-миграции удалены, см. project_state.md фаза 1).
Schedule::command('projects:reset-delivered-today')
->dailyAt('00:00')
->timezone('Europe/Moscow')
->before(fn () => $startTimes['projects:reset-delivered-today'] = microtime(true))
->onSuccess(function () use ($hb, &$startTimes): void {
$name = 'projects:reset-delivered-today';
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
$hb->recordRunResult($name, true, null, $ms);
})
->onFailure(function () use ($hb): void {
$hb->recordRunResult('projects:reset-delivered-today', false, 'Command failed', null);
});
// Task 4.2: досыл отложенной онлайн-очереди в 00:05 МСК (после сброса счётчиков в 00:00,
// вне окна 18:00→00:00 — отложенные правки уходят поставщику немедленно).
Schedule::job(new FlushDeferredOnlineSyncJob)
->dailyAt('00:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', false, 'Job failed', null));
// Плитка балансов (28.06): ежедневный сбор баланса внешних сервисов (DaData/Поставщик/
// Yandex Cloud) в 06:30 МСК. Изоляция провайдеров внутри джобы; падение сети одного
// сервиса не роняет остальных и не валит расписание.
Schedule::job(new RefreshExternalBalancesJob)
->dailyAt('06:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\External\RefreshExternalBalancesJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\External\RefreshExternalBalancesJob', false, 'Job failed', null));
// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService.
Schedule::command('projects:reset-monthly')
->monthlyOn(1, '00:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('projects:reset-monthly', true, null, null))
->onFailure(fn () => $hb->recordRunResult('projects:reset-monthly', false, 'Command failed', null));
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
// Без этой записи partitions:create-months не запускается автоматически.
Schedule::command('partitions:create-months')
->daily()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('partitions:create-months', true, null, null))
->onFailure(fn () => $hb->recordRunResult('partitions:create-months', false, 'Command failed', null));
// Hole #2 (23.05.2026): удаление устаревших месячных партиций согласно retention
// (system_settings: partition_retention_months_<table>).
// Запускается еженедельно в воскресенье в 03:00 МСК — вне пиковых часов,
// но раз в неделю достаточно (данные удаляются целыми месяцами).
Schedule::command('partitions:drop-expired')
->weeklyOn(0, '03:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
// F-P1 (152-ФЗ ретеншен): ежедневная анонимизация ПДн soft-deleted сделок старше
// retention-срока (system_settings.pd_scrub_soft_deleted_deals_days; no-op пока не задан).
// 03:30 МСК — вне пиковых часов, после partitions:drop-expired (03:00).
Schedule::command('pd:scrub-soft-deleted-deals')
->dailyAt('03:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('pd:scrub-soft-deleted-deals', true, null, null))
->onFailure(fn () => $hb->recordRunResult('pd:scrub-soft-deleted-deals', false, 'Command failed', null));
// Billing v2 Spec C §3.2: преfflight баланса в 18:00 MSK — заморозка/разморозка
// тенантов перед формированием заказа поставщику (без «бедных» клиентов).
// ВАЖНО: идёт ДО SyncSupplierProjectsJob (сдвинут на 18:05) — фильтр frozen-проектов
// должен примениться к расчёту заказа того же вечера.
Schedule::command('billing:preflight-sweep')
->dailyAt('18:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue.
// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов.
Schedule::command('invoices:expire')
->dailyAt('03:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null))
->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null));
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
Schedule::command('billing:frozen-reminder')
->dailyAt('18:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('billing:frozen-reminder', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:frozen-reminder', false, 'Command failed', null));
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
//
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
// (см. project_state.md фаза 1). Операции идемпотентны: SyncSupplierProjectsJob
// делает diff'ы (skip-no-diff), CleanupJob — UPDATE WHERE conditions, RefreshSession
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
Schedule::job(new RefreshSupplierSessionJob)->hourly()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', false, 'Job failed', null));
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
Schedule::job(new RefreshSupplierSessionJob)
->dailyAt('17:45')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
// Spec 2026-05-26-slepok-routing-protection §4.2.2:
// SnapshotProjectRoutingJob создаёт slepok №NЛ для дня N+1 в 18:02 МСК.
// Запускается ПОСЛЕ billing:preflight-sweep (18:00) и ДО SyncSupplierProjectsJob (18:05).
Schedule::job(new SnapshotProjectRoutingJob)
->dailyAt('18:02')
->timezone('Europe/Moscow')
->before(fn () => $startTimes['SnapshotProjectRoutingJob'] = microtime(true))
->onSuccess(function () use ($hb, &$startTimes): void {
$name = 'SnapshotProjectRoutingJob';
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
$hb->recordRunResult($name, true, null, $ms);
})
->onFailure(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', false, 'Job failed', null));
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
// успел проставить frozen-флаги до формирования заказа поставщику.
Schedule::job(new SyncSupplierProjectsJob)
->dailyAt('18:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
Schedule::job(new CleanupInactiveSupplierProjectsJob)
->dailyAt('02:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', false, 'Job failed', null));
Schedule::command('supplier:retry-failed')->hourly()
->onSuccess(fn () => $hb->recordRunResult('supplier:retry-failed', true, null, null))
->onFailure(fn () => $hb->recordRunResult('supplier:retry-failed', false, 'Command failed', null));
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null));
// G2-A: дайджест новых сделок клиентам — каждые 30 минут.
Schedule::job(new SendNewLeadsDigestJob)->everyThirtyMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null));
// Audit #2 Phase 14 P2: авто-детекция штормов упавших webhook-джобов.
// Сканирует за последние 10 мин, порог 200, дедуп 60 мин.
Schedule::command('incidents:watch-failures')
->everyTenMinutes()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('incidents:watch-failures', true, null, null))
->onFailure(fn () => $hb->recordRunResult('incidents:watch-failures', false, 'Command failed', null));
// Hole #1: ежедневная проверка SHA-256 hash-chain в 6 audit-таблицах.
// Разрыв → incidents_log (severity high) + email kdv1@bk.ru.
// Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
Schedule::command('audit:verify-chains')
->dailyAt('04:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('audit:verify-chains', true, null, null))
->onFailure(fn () => $hb->recordRunResult('audit:verify-chains', false, 'Command failed', null));
// Hole #6: проверка пульса планировщика — hourly МСК.
Schedule::command('scheduler:check-heartbeats')
->hourly()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
// Задача 6 (пакетный сбор источников): страховка распорядителя шага 2. Джоба
// RunAutopodborStudyJob уже зовёт AutopodborStudyScheduler::tick() в finally после
// КАЖДОГО завершения прогона — это основной канал продвижения очереди. Этот крон —
// подстраховка на случай, если дорожка освободилась не через завершение джобы
// (например воркер шага 2 не поднят/упал молча) — раз в минуту проверяет и,
// если дорожка свободна, а очередь не пуста, стартует следующего.
//
// НЕ используем ->withoutOverlapping(): требует таблицу cache_locks, которой у нас
// нет в schema.sql (см. .env.example CACHE_STORE=database + комментарий выше в этом
// файле у Hole #2). Overlap и так не страшен — pickNextRunId()/tick() уже защищены
// собственным Cache::lock('autopodbor:study:tick', 10) внутри AutopodborStudyScheduler,
// а сам pickNextRunId() проверяет status='running' атомарно на момент чтения.
Schedule::command('autopodbor:study-tick')
->everyMinute()
->onSuccess(fn () => $hb->recordRunResult('autopodbor:study-tick', true, null, null))
->onFailure(fn () => $hb->recordRunResult('autopodbor:study-tick', false, 'Command failed', null));