Files
portal/app/routes/console.php
T
Дмитрий 77f61fb1d1
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
merge: сведение ветки «Реклама Телеграм» с боевым main
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.

Сверх самого слияния:

- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
  переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
  о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
  телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
  возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
  контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
  увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
  есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
  Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
  в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
  дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.

Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:38 +03:00

371 lines
26 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\ChargeCampaignSpendJob;
use App\Jobs\ClientTg\PollTelegramModerationJob;
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Jobs\External\RefreshExternalBalancesJob;
use App\Jobs\RecalcAdAudienceJob;
use App\Jobs\Sales\SalesProspectsAdvanceJob;
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\PruneSupplierExtraChannelsJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Jobs\SyncAdAudienceJob;
use App\Jobs\SyncCampaignAudienceJob;
use App\Jobs\SyncCampaignModerationJob;
use App\Jobs\SyncVkAudienceJob;
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));
// Учёт посетителей (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));
// Telegram-модуль, задача 3.3: уборщик зависших кампаний. Кампания в `running`
// дольше 15 минут = мёртвый прогон робота (штатный ≤ 420с). Без mts_campaign_id →
// failed + возврат брони; с id (черновик создан) → needs_review, деньги НЕ трогаем.
// Каждые 10 минут — окно «зависания» 15 мин, добиваем без задержки. В песочнице
// (TG_SANDBOX) деньги не двигаются, только статус.
Schedule::job(new SweepStuckTelegramCampaignsJob)
->everyTenMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob', false, 'Job failed', null));
// Telegram-модуль, задача 3.4: опросчик вердикта модерации МТС. Кампании в
// `moderating` с mts_campaign_id — робот-читалка читает кабинет: «Отклонена» →
// rejected + причина + возврат брони + уведомление; «Одобрена» → launched +
// уведомление; «На модерации» → ждём. МТС проверяет ~4 часа, но опрашиваем чаще,
// чтобы клиент узнал вердикт без задержки. В песочнице (TG_SANDBOX) деньги не двигаются.
Schedule::job(new PollTelegramModerationJob)
->everyFifteenMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\PollTelegramModerationJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\PollTelegramModerationJob', false, 'Job 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));