From dee4a0e1a290c267dfe83db084e6e6b3f0ec9404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 3 Jun 2026 14:52:08 +0300 Subject: [PATCH] docs(imitation): phase 1 client-imitation spec + implementation plan --- ...26-06-03-portal-client-imitation-phase1.md | 445 ++++++++++++++++++ ...3-portal-client-imitation-phase1-design.md | 211 +++++++++ 2 files changed, 656 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md create mode 100644 docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md diff --git a/docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md b/docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md new file mode 100644 index 00000000..6f3560c2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md @@ -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 */ + 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). diff --git a/docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md b/docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md new file mode 100644 index 00000000..2f05a716 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md @@ -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`).