diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 41e71446..82526039 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-09T17:36:28.707Z +Last updated: 2026-07-09T17:55:59.103Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,8 +112,8 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 11216 | MsMpEng | 2.48ч | NaNч | -| 10400 | Code | 1.85ч | 0.0ч | +| 11216 | MsMpEng | 2.50ч | NaNч | +| 10400 | Code | 1.91ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md b/docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md new file mode 100644 index 00000000..8c208e56 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md @@ -0,0 +1,1086 @@ +# План: итоговая проверка заказа у поставщика + сторож времени + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** После ночного прогона робота сверять живой кабинет поставщика с заказом по формуле (письмо при расхождении) и отдельным сторожем предупреждать письмом, если робот к 20:00/20:40 не закончил. + +**Architecture:** Два независимых компонента. (1) `VerifySupplierOrderJob` диспатчится из `SyncSupplierProjectsJob` в `finally`; строит «задуманное» из тех же eligible-проектов через `SupplierOrderPlan`, читает живой кабинет `SupplierPortalClient::listProjects()`, сверяет чистой функцией `SupplierOrderVerifier::diff()`, при расхождении перепроверяет через 90 сек, пишет `supplier_order_checks` и шлёт письмо. (2) `supplier:deadline-watch` — artisan-команда на 20:00/20:40 МСК, смотрит `supplier_sync_runs` за сегодня; не закончил → письмо. + +**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16, Pest 4, очередь через Redis. Соединение к нашей БД для supplier-домена — `pgsql_supplier` (BYPASSRLS). + +**Спека:** [docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md](../specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md) + +--- + +## Файловая карта + +- Create `app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php` — таблица истории проверок. +- Create `app/app/Services/Supplier/SupplierOrderVerifier.php` — чистая функция `diff()`. +- Create `app/app/Services/Supplier/SupplierOrderPlan.php` — «задуманное» из eligible-проектов. +- Create `app/app/Jobs/Supplier/VerifySupplierOrderJob.php` — оркестратор проверки. +- Create `app/app/Console/Commands/SupplierDeadlineWatchCommand.php` — сторож времени. +- Create `app/app/Mail/SupplierOrderMismatchMail.php` (+ `app/resources/views/emails/supplier_order_mismatch_text.blade.php`). +- Create `app/app/Mail/SupplierDeadlineWarningMail.php` (+ `app/resources/views/emails/supplier_deadline_warning_text.blade.php`). +- Modify `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` — диспатч `VerifySupplierOrderJob` в `finally`. +- Modify `app/routes/console.php` — два расписания сторожа. +- Tests: `app/tests/Unit/Supplier/SupplierOrderVerifierTest.php`, `app/tests/Unit/Supplier/SupplierOrderPlanTest.php`, `app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php`, `app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php`. + +Порядок задач: сначала чистые функции (2, 3), потом инфраструктура (1, письма 6), потом джоб (5) и провод (7), потом сторож (8). + +--- + +## Task 1: Миграция `supplier_order_checks` + +**Files:** +- Create: `app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php` + +- [ ] **Step 1: Написать миграцию** + +```php +bigIncrements('id'); + $table->timestampTz('checked_at'); + $table->unsignedBigInteger('sync_run_id')->nullable(); + $table->integer('intended_rows')->default(0); + $table->integer('live_rows')->default(0); + $table->integer('mismatch_count')->default(0); + // ok | mismatch | unable_to_verify + $table->string('status', 32); + $table->jsonb('details')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->index('checked_at'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_order_checks'); + } +}; +``` + +- [ ] **Step 2: Записать в CHANGELOG схемы** + +Добавить строку в `db/CHANGELOG_schema.md` (правило §4.2): дата 2026-07-09, «+ таблица `supplier_order_checks` (история итоговой проверки заказа у поставщика), не партиционируется, не содержит ПДн». + +- [ ] **Step 3: Применить миграцию на тестовую БД и проверить** + +Тесты в проекте идут на соединении `pgsql` + БД `liderra_testing` (см. `app/phpunit.xml`), +миграции подхватываются `RefreshDatabase`. Отдельного соединения `pgsql_testing` НЕТ. +Проверка миграции — применить её на тестовую БД (creds берутся из локального `.env`): + +Run: `cd app && DB_DATABASE=liderra_testing php artisan migrate` +Expected: `Migrated: 2026_07_09_190000_create_supplier_order_checks`. + +- [ ] **Step 4: Commit** + +```bash +git add app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php db/CHANGELOG_schema.md +git commit -m "feat(supplier): таблица supplier_order_checks — история итоговой проверки заказа" +``` + +--- + +## Task 2: `SupplierOrderVerifier::diff()` — чистая сверка + +Тип строки «задуманного»: `intended` = список `['signal_type','identifier','platform','limit']` (только активные, limit≥1). «Выключенные» передаём отдельным списком ключей `['signal_type','identifier','platform']`, которые ДОЛЖНЫ быть off. «Живое» = список `['signal_type','identifier','platform','limit','on']` (`on` = bool, статус у поставщика). + +**Files:** +- Create: `app/app/Services/Supplier/SupplierOrderVerifier.php` +- Test: `app/tests/Unit/Supplier/SupplierOrderVerifierTest.php` + +- [ ] **Step 1: Написать падающий тест** + +```php + 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1], + ]; + $shouldBeOff = []; + $live = [ + ['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1, 'on' => true], + ]; + + expect(SupplierOrderVerifier::diff($intended, $shouldBeOff, $live))->toBe([]); +}); + +it('ловит limit_drift', function (): void { + $intended = [['signal_type' => 'site', 'identifier' => 'inssmart.ru', 'platform' => 'B2', 'limit' => 2]]; + $live = [['signal_type' => 'site', 'identifier' => 'inssmart.ru', 'platform' => 'B2', 'limit' => 5, 'on' => true]]; + + $m = SupplierOrderVerifier::diff($intended, [], $live); + + expect($m)->toHaveCount(1) + ->and($m[0]['kind'])->toBe('limit_drift') + ->and($m[0]['expected'])->toBe(2) + ->and($m[0]['actual'])->toBe(5); +}); + +it('ловит should_be_off (пауза не дошла = деньги)', function (): void { + $shouldBeOff = [['signal_type' => 'site', 'identifier' => 'automoney.ru', 'platform' => 'B3']]; + $live = [['signal_type' => 'site', 'identifier' => 'automoney.ru', 'platform' => 'B3', 'limit' => 1, 'on' => true]]; + + $m = SupplierOrderVerifier::diff([], $shouldBeOff, $live); + + expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('should_be_off'); +}); + +it('ловит should_be_on', function (): void { + $intended = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B1', 'limit' => 1]]; + $live = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B1', 'limit' => 1, 'on' => false]]; + + $m = SupplierOrderVerifier::diff($intended, [], $live); + + expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('should_be_on'); +}); + +it('ловит missing (задумали, у поставщика нет)', function (): void { + $intended = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B2', 'limit' => 1]]; + + $m = SupplierOrderVerifier::diff($intended, [], []); + + expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('missing'); +}); + +it('ловит orphan_extra (у поставщика лишняя наша строка)', function (): void { + $live = [['signal_type' => 'site', 'identifier' => 'ghost.ru', 'platform' => 'B1', 'limit' => 3, 'on' => true]]; + + $m = SupplierOrderVerifier::diff([], [], $live); + + expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('orphan_extra'); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderVerifierTest.php` +Expected: FAIL «Class SupplierOrderVerifier not found». + +- [ ] **Step 3: Реализовать** + +```php + $intended + * @param list $shouldBeOff + * @param list $live + * @return list + */ + public static function diff(array $intended, array $shouldBeOff, array $live): array + { + $key = static fn (array $r): string => $r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']; + + $liveByKey = []; + foreach ($live as $row) { + $liveByKey[$key($row)] = $row; + } + + $mismatches = []; + $accountedLive = []; + + // 1. Активные по формуле. + foreach ($intended as $row) { + $k = $key($row); + $accountedLive[$k] = true; + $liveRow = $liveByKey[$k] ?? null; + + if ($liveRow === null) { + $mismatches[] = self::m('missing', $row, $row['limit'], null); + + continue; + } + if ($liveRow['on'] !== true) { + $mismatches[] = self::m('should_be_on', $row, true, false); + + continue; + } + if ((int) $liveRow['limit'] !== (int) $row['limit']) { + $mismatches[] = self::m('limit_drift', $row, (int) $row['limit'], (int) $liveRow['limit']); + } + } + + // 2. Должны быть выключены. + foreach ($shouldBeOff as $row) { + $k = $key($row); + $accountedLive[$k] = true; + $liveRow = $liveByKey[$k] ?? null; + if ($liveRow !== null && $liveRow['on'] === true) { + $mismatches[] = self::m('should_be_off', $row, false, true); + } + } + + // 3. Лишние наши строки у поставщика, которых нет ни в intended, ни в shouldBeOff. + foreach ($live as $row) { + if (! isset($accountedLive[$key($row)]) && $row['on'] === true) { + $mismatches[] = self::m('orphan_extra', $row, null, (int) $row['limit']); + } + } + + return $mismatches; + } + + /** + * @param array{signal_type:string,identifier:string,platform:string} $row + * @return array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null} + */ + private static function m(string $kind, array $row, int|string|bool|null $expected, int|string|bool|null $actual): array + { + return [ + 'kind' => $kind, + 'signal_type' => $row['signal_type'], + 'identifier' => $row['identifier'], + 'platform' => $row['platform'], + 'expected' => $expected, + 'actual' => $actual, + ]; + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderVerifierTest.php` +Expected: PASS (6 тестов). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Services/Supplier/SupplierOrderVerifier.php app/tests/Unit/Supplier/SupplierOrderVerifierTest.php +git commit -m "feat(supplier): SupplierOrderVerifier::diff — чистая сверка заказа с живым кабинетом" +``` + +--- + +## Task 3: `SupplierOrderPlan::build()` — «задуманное» по формуле + +Строит из eligible-проектов карту «что должно стоять активным» + список «что должно быть выключено». Использует те же публичные хелперы, что и робот: `SupplierProjectGrouping::{resolvePlatforms,buildUniqueKeyAgnostic}` и `SupplierQuotaAllocator::{computeOrder,distributeForPlatform}`. + +**Files:** +- Create: `app/app/Services/Supplier/SupplierOrderPlan.php` +- Test: `app/tests/Unit/Supplier/SupplierOrderPlanTest.php` + +- [ ] **Step 1: Написать падающий тест** + +```php +isoWeekday() - 1); // бит пн + + $p1 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']); + $p1->daily_limit_target = 3; + $p1->delivery_days_mask = $mask; + $p1->regions = []; + + $p2 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']); + $p2->daily_limit_target = 2; + $p2->delivery_days_mask = $mask; + $p2->regions = []; + + $plan = SupplierOrderPlan::build(collect([$p1, $p2]), $tomorrow); + + // order = max(3, ceil(5/3)=2) = 3; split B1/B2/B3 = 1/1/1 + expect($plan['intended'])->toEqualCanonicalizing([ + ['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1], + ['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B2', 'limit' => 1], + ['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B3', 'limit' => 1], + ]); +}); + +it('проект не eligible на завтра (нет бита дня) → не попадает в intended', function (): void { + $tomorrow = Carbon::parse('2026-07-13'); // пн + $notMondayMask = 1 << (Carbon::parse('2026-07-14')->isoWeekday() - 1); // вт + + $p = new Project(['signal_type' => 'call', 'signal_identifier' => '7911']); + $p->daily_limit_target = 5; + $p->delivery_days_mask = $notMondayMask; + $p->regions = []; + + $plan = SupplierOrderPlan::build(collect([$p]), $tomorrow); + + expect($plan['intended'])->toBe([]); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderPlanTest.php` +Expected: FAIL «Class SupplierOrderPlan not found». + +- [ ] **Step 3: Реализовать** + +```php + list], где row = ['signal_type','identifier','platform','limit'], + * только доли ≥1 (как distributeForPlatform). + * + * Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §3, §4.2 + */ +final class SupplierOrderPlan +{ + /** + * @param Collection $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions) + * @return array{intended: list} + */ + public static function build(Collection $eligibleProjects, Carbon $targetDate): array + { + $targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday(); + + // Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob. + /** @var array, limits:list}> $groups */ + $groups = []; + + foreach ($eligibleProjects as $project) { + $platforms = SupplierProjectGrouping::resolvePlatforms($project); + if ($platforms === []) { + continue; + } + // eligible-today (маска дня на targetDate). + if (((int) $project->delivery_days_mask & (1 << ($targetWeekday - 1))) === 0) { + continue; + } + + $identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project); + $key = $project->signal_type.'|'.$identifier; + + if (! isset($groups[$key])) { + $groups[$key] = [ + 'signal_type' => (string) $project->signal_type, + 'identifier' => $identifier, + 'platforms' => $platforms, + 'limits' => [], + ]; + } + $groups[$key]['limits'][] = (int) $project->daily_limit_target; + } + + $intended = []; + foreach ($groups as $group) { + $order = SupplierQuotaAllocator::computeOrder($group['limits']); + $shares = SupplierQuotaAllocator::distributeForPlatform($order, $group['platforms']); + foreach ($shares as $platform => $limit) { + $intended[] = [ + 'signal_type' => $group['signal_type'], + 'identifier' => $group['identifier'], + 'platform' => $platform, + 'limit' => $limit, + ]; + } + } + + return ['intended' => $intended]; + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderPlanTest.php` +Expected: PASS (2 теста). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Services/Supplier/SupplierOrderPlan.php app/tests/Unit/Supplier/SupplierOrderPlanTest.php +git commit -m "feat(supplier): SupplierOrderPlan::build — задуманный заказ по формуле" +``` + +--- + +## Task 4: Письма (mailables + шаблоны) + +**Files:** +- Create: `app/app/Mail/SupplierOrderMismatchMail.php` +- Create: `app/resources/views/emails/supplier_order_mismatch_text.blade.php` +- Create: `app/app/Mail/SupplierDeadlineWarningMail.php` +- Create: `app/resources/views/emails/supplier_deadline_warning_text.blade.php` + +- [ ] **Step 1: `SupplierOrderMismatchMail`** + +```php + $mismatches + */ +final class SupplierOrderMismatchMail extends Mailable implements ShouldQueue +{ + use Queueable; + + public function __construct(public readonly array $mismatches) {} + + public function envelope(): Envelope + { + $n = count($this->mismatches); + + return new Envelope(subject: "[Лидерра] Заказ у поставщика: расхождений {$n}"); + } + + public function content(): Content + { + return new Content( + text: 'emails.supplier_order_mismatch_text', + with: [ + 'mismatches' => $this->mismatches, + 'now' => now()->timezone('Europe/Moscow')->toIso8601String(), + ], + ); + } +} +``` + +- [ ] **Step 2: Шаблон `supplier_order_mismatch_text.blade.php`** + +```blade +Итоговая проверка заказа у поставщика ({{ $now }} МСК) нашла расхождения: {{ count($mismatches) }}. + +@foreach ($mismatches as $m) +- [{{ $m['kind'] }}] {{ $m['signal_type'] }} {{ $m['identifier'] }} / {{ $m['platform'] }}: задумано «{{ var_export($m['expected'], true) }}», у поставщика «{{ var_export($m['actual'], true) }}» +@endforeach + +Расшифровка: +- missing — задумали строку, у поставщика её нет. +- limit_drift — лимит не совпал. +- should_be_off — должно быть выключено, а у поставщика включено (деньги!). +- should_be_on — должно быть включено, а выключено. +- orphan_extra — у поставщика лишняя наша строка. +``` + +- [ ] **Step 3: `SupplierDeadlineWarningMail`** + +```php +level === 'red' ? 'КРАСНЫЙ' : 'ЖЁЛТЫЙ'; + + return new Envelope(subject: "[Лидерра {$tag}] Робот-заказчик не успевает к 21:00"); + } + + public function content(): Content + { + return new Content( + text: 'emails.supplier_deadline_warning_text', + with: [ + 'level' => $this->level, + 'reason' => $this->reason, + 'now' => now()->timezone('Europe/Moscow')->toIso8601String(), + ], + ); + } +} +``` + +- [ ] **Step 4: Шаблон `supplier_deadline_warning_text.blade.php`** + +```blade +@if ($level === 'red') +🔴 КРАСНЫЙ. Робот-заказчик к 20:40 МСК ещё не закончил. До дедлайна поставщика (21:00) ~20 минут — есть риск не успеть заказать на завтра. +@else +🟡 ЖЁЛТЫЙ. Робот-заказчик к 20:00 МСК ещё не закончил. До дедлайна поставщика (21:00) меньше часа — следите. +@endif + +Причина: {{ $reason }} +Время письма: {{ $now }} МСК. +``` + +- [ ] **Step 5: Проверить рендер (быстрый smoke через tinker)** + +Run: `cd app && php artisan tinker --execute="(new App\Mail\SupplierDeadlineWarningMail('red','нет finished_at за сегодня'))->render(); echo 'RENDER_OK';"` +Expected: `RENDER_OK` без исключений. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Mail/SupplierOrderMismatchMail.php app/app/Mail/SupplierDeadlineWarningMail.php app/resources/views/emails/supplier_order_mismatch_text.blade.php app/resources/views/emails/supplier_deadline_warning_text.blade.php +git commit -m "feat(supplier): письма о расхождении заказа и о срыве дедлайна" +``` + +--- + +## Task 5: `VerifySupplierOrderJob` — оркестратор + +Собирает eligible-проекты (публичный `SyncSupplierProjectsJob::collectEligibleProjects()`), строит intended (`SupplierOrderPlan`), список shouldBeOff (наши `supplier_projects` с `inactive_since IS NOT NULL`, `tag`-эквивалент — все они из автоматизации), читает живой кабинет (`SupplierPortalClient::listProjects()` → нормализует в формат live), сверяет (`SupplierOrderVerifier::diff`). При расхождении на попытке 1 — переочередить себя с задержкой 90 сек (`attempt=2`); на попытке 2 при расхождении — письмо. Пишет строку в `supplier_order_checks`. + +**Files:** +- Create: `app/app/Jobs/Supplier/VerifySupplierOrderJob.php` +- Test: `app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php` + +- [ ] **Step 1: Написать падающий feature-тест** + +```php +mock(SupplierPortalClient::class, function ($m): void { + $m->shouldReceive('listProjects')->andReturn([ + ['name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ['name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ['name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ]); + }); + + (new VerifySupplierOrderJob(attempt: 2))->handle(); + + Mail::assertNothingQueued(); + $row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first(); + expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0); +}); + +it('пауза не дошла (should_be_off) → письмо + статус mismatch на попытке 2', function (): void { + Mail::fake(); + + // Выключенная наша supplier_project (inactive_since), а кабинет показывает её ВКЛ. + seedPausedSupplierProject('automoney.ru', 'B3', 'site'); + + $this->mock(SupplierPortalClient::class, function ($m): void { + $m->shouldReceive('listProjects')->andReturn([ + ['name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ]); + }); + + (new VerifySupplierOrderJob(attempt: 2))->handle(); + + Mail::assertQueued(SupplierOrderMismatchMail::class); + $row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first(); + expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(1); +}); +``` + +> Хелперы `seedActiveCallProject` / `seedPausedSupplierProject` добавить в `app/tests/Pest.php` или локальный helper-файл: создают строки в `projects` / `project_routing_snapshots` (snapshot_date = завтра) / `supplier_projects` через соединение `pgsql_supplier`. Значения полей — по существующим фабрикам проекта (см. `database/factories`, следовать паттерну соседних supplier-тестов `tests/Feature/Supplier/*`). + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/VerifySupplierOrderJobTest.php` +Expected: FAIL «Class VerifySupplierOrderJob not found». + +- [ ] **Step 3: Реализовать джоб** + +```php +collectEligibleProjects(); + $plan = SupplierOrderPlan::build($eligible, $targetDate); + $intended = $plan['intended']; + + // 2. shouldBeOff — наши выключенные supplier_projects. + $shouldBeOff = SupplierProject::on(self::DB_CONNECTION) + ->whereNotNull('inactive_since') + ->get(['signal_type', 'unique_key', 'platform']) + ->map(fn (SupplierProject $sp) => [ + 'signal_type' => (string) $sp->signal_type, + 'identifier' => (string) $sp->unique_key, + 'platform' => (string) $sp->platform, + ])->all(); + + // 3. Живой кабинет (только наши строки: tag _lidpotok). + try { + $live = $this->normalizeLive($client->listProjects()); + } catch (Throwable $e) { + $this->record('unable_to_verify', count($intended), 0, 0, ['error' => $e->getMessage()]); + Mail::to((string) config('services.supplier.alert_email')) + ->queue(new SupplierOrderMismatchMail([[ + 'kind' => 'unable_to_verify', 'signal_type' => '-', 'identifier' => '-', + 'platform' => '-', 'expected' => 'чтение кабинета', 'actual' => $e->getMessage(), + ]])); + + return; + } + + // 4. Сверка. + $mismatches = SupplierOrderVerifier::diff($intended, $shouldBeOff, $live); + + if ($mismatches !== [] && $this->attempt < 2) { + // Транзиентный лаг применения — перепроверить через 90 сек. + self::dispatch(2, $this->syncRunId)->delay(now()->addSeconds(self::RECHECK_DELAY_SECONDS)); + + return; + } + + $status = $mismatches === [] ? 'ok' : 'mismatch'; + $this->record($status, count($intended), count($live), count($mismatches), $mismatches); + + if ($mismatches !== []) { + Mail::to((string) config('services.supplier.alert_email')) + ->queue(new SupplierOrderMismatchMail($mismatches)); + } + } + + /** + * Портальные строки → формат SupplierOrderVerifier (только наши _lidpotok, с распознанной площадкой). + * + * @param array> $rows + * @return list + */ + private function normalizeLive(array $rows): array + { + $out = []; + foreach ($rows as $row) { + if (($row['tag'] ?? null) !== '_lidpotok') { + continue; + } + $name = (string) ($row['name'] ?? ''); + if (preg_match('/^(B[123])_/', $name, $mm) !== 1) { + continue; + } + $signalType = match ($row['type'] ?? null) { + 'hosts' => 'site', 'calls' => 'call', 'sms' => 'sms', default => null, + }; + if ($signalType === null) { + continue; + } + $out[] = [ + 'signal_type' => $signalType, + 'identifier' => (string) ($row['content'] ?? ''), + 'platform' => $mm[1], + 'limit' => (int) ($row['lim'] ?? 0), + 'on' => (string) ($row['status'] ?? '') === '1', + ]; + } + + return $out; + } + + /** + * @param list> $details + */ + private function record(string $status, int $intendedRows, int $liveRows, int $mismatchCount, array $details): void + { + DB::connection(self::DB_CONNECTION)->table('supplier_order_checks')->insert([ + 'checked_at' => now(), + 'sync_run_id' => $this->syncRunId, + 'intended_rows' => $intendedRows, + 'live_rows' => $liveRows, + 'mismatch_count' => $mismatchCount, + 'status' => $status, + 'details' => json_encode($details, JSON_UNESCAPED_UNICODE), + 'created_at' => now(), + ]); + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/VerifySupplierOrderJobTest.php` +Expected: PASS (2 теста). + +- [ ] **Step 5: Прогнать статанализ и стиль** + +Run: `cd app && composer pint && composer stan` +Expected: без ошибок по новым файлам. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Jobs/Supplier/VerifySupplierOrderJob.php app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php app/tests/Pest.php +git commit -m "feat(supplier): VerifySupplierOrderJob — итоговая проверка заказа с перепроверкой" +``` + +--- + +## Task 6: Провод — диспатч проверки из робота + +**Files:** +- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (метод `handle()`, блок `finally` около строки 195) + +- [ ] **Step 1: Обновить feature-тест робота — проверка диспатчится** + +В `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` добавить тест: + +```php +it('в конце диспатчит VerifySupplierOrderJob', function (): void { + Bus::fake([VerifySupplierOrderJob::class]); + + (new SyncSupplierProjectsJob)->handle(fakeChannelWithNoGroups()); + + Bus::assertDispatched(VerifySupplierOrderJob::class); +}); +``` + +> `fakeChannelWithNoGroups()` — вернуть заглушку `SupplierProjectChannel`, при которой групп нет (пустой eligible). Следовать существующим стабам в этом же тест-файле. + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php --filter="диспатчит VerifySupplierOrderJob"` +Expected: FAIL (не диспатчится). + +- [ ] **Step 3: Внести диспатч в `finally`** + +В `SyncSupplierProjectsJob::handle()`, внутри `finally` после `$this->recordRunSummary(...)`, добавить: + +```php + $this->recordRunSummary( + startedAt: $startedAt, + groupsTotal: count($groups), + syncedOk: $syncedOk, + manualQueued: $manualQueued, + deferred: $deferred, + failed: $failed, + aborted: $aborted, + ); + + // Итоговая проверка: после «готово» перечитать живой кабинет и сверить. + // Идёт всегда — и при штатном финише, и при обрыве (кроме auth: кабинет + // недоступен, джоб сам зафиксирует unable_to_verify). + VerifySupplierOrderJob::dispatch(); +``` + +`use` не требуется: `VerifySupplierOrderJob` лежит в том же namespace `App\Jobs\Supplier`, что и робот — вызывать по короткому имени. + +- [ ] **Step 4: Запустить — зелёные (и весь файл робота не сломан)** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` +Expected: PASS (все тесты файла). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +git commit -m "feat(supplier): робот запускает итоговую проверку заказа после прогона" +``` + +--- + +## Task 7: `supplier:deadline-watch` — сторож времени + +**Files:** +- Create: `app/app/Console/Commands/SupplierDeadlineWatchCommand.php` +- Modify: `app/routes/console.php` +- Test: `app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php` + +- [ ] **Step 1: Написать падающий feature-тест** + +```php +table('supplier_sync_runs')->insert([ + 'started_at' => now(), 'finished_at' => now(), 'groups_total' => 1, 'synced_ok' => 1, + 'manual_queued' => 0, 'deferred' => 0, 'failed' => 0, 'status' => 'ok', 'created_at' => now(), + ]); + + $this->artisan('supplier:deadline-watch', ['level' => 'yellow'])->assertExitCode(0); + + Mail::assertNothingQueued(); +}); + +it('робот сегодня НЕ закончил → красное письмо', function (): void { + Mail::fake(); + // Запуск начат, но не финиширован (finished_at NULL). + DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([ + 'started_at' => now(), 'finished_at' => null, 'groups_total' => 0, 'synced_ok' => 0, + 'manual_queued' => 0, 'deferred' => 0, 'failed' => 0, 'status' => 'ok', 'created_at' => now(), + ]); + + $this->artisan('supplier:deadline-watch', ['level' => 'red'])->assertExitCode(0); + + Mail::assertQueued(SupplierDeadlineWarningMail::class, fn ($m) => $m->level === 'red'); +}); + +it('запуска за сегодня нет вовсе → письмо (планировщик не сработал)', function (): void { + Mail::fake(); + + $this->artisan('supplier:deadline-watch', ['level' => 'yellow'])->assertExitCode(0); + + Mail::assertQueued(SupplierDeadlineWarningMail::class, fn ($m) => $m->level === 'yellow'); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php` +Expected: FAIL «command supplier:deadline-watch is not defined». + +- [ ] **Step 3: Реализовать команду** + +```php +argument('level') === 'red' ? 'red' : 'yellow'; + $today = Carbon::today('Europe/Moscow')->toDateString(); + + $finished = DB::connection('pgsql_supplier')->table('supplier_sync_runs') + ->whereRaw("(started_at AT TIME ZONE 'Europe/Moscow')::date = ?", [$today]) + ->whereNotNull('finished_at') + ->where('status', '!=', 'aborted') + ->exists(); + + if ($finished) { + $this->info('OK: робот сегодня завершил заказ.'); + + return self::SUCCESS; + } + + $reason = 'За сегодня нет завершённого (не aborted) запуска SyncSupplierProjectsJob с finished_at.'; + Mail::to((string) config('services.supplier.alert_email')) + ->queue(new SupplierDeadlineWarningMail($level, $reason)); + + $this->warn("{$level}: робот не закончил — письмо отправлено."); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php` +Expected: PASS (3 теста). + +- [ ] **Step 5: Добавить расписание** + +В `app/routes/console.php` рядом с блоком supplier-flow (после планирования `SyncSupplierProjectsJob`, около строки 163) добавить: + +```php +// Сторож дедлайна поставщика (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)); +``` + +> Проверить, что переменная `$hb` (heartbeat) в области видимости в этом месте файла — если нет, повторить паттерн `->onSuccess/onFailure` соседних `Schedule::command(...)` в файле (использовать тот же способ, что у `scheduler:check-heartbeats`). + +- [ ] **Step 6: Проверить, что расписание валидно** + +Run: `cd app && php artisan schedule:list` +Expected: в списке есть `supplier:deadline-watch yellow` (20:00) и `red` (20:40). + +- [ ] **Step 7: Commit** + +```bash +git add app/app/Console/Commands/SupplierDeadlineWatchCommand.php app/routes/console.php app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php +git commit -m "feat(supplier): сторож дедлайна 20:00/20:40 — письмо если робот не успел" +``` + +--- + +## Task 8: Полный прогон + самопроверка + +- [ ] **Step 1: Прогнать все новые тесты вместе** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderVerifierTest.php tests/Unit/Supplier/SupplierOrderPlanTest.php tests/Feature/Supplier/VerifySupplierOrderJobTest.php tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` +Expected: PASS всё. + +- [ ] **Step 2: Стиль + статанализ по всему diff** + +Run: `cd app && composer pint && composer stan` +Expected: без ошибок. + +- [ ] **Step 3: Регрессия supplier-домена (не сломали робота)** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier` +Expected: PASS. + +- [ ] **Step 4: Финальный commit при необходимости** + +```bash +git add -A +git commit -m "test(supplier): зелёная регрессия проверки заказа + сторожа" || echo "нечего коммитить" +``` + +--- + +## Выкат (ОТДЕЛЬНО, только по явному «выкатываем» заказчика) + +НЕ входит в реализацию плана — отдельный шаг после ревью: + +1. `rls-reviewer` — ревью миграции `supplier_order_checks` (новая таблица; ПДн нет, но GRANT'ы под 5 ролей проверить). +2. `prod-deploy-validator` — GO/NO-GO. +3. Миграция на **живой Managed-кластер** через `crm_migrator` (не мёртвая VM-копия), из `main`. +4. Хирургический overlay новых файлов + `composer dump-autoload -o` + restart `php8.3-fpm` + `queue:restart` (воркер кэширует код — память `feedback-queue-worker-restart-after-change`). +5. Проверить `php artisan schedule:list` на боевом: два новых `supplier:deadline-watch`. +6. Smoke: дождаться ближайшего ночного прогона (или ручной `VerifySupplierOrderJob::dispatch()`) → строка в `supplier_order_checks` со `status=ok`. +``` diff --git a/docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md b/docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md new file mode 100644 index 00000000..98b89f80 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md @@ -0,0 +1,197 @@ +# Дизайн: итоговая проверка заказа у поставщика + сторож времени + +**Дата:** 2026-07-09 +**Статус:** утверждён заказчиком (устно, «го»), готов к плану реализации +**Автор:** Claude Code (сессия расследования «почему нет поставки») + +--- + +## 1. Контекст и проблема + +Робот `App\Jobs\Supplier\SyncSupplierProjectsJob` раз в сутки (18:05 МСК) переносит +активные проекты клиентов в кабинет поставщика `crm.lead.store` (заводит/обновляет +строки B1/B2/B3, гасит осиротевшие). «Успехом» он считает **HTTP 200 + `status:OK`** +на каждую команду — то есть «поставщик **принял** команду», а НЕ «поставщик **реально +применил** её у себя». + +Три дыры, вскрытые в расследовании 09.07.2026: + +1. **Нет итоговой сверки с живым кабинетом.** Дашбордовый `SupplyReconciliation` + сравнивает задуманное с **нашими же** записями `supplier_projects`, а не с живым + списком поставщика (`listProjects`). Расхождение «мы попросили — поставщик не сделал» + автоматически никто не ловит. +2. **Именно через это прошёл денежный баг 08.07.2026** (память + `project-omega-pause-not-sent-to-supplier-batch-2026-07-08`): команды «пауза» до + поставщика вообще не уходили (омега: 57 заказов, 0 команд) → проекты продолжали + собирать = деньги. Нашли **вручную**. +3. **Стоп-кран по времени молчит.** Константа `TIME_BUDGET_CUTOFF = '20:55'` обрывает + робота, но только пишет `Log::warning` + помечает запуск `aborted`. Заказчику + **письма нет**, для heartbeat-монитора обрыв выглядит как «успех» (исключения нет). + При росте числа клиентов последовательный прогон может **перескочить 21:00** — + дедлайн поставщика (изменения после 21:00 вступают в силу только на следующие сутки; + 22:00–00:00 правки запрещены) — и мы **не успеем заказать**. + +## 2. Цель и объём + +**В объёме:** + +- **Компонент 1 — «Итоговая проверка»:** после того как робот отчитался «готово», + перечитать живой кабинет поставщика и сверить **каждую нашу строку** с формулой; + при расхождении — письмо. +- **Компонент 2 — «Сторож времени»:** независимая проверка в 20:00 (🟡) и 20:40 (🔴); + если робот к этому времени не закончил — письмо. + +**Вне объёма (отдельная задача заказчика, НЕ проектируем сейчас):** + +- Распараллеливание прогона робота. Пороги 20:00/20:40 здесь — **только сигнал письмом**, + без авто-переключения на параллель. + +## 3. Формула заказа (эталон сверки) + +Источник истины — `App\Services\Supplier\SupplierQuotaAllocator` (чистые функции): + +``` +заказ_группы = max( наибольший daily_limit , ceil( Σ daily_limit / 3 ) ) +``` + +- `ceil(Σ/3)` — ёмкость шаринга (лид продаётся ≤3 клиентам); +- `max` — крупнейший клиент должен добрать своё. + +Далее `distributeForPlatform($order, $platforms)` делит заказ по B1/B2/B3 методом +largest-remainder так, что **Σ по площадкам == заказ** (площадки с долей 0 опускаются — +кабинет отклоняет `limit=0`). + +«Проверить по формуле» = пересчитать `computeOrder` + `distributeForPlatform` из того же +источника, что и робот: слепок `project_routing_snapshots` за завтра + группировка +`App\Services\Supplier\SupplierProjectGrouping` (resolvePlatforms / buildUniqueKeyAgnostic). + +## 4. Компонент 1 — «Итоговая проверка» + +### 4.1. Запуск + +Новый джоб `App\Jobs\Supplier\VerifySupplierOrderJob`. `SyncSupplierProjectsJob` +диспатчит его **в `finally`-блоке `handle()`** (после `recordRunSummary`) — чтобы +проверка шла всегда: и при штатном финише, и при обрыве по времени/ошибке. + +Исключение: если робот упал по `SupplierAuthException` (кабинет недоступен) — проверка +тоже не сможет прочитать `listProjects`; в этом случае она пишет статус +`unable_to_verify` (без ложных «расхождений») и шлёт письмо «проверка не смогла +достучаться до кабинета». + +### 4.2. Алгоритм + +1. **Задуманное состояние.** По слепку за завтра + формуле построить карту: + `intended[signal_type|identifier][platform] = limit` для активных групп, плюс + множество наших `supplier_projects` (`tag=_lidpotok`), которые должны быть **выключены** + (осиротевшие / `inactive_since IS NOT NULL`). +2. **Живое состояние.** `SupplierPortalClient::listProjects()` → фильтр по нашим строкам + (метка `_lidpotok`). Из каждой строки берём: `name`/`content` (identifier), префикс + B1/B2/B3, `type` (hosts/calls/sms → site/call/sms), `lim`, `status`. +3. **Сверка по строкам** и сбор расхождений `mismatches[]`: + - **missing** — задумали активную строку (platform+limit), у поставщика её нет; + - **limit_drift** — строка есть, но `lim ≠ intended`; + - **should_be_off** — наша строка помечена выключенной, а у поставщика `status`=ВКЛ; + - **should_be_on** — активная по формуле, а у поставщика ВЫКЛ; + - **orphan_extra** — у поставщика есть наша `_lidpotok`-строка, которой в задуманном нет. +4. **Защита от ложной тревоги (лаг применения).** Если `mismatches` не пусто — подождать + ~60–90 сек и перечитать `listProjects` ещё раз; в письмо идут только расхождения, + которые **остались** после повторного чтения. +5. **Итог.** Записать строку в `supplier_order_checks` (см. §6). Если остались расхождения + → письмо `SupplierOrderMismatchMail` со списком (identifier / платформа / задумали X / + у поставщика Y / тип расхождения). + +### 4.3. Переиспользование + +Логику «построить intended-карту из слепка» вынести в чистый сервис +`App\Services\Supplier\SupplierOrderPlan` — им пользуются и робот (косвенно, через ту же +формулу) и проверка. Саму сверку «intended vs live → mismatches[]» вынести в чистый +`App\Services\Supplier\SupplierOrderVerifier::diff($intended, $liveRows): array` — +тестируется изолированно, без сети и БД. + +## 5. Компонент 2 — «Сторож времени» + +Artisan-команда `supplier:deadline-watch {level : yellow|red}`. + +Расписание (`routes/console.php`), время МСК: + +``` +Schedule::command('supplier:deadline-watch yellow')->dailyAt('20:00')… +Schedule::command('supplier:deadline-watch red')->dailyAt('20:40')… +``` + +Логика: + +1. Взять последнюю строку `supplier_sync_runs` за **сегодня** (МСК). +2. **Закончил** = строка есть и `finished_at IS NOT NULL` со `status != 'aborted'`. +3. Если НЕ закончил (нет строки / нет finished_at / обрыв) → письмо: + - `yellow` → `SupplierDeadlineWarningMail(level: yellow)` «к 20:00 робот не закончил, + до 21:00 меньше часа»; + - `red` → `SupplierDeadlineWarningMail(level: red)` «к 20:40 не закончил, до 21:00 ~20 + минут, риск не успеть». +4. Если закончил → тихо (в норме робот финиширует за секунды в 18:05). + +Побочный плюс: ловит и «планировщик не запустил робота» (строки за сегодня нет вовсе). + +## 6. Хранение истории + +Новая маленькая таблица `supplier_order_checks` (миграция): + +| колонка | смысл | +|---|---| +| `id` | PK | +| `checked_at` | когда проверяли | +| `sync_run_id` | ссылка на `supplier_sync_runs` (nullable) | +| `intended_rows` | сколько строк задумано | +| `live_rows` | сколько наших строк у поставщика | +| `mismatch_count` | число расхождений после повторной проверки | +| `status` | `ok` / `mismatch` / `unable_to_verify` | +| `details` | JSONB со списком расхождений (для письма и разбора) | +| `created_at` | | + +## 7. Письма + +Три новых mailable, канал — существующий адрес критических писем поставщика +(`config('services.supplier.alert_email')` = `kdv1@bk.ru` + `ops@liderra.ru`): + +- `SupplierOrderMismatchMail` — список расхождений после итоговой проверки. +- `SupplierDeadlineWarningMail` (yellow/red) — робот не успевает к порогу. +- «unable_to_verify» — переиспользовать `SupplierDeadlineWarningMail` с отдельным поводом + или отдельный mailable (решить на этапе плана; не блокер). + +Тексты — простым русским, с конкретикой (сколько строк разошлось, до дедлайна X минут). + +## 8. Обработка ошибок / крайние случаи + +- **Кабинет недоступен при проверке** → `unable_to_verify`, письмо, НЕ ложные расхождения. +- **Лаг применения у поставщика** → повторное чтение через ~60–90 сек (§4.2 п.4). +- **Обрыв робота по 20:55** → сторож уже написал 🔴 в 20:40; дополнительно (мелкое + упрочнение, опционально) — заставить сам time-budget-обрыв слать письмо (сейчас молчит). +- **Гонка «проверка запустилась, а FlushDeferredOnlineSyncJob что-то дописал»** — проверка + сверяет состояние на момент чтения; расхождения из-за отложенных онлайн-правок в окне + 18:00→00:00 — ожидаемы, поэтому проверку привязываем к завершению именно + `SyncSupplierProjectsJob`, а не к произвольному моменту. + +## 9. Тестирование (Pest) + +- **Unit** `SupplierOrderVerifier::diff` — таблица случаев: совпадение, limit_drift, + should_be_off, should_be_on, missing, orphan_extra. +- **Unit** `SupplierOrderPlan` — из фикстуры слепка строит корректную intended-карту + (в т.ч. деление по площадкам, «Вся РФ», дни недели). +- **Feature** `VerifySupplierOrderJob` — с подставным `SupplierPortalClient` (Fake): + при расхождении шлёт письмо (`Mail::fake`), при совпадении — нет; повторное чтение + гасит транзиентное расхождение. +- **Feature** `supplier:deadline-watch` — есть завершённый прогон сегодня → тихо; + нет / не завершён / aborted → шлёт письмо нужного уровня. + +## 10. Выкат + +- Миграция `supplier_order_checks` — на **живой Managed-кластер** (не мёртвая VM-копия), + из `main`, через `crm_migrator`. +- Два новых расписания + диспатч проверки из робота. +- Только с явного «выкатываем» заказчика; предварительно — `prod-deploy-validator` + (GO/NO-GO) и `rls-reviewer` при правке схемы. + +## 11. Открытые вопросы + +Нет блокирующих. Мелочи (отдельный mailable для `unable_to_verify` vs переиспользование; +слать ли письмо на сам 20:55-обрыв) — решаются на этапе плана, дизайн не меняют.