# Imitation Harness — выверенные сигнатуры (Task 0)

Опорный файл для субагентов Фазы 1. Все сигнатуры подтверждены чтением `origin/main`
(базовый коммит `bd7b1d3e`). НЕ угадывать — сверяться отсюда.

Спек: `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
План: `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md`

## ⚠️ КРИТИЧЕСКИЕ ПРАВКИ К ПЛАНУ (иначе ghost'ы)

1. **Коды субъектов — порядковые 1..89, НЕ коды ГИБДД.** В `App\Support\RussianRegions::CODE_TO_NAME`:
   `82 => 'Москва'`, `83 => 'Санкт-Петербург'`, `77 => 'Тюменская область'`.
   В тестах/сеялке использовать коды ТОЛЬКО через `RussianRegions::CODE_TO_NAME` /
   `RussianRegions::nameToCode()`. Пример в плане «77=Москва» — НЕВЕРЕН, Москва = **82**.
2. **`DaDataPhoneResponse`** — `final readonly`, конструктор 9 аргументов:
   `(?int $qc, ?int $qcConflict, ?string $type, ?string $phone, ?string $provider, ?string $region, ?string $city, ?string $timezone, array $raw)`.
3. **`DaDataPhoneClient`** — НЕ final, `__construct(private readonly HttpFactory $http)`,
   метод `cleanPhone(string $phone): DaDataPhoneResponse`. Фейк — наследник с переопределённым
   конструктором (без вызова parent) + `cleanPhone`; биндить `app()->instance(DaDataPhoneClient::class, $fake)`.
4. **Снапшот в Pest-тестах** — НЕ через job (он строит только `tomorrow`). Использовать
   существующие Pest-хелперы из `app/tests/Pest.php`: `createRoutingSnapshotFromProject(...)`
   и `linkProjectToSupplier(Project, SupplierProject)`. Для живого портала (Task 14) —
   `php artisan snapshot:rebuild --date=<activeDate>` (DELETE+INSERT, детерминированно).
5. **`ProjectFactory`** — есть хелперы `asSiteSignal(string $domain)`, `asCallSignal(string $phone)`.
   `regions` по умолчанию `'{}'` — задавать явно (массив кодов 1..89). `region_mask`/`region_mode` — legacy.
6. **`TenantFactory`** НЕ задаёт `balance_rub` (default 0) и `frozen_by_balance_at` (null) — задавать
   через `ConditionLevers`.
7. **`deals`** (v8.40) имеет `subject_code` (SMALLINT), `phone_operator` (TEXT),
   `region_substituted` (BOOLEAN default FALSE) — на них опирается X1.
8. **RLS в тестах**: паттерн из `RlsSmokeTest` — `SET LOCAL ROLE testing_rls_user` +
   `SET LOCAL app.current_tenant_id`. Sharing-flow роутинга идёт через `pgsql_supplier`
   (BYPASSRLS) — следовать существующим slepok/supplier feature-тестам.
9. **Деградация DaData**: фейк бросает `App\Services\DaData\DaDataException` (extends RuntimeException) —
   резолвер ловит её и уходит на Россвязь (ветка qc=1 / таймаут / 5xx).
10. `SupplierLeadFactory` существует (поля: supplier_project_id, platform, raw_payload, vid, phone,
    received_at, source, processed_at, deals_created_count, error).

## Сигнатуры (verbatim)

### RegionResolution (`app/app/Services/Dto/RegionResolution.php`)
```php
final readonly class RegionResolution {
  public function __construct(
    public ?int $subjectCode, public ?int $actualSubjectCode, public string $source,
    public ?string $phoneOperator, public ?int $qc, public bool $cacheHit,
    public ?array $dadataResponseMasked, public ?int $durationMs, public bool $rossvyazMatched,
  ) {}
  public static function make(?int $subjectCode, string $source, ?string $operator=null, ?int $qc=null,
    bool $cacheHit=false, ?array $dadataMasked=null, ?int $durationMs=null, bool $rossvyazMatched=false): self
  public static function fromSupplierLead(SupplierLead $lead): self
  public static function fromTag(?int $subjectCode): self
  public function withCacheHit(bool $hit): self
  public function forCache(): self
}
```

### RossvyazPrefixLookup / RossvyazRecord
```php
public function find(string $phone): ?RossvyazRecord
final readonly class RossvyazRecord { public function __construct(public ?int $subjectCode, public string $region, public string $operator) {} }
```

### LeadRouter (прод)
`matchEligibleProjects(SupplierProject $sp, ?int $resolvedSubjectCode = null): Collection<Project>`
— 3-фазный каскад (exact→all_ru→any), `routing_step` 1/2/3, взвешенный жребий (вес = остаток лимита, ≥1), cap=3. Конструктор: `__construct(private readonly Randomizer $randomizer = new Randomizer)` — в тестах инъектировать сиданный `new Randomizer(new Mt19937(seed))`.

### Снапшот
- `project_routing_snapshots` колонки: snapshot_date, project_id, tenant_id, daily_limit, delivery_days_mask, regions (int[] default '{}'), signal_type, signal_identifier, sms_senders, sms_keyword, expected_volume, delivered_count, created_at. PK (snapshot_date, project_id). Партиц. по snapshot_date. RLS.
- `snapshot:backfill --date=YYYY-MM-DD` (idempotent ON CONFLICT DO NOTHING); `snapshot:rebuild --date=YYYY-MM-DD` (DELETE+INSERT).

### Деньги (`LedgerService::chargeForDelivery(Tenant, Deal, ?SupplierLead): ChargeResult`)
always-rub; тариф по `delivered_in_month+1`; `frozen_by_balance_at` → InsufficientBalance; bcmath `balance_rub*100 ≥ price`; пишет lead_charges (charge_source='rub') + balance_transactions + supplier_lead_costs.

### Приём (`POST /api/webhook/supplier/{secret}`)
secret ≥32 (`system_settings.supplier_webhook_secret`, hash_equals); IP allowlist (testing fail-open);
rate-limit 600/мин/IP; time в ±24ч; phone `^7\d{10}$`; vid UNIQUE → дубль 200 «already_processed»; project без `B[123]_` → DIRECT.

### Тест-бутстрап
`TestCase::setUp` переводит redis cache в array. Feature-тесты: `pest()->extend(TestCase::class)->in('Feature')`. Хелперы `createRoutingSnapshotFromProject()` / `linkProjectToSupplier()` в `Pest.php`.

## Открытые мелочи
- `DaDataTimeoutException` используется в клиенте, но как отдельный класс не подтверждён — для деградации в тестах бросать базовый `DaDataException`.
- Все тесты Фазы 1 — группа `imitation` (`->group('imitation')`), вне `composer test`.
