diff --git a/app/app/Console/Commands/DealsBackfillRegionCityCommand.php b/app/app/Console/Commands/DealsBackfillRegionCityCommand.php
new file mode 100644
index 00000000..b9f58b26
--- /dev/null
+++ b/app/app/Console/Commands/DealsBackfillRegionCityCommand.php
@@ -0,0 +1,75 @@
+option('dry-run');
+ // BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
+ $conn = DB::connection('pgsql_supplier');
+ $map = RussianRegions::CODE_TO_NAME;
+
+ $rows = $conn->table('deals')
+ ->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
+ ->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
+ ->whereNull('deals.city')
+ ->whereNotNull('sl.resolved_subject_code')
+ ->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
+ ->get();
+
+ $seen = [];
+ $updated = 0;
+ foreach ($rows as $r) {
+ $dealId = (int) $r->id;
+ if (isset($seen[$dealId])) {
+ continue; // у сделки несколько доставок — обрабатываем один раз
+ }
+ $seen[$dealId] = true;
+
+ $name = $map[(int) $r->resolved_subject_code] ?? null;
+ if ($name === null) {
+ continue; // код вне справочника 1..89 — пропускаем
+ }
+
+ if (! $dryRun) {
+ $conn->table('deals')
+ ->where('id', $dealId)
+ ->where('received_at', $r->received_at) // partition key
+ ->whereNull('city') // идемпотентный страж
+ ->update(['city' => $name]);
+ }
+ $updated++;
+ }
+
+ $prefix = $dryRun ? '[dry-run] ' : '';
+ $this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
+ Log::info('deals.backfill_region_city', [
+ 'updated' => $updated,
+ 'candidates' => count($seen),
+ 'dry_run' => $dryRun,
+ ]);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/app/Console/Commands/PhoneRangesImportCommand.php b/app/app/Console/Commands/PhoneRangesImportCommand.php
new file mode 100644
index 00000000..aed96c8d
--- /dev/null
+++ b/app/app/Console/Commands/PhoneRangesImportCommand.php
@@ -0,0 +1,446 @@
+ [--force] [--dry-run]
+ * php artisan phone-ranges:import --dir=
[...]
+ *
+ * Алгоритм:
+ * 1. Резолв входных файлов (--file | --dir; --url отложен — оператор качает пакет вручную).
+ * 2. Checksum-идемпотентность: совпал с предыдущим `completed` → status='rolled_back', выход.
+ * 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) → нормализованные строки.
+ * 4. Маппинг region → subject_code через RussianRegions::nameToCode(). Несматчившиеся → лог в error.
+ * 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
+ * 6. --dry-run → staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
+ * Иначе → atomic RENAME swap + status='completed'.
+ *
+ * Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker — член crm_migrator,
+ * INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test — postgres superuser).
+ *
+ * NB (swap — operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом —
+ * RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
+ * импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
+ */
+class PhoneRangesImportCommand extends Command
+{
+ /** @var string */
+ protected $signature = 'phone-ranges:import
+ {--file= : Путь к одному CSV/XLSX файлу реестра}
+ {--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
+ {--url= : (отложено) URL пакета — скачать вручную и использовать --dir}
+ {--force : Игнорировать checksum-идемпотентность}
+ {--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
+
+ /** @var string */
+ protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
+
+ /** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
+ private const DDL_CONNECTION = 'pgsql_supplier';
+
+ /** Размер пачки для bulk INSERT в staging. */
+ private const INSERT_CHUNK = 1000;
+
+ public function handle(): int
+ {
+ $files = $this->resolveFiles();
+ if ($files === null) {
+ return self::FAILURE;
+ }
+
+ $checksum = $this->computeChecksum($files);
+ $dryRun = (bool) $this->option('dry-run');
+ $force = (bool) $this->option('force');
+
+ // 2. Идемпотентность по checksum (если не --force).
+ if (! $force) {
+ $prev = DB::table('phone_ranges_imports')
+ ->where('checksum_sha256', $checksum)
+ ->where('status', 'completed')
+ ->orderByDesc('id')
+ ->first();
+
+ if ($prev !== null) {
+ DB::table('phone_ranges_imports')->insert([
+ 'source_url' => $this->sourceLabel($files),
+ 'checksum_sha256' => $checksum,
+ 'status' => 'rolled_back',
+ 'rows_inserted' => 0,
+ 'rows_updated' => 0,
+ 'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
+ 'imported_at' => now(),
+ 'completed_at' => now(),
+ ]);
+ $this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
+
+ return self::SUCCESS;
+ }
+ }
+
+ // 3. Журнал импорта (in_progress).
+ $importId = (int) DB::table('phone_ranges_imports')->insertGetId([
+ 'source_url' => $this->sourceLabel($files),
+ 'checksum_sha256' => $checksum,
+ 'status' => 'in_progress',
+ 'imported_at' => now(),
+ ]);
+
+ try {
+ // 4. Парсинг + маппинг.
+ $unmatched = [];
+ $rows = [];
+ foreach ($files as $file) {
+ foreach ($this->parseFile($file) as $rec) {
+ $regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
+ $subjectCode = $regionNormalized === null
+ ? null
+ : (RussianRegions::nameToCode()[$regionNormalized] ?? null);
+ if ($subjectCode === null && trim($rec['region']) !== '') {
+ $unmatched[trim($rec['region'])] = true;
+ }
+ $rows[] = [
+ 'def_code' => $rec['def_code'],
+ 'from_num' => $rec['from_num'],
+ 'to_num' => $rec['to_num'],
+ 'operator' => $rec['operator'],
+ 'region' => $rec['region'],
+ 'region_normalized' => $regionNormalized,
+ 'subject_code' => $subjectCode,
+ 'imported_at' => now(),
+ 'import_id' => $importId,
+ ];
+ }
+ }
+
+ // 5. Сборка staging.
+ $this->buildStaging($rows, $importId);
+
+ $unmatchedNote = $unmatched === []
+ ? ''
+ : 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
+
+ if ($dryRun) {
+ DB::table('phone_ranges_imports')->where('id', $importId)->update([
+ 'status' => 'rolled_back',
+ 'rows_inserted' => count($rows),
+ 'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
+ 'completed_at' => now(),
+ ]);
+ $this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
+ if ($unmatchedNote !== '') {
+ $this->warn($unmatchedNote);
+ }
+
+ return self::SUCCESS;
+ }
+
+ // 6. Atomic swap (operator-validated — см. docblock).
+ $this->atomicSwap();
+
+ DB::table('phone_ranges_imports')->where('id', $importId)->update([
+ 'status' => 'completed',
+ 'rows_inserted' => count($rows),
+ 'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
+ 'completed_at' => now(),
+ ]);
+ $this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
+ if ($unmatchedNote !== '') {
+ $this->warn($unmatchedNote);
+ }
+
+ return self::SUCCESS;
+ } catch (\Throwable $e) {
+ DB::table('phone_ranges_imports')->where('id', $importId)->update([
+ 'status' => 'failed',
+ 'error' => mb_substr($e->getMessage(), 0, 2000),
+ 'completed_at' => now(),
+ ]);
+ $this->error('Импорт упал: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+
+ /**
+ * @return list|null Список файлов или null при ошибке валидации опций.
+ */
+ private function resolveFiles(): ?array
+ {
+ $file = $this->option('file');
+ $dir = $this->option('dir');
+ $url = $this->option('url');
+
+ if ($url !== null) {
+ $this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
+
+ return null;
+ }
+
+ if ($file !== null) {
+ if (! is_file($file)) {
+ $this->error("Файл не найден: {$file}");
+
+ return null;
+ }
+
+ return [$file];
+ }
+
+ if ($dir !== null) {
+ if (! is_dir($dir)) {
+ $this->error("Каталог не найден: {$dir}");
+
+ return null;
+ }
+ $found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
+ if ($found === []) {
+ $this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
+
+ return null;
+ }
+ sort($found);
+
+ return array_values($found);
+ }
+
+ $this->error('Укажите --file=<путь> или --dir=<каталог>.');
+
+ return null;
+ }
+
+ /**
+ * @param list $files
+ */
+ private function computeChecksum(array $files): string
+ {
+ if (count($files) === 1) {
+ return (string) hash_file('sha256', $files[0]);
+ }
+
+ $hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
+ sort($hashes);
+
+ return hash('sha256', implode('|', $hashes));
+ }
+
+ /**
+ * @param list $files
+ */
+ private function sourceLabel(array $files): string
+ {
+ return $this->option('url')
+ ?? $this->option('dir')
+ ?? ($files[0] ?? 'unknown');
+ }
+
+ /**
+ * Парсит один файл реестра в нормализованные строки.
+ *
+ * @return list
+ */
+ private function parseFile(string $path): array
+ {
+ $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+
+ return $ext === 'xlsx'
+ ? $this->parseXlsx($path)
+ : $this->parseCsv($path);
+ }
+
+ /**
+ * @return list
+ */
+ private function parseCsv(string $path): array
+ {
+ $content = (string) file_get_contents($path);
+ // BOM strip + split строк (CRLF/CR/LF).
+ $content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
+ $lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
+ if ($lines === []) {
+ return [];
+ }
+
+ $header = str_getcsv((string) array_shift($lines), ';');
+ $cols = $this->resolveColumns($header);
+
+ $out = [];
+ foreach ($lines as $line) {
+ if (trim($line) === '') {
+ continue;
+ }
+ $cells = str_getcsv($line, ';');
+ $rec = $this->mapCells($cells, $cols);
+ if ($rec !== null) {
+ $out[] = $rec;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
+ *
+ * @return list
+ */
+ private function parseXlsx(string $path): array
+ {
+ $reader = new XlsxReader;
+ $reader->open($path);
+
+ $out = [];
+ $cols = null;
+ foreach ($reader->getSheetIterator() as $sheet) {
+ foreach ($sheet->getRowIterator() as $row) {
+ $cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
+ if ($cols === null) {
+ $cols = $this->resolveColumns($cells);
+
+ continue;
+ }
+ $rec = $this->mapCells($cells, $cols);
+ if ($rec !== null) {
+ $out[] = $rec;
+ }
+ }
+ break; // только первый лист
+ }
+ $reader->close();
+
+ return $out;
+ }
+
+ /**
+ * Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
+ *
+ * @param list $header
+ * @return array{def:int, from:int, to:int, operator:int, region:int}
+ */
+ private function resolveColumns(array $header): array
+ {
+ $cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
+
+ foreach ($header as $i => $cell) {
+ $n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
+ if (str_contains($n, 'def') || str_contains($n, 'авс')) {
+ $cols['def'] = $i;
+ } elseif ($n === 'от') {
+ $cols['from'] = $i;
+ } elseif ($n === 'до') {
+ $cols['to'] = $i;
+ } elseif (str_contains($n, 'оператор')) {
+ $cols['operator'] = $i;
+ } elseif (str_contains($n, 'регион')) {
+ $cols['region'] = $i;
+ }
+ }
+
+ return $cols;
+ }
+
+ /**
+ * @param list $cells
+ * @param array{def:int, from:int, to:int, operator:int, region:int} $cols
+ * @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
+ */
+ private function mapCells(array $cells, array $cols): ?array
+ {
+ $def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
+ if ($def === 0) {
+ return null; // пустая/битая строка
+ }
+
+ return [
+ 'def_code' => $def,
+ 'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
+ 'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
+ 'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
+ 'region' => trim((string) ($cells[$cols['region']] ?? '')),
+ ];
+ }
+
+ /**
+ * Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
+ *
+ * id: НЕ копируем серийный default через INCLUDING DEFAULTS — он ссылается на
+ * исходную последовательность phone_ranges, которую atomic-swap уничтожает
+ * (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
+ * без default (NOT NULL violation на повторном импорте). Вместо этого даём
+ * staging собственную последовательность с уникальным по import_id именем,
+ * OWNED BY колонкой id → она переезжает при RENAME и дропается вместе со
+ * старой таблицей (без коллизий имён и без утечки последовательностей).
+ *
+ * @param list> $rows
+ */
+ private function buildStaging(array $rows, int $importId): void
+ {
+ $c = DB::connection(self::DDL_CONNECTION);
+ $this->elevate($c);
+
+ $seq = "phone_ranges_stg_seq_{$importId}";
+ $c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
+ $c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
+ $c->statement("CREATE SEQUENCE {$seq}");
+ $c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
+ $c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
+ $c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
+
+ foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
+ $c->table('phone_ranges_staging')->insert($chunk);
+ }
+ }
+
+ /**
+ * Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
+ *
+ * NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
+ * Проверяется первым реальным импортом оператора (Session 6 runbook).
+ * Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
+ * GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
+ * таблице носит имя idx_phone_ranges_staging_lookup (косметика — имя занято _old).
+ */
+ private function atomicSwap(): void
+ {
+ $c = DB::connection(self::DDL_CONNECTION);
+ $this->elevate($c);
+
+ // Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
+ // DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
+ // переименованиями не оставит phone_ranges несуществующей — откат вернёт
+ // живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
+ // Россвязь-lookup падал бы до ручного восстановления).
+ $c->transaction(function () use ($c) {
+ $c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
+ $c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
+ $c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
+ $c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
+ });
+ }
+
+ /**
+ * SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
+ * отсутствует → RESET и работаем как superuser (зеркало миграционного паттерна).
+ */
+ private function elevate(Connection $c): void
+ {
+ try {
+ $c->statement('SET ROLE crm_migrator');
+ $canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
+ if (! $canCreate || ! $canCreate->ok) {
+ $c->statement('RESET ROLE');
+ }
+ } catch (\Throwable) {
+ // окружение без роли — продолжаем как superuser
+ }
+ }
+}
diff --git a/app/app/Console/Commands/PhoneRegionSmokeCommand.php b/app/app/Console/Commands/PhoneRegionSmokeCommand.php
new file mode 100644
index 00000000..a80fa532
--- /dev/null
+++ b/app/app/Console/Commands/PhoneRegionSmokeCommand.php
@@ -0,0 +1,78 @@
+option('phone');
+ if ($phone === '') {
+ $this->error('Укажите --phone=7XXXXXXXXXX');
+
+ return self::FAILURE;
+ }
+
+ // Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
+ config(['services.dadata.enabled' => true]);
+
+ $lead = new SupplierLead([
+ 'phone' => $phone,
+ 'raw_payload' => ['tag' => (string) $this->option('tag')],
+ ]);
+
+ $r = $resolver->resolve($lead);
+
+ $region = $r->subjectCode !== null
+ ? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
+ : '—';
+
+ $this->info('Телефон: '.$this->maskPhone($phone));
+ $this->line('Источник: '.$r->source);
+ $this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
+ $this->line('Оператор: '.($r->phoneOperator ?? '—'));
+ $this->line('DaData qc: '.($r->qc ?? '—'));
+ $this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
+ $this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
+ $this->line('Длит., мс: '.($r->durationMs ?? '—'));
+ $this->newLine();
+ $this->comment('NB: запись в БД НЕ выполнялась (smoke).');
+
+ return self::SUCCESS;
+ }
+
+ private function maskPhone(string $phone): string
+ {
+ $digits = preg_replace('/\D+/', '', $phone) ?? '';
+ if (strlen($digits) < 8) {
+ return '***';
+ }
+
+ return substr($digits, 0, 4).'***'.substr($digits, -4);
+ }
+}
diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php
index 97b0f61b..ddbfe235 100644
--- a/app/app/Jobs/RouteSupplierLeadJob.php
+++ b/app/app/Jobs/RouteSupplierLeadJob.php
@@ -11,18 +11,22 @@ use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
+use App\Services\Dto\RegionResolution;
use App\Services\LeadDistributor;
+use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
+use App\Support\RussianRegions;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -128,7 +132,6 @@ class RouteSupplierLeadJob implements ShouldQueue
// Capture original error BEFORE update — $lead->update() mutates
// the in-memory model, so $lead->error after update() returns the
// suffixed value, breaking debug logs (review fix).
- // быстрый коммит
$originalError = $lead->error;
$lead->update([
'processed_at' => now(),
@@ -148,16 +151,27 @@ class RouteSupplierLeadJob implements ShouldQueue
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
$lead->update(['supplier_project_id' => $supplier->id]);
- $matched = $router->matchEligibleProjects($supplier);
- $selected = $distributor->selectRecipients($matched); // cap=3 случайных
+ // Lead region resolution (§3.11): резолв региона ДО routing-цикла, чтобы HTTP-вызов
+ // DaData (~150мс) не висел внутри tenant-транзакции. Резолвер — из контейнера (не 7-й
+ // параметр handle(), чтобы не ломать сигнатуру и существующие вызовы тестов).
+ // RegionTagResolver остаётся в DI-цепочке резолвера (fallback-слой).
+ $resolution = app(LeadRegionResolver::class)->resolve($lead);
+ $lead->update([
+ 'resolved_subject_code' => $resolution->subjectCode,
+ 'region_source' => $resolution->source,
+ 'dadata_qc' => $resolution->qc,
+ 'phone_operator' => $resolution->phoneOperator,
+ ]);
- $subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
+ // Каскад по региону (§3.9): exact → all-RF → fallback. NULL subject_code → шаг 1 пропуск.
+ $matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
+ $selected = $distributor->selectRecipients($matched);
$createdCount = 0;
$failures = [];
foreach ($selected as $project) {
try {
- if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
+ if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $resolution)) {
$createdCount++;
}
} catch (Throwable $e) {
@@ -178,6 +192,10 @@ class RouteSupplierLeadJob implements ShouldQueue
);
}
+ // Аудит резолва региона — одна строка на лид (§3.10/§7.1). Fail-safe: сбой записи
+ // аудит-лога НЕ должен ронять доставку лида (revenue-critical, 30k/сутки).
+ $this->logRegionResolution($lead, $resolution, $selected);
+
$lead->update([
'processed_at' => now(),
'deals_created_count' => $createdCount,
@@ -240,10 +258,14 @@ class RouteSupplierLeadJob implements ShouldQueue
Project $project,
NotificationService $notifier,
LedgerService $ledger,
- ?int $subjectCode,
+ RegionResolution $resolution,
): bool {
+ // routing_step проставлен LeadRouter'ом на matched-проекте; захватываем ДО
+ // переназначения $project = $lockedProject (fresh query без этого атрибута).
+ $routingStep = (int) ($project->routing_step ?? 1);
+
try {
- return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
+ return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $resolution, $routingStep): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -354,10 +376,21 @@ class RouteSupplierLeadJob implements ShouldQueue
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
// CSV-recovered received_at сохраняем как есть — отличие на минуты
// несущественно, чем риск каскадного DELETE lead_charges.
+ // §3.12: при merge обновляем регион/оператора, если webhook-резолв из
+ // источника выше рангом (dadata/rossvyaz), чем tag CSV-восстановления.
+ // deals не хранит region_source (он на supplier_leads + в журнале), поэтому
+ // ранг определяем по факту источника: dadata/rossvyaz всегда достовернее
+ // tag'а, на котором строилась CSV-recovery (RegionResolution::SOURCE_RANK).
+ $mergeUpdate = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
+ if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
+ $mergeUpdate['subject_code'] = $resolution->subjectCode;
+ $mergeUpdate['phone_operator'] = $resolution->phoneOperator;
+ $mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
+ }
DB::table('deals')
->where('id', $existingMergeable->id)
->where('received_at', $existingMergeable->received_at)
- ->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
+ ->update($mergeUpdate);
Log::info('supplier_lead.merged_into_csv_recovered', [
'supplier_lead_id' => $lead->id,
@@ -394,6 +427,13 @@ class RouteSupplierLeadJob implements ShouldQueue
? array_values(array_map('strval', $payload['phones']))
: [(string) $lead->phone];
+ // §3.10: на шаге 3 (запасной канал) регион сделки подменяется на регион
+ // клиента (первый подписанный субъект из snapshot); настоящий регион —
+ // в lead_region_resolution_log.actual_subject_code. region_substituted флажит подмену.
+ $dealSubjectCode = $routingStep < 3
+ ? $resolution->subjectCode
+ : ($this->pickSubstituteRegion((string) ($snapshot->regions ?? '{}')) ?? $resolution->subjectCode);
+
$deal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => $lead->vid,
@@ -402,7 +442,14 @@ class RouteSupplierLeadJob implements ShouldQueue
'phones' => $phones,
'status' => 'new',
'received_at' => $receivedAt,
- 'subject_code' => $subjectCode,
+ 'subject_code' => $dealSubjectCode,
+ // «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
+ // по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
+ 'city' => $resolution->subjectCode !== null
+ ? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
+ : null,
+ 'phone_operator' => $resolution->phoneOperator,
+ 'region_substituted' => $routingStep === 3,
]);
DB::table('supplier_lead_deliveries')
@@ -500,6 +547,89 @@ class RouteSupplierLeadJob implements ShouldQueue
]);
}
+ /**
+ * Аудит резолва региона лида — одна строка на лид в lead_region_resolution_log (§7.1).
+ * Fail-safe: сбой записи (например, отсутствие партиции received_at) логируется warning'ом,
+ * но НЕ прерывает доставку (revenue-critical). INSERT через pgsql_supplier (GRANT INSERT
+ * у crm_supplier_worker). Телефон маскируется до INSERT — сырой номер в лог не пишется.
+ *
+ * @param Collection $selected
+ */
+ private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void
+ {
+ try {
+ $first = $selected->first();
+ $routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null;
+ $substituted = ($routingStep === 3 && $first !== null)
+ ? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode)
+ : null;
+
+ $tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? ''));
+
+ DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([
+ 'supplier_lead_id' => $lead->id,
+ 'received_at' => $lead->received_at ?? now(),
+ 'phone_masked' => $this->maskPhone((string) $lead->phone),
+ 'subject_code_resolved' => $resolution->subjectCode,
+ 'subject_code_from_tag' => $tagCode,
+ 'region_source' => $resolution->source,
+ 'dadata_qc' => $resolution->qc,
+ 'dadata_provider' => $resolution->phoneOperator,
+ 'dadata_type' => null,
+ 'dadata_response_masked' => $resolution->dadataResponseMasked !== null
+ ? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE)
+ : null,
+ 'rossvyaz_matched' => $resolution->rossvyazMatched,
+ 'actual_subject_code' => $resolution->actualSubjectCode,
+ 'substituted_subject_code' => $substituted,
+ 'routing_step' => $routingStep,
+ 'phone_operator' => $resolution->phoneOperator,
+ 'cache_hit' => $resolution->cacheHit,
+ 'duration_ms' => $resolution->durationMs,
+ ]);
+ } catch (Throwable $e) {
+ Log::warning('lead_region_resolution.log_write_failed', [
+ 'supplier_lead_id' => $lead->id,
+ 'exception' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * Первый код субъекта из PG INT[]-литерала ('{82,83}' → 82; '{}' → null) — регион клиента
+ * для подмены на запасном канале (§3.10).
+ */
+ private function pickSubstituteRegion(string $regionsLiteral): ?int
+ {
+ return $this->parseSubjectCodes($regionsLiteral)[0] ?? null;
+ }
+
+ /**
+ * @return list '{82,83}' → [82,83]; '{}'/'' → []
+ */
+ private function parseSubjectCodes(string $regionsLiteral): array
+ {
+ $inner = trim($regionsLiteral, '{}');
+ if ($inner === '') {
+ return [];
+ }
+
+ return array_values(array_map('intval', explode(',', $inner)));
+ }
+
+ /**
+ * Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567).
+ */
+ private function maskPhone(string $phone): string
+ {
+ $digits = preg_replace('/\D+/', '', $phone) ?? '';
+ if (strlen($digits) < 8) {
+ return '***';
+ }
+
+ return substr($digits, 0, 4).'***'.substr($digits, -4);
+ }
+
/**
* Финальный callback после исчерпания всех ретраев ($tries=3).
*
diff --git a/app/app/Models/Deal.php b/app/app/Models/Deal.php
index fe544b06..4bde92a3 100644
--- a/app/app/Models/Deal.php
+++ b/app/app/Models/Deal.php
@@ -61,6 +61,9 @@ class Deal extends Model
'is_test',
'received_at',
'deleted_at',
+ // Lead region resolution (Session 1, 31.05.2026).
+ 'phone_operator',
+ 'region_substituted',
];
protected function casts(): array
@@ -77,6 +80,7 @@ class Deal extends Model
'lead_score' => 'decimal:2',
'phones' => 'array',
'is_test' => 'boolean',
+ 'region_substituted' => 'boolean',
'assigned_at' => 'datetime',
'received_at' => 'datetime',
'created_at' => 'datetime',
diff --git a/app/app/Models/SupplierLead.php b/app/app/Models/SupplierLead.php
index 92aeaaab..468dc761 100644
--- a/app/app/Models/SupplierLead.php
+++ b/app/app/Models/SupplierLead.php
@@ -41,6 +41,11 @@ class SupplierLead extends Model
'recovered_from_csv_at',
'deals_created_count',
'error',
+ // Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display.
+ 'resolved_subject_code',
+ 'region_source',
+ 'dadata_qc',
+ 'phone_operator',
];
protected function casts(): array
@@ -52,6 +57,8 @@ class SupplierLead extends Model
'recovered_from_csv_at' => 'datetime',
'vid' => 'integer',
'deals_created_count' => 'integer',
+ 'resolved_subject_code' => 'integer',
+ 'dadata_qc' => 'integer',
];
}
diff --git a/app/app/Services/DaData/DaDataBudgetGuard.php b/app/app/Services/DaData/DaDataBudgetGuard.php
new file mode 100644
index 00000000..8d66ef1d
--- /dev/null
+++ b/app/app/Services/DaData/DaDataBudgetGuard.php
@@ -0,0 +1,47 @@
+`.
+ * `Cache::increment` на redis-сторе атомарен (INCRBY) — корректно при параллельных
+ * RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые.
+ *
+ * При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3).
+ */
+class DaDataBudgetGuard
+{
+ public function canSpend(): bool
+ {
+ $capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100;
+
+ return $this->spentTodayKopecks() < $capKopecks;
+ }
+
+ public function recordSpend(int $kopecks): void
+ {
+ if ($kopecks <= 0) {
+ return;
+ }
+
+ $key = $this->dailyKey();
+ Cache::add($key, 0, now()->addDays(2));
+ Cache::increment($key, $kopecks);
+ }
+
+ public function spentTodayKopecks(): int
+ {
+ return (int) Cache::get($this->dailyKey(), 0);
+ }
+
+ private function dailyKey(): string
+ {
+ return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d');
+ }
+}
diff --git a/app/app/Services/DaData/DaDataException.php b/app/app/Services/DaData/DaDataException.php
new file mode 100644
index 00000000..b67eb35f
--- /dev/null
+++ b/app/app/Services/DaData/DaDataException.php
@@ -0,0 +1,13 @@
+ ; X-Secret: ; body [""]
+ *
+ * Retry — только на сетевые ошибки и 5xx (4xx → сразу DaDataException, без retry).
+ * Сеть/таймаут после исчерпания retry → DaDataTimeoutException; 5xx → DaDataException.
+ * Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory).
+ */
+class DaDataPhoneClient
+{
+ private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone';
+
+ public function __construct(
+ private readonly HttpFactory $http,
+ ) {}
+
+ public function cleanPhone(string $phone): DaDataPhoneResponse
+ {
+ $cfg = (array) config('services.dadata');
+ $timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));
+ $attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1);
+ $apiKey = (string) ($cfg['api_key'] ?? '');
+ $secret = (string) ($cfg['secret'] ?? '');
+
+ $lastException = null;
+
+ for ($attempt = 0; $attempt < $attempts; $attempt++) {
+ try {
+ $response = $this->http
+ ->asJson()
+ ->acceptJson()
+ ->timeout($timeoutSec)
+ ->withHeaders([
+ 'Authorization' => 'Token '.$apiKey,
+ 'X-Secret' => $secret,
+ ])
+ ->post(self::URL, [$phone]);
+ } catch (ConnectionException $e) {
+ $lastException = new DaDataTimeoutException(
+ 'DaData connection failed: '.$e->getMessage(), 0, $e,
+ );
+
+ continue; // сеть → retry
+ }
+
+ if ($response->serverError()) {
+ $lastException = new DaDataException('DaData 5xx: HTTP '.$response->status());
+
+ continue; // 5xx → retry
+ }
+
+ if (! $response->successful()) {
+ // 4xx — клиентская ошибка, retry бессмыслен.
+ throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body());
+ }
+
+ return $this->parse($response->json());
+ }
+
+ throw $lastException ?? new DaDataException('DaData failed without a response');
+ }
+
+ /**
+ * @param mixed $body декодированный JSON (ожидается массив с одним объектом)
+ */
+ private function parse($body): DaDataPhoneResponse
+ {
+ $row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : [];
+
+ return new DaDataPhoneResponse(
+ qc: isset($row['qc']) ? (int) $row['qc'] : null,
+ qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null,
+ type: isset($row['type']) ? (string) $row['type'] : null,
+ phone: isset($row['phone']) ? (string) $row['phone'] : null,
+ provider: isset($row['provider']) ? (string) $row['provider'] : null,
+ region: isset($row['region']) ? (string) $row['region'] : null,
+ city: isset($row['city']) ? (string) $row['city'] : null,
+ timezone: isset($row['timezone']) ? (string) $row['timezone'] : null,
+ raw: $row,
+ );
+ }
+}
diff --git a/app/app/Services/DaData/DaDataPhoneResponse.php b/app/app/Services/DaData/DaDataPhoneResponse.php
new file mode 100644
index 00000000..3135c095
--- /dev/null
+++ b/app/app/Services/DaData/DaDataPhoneResponse.php
@@ -0,0 +1,26 @@
+ $raw полный сырой объект ответа (для маскированного лога)
+ */
+ public function __construct(
+ public readonly ?int $qc,
+ public readonly ?int $qcConflict,
+ public readonly ?string $type,
+ public readonly ?string $phone,
+ public readonly ?string $provider,
+ public readonly ?string $region,
+ public readonly ?string $city,
+ public readonly ?string $timezone,
+ public readonly array $raw,
+ ) {}
+}
diff --git a/app/app/Services/DaData/DaDataQualityCode.php b/app/app/Services/DaData/DaDataQualityCode.php
new file mode 100644
index 00000000..7cc7ae39
--- /dev/null
+++ b/app/app/Services/DaData/DaDataQualityCode.php
@@ -0,0 +1,27 @@
+ ранг источника для CSV-merge (выше = достовернее) */
+ public const SOURCE_RANK = [
+ 'dadata' => 4,
+ 'rossvyaz' => 3,
+ 'tag' => 2,
+ 'unknown' => 1,
+ ];
+
+ /**
+ * @param array|null $dadataResponseMasked
+ */
+ 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,
+ ) {}
+
+ /**
+ * @param array|null $dadataMasked
+ */
+ 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 {
+ return new self(
+ subjectCode: $subjectCode,
+ actualSubjectCode: $subjectCode,
+ source: $source,
+ phoneOperator: $operator,
+ qc: $qc,
+ cacheHit: $cacheHit,
+ dadataResponseMasked: $dadataMasked,
+ durationMs: $durationMs,
+ rossvyazMatched: $rossvyazMatched,
+ );
+ }
+
+ public static function fromTag(?int $subjectCode): self
+ {
+ return self::make($subjectCode, $subjectCode !== null ? 'tag' : 'unknown');
+ }
+
+ /**
+ * Восстановление из persistent state лида (retry-идемпотентность §3.11) — без DaData-вызова.
+ */
+ public static function fromSupplierLead(SupplierLead $lead): self
+ {
+ return self::make(
+ subjectCode: $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
+ source: (string) ($lead->region_source ?? 'unknown'),
+ operator: $lead->phone_operator,
+ qc: $lead->dadata_qc !== null ? (int) $lead->dadata_qc : null,
+ );
+ }
+
+ public function withCacheHit(bool $hit): self
+ {
+ return new self(
+ subjectCode: $this->subjectCode,
+ actualSubjectCode: $this->actualSubjectCode,
+ source: $this->source,
+ phoneOperator: $this->phoneOperator,
+ qc: $this->qc,
+ cacheHit: $hit,
+ dadataResponseMasked: null, // §3.11: cache-hit лог не несёт masked-ответ
+ durationMs: $this->durationMs,
+ rossvyazMatched: $this->rossvyazMatched,
+ );
+ }
+
+ /**
+ * Версия для записи в кэш (§7.3): без per-call полей (masked-ответ, длительность, cache-флаг).
+ */
+ public function forCache(): self
+ {
+ return new self(
+ subjectCode: $this->subjectCode,
+ actualSubjectCode: $this->actualSubjectCode,
+ source: $this->source,
+ phoneOperator: $this->phoneOperator,
+ qc: $this->qc,
+ cacheHit: false,
+ dadataResponseMasked: null,
+ durationMs: null,
+ rossvyazMatched: $this->rossvyazMatched,
+ );
+ }
+}
diff --git a/app/app/Services/Dto/RossvyazRecord.php b/app/app/Services/Dto/RossvyazRecord.php
new file mode 100644
index 00000000..ca4d0899
--- /dev/null
+++ b/app/app/Services/Dto/RossvyazRecord.php
@@ -0,0 +1,20 @@
+tagFallback($lead, provider: null, qc: null, masked: null, start: null);
+ }
+
+ // Persistent-idempotency: уже резолвили на предыдущем try → без DaData.
+ if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
+ return RegionResolution::fromSupplierLead($lead);
+ }
+
+ $phone = (string) $lead->phone;
+ if (! preg_match('/^7\d{10}$/', $phone)) {
+ return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
+ }
+
+ $cacheKey = $this->cacheKey($phone);
+ $cached = $this->cache->get($cacheKey);
+ if ($cached instanceof RegionResolution) {
+ return $cached->withCacheHit(true);
+ }
+
+ $resolution = $this->doResolve($lead, $phone);
+
+ $ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
+ $this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays));
+
+ return $resolution;
+ }
+
+ private function doResolve(SupplierLead $lead, string $phone): RegionResolution
+ {
+ $start = microtime(true);
+ $provider = null;
+ $qc = null;
+ $masked = null;
+
+ // 1. DaData (под дневным бюджетом).
+ if ($this->budgetGuard->canSpend()) {
+ try {
+ $dadata = $this->dadataClient->cleanPhone($phone);
+ $this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
+ $qc = $dadata->qc;
+ $provider = $dadata->provider;
+ $masked = $this->maskResponse($dadata);
+
+ if (in_array($dadata->qc, [0, 3], true)) {
+ $region = (string) ($dadata->region ?? '');
+ if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) {
+ $code = DaDataRegionMap::toSubjectCode($region);
+ if ($code !== null) {
+ return RegionResolution::make(
+ $code, 'dadata',
+ operator: $provider, qc: $qc,
+ dadataMasked: $masked, durationMs: $this->ms($start),
+ );
+ }
+ // qc=0/3, но регион не маппится → страховка Россвязью.
+ }
+ // ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем).
+ } elseif ($dadata->qc === 2 || $dadata->qc === 7) {
+ // Мусор / иностранец → Россвязь не поможет, сразу tag.
+ return $this->tagFallback($lead, $provider, $qc, $masked, $start);
+ }
+ // qc=1 → Россвязь.
+ } catch (DaDataException) {
+ // Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем.
+ }
+ }
+
+ // 2. Россвязь.
+ $rossvyaz = $this->rossvyazLookup->find($phone);
+ if ($rossvyaz !== null) {
+ $code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region);
+ if ($code !== null) {
+ return RegionResolution::make(
+ $code, 'rossvyaz',
+ operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP)
+ qc: $qc, dadataMasked: $masked,
+ durationMs: $this->ms($start), rossvyazMatched: true,
+ );
+ }
+ }
+
+ // 3. Tag-fallback.
+ return $this->tagFallback($lead, $provider, $qc, $masked, $start);
+ }
+
+ private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution
+ {
+ $tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : '');
+ $tagCode = $this->tagResolver->resolve($tag);
+
+ return RegionResolution::make(
+ $tagCode,
+ $tagCode !== null ? 'tag' : 'unknown',
+ operator: $provider,
+ qc: $qc,
+ dadataMasked: $masked,
+ durationMs: $start !== null ? $this->ms($start) : null,
+ );
+ }
+
+ private function cacheKey(string $phone): string
+ {
+ return 'phone-region:'.hash('sha256', $phone);
+ }
+
+ private function ms(float $start): int
+ {
+ return (int) round((microtime(true) - $start) * 1000);
+ }
+
+ /**
+ * @return array сырой ответ DaData с маскированным телефоном (§7.1)
+ */
+ private function maskResponse(DaDataPhoneResponse $response): array
+ {
+ $raw = $response->raw;
+ if (isset($raw['phone']) && is_string($raw['phone'])) {
+ $raw['phone'] = $this->maskPhone($raw['phone']);
+ }
+
+ return $raw;
+ }
+
+ private function maskPhone(string $phone): string
+ {
+ $digits = preg_replace('/\D+/', '', $phone) ?? '';
+ if (strlen($digits) < 8) {
+ return '***';
+ }
+
+ return substr($digits, 0, 4).'***'.substr($digits, -4);
+ }
+}
diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php
index a651627d..762c32a3 100644
--- a/app/app/Services/LeadRouter.php
+++ b/app/app/Services/LeadRouter.php
@@ -10,129 +10,219 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
+use Random\Randomizer;
/**
- * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
+ * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
+ * каскадной маршрутизацией по региону (lead region resolution §3.9).
*
* Eligibility — структурно через snapshot `project_routing_snapshots` за активную
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
- * с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации
- * (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.)
- * берутся из snapshot. Из live `projects` — только `delivered_today` (счётчик
- * остатка лимита, обновляется в течение дня) и из `tenants` — `balance_rub`
- * (live auto-pause при нулевом балансе).
+ * с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации берутся из
+ * snapshot; из live `projects` — только `delivered_today` (остаток лимита),
+ * из `tenants` — `balance_rub` + `frozen_by_balance_at` (live auto-pause).
*
- * Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) — клиент Лидерры,
- * который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно
- * получает свои оплаченные лиды по уже зафиксированному slepok'у.
+ * Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
+ * 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
+ * 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
+ * 3) запасной канал (без фильтра региона) — только если первые две пусты;
+ * сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
+ * Каждый Project помечается атрибутом `routing_step` (1/2/3).
*
- * Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
- * фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
- * гарантирован тем, через какой supplier_project пришёл лид.
+ * Отбор внутри фазы при кандидатах > cap — **взвешенный жребий по остатку лимита**
+ * (вариант D1=В): шанс ∝ остатку, но у каждого кандидата шанс > 0 (вес ≥ 1) —
+ * маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
+ * Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
*
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
*
- * Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3.
+ * Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
+ * + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
*/
class LeadRouter
{
+ public function __construct(
+ private readonly Randomizer $randomizer = new Randomizer,
+ ) {}
+
/**
- * Возвращает ONE project per tenant_id — тот, у которого наибольший остаток
- * дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
- *
- * Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
- * (клиентам), каждый тенант получает ровно ОДИН проект — с наибольшим остатком.
- * LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
- * а не число проектов, потому что входные данные уже one-per-tenant.
- *
- * Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) — tenant ещё не
- * определён, SELECT видит проекты всех tenant'ов.
+ * Возвращает ≤ cap проектов (по одному на tenant), отобранных каскадом
+ * по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
*
* @return Collection
*/
- public function matchEligibleProjects(SupplierProject $supplierProject): Collection
+ public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
{
- // Активная дата слепка вычисляется в PHP — детерминирована для всего запроса,
- // тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами.
$activeDate = $this->activeSnapshotDate();
+ $cap = LeadDistributor::CAP;
- // Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
- // match с Лидерра-проектами через snapshot (project_supplier_links для
- // DIRECT-row'ов не создаются — DIRECT supplier_projects создаются автоматически
- // при получении webhook'а без B-префикса).
- if ($supplierProject->platform === 'DIRECT') {
- $directSql = <<<'SQL'
- SELECT DISTINCT ON (snap.tenant_id)
- projects.*,
- snap.daily_limit AS snapshot_daily_limit
- FROM project_routing_snapshots snap
- INNER JOIN projects ON projects.id = snap.project_id
- WHERE snap.snapshot_date = ?::date
- AND snap.signal_type = ?
- AND LOWER(snap.signal_identifier) = LOWER(?)
- AND projects.delivered_today < snap.daily_limit
- AND EXISTS (
- SELECT 1 FROM tenants
- WHERE tenants.id = snap.tenant_id
- AND tenants.balance_rub > 0
- -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
- AND tenants.frozen_by_balance_at IS NULL
- )
- ORDER BY snap.tenant_id,
- (snap.daily_limit - projects.delivered_today) DESC,
- projects.created_at,
- projects.id
- SQL;
- $directRows = DB::connection('pgsql_supplier')->select(
- $directSql,
- [$activeDate, $supplierProject->signal_type, $supplierProject->unique_key]
- );
+ // Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
+ $exact = $resolvedSubjectCode !== null
+ ? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
+ : collect();
+ $selected = $this->weightedPick($exact, $cap);
+ $this->tagStep($selected, 1);
- $this->logIfNoSnapshot($directRows, $supplierProject, $activeDate);
-
- return Project::hydrate($directRows)->values();
+ if ($selected->count() >= $cap) {
+ return $selected->take($cap)->values();
}
- // Existing B1/B2/B3 path — explicit project_supplier_links pivot.
- $sql = <<<'SQL'
+ // Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
+ $allRu = $this->queryCandidates(
+ $activeDate, $supplierProject, 'all_ru', null,
+ $selected->pluck('tenant_id')->all(),
+ );
+ $pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
+ $this->tagStep($pickedRu, 2);
+ $combined = $selected->concat($pickedRu);
+
+ if ($combined->isNotEmpty()) {
+ return $combined->take($cap)->values();
+ }
+
+ // Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
+ $fallback = $this->weightedPick(
+ $this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
+ $cap,
+ );
+ $this->tagStep($fallback, 3);
+
+ $this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);
+
+ return $fallback->take($cap)->values();
+ }
+
+ /**
+ * Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
+ * regionFilter ∈ exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
+ * упорядоченных по остатку лимита DESC, created_at, id; жребий — поверх в PHP.
+ *
+ * @param list $excludeTenantIds
+ * @return Collection
+ */
+ private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
+ {
+ $bindings = [$activeDate];
+
+ if ($sp->platform === 'DIRECT') {
+ // DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier.
+ $sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
+ $bindings[] = $sp->signal_type;
+ $bindings[] = $sp->unique_key;
+ } else {
+ $sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
+ WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
+ $bindings[] = $sp->id;
+ }
+
+ $regionWhere = '';
+ if ($regionFilter === 'exact') {
+ $regionWhere = 'AND ?::int = ANY(snap.regions)';
+ $bindings[] = $code;
+ } elseif ($regionFilter === 'all_ru') {
+ $regionWhere = "AND snap.regions = '{}'::int[]";
+ }
+
+ $excludeWhere = '';
+ if ($excludeTenantIds !== []) {
+ $placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
+ $excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
+ foreach ($excludeTenantIds as $tid) {
+ $bindings[] = $tid;
+ }
+ }
+
+ $sql = << 0
- -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
+ $regionWhere
+ $excludeWhere
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
- $rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]);
- $this->logIfNoSnapshot($rows, $supplierProject, $activeDate);
-
- return Project::hydrate($rows)->values();
+ return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
}
/**
- * Активная дата слепка по правилу slepok-инварианта:
- * до 21:00 МСК — сегодняшняя дата;
- * с 21:00 МСК — завтрашняя.
+ * Взвешенный жребий без возврата (вариант D1=В): отбирает ≤ $n кандидатов,
+ * вероятность ∝ остатку лимита, вес ≥ 1 у каждого (мелкие не отрезаются).
+ * При кандидатах ≤ $n — возвращает всех в исходном SQL-порядке (детерминизм).
*
- * Spec §4.2.3.
+ * @param Collection $candidates
+ * @return Collection
+ */
+ private function weightedPick(Collection $candidates, int $n): Collection
+ {
+ if ($n <= 0) {
+ return collect();
+ }
+
+ $pool = $candidates->values()->all();
+ if (count($pool) <= $n) {
+ return collect($pool);
+ }
+
+ $picked = [];
+ for ($i = 0; $i < $n && $pool !== []; $i++) {
+ $total = 0;
+ foreach ($pool as $p) {
+ $total += $this->weightOf($p);
+ }
+
+ $roll = $this->randomizer->getInt(1, $total);
+ $acc = 0;
+ $winner = 0;
+ foreach ($pool as $idx => $p) {
+ $acc += $this->weightOf($p);
+ if ($roll <= $acc) {
+ $winner = $idx;
+ break;
+ }
+ }
+
+ $picked[] = $pool[$winner];
+ array_splice($pool, $winner, 1);
+ }
+
+ return collect($picked);
+ }
+
+ private function weightOf(Project $project): int
+ {
+ $remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;
+
+ return max(1, $remaining);
+ }
+
+ /**
+ * @param Collection $projects
+ */
+ private function tagStep(Collection $projects, int $step): void
+ {
+ foreach ($projects as $project) {
+ $project->setAttribute('routing_step', $step);
+ }
+ }
+
+ /**
+ * Активная дата слепка: до 21:00 МСК — сегодня, с 21:00 МСК — завтра (§4.2.3).
*/
private function activeSnapshotDate(): string
{
@@ -144,11 +234,11 @@ class LeadRouter
}
/**
- * Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки
- * snapshot'а — это значит, что cron `SnapshotProjectRoutingJob` не отработал.
- * (Если строки есть, но ни одна не сматчилась — это валидный 0-результат, не алерт.)
+ * Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
+ * snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
+ * результат при наличии snapshot'ов — не алерт.
*
- * @param array $rows
+ * @param array $rows
*/
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
{
diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php
index a975b481..270a537d 100644
--- a/app/app/Services/MonthlyPartitionManager.php
+++ b/app/app/Services/MonthlyPartitionManager.php
@@ -59,6 +59,8 @@ class MonthlyPartitionManager
'saas_admin_audit_log' => 'created_at',
// Slepok routing (Этап 2, 27.05.2026)
'project_routing_snapshots' => 'snapshot_date',
+ // Lead region resolution (Session 1, 31.05.2026)
+ 'lead_region_resolution_log' => 'received_at',
];
/**
diff --git a/app/app/Services/RossvyazPrefixLookup.php b/app/app/Services/RossvyazPrefixLookup.php
new file mode 100644
index 00000000..c465973a
--- /dev/null
+++ b/app/app/Services/RossvyazPrefixLookup.php
@@ -0,0 +1,60 @@
+selectOne(
+ 'SELECT region, operator, subject_code
+ FROM phone_ranges
+ WHERE def_code = ? AND from_num <= ? AND to_num >= ?
+ ORDER BY (to_num - from_num) ASC
+ LIMIT 1',
+ [$defCode, $subscriber, $subscriber],
+ );
+
+ if ($row === null) {
+ return null;
+ }
+
+ return new RossvyazRecord(
+ subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null,
+ region: (string) $row->region,
+ operator: (string) $row->operator,
+ );
+ }
+}
diff --git a/app/app/Support/DaDataRegionMap.php b/app/app/Support/DaDataRegionMap.php
new file mode 100644
index 00000000..205091d7
--- /dev/null
+++ b/app/app/Support/DaDataRegionMap.php
@@ -0,0 +1,53 @@
+
+ */
+ public const AMBIGUOUS_REGIONS = [
+ 'Санкт-Петербург и область',
+ 'Москва и область',
+ ];
+
+ /**
+ * Ручные переопределения для имён DaData, не совпадающих с RussianRegions.
+ * На старте пуст — заполняется по findings со staging-smoke.
+ *
+ * @var array
+ */
+ public const OVERRIDES = [];
+
+ public static function toSubjectCode(string $name): ?int
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return null;
+ }
+
+ return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null;
+ }
+
+ public static function isAmbiguous(string $name): bool
+ {
+ return in_array(trim($name), self::AMBIGUOUS_REGIONS, true);
+ }
+}
diff --git a/app/app/Support/RussianRegions.php b/app/app/Support/RussianRegions.php
index 21b7e243..e8a56747 100644
--- a/app/app/Support/RussianRegions.php
+++ b/app/app/Support/RussianRegions.php
@@ -114,9 +114,97 @@ final class RussianRegions
89 => 'Ямало-Ненецкий автономный округ',
];
+ /**
+ * Алиасы нестандартных форм реестра Россвязи → каноничное имя субъекта.
+ * Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская» —
+ * перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» — спец-форма.
+ *
+ * @var array
+ */
+ private const REGION_ALIASES = [
+ 'г. Москва' => 'Москва',
+ 'Город Москва' => 'Москва',
+ 'г. Санкт-Петербург' => 'Санкт-Петербург',
+ 'г. Санкт - Петербург' => 'Санкт-Петербург',
+ 'г. Севастополь' => 'Севастополь',
+ 'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
+ 'Чувашская Республика - Чувашия' => 'Чувашская Республика',
+ 'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
+ 'Кемеровская область - Кузбасс область' => 'Кемеровская область',
+ 'Кемеровская область - Кузбасс' => 'Кемеровская область',
+ ];
+
/** @return array name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
+
+ /**
+ * Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
+ *
+ * Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
+ * (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» → «Московская обл.»),
+ * с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
+ * Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
+ * «Москва и Московская область», «г.о. Тольятти») → null.
+ */
+ public static function canonicalRegionName(string $raw): ?string
+ {
+ $segment = self::lastRegionSegment($raw);
+ if ($segment === '') {
+ return null;
+ }
+
+ // ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
+ // ловим по двум устойчивым маркерам до общих правил.
+ if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
+ return 'Ханты-Мансийский автономный округ — Югра';
+ }
+
+ if (isset(self::REGION_ALIASES[$segment])) {
+ return self::REGION_ALIASES[$segment];
+ }
+
+ // «обл.» → «область»; « АО» → « автономный округ».
+ $name = (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
+ $name = (string) preg_replace('/\s+АО$/u', ' автономный округ', $name);
+ // Дефис с пробелами → длинное тире (эталон: «Республика Северная Осетия — Алания»).
+ // Безопасно: ни одно каноническое имя не содержит дефис, окружённый пробелами
+ // (составные имена вроде «Кабардино-Балкарская» используют дефис без пробелов).
+ $name = str_replace(' - ', ' — ', $name);
+
+ if (isset(self::nameToCode()[$name])) {
+ return $name;
+ }
+
+ // Перевёрнутый порядок «Республика X» → «X Республика» (Удмуртская/Чеченская/
+ // Чувашская/Кабардино-Балкарская/Карачаево-Черкесская, Донецкая Народная/
+ // Луганская Народная). Республика-first каноны (Татарстан, Карелия…) уже
+ // отловлены прямым попаданием выше.
+ if (preg_match('/^Республика\s+(.+)$/u', $name, $m) === 1) {
+ $reordered = trim($m[1]).' Республика';
+ if (isset(self::nameToCode()[$reordered])) {
+ return $reordered;
+ }
+ }
+
+ return null;
+ }
+
+ /** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
+ public static function resolveSubjectCode(string $raw): ?int
+ {
+ $name = self::canonicalRegionName($raw);
+
+ return $name === null ? null : (self::nameToCode()[$name] ?? null);
+ }
+
+ /** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
+ private static function lastRegionSegment(string $raw): string
+ {
+ $parts = explode('|', $raw);
+
+ return trim((string) end($parts));
+ }
}
diff --git a/app/config/services.php b/app/config/services.php
index 20f1e7e9..1f046757 100644
--- a/app/config/services.php
+++ b/app/config/services.php
@@ -42,4 +42,17 @@ return [
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
],
+ // DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
+ // Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки.
+ 'dadata' => [
+ 'api_key' => env('DADATA_API_KEY'),
+ 'secret' => env('DADATA_SECRET'),
+ 'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
+ 'retries' => (int) env('DADATA_RETRIES', 1),
+ 'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
+ 'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
+ 'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
+ 'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
+ ],
+
];
diff --git a/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php b/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php
new file mode 100644
index 00000000..5d89ff04
--- /dev/null
+++ b/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php
@@ -0,0 +1,170 @@
+ok) {
+ DB::statement('RESET ROLE');
+ }
+ } catch (Throwable) {
+ // окружение без роли — продолжаем как superuser
+ }
+
+ DB::unprepared(<<<'SQL'
+ -- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым)
+ CREATE TABLE phone_ranges_imports (
+ id BIGSERIAL PRIMARY KEY,
+ imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ source_url TEXT NOT NULL,
+ rows_inserted INTEGER NOT NULL DEFAULT 0,
+ rows_updated INTEGER NOT NULL DEFAULT 0,
+ checksum_sha256 TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'in_progress'
+ CHECK (status IN ('in_progress','completed','failed','rolled_back')),
+ error TEXT,
+ completed_at TIMESTAMPTZ
+ );
+ COMMENT ON TABLE phone_ranges_imports IS
+ 'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
+
+ -- 2. phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS — публичные данные)
+ CREATE TABLE phone_ranges (
+ id BIGSERIAL PRIMARY KEY,
+ def_code SMALLINT NOT NULL,
+ from_num BIGINT NOT NULL,
+ to_num BIGINT NOT NULL,
+ operator TEXT NOT NULL,
+ region TEXT NOT NULL,
+ region_normalized TEXT,
+ subject_code SMALLINT,
+ 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-импортом.';
+
+ GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
+
+ -- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log)
+ CREATE TABLE lead_region_resolution_log (
+ id BIGSERIAL,
+ supplier_lead_id BIGINT NOT NULL,
+ received_at TIMESTAMPTZ NOT NULL,
+ phone_masked TEXT NOT NULL,
+ subject_code_resolved SMALLINT,
+ subject_code_from_tag SMALLINT,
+ region_source TEXT NOT NULL
+ CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
+ dadata_qc SMALLINT,
+ dadata_provider TEXT,
+ dadata_type TEXT,
+ dadata_response_masked JSONB,
+ rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
+ actual_subject_code SMALLINT
+ CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
+ substituted_subject_code SMALLINT
+ CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
+ routing_step SMALLINT
+ CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
+ phone_operator TEXT,
+ cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
+ duration_ms INTEGER,
+ resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ 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_source ON lead_region_resolution_log (region_source, received_at);
+ COMMENT ON TABLE lead_region_resolution_log IS
+ 'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).';
+
+ GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
+ GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
+
+ -- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2).
+ CREATE TABLE lead_region_resolution_log_y2026_m05
+ PARTITION OF lead_region_resolution_log
+ FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
+ CREATE TABLE lead_region_resolution_log_y2026_m06
+ PARTITION OF lead_region_resolution_log
+ FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
+
+ -- 4. supplier_leads: +4 колонки (denormalized display + persistent idempotency для retry).
+ 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;
+
+ -- 5. deals: +2 колонки (UI-карточка + флаг подмены региона).
+ ALTER TABLE deals
+ ADD COLUMN phone_operator TEXT,
+ ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
+ SQL);
+
+ // Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней).
+ $exists = DB::table('system_settings')
+ ->where('key', 'partition_retention_months_lead_region_resolution_log')
+ ->exists();
+ if (! $exists) {
+ DB::table('system_settings')->insert([
+ 'key' => 'partition_retention_months_lead_region_resolution_log',
+ 'value' => '12',
+ 'type' => 'int',
+ 'description' => 'Retention в месяцах для lead_region_resolution_log (~365 дней)',
+ 'updated_at' => now(),
+ ]);
+ }
+ }
+
+ public function down(): void
+ {
+ try {
+ DB::statement('SET ROLE crm_migrator');
+ $canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
+ if (! $canCreate || ! $canCreate->ok) {
+ DB::statement('RESET ROLE');
+ }
+ } catch (Throwable) {
+ // окружение без роли — продолжаем как superuser
+ }
+
+ DB::unprepared(<<<'SQL'
+ ALTER TABLE deals
+ DROP COLUMN IF EXISTS phone_operator,
+ DROP COLUMN IF EXISTS region_substituted;
+
+ ALTER TABLE supplier_leads
+ DROP COLUMN IF EXISTS resolved_subject_code,
+ DROP COLUMN IF EXISTS region_source,
+ DROP COLUMN IF EXISTS dadata_qc,
+ DROP COLUMN IF EXISTS phone_operator;
+
+ DROP TABLE IF EXISTS lead_region_resolution_log CASCADE;
+ DROP TABLE IF EXISTS phone_ranges CASCADE;
+ DROP TABLE IF EXISTS phone_ranges_imports CASCADE;
+ SQL);
+
+ DB::table('system_settings')
+ ->where('key', 'partition_retention_months_lead_region_resolution_log')
+ ->delete();
+ }
+};
diff --git a/app/tests/Feature/Console/DealsBackfillRegionCityCommandTest.php b/app/tests/Feature/Console/DealsBackfillRegionCityCommandTest.php
new file mode 100644
index 00000000..c9df4f89
--- /dev/null
+++ b/app/tests/Feature/Console/DealsBackfillRegionCityCommandTest.php
@@ -0,0 +1,102 @@
+create(['balance_rub' => '100000.00']);
+ $project = Project::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'signal_type' => 'site',
+ 'signal_identifier' => 'backfill-city.ru',
+ 'is_active' => true,
+ ]);
+
+ DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
+ $deal = Deal::create([
+ 'tenant_id' => $tenant->id,
+ 'project_id' => $project->id,
+ 'phone' => '79161234567',
+ 'phones' => ['79161234567'],
+ 'status' => 'new',
+ 'received_at' => now(),
+ 'subject_code' => $resolvedCode,
+ 'city' => $city,
+ ]);
+ DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
+
+ $lead = SupplierLead::factory()->create([
+ 'platform' => 'B1',
+ 'phone' => '79161234567',
+ 'resolved_subject_code' => $resolvedCode,
+ 'region_source' => $resolvedCode !== null ? 'dadata' : 'unknown',
+ ]);
+
+ DB::connection('pgsql_supplier')->table('supplier_lead_deliveries')->insert([
+ 'supplier_lead_id' => $lead->id,
+ 'tenant_id' => $tenant->id,
+ 'deal_id' => $deal->id,
+ 'created_at' => now(),
+ ]);
+
+ return [$tenant->id, $deal->id];
+}
+
+function dealCity(int $dealId): ?string
+{
+ // BYPASSRLS чтение (как и сам бэкфилл) — без tenant-контекста.
+ return DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->value('city');
+}
+
+it('backfills deal city from the lead resolved region code', function (): void {
+ [, $dealId] = seedDealWithResolvedLead(29); // 29 → Красноярский край
+
+ $this->artisan('deals:backfill-region-city')->assertSuccessful();
+
+ expect(dealCity($dealId))->toBe('Красноярский край');
+});
+
+it('does not touch deals that already have a city', function (): void {
+ [, $dealId] = seedDealWithResolvedLead(29, city: 'Уже стоит');
+
+ $this->artisan('deals:backfill-region-city')->assertSuccessful();
+
+ expect(dealCity($dealId))->toBe('Уже стоит');
+});
+
+it('dry-run reports candidates without writing', function (): void {
+ [, $dealId] = seedDealWithResolvedLead(29);
+
+ $this->artisan('deals:backfill-region-city', ['--dry-run' => true])->assertSuccessful();
+
+ expect(dealCity($dealId))->toBeNull();
+});
+
+it('leaves city null when the lead has no resolved region', function (): void {
+ [, $dealId] = seedDealWithResolvedLead(null);
+
+ $this->artisan('deals:backfill-region-city')->assertSuccessful();
+
+ expect(dealCity($dealId))->toBeNull();
+});
diff --git a/app/tests/Feature/Console/PhoneRangesImportCommandTest.php b/app/tests/Feature/Console/PhoneRangesImportCommandTest.php
new file mode 100644
index 00000000..5b80a170
--- /dev/null
+++ b/app/tests/Feature/Console/PhoneRangesImportCommandTest.php
@@ -0,0 +1,124 @@
+artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
+ ->assertSuccessful();
+
+ // Staging построен (dry-run не свапает и не дропает staging — данные видны в той же tx).
+ expect(DB::table('phone_ranges_staging')->count())->toBe(3);
+
+ $r495 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 495');
+ $r921 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 921');
+ $r999 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 999');
+
+ expect((int) $r495->subject_code)->toBe(82) // Москва
+ ->and((int) $r921->subject_code)->toBe(83) // Санкт-Петербург
+ ->and($r999->subject_code)->toBeNull(); // Атлантида — не маппится
+
+ // Живой phone_ranges не тронут (свапа не было).
+ expect(DB::table('phone_ranges')->count())->toBe(0);
+
+ // Журнал импорта: dry-run → rolled_back, несматчившийся регион в error.
+ $imp = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
+ expect($imp->status)->toBe('rolled_back')
+ ->and($imp->error)->toContain('Атлантида');
+});
+
+it('maps all matched rows and counts unmatched separately', function (): void {
+ $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
+ ->assertSuccessful();
+
+ $matched = DB::table('phone_ranges_staging')->whereNotNull('subject_code')->count();
+ $unmatched = DB::table('phone_ranges_staging')->whereNull('subject_code')->count();
+
+ expect($matched)->toBe(2)->and($unmatched)->toBe(1);
+});
+
+it('skips swap when checksum matches a completed import (idempotency)', function (): void {
+ $checksum = hash_file('sha256', rossvyazFixture());
+ DB::table('phone_ranges_imports')->insert([
+ 'source_url' => 'https://rossvyaz.gov.ru/prev',
+ 'checksum_sha256' => $checksum,
+ 'status' => 'completed',
+ 'imported_at' => now(),
+ 'completed_at' => now(),
+ ]);
+
+ // Не dry-run: но checksum совпал с completed → короткое замыкание ДО свапа.
+ $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture()])
+ ->assertSuccessful();
+
+ expect(DB::table('phone_ranges')->count())->toBe(0); // свапа не было
+
+ $latest = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
+ expect($latest->status)->toBe('rolled_back');
+});
+
+it('force flag bypasses idempotency note even with matching checksum', function (): void {
+ // С --dry-run + --force: идемпотентность игнорируется, но dry-run всё равно не свапает.
+ $checksum = hash_file('sha256', rossvyazFixture());
+ DB::table('phone_ranges_imports')->insert([
+ 'source_url' => 'https://rossvyaz.gov.ru/prev',
+ 'checksum_sha256' => $checksum,
+ 'status' => 'completed',
+ 'imported_at' => now(),
+ 'completed_at' => now(),
+ ]);
+
+ $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true, '--force' => true])
+ ->assertSuccessful();
+
+ // --force обошёл idempotency → staging построен заново (3 строки), но dry-run не свапнул.
+ expect(DB::table('phone_ranges_staging')->count())->toBe(3);
+ expect(DB::table('phone_ranges')->count())->toBe(0);
+});
+
+it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
+ // Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
+ // pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
+ // и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
+ $this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
+ ->assertSuccessful();
+
+ $moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
+ $orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
+ $udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
+ $togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
+
+ expect((int) $moscow->subject_code)->toBe(82)
+ ->and($moscow->region_normalized)->toBe('Москва')
+ ->and((int) $orenburg->subject_code)->toBe(62)
+ ->and($orenburg->region_normalized)->toBe('Оренбургская область')
+ ->and((int) $udmurtia->subject_code)->toBe(21)
+ ->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
+ ->and($togliatti->subject_code)->toBeNull()
+ ->and($togliatti->region_normalized)->toBeNull();
+});
+
+it('rebuilds staging id even after the live id default was dropped (post-swap state)', function (): void {
+ // После первого atomic-swap исходная id-последовательность уничтожается
+ // (DROP phone_ranges_old CASCADE), и live.id остаётся без DEFAULT. Повторный
+ // импорт обязан выдать staging.id из собственной последовательности, а не упасть
+ // на NOT NULL. Симулируем это, сняв default у phone_ranges.id.
+ DB::connection('pgsql_supplier')->statement('ALTER TABLE phone_ranges ALTER COLUMN id DROP DEFAULT');
+
+ $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
+ ->assertSuccessful();
+
+ expect(DB::table('phone_ranges_staging')->count())->toBe(3)
+ ->and(DB::table('phone_ranges_staging')->whereNull('id')->count())->toBe(0);
+});
diff --git a/app/tests/Feature/Console/PhoneRegionSmokeCommandTest.php b/app/tests/Feature/Console/PhoneRegionSmokeCommandTest.php
new file mode 100644
index 00000000..f2bb89d2
--- /dev/null
+++ b/app/tests/Feature/Console/PhoneRegionSmokeCommandTest.php
@@ -0,0 +1,38 @@
+ 'k',
+ 'services.dadata.secret' => 's',
+ 'services.dadata.daily_cap_rub' => 100000,
+ ]);
+});
+
+it('phone-region:smoke prints the resolution and writes nothing to DB', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([[
+ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
+ ]], 200)]);
+
+ $this->artisan('phone-region:smoke', ['--phone' => '79161234567'])
+ ->assertSuccessful()
+ ->expectsOutputToContain('dadata')
+ ->expectsOutputToContain('Москва');
+
+ // Smoke не пишет в БД.
+ expect(DB::table('lead_region_resolution_log')->count())->toBe(0);
+ expect(DB::table('deals')->count())->toBe(0);
+});
+
+it('phone-region:smoke fails without --phone', function (): void {
+ $this->artisan('phone-region:smoke')->assertFailed();
+});
diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
new file mode 100644
index 00000000..7af05a10
--- /dev/null
+++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
@@ -0,0 +1,229 @@
+seed(PricingTierSeeder::class);
+ DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
+ config([
+ 'services.dadata.enabled' => true,
+ 'services.dadata.api_key' => 'k',
+ 'services.dadata.secret' => 's',
+ 'services.dadata.daily_cap_rub' => 100000,
+ ]);
+});
+
+function runRegionJob(int $supplierLeadId): void
+{
+ (new RouteSupplierLeadJob($supplierLeadId))->handle(
+ app(LeadRouter::class),
+ app(SupplierProjectResolver::class),
+ app(NotificationService::class),
+ app(LedgerService::class),
+ app(LeadDistributor::class),
+ app(RegionTagResolver::class),
+ );
+}
+
+/**
+ * Создаёт маршрутизируемый лид: supplier B1 site + tenant с балансом + project + snapshot.
+ *
+ * @return array{0: SupplierLead, 1: Project, 2: Tenant, 3: SupplierProject}
+ */
+function seedRoutableLead(string $regions, string $tag, string $phone, string $key = 'vashinvestor.ru'): array
+{
+ $supplier = SupplierProject::factory()->create([
+ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
+ ]);
+ $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
+ $project = Project::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'signal_type' => 'site', 'signal_identifier' => $key,
+ 'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0,
+ 'daily_limit_target' => 100,
+ ]);
+ linkProjectToSupplier($project, $supplier);
+ createRoutingSnapshotFromProject($project, dailyLimit: 100, regions: $regions);
+
+ $vid = 432176649;
+ $lead = SupplierLead::factory()->create([
+ 'supplier_project_id' => null,
+ 'platform' => 'B1',
+ 'vid' => $vid,
+ 'phone' => $phone,
+ 'received_at' => now(),
+ 'raw_payload' => [
+ 'vid' => $vid, 'project' => "B1_{$key}", 'tag' => $tag,
+ 'phone' => $phone, 'phones' => [$phone], 'time' => now()->getTimestamp(),
+ ],
+ ]);
+
+ return [$lead, $project, $tenant, $supplier];
+}
+
+function dealFor(int $tenantId, int $projectId): ?Deal
+{
+ DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'");
+ $deal = Deal::query()->where('project_id', $projectId)->first();
+ DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
+
+ return $deal;
+}
+
+it('lead with phone uses dadata region, not the tag', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([[
+ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', 'phone' => '+7 916 123-45-67',
+ ]], 200)]);
+ // tag='Санкт-Петербург' (дал бы 83), но телефон резолвится в Москву (82).
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Санкт-Петербург', phone: '79161234567');
+
+ runRegionJob($lead->id);
+
+ $lead->refresh();
+ expect($lead->resolved_subject_code)->toBe(82)
+ ->and($lead->region_source)->toBe('dadata')
+ ->and($lead->phone_operator)->toBe('МТС');
+
+ $deal = dealFor($tenant->id, $project->id);
+ expect($deal)->not->toBeNull()
+ ->and((int) $deal->subject_code)->toBe(82) // регион из DaData, не из тега (83)
+ ->and((bool) $deal->region_substituted)->toBeFalse()
+ ->and($deal->phone_operator)->toBe('МТС');
+});
+
+it('logs exactly one region resolution row per lead', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([[
+ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
+ ]], 200)]);
+ [$lead] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
+
+ runRegionJob($lead->id);
+
+ $rows = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->get();
+ expect($rows)->toHaveCount(1);
+ expect($rows->first()->region_source)->toBe('dadata');
+ // Телефон в логе маскирован (не сырой номер) — §7.1.
+ expect($rows->first()->phone_masked)->not->toBe('79161234567');
+});
+
+it('lead with invalid phone falls back to tag', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
+ // Невалидный телефон → DaData не дёргается → tag (Москва=82).
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '123');
+
+ runRegionJob($lead->id);
+
+ $lead->refresh();
+ expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
+ Http::assertNothingSent();
+});
+
+it('lead with resolver disabled via flag uses tag', function (): void {
+ config(['services.dadata.enabled' => false]);
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
+
+ runRegionJob($lead->id);
+
+ $lead->refresh();
+ expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
+ Http::assertNothingSent();
+});
+
+it('persistent idempotency: pre-resolved lead does not re-call dadata', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
+ // Эмулируем предыдущий try: резолв уже персистнут.
+ $lead->update(['resolved_subject_code' => 83, 'region_source' => 'rossvyaz', 'phone_operator' => 'МегаФон']);
+
+ runRegionJob($lead->id);
+
+ Http::assertNothingSent(); // §3.11 — нет двойной оплаты DaData
+ $lead->refresh();
+ expect($lead->resolved_subject_code)->toBe(83)->and($lead->region_source)->toBe('rossvyaz');
+});
+
+it('step-3 fallback substitutes subject_code to client region and flags region_substituted', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([[
+ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
+ ]], 200)]);
+ // Лид по Москве (82), но клиент подписан только на Питер (83): точных нет, «вся РФ» нет → шаг 3.
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
+
+ runRegionJob($lead->id);
+
+ $deal = dealFor($tenant->id, $project->id);
+ expect($deal)->not->toBeNull()
+ ->and((int) $deal->subject_code)->toBe(83) // подменён на регион клиента (Питер)
+ ->and((bool) $deal->region_substituted)->toBeTrue();
+
+ // Настоящий регион (Москва=82) сохранён в журнале как actual_subject_code.
+ $log = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->first();
+ expect((int) $log->actual_subject_code)->toBe(82)
+ ->and((int) $log->substituted_subject_code)->toBe(83);
+});
+
+it('csv-merge updates subject_code and operator when webhook resolution outranks tag (dadata)', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
+
+ // CSV-recovered сделка: source_crm_id=null, регион из тега «неправильный» (53 = ЛО).
+ DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
+ $csvDeal = Deal::create([
+ 'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
+ 'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
+ 'received_at' => now(), 'subject_code' => 53,
+ ]);
+ DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
+
+ runRegionJob($lead->id);
+
+ $merged = dealFor($tenant->id, $project->id);
+ expect((int) $merged->id)->toBe($csvDeal->id) // merge в существующую, не новая
+ ->and((int) $merged->subject_code)->toBe(82) // обновлено DaData (82) поверх tag (53)
+ ->and($merged->phone_operator)->toBe('МТС')
+ ->and((int) $merged->source_crm_id)->toBe($lead->vid);
+
+ DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
+ expect(Deal::query()->where('project_id', $project->id)->count())->toBe(1); // второй сделки нет
+ DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
+});
+
+it('csv-merge does not overwrite subject_code when webhook resolution is tag-level', function (): void {
+ config(['services.dadata.enabled' => false]); // резолвер выключен → source='tag' (rank не выше CSV-tag)
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
+
+ DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
+ Deal::create([
+ 'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
+ 'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
+ 'received_at' => now(), 'subject_code' => 53,
+ ]);
+ DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
+
+ runRegionJob($lead->id);
+
+ $merged = dealFor($tenant->id, $project->id);
+ expect((int) $merged->subject_code)->toBe(53); // tag не выше tag → регион не тронут
+});
diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
index 26f4420b..55f634c8 100644
--- a/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
+++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
+use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
@@ -18,6 +19,7 @@ use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Http;
use Mockery as M;
use Random\Engine\Mt19937;
use Random\Randomizer;
@@ -578,7 +580,7 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
]);
// LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at.
- \App\Models\LeadCharge::factory()->create([
+ LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'deal_id' => $csvDeal->id,
'deal_received_at' => $csvDeal->received_at,
@@ -631,3 +633,35 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
// Никаких дублей deals — только один с этим vid.
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
});
+
+it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([[
+ 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
+ ]], 200)]);
+ config([
+ 'services.dadata.enabled' => true,
+ 'services.dadata.api_key' => 'k',
+ 'services.dadata.secret' => 's',
+ 'services.dadata.daily_cap_rub' => 100000,
+ ]);
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
+
+ runRouteJob($lead->id);
+
+ // deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
+ $deal = dealFor($tenant->id, $project->id);
+ expect($deal)->not->toBeNull()
+ ->and($deal->city)->toBe('Москва');
+});
+
+it('leaves deal city null when region is unknown', function (): void {
+ config(['services.dadata.enabled' => false]);
+ // Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
+ [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');
+
+ runRouteJob($lead->id);
+
+ $deal = dealFor($tenant->id, $project->id);
+ expect($deal)->not->toBeNull()
+ ->and($deal->city)->toBeNull();
+});
diff --git a/app/tests/Feature/Migrations/PhoneRangesMigrationTest.php b/app/tests/Feature/Migrations/PhoneRangesMigrationTest.php
new file mode 100644
index 00000000..2168d4f1
--- /dev/null
+++ b/app/tests/Feature/Migrations/PhoneRangesMigrationTest.php
@@ -0,0 +1,51 @@
+t)->not->toBeNull();
+
+ $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges'"))
+ ->pluck('column_name')->all();
+
+ expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
+});
+
+it('creates phone_ranges_imports journal table', function (): void {
+ expect(DB::selectOne("SELECT to_regclass('public.phone_ranges_imports') AS t")->t)->not->toBeNull();
+
+ $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges_imports'"))
+ ->pluck('column_name')->all();
+
+ expect($cols)->toContain('source_url', 'checksum_sha256', 'status', 'rows_inserted', 'rows_updated');
+});
+
+it('creates lead_region_resolution_log as a partitioned table', function (): void {
+ $partitioned = DB::selectOne(
+ "SELECT 1 AS ok
+ FROM pg_partitioned_table pt
+ JOIN pg_class c ON c.oid = pt.partrelid
+ WHERE c.relname = 'lead_region_resolution_log'"
+ );
+
+ expect($partitioned)->not->toBeNull();
+});
+
+it('adds resolution columns to supplier_leads', function (): void {
+ $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_leads'"))
+ ->pluck('column_name')->all();
+
+ expect($cols)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
+});
+
+it('adds resolution columns to deals', function (): void {
+ $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'deals'"))
+ ->pluck('column_name')->all();
+
+ expect($cols)->toContain('phone_operator', 'region_substituted');
+});
diff --git a/app/tests/Feature/PartitionsCreateMonthsTest.php b/app/tests/Feature/PartitionsCreateMonthsTest.php
index 6f412aa9..35dd9cf3 100644
--- a/app/tests/Feature/PartitionsCreateMonthsTest.php
+++ b/app/tests/Feature/PartitionsCreateMonthsTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Services\MonthlyPartitionManager;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@@ -76,10 +77,11 @@ test('идемпотентность: повторный запуск не па
expect($afterSecond)->toBe($afterFirst);
- // Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
- // (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
+ // Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
+ // (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
+ $expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
$output = Artisan::output();
- expect($output)->toContain('0 created, 48 skipped');
+ expect($output)->toContain("0 created, {$expectedSkipped} skipped");
});
test('--ahead=0 создаёт только текущий месяц', function () {
diff --git a/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php b/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php
new file mode 100644
index 00000000..e1939d56
--- /dev/null
+++ b/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php
@@ -0,0 +1,42 @@
+ 10]); // 1000 копеек
+ $guard = app(DaDataBudgetGuard::class);
+
+ expect($guard->canSpend())->toBeTrue();
+
+ $guard->recordSpend(500);
+
+ expect($guard->canSpend())->toBeTrue()
+ ->and($guard->spentTodayKopecks())->toBe(500);
+});
+
+it('blocks spend once the daily cap is reached', function (): void {
+ config(['services.dadata.daily_cap_rub' => 1]); // 100 копеек
+ $guard = app(DaDataBudgetGuard::class);
+
+ $guard->recordSpend(100);
+
+ expect($guard->canSpend())->toBeFalse();
+});
+
+it('accumulates spend across multiple calls', function (): void {
+ config(['services.dadata.daily_cap_rub' => 100]);
+ $guard = app(DaDataBudgetGuard::class);
+
+ $guard->recordSpend(30);
+ $guard->recordSpend(70);
+
+ expect($guard->spentTodayKopecks())->toBe(100);
+});
+
+it('starts at zero spend for a fresh day', function (): void {
+ $guard = app(DaDataBudgetGuard::class);
+
+ expect($guard->spentTodayKopecks())->toBe(0);
+});
diff --git a/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php b/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php
new file mode 100644
index 00000000..262196d3
--- /dev/null
+++ b/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php
@@ -0,0 +1,80 @@
+ Http::response([[
+ 'qc' => 0, 'qc_conflict' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34',
+ 'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', 'city' => null, 'timezone' => 'UTC+3',
+ ]], 200)]);
+
+ $resp = app(DaDataPhoneClient::class)->cleanPhone('79215551234');
+
+ expect($resp->qc)->toBe(0)
+ ->and($resp->provider)->toBe('МегаФон')
+ ->and($resp->region)->toBe('Санкт-Петербург и область')
+ ->and($resp->type)->toBe('Мобильный')
+ ->and($resp->raw)->toBeArray();
+});
+
+it('parses qc=3 multiple response', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([[
+ 'qc' => 3, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный',
+ ]], 200)]);
+
+ expect(app(DaDataPhoneClient::class)->cleanPhone('79991234567')->qc)->toBe(3);
+});
+
+it('sends Token auth, X-Secret header and json-array body', function (): void {
+ config(['services.dadata.api_key' => 'KEY', 'services.dadata.secret' => 'SEC']);
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
+
+ app(DaDataPhoneClient::class)->cleanPhone('79161234567');
+
+ Http::assertSent(function ($request): bool {
+ return $request->url() === 'https://cleaner.dadata.ru/api/v1/clean/phone'
+ && $request->hasHeader('Authorization', 'Token KEY')
+ && $request->hasHeader('X-Secret', 'SEC')
+ && $request->body() === '["79161234567"]';
+ });
+});
+
+it('throws DaDataTimeoutException on connection error', function (): void {
+ Http::fake(fn () => throw new ConnectionException('timeout'));
+
+ expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
+ ->toThrow(DaDataTimeoutException::class);
+});
+
+it('throws DaDataException on persistent 5xx', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response('upstream error', 500)]);
+
+ expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
+ ->toThrow(DaDataException::class);
+});
+
+it('retries once on 5xx then succeeds', function (): void {
+ Http::fakeSequence('cleaner.dadata.ru/*')
+ ->push('upstream error', 500)
+ ->push([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200);
+
+ $resp = app(DaDataPhoneClient::class)->cleanPhone('79161234567');
+
+ expect($resp->qc)->toBe(0);
+ Http::assertSentCount(2);
+});
+
+it('does not retry on 4xx client error', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response('bad request', 400)]);
+
+ expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79161234567'))
+ ->toThrow(DaDataException::class);
+
+ Http::assertSentCount(1);
+});
diff --git a/app/tests/Feature/Services/LeadRegionResolverTest.php b/app/tests/Feature/Services/LeadRegionResolverTest.php
new file mode 100644
index 00000000..00b947c0
--- /dev/null
+++ b/app/tests/Feature/Services/LeadRegionResolverTest.php
@@ -0,0 +1,215 @@
+ true,
+ 'services.dadata.api_key' => 'k',
+ 'services.dadata.secret' => 's',
+ 'services.dadata.daily_cap_rub' => 10000,
+ ]);
+});
+
+function resolverSeedImport(): int
+{
+ return (int) DB::table('phone_ranges_imports')->insertGetId([
+ 'source_url' => 'test', 'checksum_sha256' => str_repeat('b', 64),
+ 'status' => 'completed', 'imported_at' => now(),
+ ]);
+}
+
+function resolverSeedRange(int $subject, string $region = 'Москва', int $def = 916, string $operator = 'Ростелеком'): void
+{
+ DB::table('phone_ranges')->insert([
+ 'def_code' => $def, 'from_num' => 0, 'to_num' => 9999999,
+ 'operator' => $operator, 'region' => $region, 'subject_code' => $subject,
+ 'imported_at' => now(), 'import_id' => resolverSeedImport(),
+ ]);
+}
+
+function resolverLead(string $phone = '79161234567', string $tag = ''): SupplierLead
+{
+ return new SupplierLead([
+ 'phone' => $phone,
+ 'raw_payload' => ['tag' => $tag],
+ 'received_at' => now(),
+ ]);
+}
+
+function fakeDadata(array $row): void
+{
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([$row], 200)]);
+}
+
+it('dadata qc 0 returns dadata source', function (): void {
+ fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный']);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('dadata')
+ ->and($r->subjectCode)->toBe(82)
+ ->and($r->phoneOperator)->toBe('МТС')
+ ->and($r->qc)->toBe(0)
+ ->and($r->cacheHit)->toBeFalse();
+});
+
+it('dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider', function (): void {
+ fakeDadata(['qc' => 0, 'region' => 'Санкт-Петербург и область', 'provider' => 'МегаФон']);
+ resolverSeedRange(subject: 83, region: 'Санкт-Петербург');
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')
+ ->and($r->subjectCode)->toBe(83)
+ ->and($r->phoneOperator)->toBe('МегаФон') // оператор от DaData (MNP), §3.4.1
+ ->and($r->rossvyazMatched)->toBeTrue();
+});
+
+it('dadata qc 3 returns dadata with multiple flag', function (): void {
+ fakeDadata(['qc' => 3, 'region' => 'Москва', 'provider' => 'МТС']);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('dadata')->and($r->subjectCode)->toBe(82)->and($r->qc)->toBe(3);
+});
+
+it('dadata qc 1 falls back to rossvyaz', function (): void {
+ fakeDadata(['qc' => 1, 'region' => 'Москва', 'provider' => 'Билайн']);
+ resolverSeedRange(subject: 82);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
+});
+
+it('dadata qc 2 falls back to tag skipping rossvyaz', function (): void {
+ fakeDadata(['qc' => 2]);
+ resolverSeedRange(subject: 83); // если бы Россвязь дёрнули — был бы 83
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
+
+ expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82)->and($r->rossvyazMatched)->toBeFalse();
+});
+
+it('dadata qc 7 falls back to tag skipping rossvyaz', function (): void {
+ fakeDadata(['qc' => 7]);
+ resolverSeedRange(subject: 83);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
+
+ expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
+});
+
+it('dadata timeout falls back to rossvyaz', function (): void {
+ Http::fake(fn () => throw new ConnectionException('timeout'));
+ resolverSeedRange(subject: 82);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
+});
+
+it('dadata network error 5xx falls back to rossvyaz', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response('err', 500)]);
+ resolverSeedRange(subject: 82);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
+});
+
+it('budget cap exceeded skips dadata directly to rossvyaz', function (): void {
+ config(['services.dadata.daily_cap_rub' => 0]); // canSpend() → false
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
+ resolverSeedRange(subject: 82);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
+ Http::assertNothingSent();
+});
+
+it('cache hit skips dadata and rossvyaz on the second call', function (): void {
+ fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']);
+ $resolver = app(LeadRegionResolver::class);
+
+ $first = $resolver->resolve(resolverLead());
+ $second = $resolver->resolve(resolverLead());
+
+ expect($first->cacheHit)->toBeFalse()
+ ->and($second->cacheHit)->toBeTrue()
+ ->and($second->subjectCode)->toBe(82);
+ Http::assertSentCount(1);
+});
+
+it('invalid phone skips dadata returns tag', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead(phone: '123', tag: 'Москва'));
+
+ expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
+ Http::assertNothingSent();
+});
+
+it('qc 0 region null falls through to rossvyaz', function (): void {
+ fakeDadata(['qc' => 0, 'region' => null, 'provider' => 'Tele2']);
+ resolverSeedRange(subject: 82);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82)->and($r->phoneOperator)->toBe('Tele2');
+});
+
+it('unmappable dadata region falls through to rossvyaz', function (): void {
+ fakeDadata(['qc' => 0, 'region' => 'Несуществующий край', 'provider' => 'МТС']);
+ resolverSeedRange(subject: 82);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead());
+
+ expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
+});
+
+it('all three layers fail returns unknown with null subject_code', function (): void {
+ fakeDadata(['qc' => 1]); // → rossvyaz
+ // no phone_ranges seeded → rossvyaz miss; tag empty → null
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: ''));
+
+ expect($r->source)->toBe('unknown')->and($r->subjectCode)->toBeNull();
+});
+
+it('disabled feature flag returns tag without any dadata call', function (): void {
+ config(['services.dadata.enabled' => false]);
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
+
+ $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
+
+ expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
+ Http::assertNothingSent();
+});
+
+it('persistent idempotency: already-resolved lead skips dadata', function (): void {
+ Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
+ $lead = resolverLead();
+ $lead->resolved_subject_code = 83;
+ $lead->region_source = 'dadata';
+ $lead->dadata_qc = 0;
+ $lead->phone_operator = 'МегаФон';
+
+ $r = app(LeadRegionResolver::class)->resolve($lead);
+
+ expect($r->subjectCode)->toBe(83)->and($r->source)->toBe('dadata');
+ Http::assertNothingSent();
+});
diff --git a/app/tests/Feature/Services/LeadRouterCascadeTest.php b/app/tests/Feature/Services/LeadRouterCascadeTest.php
new file mode 100644
index 00000000..c55d6b55
--- /dev/null
+++ b/app/tests/Feature/Services/LeadRouterCascadeTest.php
@@ -0,0 +1,189 @@
+create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
+ $project = Project::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'is_active' => true,
+ 'daily_limit_target' => $dailyLimit,
+ 'delivered_today' => $deliveredToday,
+ 'delivery_days_mask' => 127,
+ 'signal_type' => $sp->signal_type,
+ 'signal_identifier' => $sp->unique_key,
+ ]);
+ linkProjectToSupplier($project, $sp);
+ createRoutingSnapshotFromProject(
+ $project,
+ signalType: $sp->signal_type,
+ signalIdentifier: $sp->unique_key,
+ dailyLimit: $dailyLimit,
+ regions: $regions,
+ );
+
+ return $project;
+}
+
+function b1Supplier(string $key = 'ex.ru'): SupplierProject
+{
+ return SupplierProject::query()->create([
+ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
+ 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
+ ]);
+}
+
+it('step 1: exact region match wins, others excluded', function (): void {
+ $sp = b1Supplier();
+ $spb = makeCascadeProject($sp, regions: '{83}'); // Питер
+ $msk = makeCascadeProject($sp, regions: '{82}'); // Москва
+
+ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
+
+ expect($matched->pluck('id')->all())->toBe([$msk->id])
+ ->and($matched->first()->routing_step)->toBe(1);
+});
+
+it('step 2: falls to all-RF when no exact match', function (): void {
+ $sp = b1Supplier('s2.ru');
+ $allRu = makeCascadeProject($sp, regions: '{}'); // вся РФ
+
+ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
+
+ expect($matched->pluck('id')->all())->toBe([$allRu->id])
+ ->and($matched->first()->routing_step)->toBe(2);
+});
+
+it('step 3: fallback channel when nobody subscribed to region and no all-RF', function (): void {
+ $sp = b1Supplier('s3.ru');
+ $spb = makeCascadeProject($sp, regions: '{83}'); // только Питер подписан
+
+ // resolvedSubjectCode=82 (Москва): точных нет, «вся РФ» нет → запасной канал.
+ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
+
+ expect($matched->pluck('id')->all())->toBe([$spb->id])
+ ->and($matched->first()->routing_step)->toBe(3);
+});
+
+it('exact + all-RF combine up to cap=3, exact taking priority', function (): void {
+ $sp = b1Supplier('s4.ru');
+ $e1 = makeCascadeProject($sp, regions: '{82}');
+ $e2 = makeCascadeProject($sp, regions: '{82}');
+ $r1 = makeCascadeProject($sp, regions: '{}');
+ $r2 = makeCascadeProject($sp, regions: '{}');
+
+ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
+
+ // Всего 3 (cap). Оба точных (step 1) обязаны быть; добор — ровно 1 «вся РФ» (step 2).
+ expect($matched)->toHaveCount(3);
+ $byStep = $matched->groupBy(fn ($p) => $p->routing_step);
+ expect($byStep->get(1)->pluck('id')->sort()->values()->all())->toBe(collect([$e1->id, $e2->id])->sort()->values()->all())
+ ->and($byStep->get(2))->toHaveCount(1);
+ expect(in_array($byStep->get(2)->first()->id, [$r1->id, $r2->id], true))->toBeTrue();
+});
+
+it('null resolvedSubjectCode skips exact, uses all-RF', function (): void {
+ $sp = b1Supplier('s5.ru');
+ $allRu = makeCascadeProject($sp, regions: '{}');
+ $exact = makeCascadeProject($sp, regions: '{82}');
+
+ // Резолвер не сработал → шаг 1 пропускается; матчит только «вся РФ».
+ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: null);
+
+ expect($matched->pluck('id')->all())->toBe([$allRu->id])
+ ->and($matched->first()->routing_step)->toBe(2);
+});
+
+it('cascade works for DIRECT supplier_project path too', function (): void {
+ $sp = SupplierProject::query()->create([
+ 'platform' => 'DIRECT', 'signal_type' => 'site', 'unique_key' => 'cashmotor.ru',
+ 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
+ ]);
+ $msk = makeCascadeProject($sp, regions: '{82}');
+ $spb = makeCascadeProject($sp, regions: '{83}');
+
+ $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
+
+ expect($matched->pluck('id')->all())->toBe([$msk->id])
+ ->and($matched->first()->routing_step)->toBe(1);
+});
+
+it('backward compat: no second arg behaves as all-RF/any (existing call shape)', function (): void {
+ $sp = b1Supplier('s7.ru');
+ $allRu = makeCascadeProject($sp, regions: '{}');
+
+ // Старая сигнатура (без 2-го аргумента) — дефолт null → шаг 2 all-RF матчит '{}'.
+ $matched = seededRouter()->matchEligibleProjects($sp);
+
+ expect($matched->pluck('id')->all())->toBe([$allRu->id]);
+});
+
+it('variant В: weighted pick — small client never starved, big client wins more often', function (): void {
+ $sp = b1Supplier('fair.ru');
+ // 5 клиентов на Москву, разный остаток лимита.
+ $a = makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); // остаток 100
+ $b = makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
+ $c = makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
+ $d = makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
+ $e = makeCascadeProject($sp, regions: '{82}', dailyLimit: 10); // остаток 10 — самый маленький
+
+ $wins = [];
+ $seedCount = 120;
+ for ($seed = 0; $seed < $seedCount; $seed++) {
+ $matched = seededRouter($seed)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
+ expect($matched)->toHaveCount(3); // лид всегда раздаётся ровно троим
+ foreach ($matched as $p) {
+ $wins[$p->id] = ($wins[$p->id] ?? 0) + 1;
+ }
+ }
+
+ // (1) Мелкого не отрезаем: за 120 розыгрышей хотя бы раз получил лид.
+ expect($wins[$e->id] ?? 0)->toBeGreaterThan(0);
+ // (2) Вес уважается: крупный клиент выигрывает строго чаще мелкого.
+ expect($wins[$a->id] ?? 0)->toBeGreaterThan($wins[$e->id] ?? 0);
+});
+
+it('variant В: deterministic — same seed yields same recipients', function (): void {
+ $sp = b1Supplier('det.ru');
+ makeCascadeProject($sp, regions: '{82}', dailyLimit: 100);
+ makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
+ makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
+ makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
+
+ $first = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
+ $second = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
+
+ expect($first)->toBe($second)->and($first)->toHaveCount(3);
+});
diff --git a/app/tests/Feature/Services/RegionResolutionTest.php b/app/tests/Feature/Services/RegionResolutionTest.php
new file mode 100644
index 00000000..3f7ca9d9
--- /dev/null
+++ b/app/tests/Feature/Services/RegionResolutionTest.php
@@ -0,0 +1,70 @@
+rossvyaz>tag>unknown', function (): void {
+ expect(RegionResolution::SOURCE_RANK)->toBe([
+ 'dadata' => 4, 'rossvyaz' => 3, 'tag' => 2, 'unknown' => 1,
+ ]);
+});
+
+it('make sets actualSubjectCode equal to subjectCode', function (): void {
+ $r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0);
+
+ expect($r->subjectCode)->toBe(82)
+ ->and($r->actualSubjectCode)->toBe(82)
+ ->and($r->source)->toBe('dadata')
+ ->and($r->phoneOperator)->toBe('МТС')
+ ->and($r->qc)->toBe(0)
+ ->and($r->cacheHit)->toBeFalse()
+ ->and($r->rossvyazMatched)->toBeFalse();
+});
+
+it('fromTag builds a tag-sourced resolution', function (): void {
+ $r = RegionResolution::fromTag(82);
+
+ expect($r->subjectCode)->toBe(82)
+ ->and($r->source)->toBe('tag')
+ ->and($r->phoneOperator)->toBeNull();
+});
+
+it('fromSupplierLead reconstructs a persisted resolution (idempotency)', function (): void {
+ $lead = new SupplierLead([
+ 'resolved_subject_code' => 83,
+ 'region_source' => 'dadata',
+ 'dadata_qc' => 0,
+ 'phone_operator' => 'МегаФон',
+ ]);
+
+ $r = RegionResolution::fromSupplierLead($lead);
+
+ expect($r->subjectCode)->toBe(83)
+ ->and($r->source)->toBe('dadata')
+ ->and($r->phoneOperator)->toBe('МегаФон')
+ ->and($r->qc)->toBe(0);
+});
+
+it('withCacheHit flips the flag and clears the per-call masked response', function (): void {
+ $r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => '7916***4567']);
+
+ $hit = $r->withCacheHit(true);
+
+ expect($hit->cacheHit)->toBeTrue()
+ ->and($hit->subjectCode)->toBe(82)
+ ->and($hit->dadataResponseMasked)->toBeNull();
+});
+
+it('forCache strips per-call fields before storing', function (): void {
+ $r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => 'x'], durationMs: 120);
+
+ $c = $r->forCache();
+
+ expect($c->dadataResponseMasked)->toBeNull()
+ ->and($c->durationMs)->toBeNull()
+ ->and($c->cacheHit)->toBeFalse()
+ ->and($c->subjectCode)->toBe(82)
+ ->and($c->phoneOperator)->toBe('МТС');
+});
diff --git a/app/tests/Feature/Services/RossvyazPrefixLookupTest.php b/app/tests/Feature/Services/RossvyazPrefixLookupTest.php
new file mode 100644
index 00000000..d49efc1e
--- /dev/null
+++ b/app/tests/Feature/Services/RossvyazPrefixLookupTest.php
@@ -0,0 +1,94 @@
+insertGetId([
+ 'source_url' => 'https://rossvyaz.gov.ru/test',
+ 'checksum_sha256' => str_repeat('a', 64),
+ 'status' => 'completed',
+ 'imported_at' => now(),
+ ]);
+}
+
+/**
+ * @param array $overrides
+ */
+function seedPhoneRange(array $overrides = []): void
+{
+ DB::table('phone_ranges')->insert(array_merge([
+ 'def_code' => 921,
+ 'from_num' => 5550000,
+ 'to_num' => 5559999,
+ 'operator' => 'МегаФон',
+ 'region' => 'Санкт-Петербург',
+ 'subject_code' => 83,
+ 'imported_at' => now(),
+ 'import_id' => seedRossvyazImport(),
+ ], $overrides));
+}
+
+it('mobile prefix returns correct region and operator', function (): void {
+ seedPhoneRange();
+
+ $rec = app(RossvyazPrefixLookup::class)->find('79215555123');
+
+ expect($rec)->toBeInstanceOf(RossvyazRecord::class)
+ ->and($rec->subjectCode)->toBe(83)
+ ->and($rec->region)->toBe('Санкт-Петербург')
+ ->and($rec->operator)->toBe('МегаФон');
+});
+
+it('prefers narrower range when two ranges overlap', function (): void {
+ $importId = seedRossvyazImport();
+ // Широкий диапазон (вся 495-зона) — Московская область (56).
+ seedPhoneRange([
+ 'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999,
+ 'operator' => 'Ростелеком', 'region' => 'Московская область',
+ 'subject_code' => 56, 'import_id' => $importId,
+ ]);
+ // Узкий диапазон внутри — Москва (82). Должен выиграть (ORDER BY width ASC).
+ seedPhoneRange([
+ 'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999,
+ 'operator' => 'МГТС', 'region' => 'Москва',
+ 'subject_code' => 82, 'import_id' => $importId,
+ ]);
+
+ $rec = app(RossvyazPrefixLookup::class)->find('74952005000');
+
+ expect($rec)->not->toBeNull()
+ ->and($rec->subjectCode)->toBe(82)
+ ->and($rec->region)->toBe('Москва');
+});
+
+it('returns null for unknown prefix', function (): void {
+ seedPhoneRange(); // только def_code=921
+
+ expect(app(RossvyazPrefixLookup::class)->find('79991234567'))->toBeNull();
+});
+
+it('returns null when subscriber number is outside any range', function (): void {
+ seedPhoneRange(['def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999]);
+
+ // def_code совпадает (921), но subscriber 4440000 вне [5550000, 5559999]
+ expect(app(RossvyazPrefixLookup::class)->find('79214440000'))->toBeNull();
+});
+
+it('returns null for malformed phone', function (): void {
+ seedPhoneRange();
+
+ expect(app(RossvyazPrefixLookup::class)->find('123'))->toBeNull();
+});
diff --git a/app/tests/Pest.php b/app/tests/Pest.php
index 1812e7f2..021b10e1 100644
--- a/app/tests/Pest.php
+++ b/app/tests/Pest.php
@@ -131,6 +131,7 @@ function createRoutingSnapshotFromProject(
string $signalType = 'call',
?string $signalIdentifier = null,
?int $dailyLimit = null,
+ string $regions = '{}',
): void {
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(),
@@ -138,7 +139,7 @@ function createRoutingSnapshotFromProject(
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127),
- 'regions' => '{}',
+ 'regions' => $regions,
'signal_type' => $signalType,
'signal_identifier' => $signalIdentifier,
'sms_senders' => null,
diff --git a/app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php b/app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php
new file mode 100644
index 00000000..1ff66d8c
--- /dev/null
+++ b/app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php
@@ -0,0 +1,10 @@
+toHaveKey('lead_region_resolution_log');
+ expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
+});
diff --git a/app/tests/Unit/Support/DaDataRegionMapTest.php b/app/tests/Unit/Support/DaDataRegionMapTest.php
new file mode 100644
index 00000000..3fec0588
--- /dev/null
+++ b/app/tests/Unit/Support/DaDataRegionMapTest.php
@@ -0,0 +1,35 @@
+toBe(82)
+ ->and(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56)
+ ->and(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83)
+ ->and(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
+});
+
+it('trims surrounding whitespace before mapping', function (): void {
+ expect(DaDataRegionMap::toSubjectCode(' Москва '))->toBe(82);
+});
+
+it('flags ambiguous agglomeration strings', function (): void {
+ expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue()
+ ->and(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue()
+ ->and(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse()
+ ->and(DaDataRegionMap::isAmbiguous('Санкт-Петербург'))->toBeFalse();
+});
+
+it('returns null for unmappable region', function (): void {
+ expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull()
+ ->and(DaDataRegionMap::toSubjectCode(''))->toBeNull();
+});
+
+it('resolves all 89 RussianRegions names', function (): void {
+ foreach (RussianRegions::CODE_TO_NAME as $code => $name) {
+ expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
+ }
+});
diff --git a/app/tests/Unit/Support/RussianRegionsTest.php b/app/tests/Unit/Support/RussianRegionsTest.php
new file mode 100644
index 00000000..0e87d481
--- /dev/null
+++ b/app/tests/Unit/Support/RussianRegionsTest.php
@@ -0,0 +1,102 @@
+toBe(82)
+ ->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
+ ->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
+});
+
+it('still maps a plain canonical federal-city name', function (): void {
+ expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
+});
+
+it('takes the last pipe segment as the subject region', function (): void {
+ // регион = последний сегмент после |
+ expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
+ ->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
+});
+
+it('expands the обл. abbreviation to область', function (): void {
+ expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
+ ->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
+});
+
+it('keeps already-canonical край/республика segments', function (): void {
+ expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
+ ->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
+});
+
+it('reorders the Удмуртская Республика inverted form', function (): void {
+ expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
+});
+
+it('maps the Кузбасс special form to Кемеровская область', function (): void {
+ expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
+});
+
+it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
+ expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
+})->with([
+ '-',
+ 'Российская Федерация',
+ 'Москва и Московская область', // неоднозначно — два субъекта
+ 'г.о. Тольятти', // нет региона в строке
+ 'г.о. город Уфа',
+ '',
+ ' ',
+]);
+
+it('exposes the canonical name via canonicalRegionName', function (): void {
+ expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
+ ->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
+ ->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
+});
+
+it('expands the АО abbreviation to автономный округ', function (): void {
+ expect(RussianRegions::resolveSubjectCode('Ненецкий АО'))->toBe(86)
+ ->and(RussianRegions::resolveSubjectCode('Чукотский АО'))->toBe(88)
+ ->and(RussianRegions::resolveSubjectCode('г. Салехард|Ямало-Ненецкий АО'))->toBe(89);
+});
+
+it('maps Ханты-Мансийск variants to ХМАО — Югра', function (): void {
+ expect(RussianRegions::resolveSubjectCode('г. Сургут|Ханты-Мансийский Автономный округ - Югра АО'))->toBe(87)
+ ->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский АО - Югра'))->toBe(87)
+ ->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский Автономный округ - Югра.'))->toBe(87);
+});
+
+it('reorders inverted Республика X forms', function (): void {
+ expect(RussianRegions::resolveSubjectCode('Республика Чеченская'))->toBe(23)
+ ->and(RussianRegions::resolveSubjectCode('Республика Кабардино-Балкарская'))->toBe(8)
+ ->and(RussianRegions::resolveSubjectCode('Республика Карачаево-Черкесская'))->toBe(10)
+ ->and(RussianRegions::resolveSubjectCode('Республика Донецкая Народная'))->toBe(6)
+ ->and(RussianRegions::resolveSubjectCode('Республика Луганская Народная'))->toBe(14);
+});
+
+it('keeps Республика-first canonical names as-is', function (): void {
+ expect(RussianRegions::resolveSubjectCode('Республика Татарстан'))->toBe(19)
+ ->and(RussianRegions::resolveSubjectCode('Республика Карелия'))->toBe(11);
+});
+
+it('handles irregular subject spellings (Саха, Чувашия, Кузбасс)', function (): void {
+ expect(RussianRegions::resolveSubjectCode('у. Мирнинский|Республика Саха /Якутия/'))->toBe(17)
+ ->and(RussianRegions::resolveSubjectCode('г. Чебоксары|Чувашская Республика - Чувашия'))->toBe(24)
+ ->and(RussianRegions::resolveSubjectCode('Кемеровская область - Кузбасс область'))->toBe(48);
+});
+
+it('maps Moscow / SPb spelling variants', function (): void {
+ expect(RussianRegions::resolveSubjectCode('Город Москва'))->toBe(82)
+ ->and(RussianRegions::resolveSubjectCode('г. Санкт - Петербург'))->toBe(83);
+});
+
+it('normalizes spaced hyphen to em-dash (Северная Осетия — Алания)', function (): void {
+ expect(RussianRegions::resolveSubjectCode('Республика Северная Осетия - Алания'))->toBe(18)
+ ->and(RussianRegions::resolveSubjectCode('г. Владикавказ|Республика Северная Осетия - Алания'))->toBe(18);
+});
diff --git a/app/tests/fixtures/rossvyaz/messy.csv b/app/tests/fixtures/rossvyaz/messy.csv
new file mode 100644
index 00000000..03ecca1a
--- /dev/null
+++ b/app/tests/fixtures/rossvyaz/messy.csv
@@ -0,0 +1,5 @@
+АВС/ DEF;От;До;Емкость;Оператор;Регион
+495;2000000;2009999;10000;ОАО МГТС;г. Москва
+922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
+987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
+902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
diff --git a/app/tests/fixtures/rossvyaz/sample.csv b/app/tests/fixtures/rossvyaz/sample.csv
new file mode 100644
index 00000000..2fae21e4
--- /dev/null
+++ b/app/tests/fixtures/rossvyaz/sample.csv
@@ -0,0 +1,4 @@
+АВС/ DEF;От;До;Емкость;Оператор;Регион
+495;2000000;2009999;10000;ОАО МГТС;Москва
+921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
+999;0000000;0009999;10000;Тест Оператор;Атлантида
diff --git a/app/vitest.config.tools.mjs b/app/vitest.config.tools.mjs
new file mode 100644
index 00000000..6d9701fd
--- /dev/null
+++ b/app/vitest.config.tools.mjs
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vitest/config';
+
+// Minimal Vitest config for tools/*.test.mjs (Node environment, no Vue/DOM).
+// Separate from vitest.config.ts which targets tests/Frontend/**/*.ts.
+// Run from repo root: node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['../tools/*.test.mjs'],
+ exclude: ['../tools/ruflo-*.test.mjs', '../tools/subagent-prompt-prefix.test.mjs'],
+ },
+});
diff --git a/cspell-words.txt b/cspell-words.txt
index 7d0733b8..fff8d2f0 100644
--- a/cspell-words.txt
+++ b/cspell-words.txt
@@ -2132,3 +2132,11 @@ SLSA
форки
хостед
офиц
+
+# Lead region resolution (2026-05-31) — DaData / Rossvyaz region detection
+rossvyaz
+россвязь
+россвязи
+dadata
+kopecks
+qc
diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md
index 8420da54..42d93609 100644
--- a/db/CHANGELOG_schema.md
+++ b/db/CHANGELOG_schema.md
@@ -2,7 +2,61 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
-**Файл схемы:** `schema.sql` (текущая версия — v8.39, консолидированная — разворачивает БД с нуля).
+**Файл схемы:** `schema.sql` (текущая версия — v8.40, консолидированная — разворачивает БД с нуля).
+
+## v8.40 (2026-05-31) — lead region resolution (phone_ranges + resolution_log + supplier_leads/deals columns)
+
+Резолюция настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback)
+и переключение `LeadRouter` на каскадную маршрутизацию по региону. Эта запись покрывает
+только схемные изменения Session 1 (таблицы и колонки); бизнес-логика — в последующих сессиях.
+
+Спека: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
+План: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
+Миграция: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`.
+
+**Добавлено:**
+
+- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (SaaS-level, без RLS).
+ Поля: `source_url`, `rows_inserted`/`rows_updated`, `checksum_sha256`, `status`
+ (`in_progress`/`completed`/`failed`/`rolled_back`), `error`, `completed_at`.
+ GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
+- **`phone_ranges`** — реестр диапазонов нумерации Россвязи (SaaS-level, без RLS — публичные данные).
+ Поля: `def_code` (код ABC/DEF), `from_num`/`to_num`, `operator`, `region`, `region_normalized`,
+ `subject_code` (1..89), `imported_at`, `import_id`→`phone_ranges_imports`. 3 CHECK
+ (`def_code` 300..999, `subject_code` 1..89, `from_num` ≤ `to_num`). Индекс
+ `idx_phone_ranges_lookup (def_code, from_num, to_num)`. GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
+- **`lead_region_resolution_log`** — PARTITION BY RANGE (`received_at`), composite PK
+ `(id, received_at)`. Аудит резолва региона на лид: `phone_masked`, `subject_code_resolved`/
+ `subject_code_from_tag`, `region_source` (`dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`/
+ `dadata_provider`/`dadata_type`/`dadata_response_masked` (JSONB), `rossvyaz_matched`,
+ `actual_subject_code`/`substituted_subject_code` (1..89), `routing_step` (1..3),
+ `phone_operator`, `cache_hit`, `duration_ms`, `resolved_at`. Индексы `idx_lrrl_lead_id` +
+ `idx_lrrl_source (region_source, received_at)`. GRANT SELECT,INSERT `crm_supplier_worker` /
+ SELECT `crm_app_user`. Стартовые партиции `lead_region_resolution_log_y2026_m05`, `_y2026_m06`.
+- **`MonthlyPartitionManager::PARTITIONED_TABLES`** +entry `'lead_region_resolution_log' => 'received_at'`.
+- **`system_settings`** +key `partition_retention_months_lead_region_resolution_log = '12'` (retention ~365 дней).
+
+**Изменено:**
+
+- **`supplier_leads`** +4 колонки: `resolved_subject_code` (CHECK 1..89), `region_source`
+ (CHECK `dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`, `phone_operator`. Persistent-idempotency
+ резолва (retry не повторяет DaData-вызов).
+- **`deals`** +2 колонки: `phone_operator`, `region_substituted` BOOLEAN NOT NULL DEFAULT FALSE
+ (флаг подмены региона на запасном канале — `routing_step` 3).
+
+**NB консолидация:** как и v8.39 (`project_routing_snapshots`), полный DDL живёт в дельта-миграции,
+а не в теле `schema.sql` — тело отражает последнюю точку консолидации, заголовок/CHANGELOG ведут
+дельты. Свежий деплой: миграция `0001` грузит `schema.sql` → дельта-миграция `2026_05_31` добавляет
+эти объекты. Иначе был бы двойной `CREATE TABLE` (0001 + дельта) и `migrate` упал бы.
+
+**NB GRANT'ы:** план Task 1.3 указывал `crm_readonly`, но этой роли на dev/прод нет —
+фактические GRANT'ы выданы `crm_app_user` + `crm_supplier_worker` (проверено по `pg_roles`).
+
+**NB 152-ФЗ:** `phone_masked` в логе — маскированный телефон (`7XXX***YYYY`), `dadata_response_masked`
+хранит ответ DaData без сырого номера (spec §7.1). Полное `pg_anonymizer`-маскирование —
+шаг раскатки (spec §7.2), вне Session 1.
+
+---
## v8.39 (2026-05-27) — project_routing_snapshots (Slepok routing Этап 2)
diff --git a/db/schema.sql b/db/schema.sql
index 8932d639..7ef7f949 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -1,6 +1,7 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
--- Версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
+-- Версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39))
+-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
-- Базовая версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
-- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)