77f61fb1d1
Слияние 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>
371 lines
26 KiB
PHP
371 lines
26 KiB
PHP
<?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));
|