docs(imitation): phase 1 client-imitation spec + implementation plan

This commit is contained in:
Дмитрий
2026-06-03 14:52:08 +03:00
parent bd7b1d3e0f
commit dee4a0e1a2
2 changed files with 656 additions and 0 deletions
@@ -0,0 +1,445 @@
# Phase 1 — Portal Client Imitation Implementation Plan
> **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:** Построить безопасный стенд имитации работы портала глазами клиента на копии (= боевой код) и прогнать на нём все значимые ситуации, чтобы поймать логические ошибки до Фазы 2.
**Architecture:** Общие «кирпичи» (подставной DaData-клиент, инъектор заявок, генератор снапшота, рычаги условий, сеялка клиентов/проектов) используются в ДВУХ дорожках: (1) автоматический Pest-набор сценариев с жёсткими проверками поведения; (2) сеялка для наполнения живого локального портала, чтобы владелец смотрел «глазами клиента». Ничего боевого не трогаем; деньги и DaData — локальные/подставные.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16 (5 ролей + RLS) / Redis (Memurai). Зависимости из боевого пути: `RouteSupplierLeadJob`, `LeadRegionResolver`, `LeadRouter`, `LedgerService`, `SnapshotProjectRoutingJob`, `SupplierWebhookController`.
**Спек:** `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
---
## Структура файлов (что создаём / трогаем)
Создаём (всё под тестовый/служебный неймспейс, прод-код НЕ меняем):
- `app/database/seeders/Imitation/ImitationClientsSeeder.php` — сеялка тестовых клиентов/проектов (§6.1, §6.3).
- `app/app/Support/Imitation/FakeDaDataPhoneClient.php` — подставной DaData-клиент (детерминированные ответы по номеру).
- `app/app/Support/Imitation/LeadInjector.php` — инъектор синтетических заявок (через webhook-endpoint).
- `app/app/Support/Imitation/ConditionLevers.php` — рычаги: баланс, лимит, пауза, заморозка, регионы, дни.
- `app/app/Support/Imitation/SnapshotForge.php` — обёртка генерации снапшота (+ форс активной даты).
- `app/app/Console/Commands/Imitation/ImitationSeedCommand.php` — наполнить живой локальный портал для UI-осмотра.
- `app/tests/Feature/Imitation/*.php` — Pest-набор сценариев (по задаче на группу).
- `docs/superpowers/runbooks/2026-06-03-phase1-imitation-runbook.md` — ручной UI-проход + наблюдение естественного цикла + шаблон отчёта.
> **NB по среде:** все Pest-тесты Фазы 1 живут в группе `@group imitation` и НЕ входят в обычный `composer test` (иначе засорят регрессию). Гоняются явно: `php artisan test --group=imitation`.
---
## Task 0: Разведка точных сигнатур (investigation, без кода)
Прежде чем писать «кирпичи», прочитать и зафиксировать точные интерфейсы — чтобы не угадывать.
- [ ] **Step 1: Прочитать DaData-слой**
Read: `app/app/Services/DaData/DaDataPhoneClient.php`, `app/app/Services/DaData/DaDataPhoneResponse.php`, `app/app/Services/DaData/DaDataBudgetGuard.php`.
Зафиксировать: интерфейс/класс `DaDataPhoneClient` (метод `cleanPhone(string): DaDataPhoneResponse`), поля `DaDataPhoneResponse` (`qc`, `region`, `provider`, `raw`).
- [ ] **Step 2: Прочитать резолвер Россвязи + DTO**
Read: `app/app/Services/RossvyazPrefixLookup.php`, `app/app/Services/Dto/RegionResolution.php`, `app/app/Support/DaDataRegionMap.php`, `app/app/Support/RussianRegions.php`.
Зафиксировать: `RossvyazPrefixLookup::find(string $phone)``?RossvyazRecord`; маппинг кодов субъектов.
- [ ] **Step 3: Прочитать фабрики и снапшот-команды**
Read: `app/database/factories/{TenantFactory,UserFactory,ProjectFactory,SupplierProjectFactory}.php`, `app/app/Jobs/SnapshotProjectRoutingJob.php`, `app/app/Console/Commands/SnapshotBackfillCommand.php`.
Зафиксировать: какие поля обязательны у фабрик; как именно запускается генерация снапшота (job dispatch vs artisan); схема `project_routing_snapshots` (колонки `snapshot_date`, `project_id`, `tenant_id`, `daily_limit`, `regions`, `signal_type`, `signal_identifier`, `delivered_count`).
- [ ] **Step 4: Прочитать схему ключевых таблиц**
Read (grep в `db/schema.sql`): `projects`, `tenants`, `supplier_projects`, `project_supplier_links`, `deals`, `lead_charges`, `balance_transactions`, `supplier_lead_costs`, `lead_region_resolution_log`, `phone_ranges`, `pricing_tiers`, `suppliers`, `system_settings`.
Зафиксировать обязательные колонки/CHECK (особенно `chk_supplier_projects_b1_not_for_sms`, `frozen_by_balance_at`, `regions int[]`, `subject_code`, `city`).
- [ ] **Step 5: Зафиксировать находки** в комментарии-шапке `app/app/Support/Imitation/README.md` (создать) — список подтверждённых сигнатур, на которые опираются последующие задачи. Коммит:
```
git add app/app/Support/Imitation/README.md
git commit -m "docs(imitation): pin verified signatures for phase 1 harness"
```
---
## Task 1: Подставной DaData-клиент (детерминированный регион)
**Files:**
- Create: `app/app/Support/Imitation/FakeDaDataPhoneClient.php`
- Test: `app/tests/Feature/Imitation/FakeDaDataClientTest.php`
- [ ] **Step 1: Написать падающий тест**
Тест: связываем `FakeDaDataPhoneClient` как `DaDataPhoneClient` в контейнере, прогоняем `LeadRegionResolver::resolve()` на лиде с номером, для которого фейк отдаёт qc=0 + region='Москва', и проверяем `RegionResolution->source === 'dadata'` и `subjectCode === 77`.
```php
it('resolves dadata branch via fake client', function () {
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient)->stub('79990000077', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(\App\Services\DaData\DaDataPhoneClient::class, $fake);
$lead = SupplierLead::factory()->create(['phone' => '79990000077', 'raw_payload' => ['tag' => '']]);
$res = app(\App\Services\LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('dadata');
expect($res->subjectCode)->toBe(77);
})->group('imitation');
```
- [ ] **Step 2: Прогнать — убедиться, что падает**
Run: `php artisan test --filter=FakeDaDataClientTest`
Expected: FAIL (класс `FakeDaDataPhoneClient` не существует).
- [ ] **Step 3: Реализовать фейк** (сигнатуру `cleanPhone` и поля ответа взять из Task 0 Step 1)
```php
final class FakeDaDataPhoneClient extends \App\Services\DaData\DaDataPhoneClient
{
/** @var array<string, \App\Services\DaData\DaDataPhoneResponse> */
private array $byPhone = [];
public function stub(string $phone, int $qc, ?string $region = null, ?string $provider = null): self
{
$this->byPhone[$phone] = new \App\Services\DaData\DaDataPhoneResponse(
qc: $qc, region: $region, provider: $provider,
raw: ['phone' => $phone, 'qc' => $qc, 'region' => $region, 'provider' => $provider],
);
return $this;
}
public function cleanPhone(string $phone): \App\Services\DaData\DaDataPhoneResponse
{
return $this->byPhone[$phone]
?? throw new \App\Services\DaData\DaDataException("no stub for {$phone}");
}
}
```
> Если `DaDataPhoneClient` — `final` или его конструктор требует аргументы (узнать в Task 0): сделать фейк через общий интерфейс или `Mockery`, а не `extends`. Точную форму подтвердить чтением.
- [ ] **Step 4: Прогнать — убедиться, что проходит**
Run: `php artisan test --filter=FakeDaDataClientTest`
Expected: PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Support/Imitation/FakeDaDataPhoneClient.php app/tests/Feature/Imitation/FakeDaDataClientTest.php
git commit -m "feat(imitation): deterministic fake DaData phone client"
```
---
## Task 2: Инъектор синтетических заявок (через webhook-endpoint)
**Files:**
- Create: `app/app/Support/Imitation/LeadInjector.php`
- Test: `app/tests/Feature/Imitation/LeadInjectorTest.php`
- [ ] **Step 1: Падающий тест** — инъектор шлёт валидную заявку на `POST /api/webhook/supplier/{secret}` (секрет берём из `system_settings`, IP-allowlist на testing fail-open) и получает 202 + создаётся `SupplierLead`.
```php
it('injects a lead via supplier webhook', function () {
$secret = str_repeat('x', 40);
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_webhook_secret'], ['value' => $secret]);
$injector = new LeadInjector($secret);
$resp = $injector->site('vashinvestor.ru', phone: '79991112233', tag: 'Москва', platform: 'B1');
expect($resp->status())->toBe(202);
expect(SupplierLead::where('phone', '79991112233')->exists())->toBeTrue();
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает** (`LeadInjector` не существователь). Run: `php artisan test --filter=LeadInjectorTest` → FAIL.
- [ ] **Step 3: Реализовать инъектор** (поля payload — из `SupplierWebhookController::receive` validate-правил: `vid`, `project`, `phone`, `time`, `tag`, `phones`)
```php
final class LeadInjector
{
public function __construct(private readonly string $secret) {}
public function site(string $domain, string $phone, ?string $tag = null, string $platform = 'B1', ?int $vid = null): \Illuminate\Testing\TestResponse
{
return $this->send("{$platform}_{$domain}", $phone, $tag, $vid);
}
public function call(string $number, string $phone, ?string $tag = null, string $platform = 'B1', ?int $vid = null): \Illuminate\Testing\TestResponse
{
return $this->send("{$platform}_{$number}", $phone, $tag, $vid);
}
private function send(string $project, string $phone, ?string $tag, ?int $vid): \Illuminate\Testing\TestResponse
{
return test()->postJson("/api/webhook/supplier/{$this->secret}", array_filter([
'vid' => $vid ?? random_int(1_000_000, 9_999_999),
'project' => $project,
'phone' => $phone,
'time' => now()->timestamp,
'tag' => $tag,
], fn ($v) => $v !== null));
}
}
```
> `random_int` запрещён в workflow-скриптах, но это обычный Laravel-код — допустимо. Для детерминизма в тестах `vid` передавать явно.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=LeadInjectorTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Support/Imitation/LeadInjector.php app/tests/Feature/Imitation/LeadInjectorTest.php
git commit -m "feat(imitation): synthetic lead injector via supplier webhook"
```
---
## Task 3: Генератор снапшота + рычаги условий
**Files:**
- Create: `app/app/Support/Imitation/SnapshotForge.php`, `app/app/Support/Imitation/ConditionLevers.php`
- Test: `app/tests/Feature/Imitation/SnapshotForgeTest.php`
- [ ] **Step 1: Падающий тест** — после создания проекта `SnapshotForge::rebuild()` создаёт строку в `project_routing_snapshots` за активную дату.
```php
it('builds a routing snapshot for active date', function () {
$project = Project::factory()->create(['is_active' => true, 'daily_limit_target' => 10]);
(new SnapshotForge)->rebuild();
$active = (new SnapshotForge)->activeDate();
expect(DB::connection('pgsql_supplier')->table('project_routing_snapshots')
->where('snapshot_date', $active)->where('project_id', $project->id)->exists())->toBeTrue();
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает.** Run: `php artisan test --filter=SnapshotForgeTest` → FAIL.
- [ ] **Step 3: Реализовать `SnapshotForge`** (механизм генерации — из Task 0 Step 3: dispatch `SnapshotProjectRoutingJob` синхронно ИЛИ вызов `SnapshotBackfillCommand`; активная дата — копия правила из `LeadRouter::activeSnapshotDate`) и `ConditionLevers` (методы: `setBalance(Tenant,$rub)`, `drainBalance(Tenant)`, `fillToLimit(Project)`, `pause(Project)`, `freeze(Tenant)`, `setRegions(Project,array)`, `setDays(Project,int)`).
> Точные вызовы снапшота и колонки — подтвердить по Task 0.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=SnapshotForgeTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Support/Imitation/SnapshotForge.php app/app/Support/Imitation/ConditionLevers.php app/tests/Feature/Imitation/SnapshotForgeTest.php
git commit -m "feat(imitation): snapshot forge + condition levers"
```
---
## Task 4: Сеялка тестовых клиентов и проектов (матрица §6.1 + топологии §6.3)
**Files:**
- Create: `app/database/seeders/Imitation/ImitationClientsSeeder.php`
- Test: `app/tests/Feature/Imitation/SeederTest.php`
- [ ] **Step 1: Падающий тест** — сеялка создаёт 36 одиночных проектов (2 сигнала × 3 региона × 2 дня × 3 лимита) + клиентов под топологии G1/G2/G4; все помечены тестовыми (`name` с префиксом `IMIT-`).
```php
it('seeds the single-project matrix', function () {
(new ImitationClientsSeeder)->run();
expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36);
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает.** Run: `php artisan test --filter=SeederTest` → FAIL.
- [ ] **Step 3: Реализовать сеялку** — цикл по осям (сигнал ∈ {site,call}; регион ∈ {[], [77], [77,78]}; дни ∈ {127, 31}; лимит ∈ {3,30,300}); для каждого — Tenant+User+Project+`project_supplier_links` на общий тестовый `SupplierProject`. Топологии G1/G2/G4 — отдельными методами. Все имена с префиксом `IMIT-`.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=SeederTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/database/seeders/Imitation/ImitationClientsSeeder.php app/tests/Feature/Imitation/SeederTest.php
git commit -m "feat(imitation): test clients + project matrix seeder"
```
---
## Task 5: Региональный каскад резолвера (§7 этап 1: п.9-17)
**Files:**
- Test: `app/tests/Feature/Imitation/RegionResolverCascadeTest.php`
- [ ] **Step 1: Падающие тесты** — по ветке на тест, с `FakeDaDataPhoneClient` (Task 1) и засеянными `phone_ranges`:
- флаг `enabled=false``source='tag'`;
- qc=0 + 'Москва' → `dadata`/77;
- qc=1 → Россвязь (номер в засеянном диапазоне) → `rossvyaz`;
- qc=2 → сразу `tag`;
- DaData бросает `DaDataException` → Россвязь;
- повтор того же номера → `cache_hit=true`, второй раз DaData не зовётся;
- на лид записались `resolved_subject_code`/`region_source`/`dadata_qc`/`phone_operator`.
```php
it('falls through to rossvyaz on qc=1', function () {
config(['services.dadata.enabled' => true]);
// засеять phone_ranges так, чтобы 79995550011 → субъект 78 (см. Task 0 формат)
app()->instance(DaDataPhoneClient::class, (new FakeDaDataPhoneClient)->stub('79995550011', qc: 1));
$lead = SupplierLead::factory()->create(['phone' => '79995550011', 'raw_payload' => ['tag' => '']]);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz');
expect($res->rossvyazMatched)->toBeTrue();
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падают** (если резолвер на копии работает иначе → это уже найденный баг, фиксируем в отчёт). Run: `php artisan test --filter=RegionResolverCascadeTest`.
- [ ] **Step 3: Анализ результатов** — это ПРОВЕРОЧНЫЕ тесты против существующего боевого кода, не TDD-разработка. Если ветка ведёт себя не по спеку — записать находку в runbook-отчёт (Task 13), не «чинить тест».
- [ ] **Step 4: Коммит тестов**
```
git add app/tests/Feature/Imitation/RegionResolverCascadeTest.php
git commit -m "test(imitation): region resolution cascade coverage"
```
---
## Task 6: Сценарий A — взвешенный жребий по объёму (+ X2 статистика)
**Files:**
- Test: `app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php`
- [ ] **Step 1: Тест распределения** — 5 клиентов на одном источнике, один регион, остатки лимита {300,30,30,3,3}; сидируем `Randomizer` (Mt19937) детерминированно; прогоняем N=300 заявок через инъектор; считаем доли получателей.
```php
it('splits leads weighted by remaining limit, small client > 0', function () {
// bind Randomizer with fixed Mt19937 seed (см. LeadRouter конструктор)
// seed 5 tenants/projects on one supplier_project, regions=[77], limits as above
// inject 300 leads with phone resolving to subject 77
// assert: big client got most; smallest client count > 0; shares roughly ∝ limits
})->group('imitation');
```
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioA_WeightedLotteryTest`. Зафиксировать фактические доли в отчёт.
- [ ] **Step 3: Коммит**
```
git add app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php
git commit -m "test(imitation): scenario A weighted lottery + distribution stats"
```
---
## Task 7: Сценарии B/C — каскад по региону (фазы 1/2)
**Files:** Test: `app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php`
- [ ] **Step 1: Тесты** — (B) клиенты с `regions=[77]` + клиент `regions=[]`: лид субъекта 77 → точному (`routing_step=1`), лид субъекта 50 (ни у кого точного) → клиенту «вся РФ» (`routing_step=2`). (C) каждому свой регион → лид уходит только своему. Проверять `deals.subject_code` и `routing_step` через `lead_region_resolution_log`.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioBC_RegionCascadeTest`. Находки — в отчёт.
- [ ] **Step 3: Коммит** `test(imitation): scenarios B/C region cascade`.
---
## Task 8: Сценарий D — дни доставки
**Files:** Test: `app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php`
- [ ] **Step 1: Тест** — два клиента на источнике; одному `delivery_days_mask` БЕЗ сегодняшнего дня (через `ConditionLevers::setDays` + пересборка снапшота); лид уходит только активному сегодня.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioD_DeliveryDaysTest`.
- [ ] **Step 3: Коммит** `test(imitation): scenario D delivery days`.
---
## Task 9: Сценарии E1/E2/F — две заморозки + лимит
**Files:** Test: `app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php`
- [ ] **Step 1: Тесты:**
- **E1** — клиент с балансом ниже цены лида: после доставки `InsufficientBalance` → проект `is_active=false`, письмо `ZeroBalancePaused` поставлено (Mail::fake), заявка ушла следующему.
- **E2** — клиент с `frozen_by_balance_at` (через `ConditionLevers::freeze`): исключён из подбора ещё на этапе фильтра (в подборе его нет).
- **F** — клиент с `delivered_today = snapshot.daily_limit`: выбывает, заявка другим.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioEF_FreezeLimitTest`.
- [ ] **Step 3: Коммит** `test(imitation): scenarios E1/E2/F freezes + limit`.
---
## Task 10: Сценарий G3 — «осиротевшая» заявка
**Files:** Test: `app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php`
- [ ] **Step 1: Тест** — один источник, все клиенты приведены в негодность (пауза/лимит/чужой регион); инъектируем лид; проверяем: сделок 0, списаний 0, `SupplierLead.processed_at` проставлен, `deals_created_count=0`, исключений нет; запись о лиде сохранена (видно «непроданный» лид).
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioG3_OrphanLeadTest`. Зафиксировать: где именно «оседает» непроданный лид.
- [ ] **Step 3: Коммит** `test(imitation): scenario G3 orphan lead`.
---
## Task 11: G5a/b/c + G6 — особые заявки и дубли
**Files:** Test: `app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php`
- [ ] **Step 1: Тесты:** G5a (qc=2/7 → tag), G5b (DaData недоступен/qc=1 → Россвязь), G5c (ни DaData, ни Россвязь, пустой тег → `unknown`), G6 (две заявки с одним `vid` → второй ответ 200 «already_processed», вторая сделка не создаётся).
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioG5G6_SpecialLeadsTest`.
- [ ] **Step 3: Коммит** `test(imitation): scenarios G5/G6 special leads + dedup`.
---
## Task 12: X1 — подмена региона на шаге 3 + журнал; X3 — сводка источника
**Files:** Test: `app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php`
- [ ] **Step 1: Тесты:**
- **X1** — на источнике только клиент(ы) с конкретным регионом, отличным от региона лида, и НЕТ клиента «вся РФ» → каскад уходит в фазу 3; проверяем: `deals.subject_code` подменён на регион клиента, `deals.city` = имя НАСТОЯЩЕГО региона лида, `lead_region_resolution_log.actual_subject_code` = настоящий, `substituted_subject_code` заполнен, `routing_step=3`.
- **X3** — прогнать смесь лидов и собрать сводку `region_source` (dadata/rossvyaz/tag/unknown) из `lead_region_resolution_log`.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=ScenarioX1X3_SubstitutionJournalTest`.
- [ ] **Step 3: Коммит** `test(imitation): X1 step-3 substitution + X3 source breakdown`.
---
## Task 13: Топологии G1/G2/G4 + деньги + приём
**Files:** Test: `app/tests/Feature/Imitation/TopologyMoneyIntakeTest.php`
- [ ] **Step 1: Тесты:**
- **G1/G2/G4** — один клиент на нескольких источниках; паутина; один клиент 2 проекта на одном источнике с разными регионами — проверяем корректность подбора в каждом узле.
- **Деньги** — после доставки: `lead_charges` (ступень/цена/`charge_source='rub'`), `balance_transactions` (отрицательная сумма + остаток), `supplier_lead_costs`; bcmath без потери копеек; цена по `delivered_in_month+1`.
- **Приём** — неверный секрет → 404; флуд → 429; `time` вне ±24ч → отказ; телефон не `7\d{10}` → 422.
- [ ] **Step 2: Прогнать.** Run: `php artisan test --filter=TopologyMoneyIntakeTest`.
- [ ] **Step 3: Коммит** `test(imitation): topologies + money + intake checks`.
---
## Task 14: Команда наполнения живого портала (UI-осмотр)
**Files:**
- Create: `app/app/Console/Commands/Imitation/ImitationSeedCommand.php`
- Test: `app/tests/Feature/Imitation/ImitationSeedCommandTest.php`
- [ ] **Step 1: Падающий тест**`artisan imitation:seed` запускает `ImitationClientsSeeder`, биндит `FakeDaDataPhoneClient`, сеет `phone_ranges`, генерит снапшот и инъектирует пачку лидов; завершается кодом 0; в БД появляются сделки.
```php
it('populates the running portal for UI review', function () {
$this->artisan('imitation:seed', ['--leads' => 20])->assertExitCode(0);
expect(Deal::where('status', 'new')->count())->toBeGreaterThan(0);
})->group('imitation');
```
- [ ] **Step 2: Прогнать — падает.** Run: `php artisan test --filter=ImitationSeedCommandTest` → FAIL.
- [ ] **Step 3: Реализовать команду** — собрать «кирпичи» (Tasks 1-4) в один сценарий заполнения; защита `if (app()->environment('production')) { abort }` — НИКОГДА не на проде.
- [ ] **Step 4: Прогнать — проходит.** Run: `php artisan test --filter=ImitationSeedCommandTest` → PASS.
- [ ] **Step 5: Коммит**
```
git add app/app/Console/Commands/Imitation/ImitationSeedCommand.php app/tests/Feature/Imitation/ImitationSeedCommandTest.php
git commit -m "feat(imitation): imitation:seed command to populate local portal"
```
---
## Task 15: Runbook + отчёт + регрессия
**Files:**
- Create: `docs/superpowers/runbooks/2026-06-03-phase1-imitation-runbook.md`
- [ ] **Step 1: Написать runbook** — пошагово: (1) сверка Шаг 0 (прод-коммит, роли, справочники, флаги); (2) `php artisan imitation:seed`; (3) ручной UI-проход глазами клиента (логин, проекты, лента сделок, смена статуса, экспорт CSV/XLSX, баланс, тарифы, уведомление-колокольчик); (4) наблюдение естественного цикла (форс снапшота, сброс `delivered_today`, прогон `CsvReconcileJob`); (5) шаблон отчёта «ожидали / получили / находки».
- [ ] **Step 2: Прогнать весь набор сценариев**
Run: `php artisan test --group=imitation`
Expected: все зелёные ИЛИ список находок (расхождений с ожиданием) для отчёта.
- [ ] **Step 3: Регрессия проекта** (имитация ничего не сломала)
Run: `composer test` (Pest --parallel) и `npm run test:vue`
Expected: GREEN (группа `imitation` исключена из обычного прогона).
- [ ] **Step 4: Заполнить отчёт** в runbook: что проверено, какие находки, что починено.
- [ ] **Step 5: Коммит**
```
git add docs/superpowers/runbooks/2026-06-03-phase1-imitation-runbook.md
git commit -m "docs(imitation): phase 1 runbook + results report"
```
---
## Self-Review (выполнено при написании)
**1. Покрытие спека:** §6.1 матрица → Task 4; §6.2 A→Task6, B/C→Task7, D→Task8, E1/E2/F→Task9, G3→Task10; §6.3 топологии→Task13; §6.4 G5/G6→Task11; §6.5 X1/X3→Task12 (X2→Task6, X4 опц. — не отдельной задачей, помечен в спеке как необязательный); §7 этап0→Task13, этап1→Task5, этап2→Task6-8/12, этап3→Task7/12, этап4→Task9/13, этап5→Task12/13, этап6→Task10/11, этап7→Task15; Шаг 0 сверка→Task15 Step1 + (база кода — OQ-1, до старта). DaData-замена→Task1; снапшот→Task3; рычаги→Task3; инъектор→Task2.
**2. Призраки:** точные сигнатуры DaData-клиента, Россвязи, фабрик, снапшота и колонок НЕ выдуманы — вынесены в Task 0 как обязательная разведка; в задачах, где код зависит от них, стоит явная отсылка «подтвердить по Task 0».
**3. Согласованность имён:** `FakeDaDataPhoneClient`, `LeadInjector`, `SnapshotForge`, `ConditionLevers`, `ImitationClientsSeeder`, `imitation:seed`, группа `imitation` — единообразны во всех задачах.
**Известные пробелы (осознанные):** X4 (граница месяца) — опционально, не отдельной задачей; CSV-импорт клиентом и исходящий webhook — вне Фазы 1 (см. спек §3).
@@ -0,0 +1,211 @@
# Дизайн: имитация работы портала глазами клиента — ФАЗА 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`).