# Spec: Lead Region Resolution via DaData + Россвязь

**Версия:** v0.5 — 30.05.2026
**Статус:** DRAFT READY (untracked, все вопросы закрыты после adversarial-review 30.05; готова к writing-plans фазе)
**Заказчик:** Дмитрий
**Связано:** [Plan 6 regions subject-level](2026-05-14-plan-6-regions-subject-level-design.md), [supplier-integration MVP](2026-05-10-supplier-integration-design.md), [project-migration redesign §4.4](2026-05-20-project-migration-redesign-design.md), [slepok routing protection](2026-05-26-slepok-routing-protection-design.md)

---

## §0 Контекст (как есть на 30.05.2026, после adversarial-review)

### Истинная бизнес-проблема (подтверждено заказчиком 30.05)

Реальная проблема НЕ в том, что поставщик присылает неверный тег. Реальная проблема — **разные клиенты Лидерры используют один и тот же источник (сайт/телефон/SMS-отправитель) с разными региональными настройками**.

Пример: сайт `example.com` используют 4 клиента — у Иванова проект на Москву, у Петрова на Питер, у Сидорова на Красноярский край, у Кузнецова «вся РФ». Поставщику мы не можем передать «отдельный supplier_project per региону клиента» — у поставщика unique_key один (`B1_example.com`), один supplier_project. Поэтому при создании этого supplier_project в Лидерре мы **сами ставим тег `РФ`** (потому что выбрать «и Москва, и Питер, и Красноярск, и вся РФ» нельзя).

Лид прилетает с тегом `РФ` (наш собственный тег, который мы себе сами поставили). Текущий `LeadRouter` берёт всех 4 клиентов как кандидатов на этот источник и через `DISTINCT ON tenant_id` отдаёт каждому копию (cap=3) — **без учёта региона лида**. В итоге Иванов (Москва) получает питерский лид, Петров (Питер) получает московский. Это и есть «всё разваливается».

### Текущая модель в коде (зафиксировано через прямой code-walking)

- **`deals.subject_code SMALLINT`** (1..89; NULL=вся РФ) пишется в [`RouteSupplierLeadJob::createDealCopyForProject`](../../../app/app/Jobs/RouteSupplierLeadJob.php#L405) из тега поставщика через [`RegionTagResolver`](../../../app/app/Services/RegionTagResolver.php) (`RussianRegions::nameToCode()[$tag]`).
- **`LeadRouter::matchEligibleProjects`** ([LeadRouter.php](../../../app/app/Services/LeadRouter.php)) НЕ использует `subject_code` в WHERE. Матчит только по `signal_type+signal_identifier` (DIRECT) или `project_supplier_links` (B1/B2/B3). Регион клиента (`projects.regions INT[]`) есть в snapshot, но в SELECT не интерпретируется.
- **`supplier_projects.subject_code`** — dead column. `SupplierProjectResolver::resolveOrStub` его не передаёт в `firstOrCreate`; все supplier_projects сейчас имеют `subject_code=NULL`. Schema готова к Plan 6.5 переходу, код пока нет.
- **Plan 6 spec** ([2026-05-14-plan-6-regions-subject-level-design.md §1](2026-05-14-plan-6-regions-subject-level-design.md)) явно: «Рефакторинг LeadRouter — продолжает читать region_mask, переключение на regions[] отложено до Plan 6.5». Эта спека и есть Plan 6.5 — она впервые подключает subject-level фильтрацию к маршрутизатору.
- **Один tenant — один проект на один источник.** Реализовано в [`ProjectService::assertSourceUnique`](../../../app/app/Services/Project/ProjectService.php#L454) (вызывается из `create` и `update`). Не в БД (только обычный INDEX, не UNIQUE), а на уровне сервиса.
- **Один лимит на проект, независимо от количества регионов в проекте.** Регионы в проекте — это фильтр входящих, а не разные «корзины» с разными лимитами.

### Что было раньше: phone-prefix маршрутизация (DEPRECATED)

[`PhonePrefixService`](../../../app/app/Services/PhonePrefixService.php) исторически фильтровал лиды по битмаске 8 федеральных округов (`projects.region_mask`). Снят с маршрутизации в эпике миграции проектов:

> «Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix фильтр убран: для мобильных он no-op, а регион гарантирован тем, через какой supplier_project пришёл лид.»

В комментарии `PhonePrefixService` явно: *«Полный справочник Минсвязи DEF-codes (40K+ строк) — отложен на Plan 5 / future.»* Эта спека и есть та самая «future».

### Масштаб

До **30 000 лидов/день** через `RouteSupplierLeadJob`. Канал webhook + резервный канал CSV-recovery (`CsvReconcileJob`).

### Что есть для верификации

Поле `supplier_leads.phone` (тип TEXT, формат 11 цифр с лидирующей `7` — гарантировано регэкспом `/^7\d{10}$/` в [`SupplierWebhookController`](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L87)). Телефон — независимый сигнал, не контролируемый поставщиком. Резолв phone → region и phone → оператор делается через DaData (внешний REST-API) и локальный реестр Россвязи как fallback.

---

## §1 Цели и не-цели

### Цели

1. **Резолвить `subject_code` лида по телефону** независимо от тега поставщика (DaData → Россвязь → tag-fallback).
2. **Сохранять оператора связи** (DaData `provider`, с учётом MNP) — для будущих сервисов Лидерры (рассылки, звонки, аналитика по операторам). Текущая логика не использует, но поле обязательно с момента запуска.
3. **Правка `LeadRouter`** (Plan 6.5 в рамках этой спеки): использовать `resolved_subject_code` лида + `projects.regions[]` клиента для каскадной фильтрации кандидатов-tenants.
4. **Каскад маршрутизации**:
   - Шаг 1 — клиенты с точным совпадением региона
   - Шаг 2 — клиенты с «вся РФ» (regions=[])
   - Шаг 3 — запасной канал: лид с регионом, на который никто не подписан, всё равно отдаётся (любой клиент с этим источником и остатком лимита) с подменой региона в карточке на один из выбранных клиентом
5. **Не терять лиды.** Запасной канал (шаг 3) гарантирует доставку — мы заплатили поставщику за этот лид, не можем выбрасывать.
6. **Никогда не показывать клиенту «не его регион»** в карточке сделки. В сделке всегда subject_code из списка regions проекта клиента. Настоящий резолвенный регион хранится отдельно, во внутреннем журнале — для аналитики и спора с поставщиком.
7. **Идемпотентность по бюджету DaData** — один телефон = один DaData call в окне 30 дней (кэш sha256(phone)).
8. **Аудит каждого решения** (`lead_region_resolution_log`) — для разбора жалоб и наблюдения за качеством поставщика.

### Не-цели (out of scope этой спеки)

- **GeoIP по IP клиента** — отдельная задача, требует payload-расширения от поставщика.
- **SMS-подтверждение региона** — ломает конверсию.
- **Backfill старых лидов** задним числом — отдельная джоба после MVP.
- **Поддержка иностранных номеров** — DaData помечает `qc=7`, оставляем `subject_code=NULL`.
- **Удаление `RegionTagResolver` / `raw_payload['tag']`** — после 1-2 мес мониторинга, отдельная спека.
- **Удаление `PhonePrefixService` / `projects.region_mask`** — отдельный Plan 6.5 cleanup.
- **Гармонизация `supplier_projects.subject_code` с phone-резолвом** — за рамками; нам достаточно, что это dead column.
- **Передача клиенту настоящего региона лида** (когда регион не совпал с его настройками) — клиент видит только подменённый из своего списка regions. Внутренний журнал для нас, не для клиента.

---

## §2 Бизнес-инварианты (зафиксированы заказчиком 30.05)

### И-1. Резолв региона по телефону

Для каждого `SupplierLead.phone`:

```
1. DaData clean/phone — если уверенный ответ И регион не в ambiguous-list →
   берём region+provider → resolved_subject_code
2. Россвязь по def-коду — берём region → resolved_subject_code,
   provider оставляем из DaData если она ответила (с MNP), иначе из Россвязи
3. tag-fallback (RegionTagResolver) — как сейчас, provider=NULL
```

Лид никогда не остаётся без попытки определить регион.

### И-2. Оператор всегда от DaData

DaData учитывает MNP (перенос номера между операторами). Заказчик подтвердил (30.05): **оператор нужен актуальный** (для будущих сервисов рассылок/звонков). Поэтому даже когда регион берём из Россвязи (ambiguous-list или qc=1), DaData всё равно дёргаем — ради `provider`.

Исключение: при `qc=2` (мусор), `qc=7` (иностранец) или DaData недоступен — provider = NULL.

### И-3. Один телефон = один DaData call в окне 30 дней

Кэш `sha256("phone-region:" + phone)` хранит `RegionResolution` DTO (subject_code, provider, qc, region_source). TTL 30 дней. Срок выбран заказчиком (30.05) как баланс между экономией бюджета (~80% cache-hit ожидаемо) и актуальностью оператора при MNP (за 30 дней оператор успевает обновиться).

### И-4. Каскад маршрутизации (правка `LeadRouter`)

Для каждого лида с `resolved_subject_code = X`, источник = S, кандидаты — все клиенты, у которых есть проект с источником S:

```
Шаг 1. Точное совпадение
  Среди кандидатов выбираем тех, у кого проект имеет X в массиве regions.
  Сортируем по остатку лимита (delivered_today vs daily_limit) DESC.
  Берём до cap=3.

Шаг 2. «Вся РФ»
  Если набралось <3 — добираем кандидатов с проектами regions=[]
  (пустой массив = принимает любой регион). Сортируем так же.

Шаг 3. Запасной канал
  Если шаги 1+2 дали 0 кандидатов — берём любых кандидатов
  с этим источником и остатком лимита. Сортируем так же. До cap=3.
  В сделках этих клиентов subject_code подменяем на один из его regions
  (первый по порядку — выбор не критичен, лимит общий на проект).
  region_substituted=TRUE, actual_subject_code=X (настоящий) — в журнал.
```

### И-5. Никогда не выбрасываем лид

Если на шаге 3 совсем никого нет (например, у всех кандидатов лимит исчерпан) — это валидное состояние «лид не доставлен сегодня», обрабатывается существующей логикой (failed_webhook_jobs + retry). Никаких новых path'ов.

### И-6. Клиент видит регион только из своих настроек

`deals.subject_code` для клиента всегда либо настоящий резолвенный регион (если клиент подписан на него — шаги 1+2), либо один из выбранных клиентом регионов (шаг 3). **Никогда** «не его регион». Настоящий регион — в нашем внутреннем `lead_region_resolution_log.actual_subject_code`.

### И-7. Биллинг как обычно

Списываем с клиента полную цену по pricing_tier за каждую копию лида, в том числе на шаге 3. Заказчик подтвердил (30.05): убыток от выбрасывания > риск жалобы клиента на «не свой регион».

### И-8. CSV-merge — обновляем регион если новый источник лучше

При merge CSV-recovered deal с догнавшим webhook'ом (24h окно по phone+project+tenant) — обновляем `deals.subject_code` если новый `region_source` имеет более высокий ранг:

```
dadata > rossvyaz > tag > unknown
```

Также обновляем `deals.phone_operator` если был NULL/устаревший.

---

## §3 Архитектура

### 3.1 Новые компоненты

```
app/Services/
├── LeadRegionResolver.php          ← новый главный сервис
├── DaData/
│   ├── DaDataPhoneClient.php       ← HTTP-клиент REST API
│   ├── DaDataPhoneResponse.php     ← value object (DTO ответа)
│   └── DaDataQualityCode.php       ← enum для qc-значений
└── RossvyazPrefixLookup.php        ← локальный матчер префикса
```

```
app/Console/Commands/
└── PhoneRangesImportCommand.php    ← cron-импорт реестра Россвязи
```

```
database/migrations/
└── <timestamp>_create_phone_ranges_and_resolution_log.php
```

### 3.2 Точка вставки в `RouteSupplierLeadJob`

**Сейчас** ([`RouteSupplierLeadJob.php:154`](../../../app/app/Jobs/RouteSupplierLeadJob.php#L154)):

```php
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
```

**Становится:**

```php
$resolution = $leadRegionResolver->resolve($lead);
$subjectCode = $resolution->subjectCode;
// + persist $resolution в lead_region_resolution_log
```

`LeadRegionResolver` инжектится через DI рядом с `$tagResolver` (тот сохраняется как deprecated dependency для fallback внутри resolver'а).

### 3.3 `LeadRegionResolver` — алгоритм

```
function resolve(SupplierLead $lead): RegionResolution
{
    // 0. Защита от мусора
    if (!isValidRuPhone($lead->phone)) {
        return RegionResolution::fromTag($lead, source: 'tag');
    }

    // 1. Кэш (sha256(phone), TTL 30 дней)
    $cached = $cache->get($this->cacheKey($lead->phone));
    if ($cached !== null) {
        return $cached->withLeadId($lead->id);
    }

    // 2. DaData
    if ($this->budgetGuard->canSpend()) {
        try {
            $dadata = $this->dadataClient->cleanPhone($lead->phone);

            if (in_array($dadata->qc, [0, 3], true)) {
                $subjectCode = $this->mapRegionNameToCode($dadata->region);
                if ($subjectCode !== null) {
                    return $this->finalize($lead, $subjectCode, 'dadata', $dadata);
                }
                // qc=0, но регион не маппится в наш справочник — логируем,
                // идём в Россвязь как страховку (мини-вероятность спорного маппинга).
                $this->logUnmappableDadataRegion($lead, $dadata);
            } elseif ($dadata->qc !== 1) {
                // qc=2 (мусор) или qc=7 (иностранец) → DaData не помог;
                // на Россвязь идти бессмысленно, сразу tag-fallback.
                return $this->finalize($lead, null, 'tag', $dadata);
            }
            // qc=1 → DaData не уверен, идём в Россвязь.
        } catch (DaDataException | DaDataTimeoutException $e) {
            // Сеть/таймаут/превышен лимит — деградируем на Россвязь, не падаем.
            $this->logDadataFailure($lead, $e);
        }
    }

    // 3. Россвязь
    $rossvyaz = $this->rossvyazLookup->find($lead->phone);
    if ($rossvyaz !== null) {
        $subjectCode = $this->mapRegionNameToCode($rossvyaz->region);
        if ($subjectCode !== null) {
            return $this->finalize($lead, $subjectCode, 'rossvyaz', $rossvyaz);
        }
    }

    // 4. Tag-fallback (текущая логика)
    return $this->finalize(
        $lead,
        $this->tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? '')),
        'tag',
        null,
    );
}
```

### 3.4 Решающее правило по DaData `qc`

| `qc` | Что значит | Действие |
|---|---|---|
| `0` | Российский, распознан уверенно | если `region` ∉ ambiguous-list (§3.4.1) → берём, source=`dadata`; иначе → шаг 3 Россвязь |
| `3` | Несколько номеров, взят первый | то же правило + флаг `dadata_multiple` в логе |
| `1` | Распознан с допущениями | → шаг 3 Россвязь |
| `2` | Пустой/некорректный | → шаг 4 tag-fallback (Россвязь тоже не поможет) |
| `7` | Иностранный | → шаг 4 tag-fallback (наш справочник по РФ) |
| таймаут/ошибка | DaData недоступен | → шаг 3 Россвязь (страховка) |
| qc=0 + region=NULL | мобильный без региональной привязки (фед.) | → шаг 3 Россвязь (Q6/Q7 решение) |

`qc_conflict` — не используем; DaData сравнивает телефон с адресом, который мы не передаём.

### 3.4.1 Ambiguous-list — DaData всё равно дёргаем (ради оператора), но регион берём из Россвязи

DaData по мобильным префиксам, охватывающим **город + область** одной агломерации, отдаёт **объединённую строку**. Подтверждённый пример: мобильный +7-911-... → `region: "Санкт-Петербург и область"`.

У нас и у клиентов это **2 разных субъекта**: код 78 СПб и код 47 ЛО (аналогично 77 Москва и 50 МО).

**Правило (обновлено 30.05 после ответа заказчика про оператора):** если DaData вернула `region ∈ ambiguous-list` — **`region` от DaData игнорируем**, идём в Россвязь по тому же телефону за точным регионом. Но **DaData call всё равно выполнен** — `provider` от DaData используем как актуального оператора (Россвязь не учитывает MNP).

```php
private const DADATA_AMBIGUOUS_REGIONS = [
    'Санкт-Петербург и область',
    'Москва и область',
    // Расширяется по мере накопления данных на staging (см. §16 acceptance).
];
```

**Поэтому отказались от ранее обсуждавшейся pre-DaData skip оптимизации** (когда мы хотели для префиксов 911/921/916/985 пропускать DaData полностью — экономия ~600 ₽/день). Теперь это невозможно: оператор от DaData нужен для 100% мобильных лидов. Экономии нет, точность оператора есть.

**Open assumption (проверяется в staging-smoke §9.4):** Россвязь действительно различает эти 4 субъекта по subscriber-блоку внутри def-кода. Если для какого-то префикса реестр Россвязи даёт ту же объединённую запись (оператор зарегистрировал блок «на регион 78+47 общий») — fallback на tag.

**NB:** До staging-прогона список ambiguous-регионов расширяется только после реального наблюдения. Возможные кандидаты (Севастополь+Крым) — проверим smoke'ом, не добавляем заранее.

### 3.5 Маппинг DaData `region` → наш `subject_code`

**Проблема:** DaData отдаёт строку типа `"Санкт-Петербург и область"`. У нас в [`RussianRegions`](../../../app/app/Support/RussianRegions.php) отдельно: код 78 «Санкт-Петербург» и код 47 «Ленинградская область». Прямого `nameToCode()[$dadataRegion]` не получится.

**Решение:** новый Support-класс `DaDataRegionMap` (PHP-зеркало JSON-таблицы) с явными маппингами проблемных случаев. Для большинства регионов DaData отдаёт **ровно** официальное имя из ст. 65 КРФ (формат справочника DaData идентичен Россвязи), поэтому маппинг покроется в основном fallback'ом «если строка совпадает с `RussianRegions::nameToCode()` — берём; иначе lookup в `DaDataRegionMap::overrides`».

Конкретный override-список — открытый вопрос, см. §12 Q1.

### 3.6 `DaDataPhoneClient` — спецификация HTTP-вызова

```
POST https://cleaner.dadata.ru/api/v1/clean/phone
Headers:
  Content-Type: application/json
  Accept: application/json
  Authorization: Token <DADATA_API_KEY>
  X-Secret: <DADATA_SECRET>
Body: ["<phone>"]
Timeout: 2000 ms (configurable via DADATA_TIMEOUT_MS)
Retries: 1 (configurable; only на сетевые/5xx)
```

Ответ — массив с одним элементом (один номер → один объект). Парсится в `DaDataPhoneResponse` DTO:

```php
final class DaDataPhoneResponse
{
    public function __construct(
        public readonly ?int $qc,
        public readonly ?int $qcConflict,
        public readonly ?string $type,        // "Мобильный" | "Стационарный" | …
        public readonly ?string $phone,       // нормализованный
        public readonly ?string $provider,    // оператор (после MNP)
        public readonly ?string $region,      // имя субъекта
        public readonly ?string $city,        // только для стационарных
        public readonly ?string $timezone,
        public readonly array $raw,           // полный сырой JSON
    ) {}
}
```

### 3.7 `RossvyazPrefixLookup` — алгоритм матчинга

Реестр Россвязи: таблица `phone_ranges` с диапазонами `(def_code, from_num, to_num)` → `region`, `operator`.

Алгоритм:

```sql
SELECT region, operator
FROM phone_ranges
WHERE def_code = $1
  AND from_num <= $2
  AND to_num >= $2
ORDER BY (to_num - from_num) ASC   -- предпочтение более узкому диапазону
LIMIT 1;
```

PHP-обёртка:

```php
public function find(string $phone): ?RossvyazRecord
{
    // phone = "7921555XXXX" → def_code=921, subscriber=5551234
    $defCode = (int) substr($phone, 1, 3);
    $subscriber = (int) substr($phone, 4);
    // SQL выше.
}
```

Производительность: индекс `(def_code, from_num, to_num)` btree — миллисекунды на запрос. Реестр ~600-800k строк (см. §6).

### 3.8 Где не работает phone-резолвер

- `qc=2` — мусор: пустой/невалидный/слишком короткий
- `qc=7` — иностранец
- Номер не начинается с `7` и не 11 цифр (валидация на входе ещё до DaData)
- DaData недоступен И в Россвязи префикса нет

Во всех этих случаях — fallback на tag, как сейчас.

### 3.9 Каскадная маршрутизация (правка `LeadRouter` — Plan 6.5 в рамках спеки)

**Сейчас** ([LeadRouter::matchEligibleProjects](../../../app/app/Services/LeadRouter.php#L54)) WHERE-блок не использует subject_code. Все клиенты с подходящим source-key проходят равноправно, сортировка только по остатку лимита.

**После правки** — добавляем PHP-orchestration вокруг существующего SQL'я. Один SQL-запрос разбивается на три фазы по приоритету; PHP управляет добором кандидатов до cap=3.

Псевдокод:

```php
public function matchEligibleProjects(
    SupplierProject $supplierProject,
    ?int $resolvedSubjectCode,   // НОВЫЙ параметр; NULL = резолвер не сработал
): Collection {
    $activeDate = $this->activeSnapshotDate();
    $cap = 3;

    // Шаг 1: точное совпадение региона
    // (только если resolvedSubjectCode известен)
    $exact = $resolvedSubjectCode !== null
        ? $this->queryCandidates($activeDate, $supplierProject, regionFilter: 'exact', code: $resolvedSubjectCode, limit: $cap)
        : collect();

    if ($exact->count() >= $cap) {
        return $exact->take($cap);
    }

    // Шаг 2: «вся РФ» (regions = '{}')
    $allRu = $this->queryCandidates(
        $activeDate, $supplierProject,
        regionFilter: 'all_ru',
        excludeTenantIds: $exact->pluck('tenant_id')->all(),
        limit: $cap - $exact->count(),
    );
    $combined = $exact->concat($allRu);

    if ($combined->count() > 0) {
        // На шагах 1/2 нашлись кандидаты — шаг 3 не нужен.
        return $combined->take($cap);
    }

    // Шаг 3: запасной канал (никто не подписан на этот регион и нет «вся РФ»)
    // Берём всех с этим источником и остатком лимита, без фильтра по региону.
    // Сделки в этих проектах получат подменённый subject_code (см. §3.10).
    return $this->queryCandidates(
        $activeDate, $supplierProject,
        regionFilter: 'any',
        limit: $cap,
    );
}
```

`queryCandidates(...)` — приватный метод, расширяет существующий SQL добавлением WHERE-фильтра по `snap.regions`:

- `regionFilter='exact'` → `AND ?::int = ANY(snap.regions)`
- `regionFilter='all_ru'` → `AND snap.regions = '{}'::int[]`
- `regionFilter='any'` → без фильтра (как сейчас)

DISTINCT ON (tenant_id) + сортировка по остатку лимита остаётся в SQL'е. PHP только склеивает результаты трёх фаз и обрезает по cap=3.

`excludeTenantIds` — чтобы клиент, попавший в шаг 1, не попал ещё раз в шаг 2 (если у него есть и точный, и «вся РФ» проект — что невозможно по `assertSourceUnique`, но защищаемся явно).

Возвращаемая Collection — каждый Project обёрнут с дополнительным полем `routing_step` (1/2/3) — RouteSupplierLeadJob использует это для решения о подмене региона в сделке (см. §3.10).

### 3.11 Persistent idempotency и транзакционность persist'а

**Где вызывается `resolve()`:** в `RouteSupplierLeadJob::handle()` **ДО** входа в DB::transaction циклов по проектам. Это важно — HTTP-вызов DaData (~150мс) не должен висеть внутри транзакции, иначе блокирует PgBouncer pool и lockForUpdate(Tenant).

**Persist результата** (`supplier_leads` UPDATE + `lead_region_resolution_log` INSERT) делается атомарно в **одной короткой `DB::transaction()`** сразу после `resolve()`, до начала циклов по проектам.

**Persistent idempotency** для retry-сценария (`tries=3`): в начале `resolve()` проверяется persistent state:

```php
if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
    // Уже резолвили на предыдущем try — возвращаем без DaData call.
    return RegionResolution::fromSupplierLead($lead);
}
```

Это защищает от **двойной оплаты DaData** при Redis-restart (когда кэш потерян, но persistent state в БД остался).

**Cache hit логируется:** даже при cache hit мы пишем строку в `lead_region_resolution_log` с `cache_hit=true`, `dadata_response_masked=NULL`. Это нужно для метрик `phone_resolution.source.*` — без логирования cache hit'ов аналитика покажет искажённое распределение источников.

### 3.12 CSV-merge — обновление `subject_code` и `phone_operator` по рангу источника

При merge CSV-recovered deal с догнавшим webhook'ом ([RouteSupplierLeadJob.php:340-369](../../../app/app/Jobs/RouteSupplierLeadJob.php#L340)):

```php
private const SOURCE_RANK = [
    'dadata'   => 4,
    'rossvyaz' => 3,
    'tag'      => 2,
    'unknown'  => 1,
];

// при merge
if ($newResolution !== null && $existingMergeable->region_source !== null) {
    $newRank = self::SOURCE_RANK[$newResolution->source] ?? 0;
    $oldRank = self::SOURCE_RANK[$existingMergeable->region_source] ?? 0;

    if ($newRank > $oldRank) {
        $updateFields['subject_code'] = $newResolution->subjectCode;
        $updateFields['phone_operator'] = $newResolution->phoneOperator;
        $updateFields['region_source'] = $newResolution->source;
    }
}
```

Пример: CSV-recovered deal был с `region_source='tag'`, subject_code=99 (мусор из тега). Webhook через 30 мин дал DaData-результат subject_code=77, source='dadata'. Ранг dadata=4 > tag=2 → обновляем оба поля.

Если оба источника одного ранга (например, оба dadata) — НЕ обновляем (consistency). Если новый источник хуже — НЕ обновляем (stability).

**NB:** `deals.subject_code` обновляется на партиционированной таблице — обязательно включать `received_at` в WHERE для pruning'а партиций (см. [Deal::setKeysForSaveQuery](../../../app/app/Models/Deal.php#L93)). DB::table('deals')->where('id', ...)->where('received_at', $existing->received_at)->update(...) — паттерн из существующего merge-кода.

### 3.13 Client-side rate-limit для DaData (защита от 429)

DaData лимит: 20 RPS с одного IP. Наш пик — 1-2 RPS, но при burst-ретрансляции от поставщика (50 webhook'ов за секунду) можем упереться.

**Решение:** Redis token bucket 18 RPS (запас 10%). Реализация — `RateLimiter::for('dadata-cleaner', ...)`. Когда bucket пуст: `Cache::lock('dadata-rps-wait')->block(seconds: 2)` — ждём свободный слот, либо если 2 сек не хватило, идём в Россвязь (бесплатна, ничего не теряем кроме оператора).

Метрика `phone_resolution.dadata.rate_limited_count` — для дашборда.

### 3.10 Подмена `subject_code` в сделке на шаге 3 запасного канала

Когда `LeadRouter` вернул проекты с `routing_step=3`, в `RouteSupplierLeadJob::createDealCopyForProject` выбираем subject_code иначе:

```php
$dealSubjectCode = $project->routing_step < 3
    ? $resolvedSubjectCode    // настоящий — клиент подписан или принимает всё
    : $this->pickSubstituteRegion($project);

$dealRegionSubstituted = $project->routing_step === 3;

Deal::create([
    ...,
    'subject_code' => $dealSubjectCode,
    'region_substituted' => $dealRegionSubstituted,
]);
```

`pickSubstituteRegion($project)`:

- если `$project->regions` пустой массив (проект «вся РФ» — но он бы попал в шаг 2, не 3 — оборонительный fallback) → `$resolvedSubjectCode` (нет смысла подменять, клиент принимает любой)
- если `$project->regions` непустой → берём **первый по порядку** (`$project->regions[0]`). Выбор не критичен — у клиента один лимит на проект независимо от региона, регионы не имеют «своих» лимитов. Первый по порядку детерминирован для тестов.

Подменённый regionвсегда остаётся в журнале как `region_substituted=true`, а настоящий `resolvedSubjectCode` — в `lead_region_resolution_log.actual_subject_code` для аналитики.

---

## §4 Изменения схемы БД

Новые таблицы и колонки — отдельная миграция, без правки существующих структур, чтобы минимизировать риск collision с параллельными ветками.

### 4.1 `phone_ranges` — реестр Россвязи

**SaaS-level (без RLS)**, реестр публичных данных.

```sql
CREATE TABLE phone_ranges (
    id              BIGSERIAL PRIMARY KEY,
    def_code        SMALLINT NOT NULL,        -- 3 цифры после 7: 495, 921, 916, …
    from_num        BIGINT NOT NULL,          -- начало диапазона (7 цифр subscriber-части)
    to_num          BIGINT NOT NULL,          -- конец включительно
    operator        TEXT NOT NULL,            -- название оператора (как в реестре)
    region          TEXT NOT NULL,            -- имя субъекта (как в реестре)
    region_normalized TEXT,                   -- нормализованное к нашему справочнику, NULL если не сматчилось
    subject_code    SMALLINT,                 -- 1..89 после маппинга к RussianRegions
    imported_at     TIMESTAMPTZ NOT NULL,
    import_id       BIGINT NOT NULL REFERENCES phone_ranges_imports(id),

    CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
    CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
    CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);

CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
    'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.';
```

Грантирование: `crm_readonly` + `crm_supplier_worker` имеют SELECT. INSERT/UPDATE — только `crm_migrator` через импорт-команду.

### 4.2 `phone_ranges_imports` — журнал импортов

```sql
CREATE TABLE phone_ranges_imports (
    id              BIGSERIAL PRIMARY KEY,
    imported_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    source_url      TEXT NOT NULL,                -- URL XLSX-выписки или manual:<filename>
    rows_inserted   INTEGER NOT NULL DEFAULT 0,
    rows_updated    INTEGER NOT NULL DEFAULT 0,
    checksum_sha256 TEXT NOT NULL,                -- хэш файла-источника для idempotency
    status          TEXT NOT NULL DEFAULT 'in_progress'
        CHECK (status IN ('in_progress', 'completed', 'failed', 'rolled_back')),
    error           TEXT,
    completed_at    TIMESTAMPTZ
);
```

### 4.3 `lead_region_resolution_log` — аудит каждого резолва

**SaaS-level**, секционирована по месяцу `received_at` (паттерн `activity_log` / `balance_transactions`).

```sql
CREATE TABLE lead_region_resolution_log (
    id                  BIGSERIAL,
    supplier_lead_id    BIGINT NOT NULL,                  -- FK на supplier_leads (без cascade — лид может быть удалён, лог остаётся для аналитики)
    received_at         TIMESTAMPTZ NOT NULL,             -- partition key (= supplier_leads.received_at)
    phone_masked        TEXT NOT NULL,                    -- "7921***5234" (первые 4 + последние 4, середина *)
    subject_code_resolved   SMALLINT,                     -- что в итоге написали в deal
    subject_code_from_tag   SMALLINT,                     -- что бы получили без новой логики (для A/B-аналитики)
    region_source       TEXT NOT NULL                     -- 'dadata' | 'rossvyaz' | 'tag' | 'unknown'
        CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
    dadata_qc           SMALLINT,                         -- NULL если до DaData не дошли
    dadata_provider     TEXT,
    dadata_type         TEXT,                             -- "Мобильный"/"Стационарный"/...
    dadata_response_masked  JSONB,                        -- полный ответ DaData с маскированным phone
    rossvyaz_matched    BOOLEAN NOT NULL DEFAULT FALSE,
    resolved_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    duration_ms         INTEGER,                          -- общая длительность resolve()

    PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);

CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_received_at ON lead_region_resolution_log (received_at);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
```

Создание партиций — через существующий [`MonthlyPartitionManager`](../../../app/app/Services/MonthlyPartitionManager.php) и cron `partitions:create-months`.

### 4.4 Денормализация в `supplier_leads`

Добавляем 4 inline-колонки для быстрого показа в карточке лида без JOIN на партиционированный лог + persistent idempotency для retry-сценария (§3.x):

```sql
ALTER TABLE supplier_leads
  ADD COLUMN resolved_subject_code SMALLINT
    CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
  ADD COLUMN region_source TEXT
    CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
  ADD COLUMN dadata_qc SMALLINT,
  ADD COLUMN phone_operator TEXT;  -- актуальный оператор от DaData (с MNP)
```

Полная история (`dadata_response_masked`, `duration_ms`, `subject_code_from_tag`, `phone_masked`, `actual_subject_code`) остаётся в `lead_region_resolution_log`.

### 4.5 Добавления в `deals`

Для UI-карточки клиента и будущих сервисов рассылок/звонков (И-2):

```sql
ALTER TABLE deals
  ADD COLUMN phone_operator TEXT,
  ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
```

- `phone_operator` — оператор от DaData (с MNP), для будущих сервисов. Заполняется в `RouteSupplierLeadJob::createDealCopyForProject` из `RegionResolution`.
- `region_substituted` — флаг «регион в сделке подменён на один из regions клиента» (шаг 3 запасного канала). Клиент это **не видит** в UI; у нас используется в аналитике + для логики merge (см. §3.11 ниже).

### 4.6 Дополнения в `lead_region_resolution_log`

К полям из §4.3 добавляем:

```sql
ALTER TABLE lead_region_resolution_log
  ADD COLUMN actual_subject_code SMALLINT       -- настоящий резолвенный регион
    CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
  ADD COLUMN substituted_subject_code SMALLINT  -- что подменили в сделке (если шаг 3)
    CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
  ADD COLUMN routing_step SMALLINT              -- 1=точное, 2=всяРФ, 3=запасной канал
    CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
  ADD COLUMN phone_operator TEXT,               -- от DaData
  ADD COLUMN cache_hit BOOLEAN NOT NULL DEFAULT FALSE;
```

### 4.7 Не трогаем

- `supplier_projects.subject_code` — dead column, не наша зона (Plan 6.5+ cleanup).
- `projects.regions` (Plan 6) — потребитель `resolved_subject_code` через новый WHERE-фильтр, формат не меняется.
- `projects.region_mask` (DEPRECATED) — без изменений.
- Snapshot `project_routing_snapshots.regions` — уже содержит `projects.regions` (см. [SnapshotProjectRoutingJob.handle](../../../app/app/Jobs/SnapshotProjectRoutingJob.php#L55)), готов к нашему WHERE-фильтру.

---

## §5 Конфигурация

### 5.1 `.env` + YC Lockbox

```env
DADATA_API_KEY=<via YC Lockbox secret 'dadata-api-key'>
DADATA_SECRET=<via YC Lockbox secret 'dadata-secret'>
DADATA_TIMEOUT_MS=2000
DADATA_RETRIES=1
DADATA_DAILY_CAP_RUB=10000        # мягкий лимит, при превышении — skip DaData
PHONE_REGION_CACHE_TTL_DAYS=30
LEAD_REGION_RESOLVER_ENABLED=true # feature-flag для аварийного выключения
```

### 5.2 Feature-flag для постепенной раскатки

`LEAD_REGION_RESOLVER_ENABLED=false` → `LeadRegionResolver::resolve()` сразу возвращает tag-fallback (то же, что текущее поведение). Включаем глобально после smoke на staging.

### 5.3 Бюджет DaData

`DaDataBudgetGuard` — отдельный сервис, читающий `phone_resolution.dadata.spent_today_kopecks` из Redis. При исчерпании дневного лимита `DADATA_DAILY_CAP_RUB` — `canSpend()` возвращает `false`, resolver сразу идёт в Россвязь.

Накопление расхода — атомарный `INCRBY` после каждого успешного вызова DaData (~20 копеек = 20 единиц).

---

## §6 Cron-импорт реестра Россвязи

### 6.1 Источник

[`rossvyaz.gov.ru/deyatelnost/resurs-numeracii/vypiska-iz-reestra-sistemy-i-plana-numeracii`](https://rossvyaz.gov.ru/deyatelnost/resurs-numeracii/vypiska-iz-reestra-sistemy-i-plana-numeracii)

Выкладывают пакет CSV/XLSX по каждому коду DEF (~500-600 файлов; объём ~50 МБ).

### 6.2 Команда

```bash
php artisan phone-ranges:import --url=<URL> [--force] [--dry-run]
```

Алгоритм:

1. `wget` всех файлов реестра → `storage/app/rossvyaz/<imported_at>/`.
2. Парсинг XLSX → нормализованные строки.
3. Маппинг `region` → `subject_code` через `RussianRegions::nameToCode()`. Несматчившиеся — лог в `phone_ranges_imports.error`.
4. `BEGIN; COPY → phone_ranges_staging; COMMIT;` — staging-таблица.
5. Сверка checksum'а файлов с предыдущим импортом: если идентичен — `status='rolled_back'`, EXIT (idempotency).
6. `BEGIN; RENAME phone_ranges → phone_ranges_old; RENAME phone_ranges_staging → phone_ranges; COMMIT;` — atomic swap.
7. `phone_ranges_imports.status='completed'`, `completed_at=NOW()`.
8. `phone_ranges_old` — удаляется только при следующем успешном импорте (т.е. всегда есть одна предыдущая версия для отката).

### 6.3 Расписание

Cron раз в месяц, 4-е число в 03:00 МСК (после месячного обновления реестра Россвязью):

```bash
# В config/scheduler.php или artisan-run workflow
0 0 4 * * php artisan phone-ranges:import --url=https://rossvyaz.gov.ru/...
```

### 6.4 Ручной откат

```bash
php artisan phone-ranges:rollback   # swap phone_ranges ↔ phone_ranges_old
```

---

## §7 152-ФЗ и ПДн

### 7.1 Маскирование телефона в логах

- `lead_region_resolution_log.phone_masked` — формат `7XXX***YYYY` (первые 4 + последние 4 цифры).
- `lead_region_resolution_log.dadata_response_masked` — JSON с маскированным `phone`/`source`. Маскирование — в коде PHP **до** INSERT, не через триггер.
- Application-логи (`Log::info` и т.п.) — только `phone_masked`, никогда сырой номер.

### 7.2 `pg_anonymizer`

Для dev-дампов prod-БД настроены маски через `pg_anonymizer` ([setup](../../security/pgaudit-anonymizer-setup.md)). Добавить маски на:

- `lead_region_resolution_log.phone_masked` — уже маскирован, но на всякий случай **truncate** (≤4 первых цифр).
- `lead_region_resolution_log.dadata_response_masked` → `anon.partial_email` для `phone` поля? **Открытый вопрос Q5 §12.**

### 7.3 Кэш

Redis-кэш ключевания: `sha256("phone-region:" + phone)` — **без сырого номера в ключе**. Значение кэша — DTO `RegionResolution`, без `phone` (только `subjectCode`, `regionSource`, `qc`).

### 7.4 Период хранения

`lead_region_resolution_log` — храним всё (партиции по месяцу, после 365 дней — `DETACH` партиции и архив в S3 через существующий механизм `MonthlyPartitionManager`).

---

## §8 Метрики и алерты

### 8.1 Метрики (Prometheus / Sentry custom)

```
phone_resolution.source.dadata       # сколько лидов через DaData
phone_resolution.source.rossvyaz     # сколько через Россвязь
phone_resolution.source.tag          # сколько ушло в tag-fallback
phone_resolution.source.unknown      # сколько ушло без региона совсем

phone_resolution.dadata.qc.0..7      # распределение qc
phone_resolution.dadata.error        # сетевые/таймаут
phone_resolution.dadata.budget_capped  # деградация на Россвязь из-за бюджета

phone_resolution.rossvyaz.hit
phone_resolution.rossvyaz.miss

phone_resolution.cache.hit
phone_resolution.cache.miss

phone_resolution.latency_ms          # p50/p95/p99 общего resolve()
phone_resolution.dadata.latency_ms   # p50/p95/p99 HTTP-вызова
```

### 8.2 Алерты

| Триггер | Реакция |
|---|---|
| `source.tag` > 20% в течение часа | DaData+Россвязь массово деградируют — paging |
| `source.unknown` > 5% в течение часа | Мусорные номера, ломаная валидация на входе или поставщик |
| `dadata.qc.1` > 5% в течение часа | DaData качество просело — review |
| `rossvyaz.miss` > 10% | Реестр устарел (последний импорт > 60 дней) |
| `dadata.budget_capped > 0` | Дневной лимит исчерпан — нотификация в Telegram |
| `dadata.latency_ms p95 > 1500` | DaData медленный — проверить сеть/тариф |

### 8.3 Дашборд

Grafana панель «Lead Region Resolution»:

- Линии распределения по `source` (stacked area)
- Гистограмма `qc`
- Latency p50/p95/p99
- Бюджет DaData (сегодня / месяц / форкаст до конца месяца)
- Топ-5 регионов по объёму

---

## §9 Тестирование (TDD)

### 9.1 Unit-тесты

```
tests/Unit/Services/LeadRegionResolverTest.php
  - dadata_qc_0_returns_dadata_source
  - dadata_qc_3_returns_dadata_with_multiple_flag
  - dadata_qc_1_falls_back_to_rossvyaz
  - dadata_qc_2_falls_back_to_tag_skipping_rossvyaz
  - dadata_qc_7_falls_back_to_tag_skipping_rossvyaz
  - dadata_timeout_falls_back_to_rossvyaz
  - dadata_network_error_falls_back_to_rossvyaz
  - budget_cap_exceeded_skips_dadata_directly_to_rossvyaz
  - cache_hit_skips_dadata_and_rossvyaz
  - invalid_phone_skips_dadata_returns_tag
  - unmappable_dadata_region_falls_through_to_rossvyaz
  - all_three_layers_fail_returns_unknown_with_null_subject_code

tests/Unit/Services/RossvyazPrefixLookupTest.php
  - mobile_prefix_returns_correct_region
  - city_code_3digit_returns_correct_region
  - city_code_4digit_more_specific_wins_over_3digit
  - unknown_prefix_returns_null

tests/Unit/Services/DaData/DaDataPhoneClientTest.php
  - mocked HTTP с фикстурами ответов 0/1/2/3/7
  - timeout exception
  - 5xx retry
  - 4xx no retry

tests/Unit/Support/DaDataRegionMapTest.php
  - "Санкт-Петербург и область" → ambiguous, требует override
  - "Москва" → код 77
  - "Московская область" → код 50
  - все 89 регионов из RussianRegions резолвятся
```

### 9.2 Integration-тесты

```
tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
  - lead_with_phone_uses_dadata_region_not_tag
  - lead_with_phone_dadata_disagrees_with_tag_writes_log
  - lead_with_invalid_phone_falls_back_to_tag
  - lead_with_dadata_disabled_via_flag_uses_tag
  - region_resolution_logged_per_lead
```

### 9.3 Pest 4 совместимость

Все тесты пишутся в Pest 4 (текущий dev-стандарт), без shared state — для `--parallel` совместимости. DaData HTTP мокается через `Http::fake()` — без реальных сетевых вызовов в CI.

### 9.4 Smoke на staging

Отдельный artisan-команда `phone-region:smoke --phone=7921555XXXX`:

- Дёргает живой DaData (с реальным ключом staging).
- Дёргает Россвязь.
- Печатает оба ответа и решение resolver'а.
- Не пишет в БД.

---

## §10 Раскатка

### 10.1 Этапы

1. **Миграция схемы** — `phone_ranges*` + `lead_region_resolution_log` + (опционально) колонки в `supplier_leads`.
2. **Импорт реестра Россвязи** — первый ручной запуск `phone-ranges:import`.
3. **Деплой кода с `LEAD_REGION_RESOLVER_ENABLED=false`** — резолвер не используется, существующий tag-flow продолжает работать. Это «нулевая раскатка» — проверяем, что миграция БД и команды работают.
4. **Включение для 1% трафика** — feature-flag в виде «hash(phone) % 100 < N». Алерты, дашборд.
5. **Раскатка на 100%** через 24-48 часов мониторинга.
6. **Backfill старых лидов** — отдельный план, не часть MVP.
7. **Через 1 месяц мониторинга** — можно начинать обсуждение «выключить `raw_payload['tag']` как нормативный источник, перейти на phone-only». Не часть этой спеки.

### 10.2 Аварийный откат

`LEAD_REGION_RESOLVER_ENABLED=false` → возврат к tag-flow без деплоя кода. Время отката — секунды.

---

## §11 Деньги

### 11.1 Прямая стоимость DaData (без оптимизаций)

- Тариф: 20 копеек / запрос
- Объём: 30 000 лидов/день
- **= 6 000 ₽/день = ~180 000 ₽/мес** при 100% покрытии и без кэша

### 11.2 Экономия за счёт кэша 30 дней по `sha256(phone)`

Cache-hit-rate ожидается **15-25%** (повторные лиды с того же телефона: одна заявка через несколько каналов / повторная заявка через 1-3 недели / поставщик ретранслирует одного лида CSV+webhook).

Срок 30 дней выбран заказчиком (30.05) как баланс между экономией бюджета и актуальностью оператора при MNP.

| Cache-hit-rate | DaData запросов/день | Расход/день | Расход/месяц |
|---|---|---|---|
| 0% | 30 000 | 6 000 ₽ | 180 000 ₽ |
| 15% | 25 500 | 5 100 ₽ | 153 000 ₽ |
| 20% (ожидаемо) | 24 000 | 4 800 ₽ | **~144 000 ₽** |
| 25% | 22 500 | 4 500 ₽ | 135 000 ₽ |

Точная доля видна через 1-2 недели после раскатки по метрикам `phone_resolution.cache.{hit,miss}`.

### 11.3 Бюджетные ступени у DaData

При объёмах > 500k запросов/мес — переход на тариф «Стандарт» с предоплатой и скидкой ~30%. При > 5М/мес — индивидуальный тариф. **Расчёт по факту, через 1-2 мес после раскатки.**

### 11.4 Лимиты API

- 20 запросов/сек с одного IP — наш пик ~1-2 RPS, защита через client-side rate-limit (см. §3.13)
- 60 новых соединений / минуту — keep-alive в HTTP-клиенте решает (см. §3.6)

### 11.5 Бюджетный hard-stop

`DADATA_DAILY_CAP_RUB` (`.env`) — мягкий лимит. При исчерпании `DaDataBudgetGuard::canSpend()` возвращает false, resolver идёт сразу в Россвязь (без оператора в этой ситуации — потеря качества для оставшейся части дня, не блокер). Алерт в Telegram «бюджет DaData исчерпан».

---

## §12 Открытые вопросы — все закрыты после adversarial-review 30.05

### Q1 — Маппинг неоднозначных регионов DaData ✅ ЗАКРЫТ (29.05)

**Решение:** для 4 регионов (Москва 77, Московская область 50, СПб 78, Ленинградская область 47) при `region ∈ ambiguous-list` `region` от DaData игнорируем, идём в Россвязь. DaData всё равно дёргаем ради `provider` (см. Q9 ниже). Регламент — §3.4.1.

### Q2 — Конфликт DaData ↔ tag ✅ ЗАКРЫТ (29.05)

**Решение:** по конструкции конфликта нет. На ambiguous-регионы тег не используется (берём Россвязь). На остальные 85 регионов tag — только fallback при недоступности DaData/Россвязи.

### Q3 — `type=Колл-центр` ✅ ЗАКРЫТ (29.05)

**Решение:** принимаем как обычный лид, без флага. Маршрутизируем по региону.

### Q4 — Денормализация в `supplier_leads` ✅ ЗАКРЫТ (30.05)

**Решение:** (a) — добавляем 4 inline-колонки (`resolved_subject_code` / `region_source` / `dadata_qc` / `phone_operator`) в `supplier_leads` (§4.4). Полная история — в `lead_region_resolution_log`.

### Q5 — Маскирование `dadata_response` для дампов ✅ ЗАКРЫТ (29.05)

**Решение:** оставляем как есть, телефон уже маскирован в коде до INSERT. pg_anonymizer на это поле не настраиваем.

### Q6 — Мобильный без региона ✅ ЗАКРЫТ (29.05)

**Решение:** при `region=NULL` у мобильного → Россвязь. §3.4.

### Q7 — qc=0 + region=NULL ✅ ЗАКРЫТ (29.05)

**Решение:** в Россвязь. §3.4.

### Q8 — Реальная бизнес-проблема ✅ ЗАКРЫТ (30.05, adversarial)

**Решение заказчика:** проблема — несколько разных клиентов используют один источник с разными региональными настройками; маршрутизатор сейчас раздаёт лиды без учёта региона, нужна правка `LeadRouter` (Plan 6.5 в рамках спеки). Зафиксировано в §0 и И-4.

### Q9 — Оператор связи: актуальный (DaData) или исторический (Россвязь) ✅ ЗАКРЫТ (30.05)

**Решение заказчика:** актуальный (DaData, с MNP). Для будущих сервисов рассылок/звонков нужен фактический оператор. Pre-DaData skip для ambiguous-регионов отменён — DaData дёргается всегда (ради provider), регион берём из Россвязи когда нужно.

### Q10 — Логика каскада маршрутизатора ✅ ЗАКРЫТ (30.05)

**Решение заказчика:** точное совпадение → «вся РФ» → запасной канал (любой подписчик источника с остатком лимита). Точные имеют приоритет, чтобы РФ-клиент не съел все региональные лимиты. §3.9, И-4.

### Q11 — Уникальность «один источник на клиента» ✅ ЗАКРЫТ (30.05)

**Решение:** реализовано в `ProjectService::assertSourceUnique` (не в БД). У клиента может быть только один проект на один источник; разные клиенты могут использовать один источник с разными regions. Зафиксировано в §0.

### Q12 — Лимит на регион или на проект ✅ ЗАКРЫТ (30.05)

**Решение заказчика:** один лимит на проект независимо от количества регионов. Регионы в проекте — фильтр, не корзины. §0, И-4.

### Q13 — Шаг 3 запасного канала ✅ ЗАКРЫТ (30.05)

**Решение заказчика:** отдаём всем подходящим (cap=3) с пометкой; биллинг как обычно. Подмена subject_code на один из regions проекта клиента — первый по порядку (выбор не критичен, лимит общий). Настоящий регион — в `lead_region_resolution_log`. §3.10, И-4, И-6.

### Q14 — CSV-merge: обновлять регион или оставлять ✅ ЗАКРЫТ (30.05)

**Решение заказчика:** (а) — обновлять если новый источник лучше по рангу `dadata > rossvyaz > tag > unknown`. §3.12, И-8.

### Q15 — Срок кэша DaData ✅ ЗАКРЫТ (30.05)

**Решение заказчика:** 30 дней. Баланс между экономией бюджета и актуальностью оператора при MNP. §11.2.

---

## §13 Связанные документы

- [Plan 6 regions subject-level design](2026-05-14-plan-6-regions-subject-level-design.md) — текущий формат `projects.regions INT[]` 1..89, конституционный порядок.
- [Supplier integration MVP](2026-05-10-supplier-integration-design.md) §6 step 2 — где жил исходный `PhonePrefixService`, с пометкой «полный справочник — future».
- [Project migration redesign](2026-05-20-project-migration-redesign-design.md) §4.4 — где появился `RegionTagResolver` (tag → subject_code).
- [Slepok routing protection](2026-05-26-slepok-routing-protection-design.md) — `subject_code` входит в snapshot, влияние на эту спеку **нулевое** (мы не меняем сам формат snapshot'а, только источник `subject_code`).
- [pg_audit + pg_anonymizer setup](../../security/pgaudit-anonymizer-setup.md) — для §7 маскирования.
- [ADR-018 audit hash-chain per-tenant](../../adr/ADR-018-audit-chain-per-tenant-semantics.md) — паттерн `audit_chain_hash`, **не относится** к этой спеке (мы пишем в обычный log, не audit-chain).

---

## §14 Не входит в эту фичу

- Уточнение региона по IP клиента (GeoIP).
- SMS-подтверждение региона у клиента.
- Авто-исправление старых лидов задним числом.
- Уточнение оператора в реальном времени при MNP (DaData это уже отдаёт в `provider`, отдельная логика не нужна).
- Поддержка иностранных номеров (DaData помечает `qc=7`, оставляем `subject_code=NULL`).
- Снятие `RegionTagResolver` / `raw_payload['tag']` из нормативных источников — отдельная спека после 1-2 мес мониторинга.
- Снятие `PhonePrefixService` и `projects.region_mask` (DEPRECATED Plan 6.5) — отдельная задача.

---

## §15 Риски

| Риск | Вероятность | Влияние | Митигация |
|---|---|---|---|
| DaData падает на N часов | Низкая | Средняя (деградация на Россвязь — она оффлайн) | Россвязь fallback + кэш |
| Россвязь реестр меняет формат XLSX | Низкая | Средняя | Cron алерт на «import failed», ручной разбор, manual override через `--url=file://...` |
| `DADATA_API_KEY` утекает | Низкая | Высокая | YC Lockbox, ротация ключей через .env-redeploy |
| Биллинг DaData превышает прогноз x2 | Средняя | Средняя | `DADATA_DAILY_CAP_RUB` + алерт + переход на Россвязь как primary |
| Маппинг регионов даёт неверный код для X% лидов | Средняя | Высокая | A/B-аналитика `subject_code_from_tag` vs `subject_code_resolved` в логе, ручной разбор |
| Кэш-ключ коллизит при rotation key | Очень низкая | Низкая | sha256 — pure-data hash, не зависит от прод-ключей |
| Бизнес-инвариант «никогда не теряем регион» нарушен из-за NULL subject_code | Низкая | Средняя | Это валидное состояние (= «вся РФ»), маршрутизатор обрабатывает; не блокер |

---

## §16 Acceptance criteria

После раскатки на 100%:

- [ ] `phone_resolution.source.dadata` > 70% (DaData доминирующий источник)
- [ ] `phone_resolution.source.rossvyaz` > 5% (fallback живой)
- [ ] `phone_resolution.source.tag` < 15% (резкое снижение от текущих 100%)
- [ ] `phone_resolution.source.unknown` < 2%
- [ ] `phone_resolution.latency_ms p95 < 500` (включая DaData HTTP)
- [ ] Дашборд Grafana «Lead Region Resolution» доступен
- [ ] Все 12 unit-тестов GREEN; integration GREEN на CI
- [ ] Smoke на staging прогон: 100 случайных prod-лидов → 100 записей в `lead_region_resolution_log`, distribution в пределах ожидаемого
- [ ] Дневной бюджет DaData < 7 000 ₽ в среднем (с учётом cache-hit-rate)
- [ ] Первый монтаж реестра Россвязи (`phone_ranges_imports.status='completed'`)
- [ ] Pravila §16.4 «не использован ≠ проблема» соблюдён: новые узлы (если добавим в Tooling Прил. Н) корректно классифицированы

---

## §17 Метаданные документа

- **Автор:** Claude (через `superpowers:brainstorming` cycle на основе диалога 29-30.05.2026; adversarial-review с прямым code-walking файлов LeadRouter / RouteSupplierLeadJob / SupplierProjectResolver / LedgerService / SnapshotProjectRoutingJob / CsvReconcileJob / SupplierWebhookController / ProjectService / ProjectController / StoreProjectRequest / UpdateProjectRequest / Plan 6 spec)
- **Аудитория:** Дмитрий + следующая сессия implementer'а
- **Жанр:** design spec (`docs/superpowers/specs/`), не implementation plan
- **Implementation plan:** будет создан отдельно — `docs/superpowers/plans/2026-05-29-lead-region-resolution.md` через skill `superpowers:writing-plans`
- **Размер оценки реализации:** 6-7 рабочих дней через subagent-driven-development:
  - Day 1: миграции БД (`phone_ranges*`, `lead_region_resolution_log`, ALTER `supplier_leads` +4, ALTER `deals` +2) + первый импорт реестра Россвязи + регистрация партиций в `MonthlyPartitionManager`
  - Day 2: `DaDataPhoneClient` (HTTP + rate-limit + budget guard) + `RossvyazPrefixLookup` + кэш sha256(phone) TTL=30д (TDD unit)
  - Day 3: `LeadRegionResolver` (qc-decision + ambiguous-list + persistent-idempotency + cache-hit логирование) (TDD unit)
  - Day 4: `LeadRouter` каскад (точное → всяРФ → запасной с подменой) + `pickSubstituteRegion` (TDD unit + integration через RouteSupplierLeadJob)
  - Day 5: CSV-merge update по рангу источника + integration `RouteSupplierLeadJob` (resolve до транзакции, persist в одной короткой транзакции, передача `routing_step` в createDealCopyForProject) + feature-flag `LEAD_REGION_RESOLVER_ENABLED`
  - Day 6: метрики Prometheus + дашборд Grafana + алерты + staging-smoke на 100 prod-номеров + раскатка hash(phone)%100<1%
  - Day 7: мониторинг + раскатка до 100% + добивка по findings первой недели + резерв на тонкий тюнинг ambiguous-list по реальным DaData-ответам

**Изменение от v0.3:** в v0.3 была оценка 4-6 дней без правки LeadRouter; теперь спека покрывает и резолвер, и правку маршрутизатора (Plan 6.5 включён в этот эпик), поэтому +2 дня на маршрутизатор и подмену региона.

---

## Changelog

- **v0.5 (30.05.2026 — поздний вечер) — крупный rewrite после adversarial-review:**
  - §0 переписан: настоящая бизнес-проблема — несколько разных клиентов используют один источник с разными regions; тег «РФ» — наш собственный (ставим в supplier_project когда выбора нет); зафиксированы факты из прямого code-walking (`LeadRouter` не использует subject_code, `supplier_projects.subject_code` — dead column, `ProjectService::assertSourceUnique` блокирует «один tenant — два проекта на один источник», один лимит на проект)
  - §1 переписан: добавлены цели «правка LeadRouter» (Plan 6.5 в рамках спеки), «сохранять оператора DaData», «каскадная фильтрация», «никогда не показывать клиенту не его регион», «никогда не выбрасывать лид»
  - §2 расширен до 8 бизнес-инвариантов И-1..И-8
  - §3.4.1 переписан: DaData всё равно дёргаем при ambiguous-list ради оператора, регион берём из Россвязи (отказались от pre-DaData skip)
  - §3.9 (новый) — каскадная маршрутизация в `LeadRouter` (3 фазы: точное → всяРФ → запасной)
  - §3.10 (новый) — подмена `subject_code` в сделке на шаге 3 запасного канала
  - §3.11 (новый) — persistent idempotency через колонки `supplier_leads` (защита от двойной оплаты DaData при Redis-restart) + транзакционность persist'а + cache-hit логирование
  - §3.12 (новый) — CSV-merge update `subject_code` и `phone_operator` по рангу источника `dadata > rossvyaz > tag > unknown`
  - §3.13 (новый) — client-side rate-limit для защиты от DaData 429
  - §4.4 — `supplier_leads` +4 колонки (добавлен `phone_operator`)
  - §4.5 (новый) — `deals` +2 колонки (`phone_operator`, `region_substituted`)
  - §4.6 (новый) — `lead_region_resolution_log` +5 колонок (`actual_subject_code`, `substituted_subject_code`, `routing_step`, `phone_operator`, `cache_hit`)
  - §11 расширен: таблица расходов по cache-hit-rate, бюджетный hard-stop
  - §12 +Q8-Q15 закрыты по ответам заказчика 30.05 (всего 15 закрытых вопросов)
  - §17 оценка пересмотрена 4-6 → 6-7 дней (правка LeadRouter добавлена в scope)
- **v0.4 (30.05.2026 — день) — пропущена** (последовательность нумерации перешла v0.3 → v0.5 при rewrite).
- **v0.3 (30.05.2026)** — Q4 закрыт: денормализация в `supplier_leads`. §4.4 переписан. Все 7 первых вопросов закрыты — спека готова к writing-plans (до adversarial-review).
- **v0.2 (30.05.2026)** — закрыты Q1, Q2, Q3, Q5, Q6, Q7. §3.4 расширен. §3.4.1 (новый) — ambiguous-list. Q4 открыт.
- **v0.1 (29.05.2026)** — первый драфт. Untracked-файл.
