docs(supplier): спека + план итоговой проверки заказа и сторожа дедлайна
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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-сессий.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+197
@@ -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-обрыв) — решаются на этапе плана, дизайн не меняют.
|
||||
Reference in New Issue
Block a user