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_). // Запускается еженедельно в воскресенье в 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)); // Учёт посетителей (spec 2026-07-13, 152-ФЗ): гости старше 180 дней удаляются. // В site_visitors лежит сырой IP — держим срок хранения коротким. Schedule::command('visitors:prune') ->weeklyOn(0, '03:15') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('visitors:prune', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('visitors:prune', false, 'Command failed', null)); // Task 5 (реклама на кандидатов v2): ночной пересчёт состояний рекламы по стадиям // связанных карточек воронки — кто крутится, кто на паузе, кто гаснет совсем. // Ничего не заливает в Яндекс, это следующий шаг (SyncAdAudienceJob, Task 6). // 03:10 МСК — до pd:scrub (03:30), вне пиковых часов. Schedule::job(new RecalcAdAudienceJob('pgsql_supplier')) ->dailyAt('03:10') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\RecalcAdAudienceJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\RecalcAdAudienceJob', false, 'Job failed', null)); // Task 6 (реклама на кандидатов v2): ночная заливка состава аудитории в Яндекс — // следом за пересчётом состояний (03:10), чтобы уходил уже свежий список. // Рубильник sales_ad_audience_platforms.enabled (yandex) = false — джоб не шлёт ничего. // pgsql_supplier — джоб читает sales_prospects (стадия для решения по срокам), // а на дефолтной роли crm_app_user к ним нет прав (боевая поломка 19.07). Schedule::job(new SyncAdAudienceJob('pgsql_supplier')) ->dailyAt('03:20') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncAdAudienceJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncAdAudienceJob', false, 'Job failed', null)); // Task 8 (выбор площадки прогрева): заливка в ВК — следом за яндексовой. Ниже // порога в 2000 номеров и без ключа доступа джоб не делает к ВК ни одного // обращения, только проставляет статус (no_access / waiting_volume). Schedule::job(new SyncVkAudienceJob('pgsql_supplier')) ->dailyAt('03:30') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncVkAudienceJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncVkAudienceJob', false, 'Job 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)); // Сторож дедлайна поставщика (21:00 МСК). yellow=20:00, red=20:40 — письмо, // если робот к порогу не закончил. Spec 2026-07-09-supplier-order-verification §5. Schedule::command('supplier:deadline-watch yellow') ->dailyAt('20:00') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', false, 'Command failed', null)); Schedule::command('supplier:deadline-watch red') ->dailyAt('20:40') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch red', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch red', false, 'Command failed', null)); // Чистка доп-каналов авторасширения поставщика (B6=dop2/B8/…) в 20:00 МСК — // до снимка поставщика (21:00), в разрешённом окне правок. Оставляет только B1/B2/B3. // Spec: docs/superpowers/specs/2026-07-16-supplier-prune-extra-channels-design.md Schedule::job(new PruneSupplierExtraChannelsJob) ->dailyAt('20:00') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\PruneSupplierExtraChannelsJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\PruneSupplierExtraChannelsJob', 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)); // Этап 3 «Автожизнь» воронки продаж: пересчёт авто-стадий по деньгам аккаунта каждые 15 мин. // pgsql_admin (crm_admin_user) — кросс-тенант биллинг + запись sales_prospects. Schedule::job(new SalesProspectsAdvanceJob('pgsql_admin'))->everyFifteenMinutes() ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Sales\SalesProspectsAdvanceJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Sales\SalesProspectsAdvanceJob', 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)); // База знаний ИИ-бота: ночная переиндексация статей resources/help // (спека 2026-07-02-jivo-ai-support-bot-design §3). Изменил статью — ночью бот знает. // Без ->onOneServer(): требует cache_locks (см. NB выше у Hole #2/withoutOverlapping), // а команда идемпотентна — полная перезаливка в транзакции, повтор безвреден. Schedule::command('help:rebuild-knowledge') ->dailyAt('04:30') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('help:rebuild-knowledge', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('help:rebuild-knowledge', false, 'Command failed', null)); // Сверка зависших онлайн-пополнений (14.07.2026). Судьбу платежа мы узнаём из webhook'а — // но он может не дойти: тогда ОПЛАЧЕННЫЙ платёж не зачислится (потеря денег клиента), а // отменённый навсегда повиснет «ожидает оплаты» (кабинет врёт клиенту). Каждые 5 минут // сами спрашиваем шлюз про каждый pending старше 15 минут и закрываем его по правде. Schedule::command('billing:reconcile-payments') ->everyFiveMinutes() ->onSuccess(fn () => $hb->recordRunResult('billing:reconcile-payments', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('billing:reconcile-payments', false, 'Command failed', null)); // Рекламный модуль Директа (Часть B1). Джобы сами перечисляют кампании через // pgsql_supplier (BYPASSRLS) — на дефолтной роли crm_app_user очередь без tenant- // контекста RLS дала бы 0 строк. Времена МСК, вне пиковых часов и не пересекаются // с уже занятыми слотами (03:20 SyncAd, 03:30 SyncVk, 04:00 audit). Schedule::job(new SyncCampaignAudienceJob) ->dailyAt('03:50') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', false, 'Job failed', null)); Schedule::job(new ChargeCampaignSpendJob) ->dailyAt('04:20') ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', false, 'Job failed', null)); Schedule::job(new SyncCampaignModerationJob) ->everyTwoHours() ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', false, 'Job failed', null)); // Сторож очереди робота-грузчика креативов. Задание, брошенное «в работе», держит очередь // для ВСЕХ клиентов: пока хоть одно в работе, выдача отвечает «работы нет» и ни одна // кампания не стартует. Робот может умереть молча — разбирать пробку руками некому. // Каждые 10 минут при пороге в 30 минут: свежее задание сторож не отбирает. Schedule::command('creative-jobs:reap') ->everyTenMinutes() ->timezone('Europe/Moscow') ->onSuccess(fn () => $hb->recordRunResult('creative-jobs:reap', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('creative-jobs:reap', false, 'Command failed', null));