docs(imitation): phase 1 client-imitation spec + implementation plan
This commit is contained in:
@@ -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`).
|
||||
Reference in New Issue
Block a user