# 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`

---

## Статус исполнения и поправки (обновлено 03.06.2026)

**Сделано (закоммичено на ветке `worktree-prod-imitation-clients`):**
- Спек + план — `dee4a0e1`.
- Task 0 (сигнатуры, `app/app/Support/Imitation/README.md`) — `bad947a5`.
- Task 1 (FakeDaData) — `a54b0346`, **7 тестов GREEN**.

**Написано, НЕ закоммичено (на диске):** Task 2 `LeadInjector` — `app/tests/Support/Imitation/LeadInjector.php` + `app/tests/Feature/Imitation/LeadInjectorTest.php` (был GREEN в изоляции; докоммитить после стабилизации env).

**ПОПРАВКИ к этому плану (обязательны):**
1. **Namespace стенда:** все переиспользуемые «кирпичи» (`FakeDaDataPhoneClient`, `LeadInjector`, `SnapshotForge`, `ConditionLevers`, scenario-helpers) — в **`Tests\Support\Imitation`** (`app/tests/Support/Imitation/...`), НЕ `App\Support\Imitation` (TDD-гейт блокирует production-path в потоке субагента). Живую сеялку (Task 14) — самодостаточной в `database/seeders` (app-namespace) отдельно.
2. **Коды субъектов — порядковые 1..89, НЕ ГИБДД:** Москва=**82**, СПб=**83**, 77=Тюменская обл. Только через `App\Support\RussianRegions::CODE_TO_NAME`.
3. **Правило для субагентов:** гонять ТОЛЬКО свой `--filter` (`php artisan test --filter=XxxTest`), НИКОГДА весь suite — иначе sequential-pollution (48 известных падений) + пустые сиды + красный sentinel.
4. **Контроллеру** роутер-гейт не даёт запускать `php artisan test`/`cd && …` — все прогоны тестов делают субагенты.

**НОВАЯ Task 0.5 (выполнить ПЕРВОЙ, до Task 2 commit и сценариев): поднять imitation-тест-окружение.**
- Create `app/.env.testing` (DB: postgres / `liderra_dev_pass` / `liderra_testing`) + добавить строку `.env.testing` в `app/.gitignore`.
- Убрать DB_USERNAME/DB_PASSWORD из `app/phpunit.xml` (откат правки субагента №1 — секрет вне git).
- Create `app/tests/Support/Imitation/ImitationTestCase.php` (или trait) — в setup сидит справочные данные: `pricing_tiers` (7 ступеней), пример `phone_ranges` (диапазон → субъект), `suppliers` (b1/b2/b3/direct), при необходимости регионы. Сценарные тесты наследуют его.
- Verify: `php artisan test --filter=FakeDaDataClientTest` и `--filter=LeadInjectorTest` → GREEN.
- Commit env-фикс + Task 2.

**Отложенные хвосты (добить в самом конце):** вычистка пароля из истории ветки (если был запушен — не запушен); финальная регрессия; runbook.

---

## Структура файлов (что создаём / трогаем)

Создаём (всё под тестовый/служебный неймспейс, прод-код НЕ меняем):
- `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).
