# Дизайн: имитация работы портала глазами клиента — ФАЗА 1 (репетиция у себя)

**Дата:** 03.06.2026
**Статус:** design (черновик на согласовании, ревизия 2 — выверен по боевому коду `origin/main`)
**Автор:** brainstorm-сессия с владельцем (Дмитрий)
**Ветка:** `worktree-prod-imitation-clients`

---

## 1. Зачем это нужно (простыми словами)

Хотим посмотреть на портал **глазами клиента** — будто наши клиенты зарегистрировались
и начали работать: создают проекты, на них льются заявки, портал их раздаёт, списывает
деньги, ведёт отчёты. Цель — убедиться, что весь рабочий цикл портала ведёт себя правильно
во всех значимых ситуациях, и поймать ошибки **до** того, как они проявятся на реальных
клиентах и реальных деньгах.

## 2. Общая схема: две фазы (контекст)

- **Фаза 1 — «репетиция у себя» (этот документ).** На точной копии портала сами создаём
  клиентов, сами шлём придуманные заявки, сами создаём нужные ситуации. Деньги и сервис
  определения региона (DaData) — заменены (локальные начисления / подставной клиент), чтобы
  ошибка ничего не стоила и не пачкала боевое. Задача — выловить логические ошибки.
- **Фаза 2 — «вживую» (отдельный документ, позже).** Боевой портал, 5-6 тестовых клиентов,
  неделя, реальные заявки на отдельно выделенные источники, расписание + сверка. **Не входит.**

## 3. Граница Фазы 1

**Входит:** копия портала + сверка с боевым; тестовый стенд; полный прогон проверок (§7) на
всех значимых ситуациях (§6); поиск ошибок → починка → перепрогон.

**Не входит (осознанно):**
- Фаза 2 (боевой недельный прогон).
- Аспекты, которые на Windows-копии не воспроизвести: `pg_audit`, `pg_anonymizer`
  (маскирование/аудит-журнал БД), пулер PgBouncer — только Фаза 2.
- Реальные внешние вызовы (платный DaData, реальные деньги) — заменяются.
- **Исходящая доставка лида клиенту по webhook НЕ проверяется как внешний вызов** — по факту
  кода `OutboundWebhookSubscription` — это только настройка, в пути доставки лида она НЕ
  задействована (push не реализован). Клиент видит лиды в CRM / через API. Внешнего исходящего
  вызова мокать не нужно.
- **CSV-импорт лидов клиентом** (`ImportController`/`ImportLeadsJob`) — отдельный вход, **не в
  Фазе 1** (тестовые клиенты — покупатели лидов, не импортёры). CSV-сверка поставщика
  (`CsvReconcileJob`) и CSV-merge в доставке — проверяем (см. §7), сам импорт клиентом — нет.

## 4. Среда Фазы 1 и сверка «копия = боевой» (Шаг 0)

Смысл Фазы 1 — что ошибка на копии есть и на боевом. Поэтому до прогонов сверяем:

| Что сверяем | Как / условие |
|---|---|
| **Стартовая точка кода** | копия на коммите, идентичном задеплоенному на прод. Регион-фича влита в `origin/main` (каскад + взвешенный жребий + резолвер) — копию поднимаем на ней. Точный прод-коммит подтверждается перед стартом (OQ-1). |
| **Схема БД** | таблицы / индексы / RLS / функции / триггеры / партиции идентичны. |
| **Роли БД** | локально создаём те же 5 ролей (`db/00_create_roles.sql`) — доступ через `pgsql_supplier` и RLS должны вести себя как на проде. |
| **Справочники** | реестр Россвязи (`phone_ranges`, нормализованные регионы), тарифные ступени, карта регионов (89 субъектов), сидовые поставщики (`b1`/`b2`/`b3`/`direct`). |
| **Настройки** | расписание cron, тайминг слепка 18:00/21:00 МСК. |

**Замены внешних зависимостей в Фазе 1:**
- **DaData (определение региона).** По факту кода `LeadRegionResolver`: каскад
  DaData → Россвязь → тег, под флагом `services.dadata.enabled`. Заменяем **подставным
  `DaDataPhoneClient`** (биндим в контейнер, отдаёт заранее заданные `DaDataPhoneResponse` —
  qc/регион/оператор по номеру), флаг `enabled=true`. Это гоняет **все ветки каскада**
  (qc 0/3 маппится → dadata; qc 0/3 ambiguous/не-маппится → Россвязь; qc 1 / таймаут / 5xx →
  Россвязь; qc 2/7 → tag) детерминированно и бесплатно. Для ветки «Россвязь» сеем `phone_ranges`.
- **Деньги.** Баланс начисляем сами (админ-функция). Списания идут по-настоящему по коду
  (тарифные ступени, bcmath, обе заморозки), но это локальные цифры — реальных рублей нет.

**Прерывание-критично (Шаг 0, найдено при выверке):** маршрутизатор берёт ВСЁ из таблицы
снапшота `project_routing_snapshots` за активную дату. **Нет снапшота → ни одна заявка никуда
не уйдёт** (только лог ошибки `lead_router.no_snapshot_for_active_date`). Поэтому setup Фазы 1
ОБЯЗАН сгенерировать снапшот (`SnapshotProjectRoutingJob` / `SnapshotBackfillCommand` /
`SnapshotRebuildCommand`) после создания проектов и после каждой смены настроек. Слепок-инвариант:
смена настройки сегодня → эффект со следующего снапшота (18:00/21:00 МСК), поэтому Фаза 1 должна
уметь **двигать время / пересобирать снапшот**, иначе сценарии со сменой настроек не отработают
за один прогон.

**Известные неустранимые расхождения копии:** `pg_audit`, `pg_anonymizer`, PgBouncer — на
Windows-копии не ставятся, уходят в Фазу 2.

## 5. Тестовый стенд (инструменты имитации)

1. **Сеялка** — тестовые клиенты (тенанты) + пользователи + проекты по матрице (§6.1) +
   расстановка по топологиям (§6.3) + начисление баланса.
2. **Генерация снапшота** — обёртка над `SnapshotProjectRoutingJob`/backfill/rebuild; вызывается
   после сеялки и после смены настроек; умеет «активную дату» (слепок-инвариант).
3. **«Пушка заявок»** — отправляет придуманные заявки на вход портала (тот же webhook-endpoint
   поставщика) с управляемыми полями: сигнал (сайт/телефон), источник (B1/B2/B3/DIRECT), телефон,
   тег, `vid`, `time`.
4. **Подставной `DaDataPhoneClient`** — биндится в контейнер, возвращает заданный
   `DaDataPhoneResponse` (qc/регион/оператор) по номеру → детерминированный регион-каскад.
5. **«Рычаги условий»** — приводят клиентов в нужное состояние: начислить/обнулить баланс;
   добить `delivered_today` до лимита; поставить проект на паузу (`is_active=false`);
   **заморозить тенанта по балансу** (`frozen_by_balance_at`); сменить регионы/дни; задать тариф.

Все инструменты — тестовые/служебные команды, боевое не трогают.

## 6. Какие ситуации прогоняем (полное покрытие)

Покрытие в Фазе 1 — **максимальное** (безопасно и бесплатно).

### 6.1. Матрица одиночного проекта

Оси (по форме создания проекта; СМС исключена решением владельца):

| Ось | Значения |
|---|---|
| Сигнал | сайт / телефон (2) |
| Регион | вся РФ / один субъект / несколько субъектов (3) |
| Дни доставки | все 7 / частично (2) |
| Дневной лимит | низкий / средний / высокий (3) |

Полный перебор = **2 × 3 × 2 × 3 = 36** одиночных проектов.

### 6.2. Сценарии конкуренции (один источник делят несколько клиентов) — главное

| | Сценарий | Что проверяем | Механика портала |
|---|---|---|---|
| **A** | один источник → 4-5 клиентов, разные объёмы, один регион | деление по остатку лимита; мелкого не отрезают | **взвешенный жребий** (вес = остаток, ≥ 1), cap = 3 |
| **B** | один источник → точные регионы + клиент «вся РФ» | региону — точному (фаза 1), остаток — на «вся РФ» (фаза 2) | каскад фазы 1→2 |
| **C** | один источник → каждому свой регион | каждому только его заявки (фаза 1 по своему субъекту) | каскад фаза 1 |
| **D** | один источник → часть клиентов сегодня не работает | делят только активные сегодня | фильтр дней в снапшоте |
| **E1** | один источник → у клиента кончился баланс на доставке | проект → пауза (`is_active=false`), письмо 1/час, заявка идёт следующему | auto-pause на `InsufficientBalance` |
| **E2** | один источник → клиент заморожен по балансу (`frozen_by_balance_at`) | заморожённый исключён из подбора ещё на этапе фильтра | отдельный механизм заморозки |
| **F** | один источник → клиент упёрся в дневной лимит | выбывание, остаток другим | `delivered_today ≥ snapshot.daily_limit` |
| **G3** | один источник → все три фазы пусты (никто не подошёл) | «осиротевшая» заявка: никому, портал не падает, не списывает; видно ли её | пустой каскад (фаза 3 тоже пуста) |

### 6.3. Топологии

| | Топология |
|---|---|
| **G1** | один клиент сидит на нескольких источниках сразу |
| **G2** | паутина: много клиентов ↔ много источников одновременно |
| **G4** | один клиент держит 2 проекта на одном источнике с разными регионами |

### 6.4. Особые заявки (своя придуманная заявка)

| | Случай | Что проверяем |
|---|---|---|
| **G5a** | DaData вернул мусор/иностранца (qc 2/7) | каскад уходит сразу в tag |
| **G5b** | DaData недоступен/таймаут/qc 1 | каскад деградирует на Россвязь (по `phone_ranges`) |
| **G5c** | ни DaData, ни Россвязь не дали код | tag-fallback; пустой тег → `unknown` |
| **G6** | одна и та же заявка дважды (один `vid`) | защита от дублей (200 «already_processed», без второй сделки) |

### 6.5. Расширения (идеи владельца, добавлены в покрытие)

| | Проверка |
|---|---|
| **X1** | **Подмена региона на шаге 3 глазами клиента:** при запасном канале сделке ставят регион клиента (`subject_code` подменён), но «Город» в карточке = НАСТОЯЩИЙ регион лида, а настоящий субъект — в `lead_region_resolution_log.actual_subject_code` + флаг подмены. Проверяем, что клиент видит правильный город и подмена зафиксирована в журнале. |
| **X2** | **Статистика взвешенного жребия:** прогнать много заявок на сценарий A и убедиться, что доли получателей близки к долям остатков лимита, а мелкий клиент получает > 0. |
| **X3** | **Сводка по источнику региона:** сколько лидов определилось через dadata / rossvyaz / tag / unknown (поле `region_source` + журнал). |
| **X4** | **Граница месяца (опц.):** тариф зависит от `delivered_in_month`; проверить смену тарифной ступени при переходе через границу месяца. *(на усмотрение — может быть перебор для Фазы 1.)* |

## 7. Полный список проверок поведения (выверен по боевому коду `origin/main`)

Источники: `SupplierWebhookController`, `RouteSupplierLeadJob`, `LeadRegionResolver`,
`LeadRouter`, `LeadDistributor`, `LedgerService`.

### Этап 0. Приём заявки (`SupplierWebhookController`)
1. Неверный секрет → 404. 2. IP вне белого списка → 404 (на проде пустой = режем всех; на копии — пускаем). 3. Флуд > 600/мин с IP → 429. 4. `time` за пределами ±24 ч → отклонить (защита партиции). 5. Телефон не `7XXXXXXXXXX` → 422. 6. Повтор по `vid` → 200 «already_processed». 7. Проект без `B1/B2/B3` → DIRECT. 8. Запись в `webhook_log` на каждый исход.

### Этап 1. Определение региона лида (`LeadRegionResolver`) — НОВОЕ
9. Флаг `services.dadata.enabled=false` → сразу tag (старое поведение).
10. Уже резолвили на прошлой попытке (`resolved_subject_code`/`region_source` есть) → без повторного DaData (идемпотентность, защита от двойной оплаты).
11. qc 0/3 + регион маппится и не ambiguous → `source=dadata`.
12. qc 0/3 + ambiguous/не-маппится → Россвязь (оператор от DaData сохраняем).
13. qc 1 / таймаут / 5xx / бюджет исчерпан → Россвязь.
14. qc 2/7 → сразу tag.
15. Россвязь нашла префикс → `source=rossvyaz`; не нашла → tag; пустой тег → `unknown`.
16. На лид пишутся `resolved_subject_code`, `region_source`, `dadata_qc`, `phone_operator`.
17. Кэш по sha256(phone) — повтор того же номера не ходит в DaData (`cache_hit`).

### Этап 2. Подбор получателей (`LeadRouter` каскад + взвешенный жребий) — ПЕРЕПИСАНО
18. Берутся только проекты из снапшота активной даты: `delivered_today < snapshot.daily_limit`, баланс > 0, `frozen_by_balance_at IS NULL`, подписан на источник; один проект на клиента (наибольший остаток).
19. **Фаза 1** — точное совпадение субъекта (`resolved_subject_code = ANY(snap.regions)`), только если резолвер дал код. Помечается `routing_step=1`.
20. **Фаза 2** — «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов, исключая уже выбранных клиентов. `routing_step=2`.
21. **Фаза 3** — запасной канал (без фильтра региона), только если фазы 1+2 пусты. `routing_step=3`.
22. **Взвешенный жребий** внутри фазы при кандидатах > cap: шанс ∝ остатку лимита, **вес ≥ 1** (мелкий клиент не отрезан); cap = 3 (лид максимум 3 разным клиентам). Детерминизм в тестах через сид Mt19937.
23. Снапшота на активную дату нет → лог ошибки. 24. Все три фазы пусты → заявка никому (G3).

### Этап 3. Доставка каждому выбранному (транзакция под блокировками)
25. Проект на паузе после слепка (`is_active=false` под локом) → не доставляем. 26. `delivered_today ≥ snapshot.daily_limit` под локом → пропуск. 27. CSV-догон: webhook после CSV-восстановленной сделки → объединяем без повторного списания; **если источник webhook достовернее тега (dadata/rossvyaz) — обновляем регион/оператора/город сделки**. 28. Одна доставка одному клиенту строго один раз. 29. Создание сделки (статус «new», `phones[]`).
30. **Подмена региона на шаге 3:** `routing_step<3` → `subject_code` = настоящий резолв; `routing_step=3` → `subject_code` подменяется на регион клиента, а `city` = имя НАСТОЯЩЕГО региона; настоящий субъект → в журнал (`actual_subject_code`).

### Этап 4. Деньги (`LedgerService`, always-rub) + две заморозки
31. Цена по тарифной ступени = `delivered_in_month + 1`. 32. **Заморозка 1:** `frozen_by_balance_at` → отказ списания → auto-pause (та же ветка, что недостаток баланса). 33. bcmath: `balance_rub×100 ≥ цена`, иначе отказ — без потери копеек. 34. Списание `balance_rub -= цена`, `delivered_in_month++`. 35. Записи в `lead_charges` / `balance_transactions` / `supplier_lead_costs`. 36. **Заморозка 2 (auto-pause):** недостаток баланса на доставке → проект `is_active=false` (через BYPASSRLS) + письмо «нулевой баланс» (1/час/клиент) + переход к следующему клиенту.

### Этап 5. Счётчики, аудит, уведомления, журнал региона
37. `delivered_today++`, `delivered_in_month++`, `snapshot.delivered_count++`. 38. `ActivityLog` (создание сделки). 39. Аудит ПДн (152-ФЗ, `PdAuditLogger`). 40. Уведомление клиенту + колокольчик. 41. **Журнал региона** `lead_region_resolution_log` — одна строка на лид (`subject_code_resolved`, `subject_code_from_tag`, `region_source`, `dadata_qc`, `rossvyaz_matched`, `actual_subject_code`, `substituted_subject_code`, `routing_step`, `cache_hit`, маскированный телефон); **fail-safe** — сбой журнала НЕ роняет доставку.

### Этап 6. Падения и шторма
42. Все выбранные упали → исключение → 3 попытки → `failed_webhook_jobs`. 43. Заявка удалена/уже обработана/терминальная ошибка → без шторма повторов.

### Этап 7. Естественный цикл (наблюдаем; время форсим)
44. Слепок: смена настроек → эффект со следующего снапшота. 45. Сброс `delivered_today` в 00:00 МСК. 46. `CsvReconcileJob` (ежечасно): дрейф > 5% → алерт. 47. Клиентский интерфейс: регистрация/2FA, проекты, лента сделок, смена статуса, экспорт CSV/XLSX, напоминания, баланс, тарифы.

## 8. Критерий успеха Фазы 1

- Каждый пункт §7 на ситуациях §6 ведёт себя как ожидается (фиксируем «ожидали / получили»).
- Найденные ошибки задокументированы, починены, прогон повторён до чистоты.
- Регрессия проекта зелёная (Pest, Vitest, сборка).
- Понятный отчёт: что проверили, что нашли, что починили.

## 9. Открытые вопросы

- **OQ-1.** Точный прод-коммит для сверки (Шаг 0) подтверждается перед стартом.
- **OQ-2.** Где поднимать копию: native-Windows (быстро, без расширений/пулера — принято для логики) или Linux-копия ближе к проду.
- **OQ-3.** Объём «пушки заявок» на сценарий (особенно X2 — статистика жребия) — уточняется в плане.
- **OQ-4.** Подставной DaData: задаём ответы кодом (фабрика сценариев) — формат фикстур уточняется в плане.

## 10. Что дальше

После согласования — подробный **план работ** (`superpowers:writing-plans`).
