# Runbook — Имитация портала, Фаза 1 (репетиция у себя)

**Дата:** 03–04.06.2026. **Ветка:** `worktree-prod-imitation-clients` (база `origin/main` `bd7b1d3e`).
**Спек:** `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
**План:** `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md`

Цель Фазы 1 — посмотреть на портал глазами клиента на копии (= боевой код) и поймать
логические ошибки до Фазы 2. Деньги — локальные, DaData — подставная/выключенная.

---

## 1. Сверка «копия = боевой» (Шаг 0)

| Что | Как проверить |
|---|---|
| Код | Копия на `origin/main` + ветка `worktree-prod-imitation-clients`. Региональная фича влита. |
| Схема БД | `db/schema.sql` (полная текущая) грузится миграцией `0001`; дельта-миграции идемпотентны. |
| Роли БД | На dev — `postgres` superuser; `pgsql_supplier` фоллбэчит на него (RLS/BYPASSRLS как на проде). |
| Справочники | `pricing_tiers` (7 ступеней, `PricingTierSeeder`), `phone_ranges` (по требованию), поставщики b1/b2/b3/direct (миграции). |
| Тест-БД | `liderra_testing` пересобирается `migrate:fresh` (см. ниже). |

### Пересборка тест-БД (если нужна чистая)

```bash
cd app
DB_DATABASE=liderra_testing DB_USERNAME=postgres DB_PASSWORD=liderra_dev_pass \
  php artisan migrate:fresh --force
```

> **NB.** `migrate:fresh` чинится тремя вещами (коммит `22f6178b`): `MonthlyPartitionManager::ensureMonth`
> пропускает партиционированную таблицу без существующего родителя; миграция `0001` идёт с
> `$withinTransaction=false` (DDL коммитится до `partitions:create-months`); дельта-миграции
> `add_balance_freeze` / `add_paused_at` идемпотентны (`DROP POLICY IF EXISTS` / проверки колонки/индекса).
> **НИКОГДА не запускать `migrate:fresh` посреди работы субагентов** — это общая тест-БД.

---

## 2. Наполнить живой локальный портал (`imitation:seed`)

```bash
cd app
php artisan imitation:seed --leads=20 --clients=3
```

Команда (app-namespace, **запрещена на production**): создаёт несколько профинансированных
тестовых клиентов на общем источнике B2, выключает DaData (регион берётся из тега), пересобирает
снапшот за активную дату и инжектирует синтетические заявки через **настоящий**
`RouteSupplierLeadJob` — появляются сделки, списания, уведомления, как в проде.

Опции: `--leads=N` (число заявок), `--clients=N` (число клиентов).

---

## 3. Ручной проход глазами клиента (UI)

1. Логин / 2FA.
2. Проекты — список, карточка проекта.
3. Лента сделок — новые сделки (`status='new'`), карточка сделки (телефон, **Город** = настоящий
   регион лида даже при подмене региона на шаге 3).
4. Смена статуса сделки.
5. Экспорт CSV / XLSX.
6. Баланс — текущий остаток, история списаний (`balance_transactions`).
7. Тарифы — текущая ступень (по `delivered_in_month`).
8. Колокольчик — уведомление о новой сделке; при нулевом балансе — письмо «нулевой баланс».

---

## 4. Наблюдение естественного цикла (время форсим)

- **Слепок:** смена настройки сегодня → эффект со следующего снапшота. Форсить:
  `php artisan snapshot:rebuild --date=<активная_дата>` (DELETE+INSERT).
- **Сброс `delivered_today`** в 00:00 МСК — обнуление дневных счётчиков.
- **`CsvReconcileJob`** (ежечасно) — дрейф > 5% → алерт.

---

## 5. Шаблон отчёта «ожидали / получили / находки»

| Сценарий | Ожидали | Получили | Вывод |
|---|---|---|---|
| … | … | … | OK / находка |

---

## 6. Результаты Фазы 1 (заполнено)

### Автоматический набор сценариев — `php artisan test --group=imitation`

**Все 54 теста / 194 assertions — GREEN** (36с, изоляция через `DatabaseTransactions`).
Покрытие по плану:

| Группа | Файл | Результат |
|---|---|---|
| Кирпичи | FakeDaData / LeadInjector / SnapshotForge / ConditionLevers / Seeder | GREEN |
| Регион-каскад резолвера (§7 эт.1) | `RegionResolverCascadeTest` (11) | GREEN |
| A — взвешенный жребий + X2 | `ScenarioA_WeightedLotteryTest` | P0(300)→76% / P3,P4(3)→3 каждый (мелкий не отрезан) |
| B/C — каскад по региону | `ScenarioBC_RegionCascadeTest` (4) | exact step1 + all-RF step2; изоляция по регионам |
| D — дни доставки | `ScenarioD_DeliveryDaysTest` | неактивный сегодня отсутствует в снапшоте |
| E1/E2/F — заморозки + лимит | `ScenarioEF_FreezeLimitTest` (3) | auto-pause на InsufficientBalance; заморозка/лимит — фильтр роутера |
| G3 — осиротевшая заявка | `ScenarioG3_OrphanLeadTest` | оседает в `supplier_leads` (`processed_at`, `deals_created_count=0`, `error=NULL`) |
| G5/G6 — особые + дубли | `ScenarioG5G6_SpecialLeadsTest` (9) | qc→source; дедуп vid → 200 already_processed |
| X1/X3 — подмена + журнал | `ScenarioX1X3_SubstitutionJournalTest` (2) | step-3 подмена subject_code, Город=настоящий регион, журнал actual/substituted |
| Топологии + деньги + приём | `TopologyMoneyIntakeTest` (11) | G1/G2/G4 без утечек; деньги bcmath без копеек; приём 404/422/429 |
| Живая команда | `ImitationSeedCommandTest` | exit 0 + сделки создаются |

### Находки (главная ценность Фазы 1)

- **F1 (инфраструктура, починено `4dfcde99`):** `ImitationTestCase::seedPhoneRange()` использовал
  несуществующие колонки (`range_from/range_to/region_name`) и не заполнял FK `import_id` →
  любой Россвязь-тест падал. Исправлено на реальные колонки `from_num/to_num/...` + anchor-импорт.
- **F2/F3 (план vs реальность, кода не трогали):** план §6.4/§7 говорит «qc=2/7 / флаг off → `tag`»,
  по факту резолвер возвращает `unknown`, если тег пустой (`tag` — только когда тег = валидный
  регион). Россвязь при qc=2/7 не зовётся — это верно. Тесты утверждают реальное поведение.
- **Денежная корректность (главная ставка) — чиста:** `lead_charges` (ступень/цена/`charge_source='rub'`),
  `balance_transactions` (минус + остаток), `supplier_lead_costs`; списание bcmath без потери копеек;
  цена по `delivered_in_month+1`; tier-граница (100 → ступень 2). Подтверждено `TopologyMoneyIntakeTest`.
- **Подмена региона на шаге 3 — корректна:** `deals.subject_code` = регион клиента, `deals.city` =
  имя НАСТОЯЩЕГО региона лида, журнал `actual_subject_code`/`substituted_subject_code`, `routing_step=3`.
- **Осиротевший лид «невидим»:** ищется только запросом
  `supplier_leads WHERE deals_created_count=0 AND processed_at IS NOT NULL AND error IS NULL`
  (не в `failed_webhook_jobs`). Стоит иметь админ-вид «непроданные лиды».
- **Инжект через payload:** `RouteSupplierLeadJob` ре-резолвит supplier из `raw_payload['project']`
  по `(platform, unique_key)` (`parseProjectField`→`resolveOrStub`); `unique_key` должен быть
  доменом, чтобы распарситься как `site`. Учтено в `LeadInjector` и `imitation:seed`.
- **Worktree-env (не код):** `app/.env` в этом worktree содержит APP_KEY неверной длины →
  существующий `SupplierWebhookTest` и шифрование падают; тесты, которым нужно шифрование, чинят
  ключ в `beforeEach`. Это проблема окружения копии, не прода.

### Проектная регрессия (имитация ничего не сломала)

- `composer test` (single-process): 22 падения — **пре-существующее single-process загрязнение**,
  не из имитации. Подтверждено: `ProjectExtensionsTest` 6/6 и `SupplierProjectTest` 6/6 **в изоляции**
  зелёные; `IncidentsWatchFailures` — задокументированный polluter (CLAUDE.md). Малые count'ы (4/5),
  не 36+ → имитационные тесты не текут (rollback `DatabaseTransactions` работает).
- `pest --parallel` (CI-режим, где загрязнение исчезает) — **в этом Windows-worktree падает на
  bootstrap paratest** (container error, env-проблема). На CI/Linux — штатный режим.
- Прод-правки имитации (clean migrate:fresh fix) verified: `MonthlyPartitionManagerTest` 15/15 +
  `migrate:fresh` проходит полностью.
- Фронтенд имитацией не затронут.

---

## 7. Хвосты (открыты, не блокируют Фазу 1)

- Чистка bootstrap-`beforeEach` в `app/tests/Feature/Imitation/SeederTest.php` (теперь no-op после
  восстановления БД — можно упростить до обычного паттерна).
- Удаление пароля БД из `app/phpunit.xml` (вынести в `.env.testing`; ⚠️ `.env.testing` грузится
  Laravel ВМЕСТО `.env` → нужен полный env, а не только DB-creds; проверить fallback на untracked `.env`).
- Прод-выкатка региональной фичи / Фаза 2 — отдельно.
