docs(supplier): спека + план итоговой проверки заказа и сторожа дедлайна

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-10 04:43:43 +03:00
parent 4842c728c8
commit da7dc55add
3 changed files with 1286 additions and 3 deletions
+3 -3
View File
@@ -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-сессий.
@@ -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-обрыв) — решаются на этапе плана, дизайн не меняют.