Compare commits

..

15 Commits

Author SHA1 Message Date
Дмитрий 1107979168 chore(region): add cspell dictionary terms (DaData/Rossvyaz) 2026-06-01 07:39:43 +03:00
Дмитрий 849e467924 fix(region): wrap phone_ranges swap in a transaction + drop stray comment (code-review) 2026-06-01 07:32:15 +03:00
Дмитрий c959c03f55 docs(region): rollout runbook + session progress 2026-06-01 07:21:24 +03:00
Дмитрий 893a142812 feat(region): phone-region:smoke staging command 2026-06-01 07:21:15 +03:00
Дмитрий dae2085ea0 feat(region): RouteSupplierLeadJob — resolve region + persist + fail-safe log + step-3 substitution + CSV-merge 2026-06-01 07:21:08 +03:00
Дмитрий 048f3ad6a2 feat(region): Deal — region_substituted + phone_operator fields 2026-06-01 07:21:01 +03:00
Дмитрий 8be1db34b8 feat(region): LeadRouter cascade routing (exact→all-RF→fallback) + weighted pick variant В + routing_step 2026-06-01 07:19:54 +03:00
Дмитрий 9e05d8f728 test(region): createRoutingSnapshotFromProject accepts regions param 2026-06-01 07:19:46 +03:00
Дмитрий 4bb94257cf feat(region): LeadRegionResolver orchestrator (full qc cascade) 2026-06-01 07:19:37 +03:00
Дмитрий b91b6d5008 feat(region): DaData layer (region map, config, enum, client, budget guard) 2026-06-01 07:19:29 +03:00
Дмитрий b822042a66 feat(region): phone-ranges:import command (parse/map/dry-run/idempotency) 2026-06-01 07:18:23 +03:00
Дмитрий b25aa025e4 feat(region): RossvyazPrefixLookup + RossvyazRecord DTO 2026-06-01 07:18:17 +03:00
Дмитрий 635d631eae chore(region): sync db/schema.sql + CHANGELOG (v8.40) 2026-06-01 07:18:09 +03:00
Дмитрий ec21971888 feat(region): schema migration + MonthlyPartitionManager registration 2026-06-01 07:12:08 +03:00
Дмитрий 618519c7e8 fix(openapi): drop [] from status_in param name 2026-05-31 15:53:33 +03:00
41 changed files with 3071 additions and 98 deletions
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
/**
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
*
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
* php artisan phone-ranges:import --dir=<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) {
$subjectCode = RussianRegions::nameToCode()[trim($rec['region'])] ?? 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'],
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
];
}
}
// 5. Сборка staging.
$this->buildStaging($rows);
$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<string>|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<string> $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<string> $files
*/
private function sourceLabel(array $files): string
{
return $this->option('url')
?? $this->option('dir')
?? ($files[0] ?? 'unknown');
}
/**
* Парсит один файл реестра в нормализованные строки.
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseFile(string $path): array
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $ext === 'xlsx'
? $this->parseXlsx($path)
: $this->parseCsv($path);
}
/**
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
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<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
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<string> $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<string> $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) и заливает строки.
*
* @param list<array<string, mixed>> $rows
*/
private function buildStaging(array $rows): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING DEFAULTS INCLUDING CONSTRAINTS)');
$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(\Illuminate\Database\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
}
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
/**
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
* DaData Россвязь tag и печатает решение. В БД ничего НЕ пишет.
*
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
*
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
* DADATA_API_KEY делает платный вызов запускать осознанно.
*/
class PhoneRegionSmokeCommand extends Command
{
/** @var string */
protected $signature = 'phone-region:smoke
{--phone= : Телефон в формате 7XXXXXXXXXX}
{--tag= : Регион-тег поставщика (fallback-слой)}';
/** @var string */
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
public function handle(LeadRegionResolver $resolver): int
{
$phone = (string) $this->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);
}
}
+132 -9
View File
@@ -11,7 +11,9 @@ 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;
@@ -23,6 +25,7 @@ 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 +131,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 +150,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 +191,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 +257,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 +375,20 @@ 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;
}
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 +425,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 +440,9 @@ class RouteSupplierLeadJob implements ShouldQueue
'phones' => $phones,
'status' => 'new',
'received_at' => $receivedAt,
'subject_code' => $subjectCode,
'subject_code' => $dealSubjectCode,
'phone_operator' => $resolution->phoneOperator,
'region_substituted' => $routingStep === 3,
]);
DB::table('supplier_lead_deliveries')
@@ -500,6 +540,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<int, Project> $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<int> '{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).
*
+4
View File
@@ -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',
+7
View File
@@ -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',
];
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Support\Facades\Cache;
/**
* Дневной бюджет на платные вызовы DaData (spec §5.3 / §11).
*
* Расход копится в копейках под дневным ключом `phone_resolution:dadata:spent_kopecks:<YYYY-MM-DD>`.
* `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');
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use RuntimeException;
/**
* Не-2xx ответ DaData (после исчерпания retry) или иная ошибка вызова.
* LeadRegionResolver ловит её и деградирует на Россвязь (spec §3.3).
*/
class DaDataException extends RuntimeException {}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* HTTP-обёртка над DaData clean/phone (spec §3.6).
*
* POST https://cleaner.dadata.ru/api/v1/clean/phone
* Authorization: Token <key> ; X-Secret: <secret> ; body ["<phone>"]
*
* 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,
);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Распарсенный ответ DaData clean/phone (один номер один объект), spec §3.6.
*/
final class DaDataPhoneResponse
{
/**
* @param array<string, mixed> $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,
) {}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Код качества (`qc`) ответа DaData clean/phone.
*
* Семантика DaData:
* 0 телефон распознан уверенно;
* 1 распознан с допущениями (требует проверки);
* 2 пустой / невозможно распознать;
* 3 несколько телефонов в одном поле;
* 7 иностранный номер.
*
* Решения каскада по qc в LeadRegionResolver (spec §3.4). Enum используется
* для читаемости и tryFrom() при парсинге; необъявленные значения остаются как int.
*/
enum DaDataQualityCode: int
{
case RECOGNIZED = 0;
case ASSUMPTIONS = 1;
case EMPTY = 2;
case MULTIPLE = 3;
case FOREIGN = 7;
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Сетевая ошибка / таймаут DaData (после исчерпания retry на сетевые сбои).
* Подкласс DaDataException catch(DaDataException) покрывает оба случая.
*/
class DaDataTimeoutException extends DaDataException {}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
use App\Models\SupplierLead;
/**
* Результат резолва региона лида (LeadRegionResolver, spec §3.3).
*
* `subjectCode` итоговый код субъекта (используется маршрутизатором);
* `actualSubjectCode` настоящий резолв (для лога actual_subject_code; на этапе
* резолва равен subjectCode, подмена региона концерн RouteSupplierLeadJob §3.10).
* `source` dadata|rossvyaz|tag|unknown ранг см. SOURCE_RANK (CSV-merge §3.12).
*/
final readonly class RegionResolution
{
/** @var array<string, int> ранг источника для CSV-merge (выше = достовернее) */
public const SOURCE_RANK = [
'dadata' => 4,
'rossvyaz' => 3,
'tag' => 2,
'unknown' => 1,
];
/**
* @param array<string, mixed>|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<string, mixed>|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,
);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
/**
* Read-only результат поиска по реестру нумерации Россвязи (`phone_ranges`).
*
* `subjectCode` код субъекта РФ 1..89 (см. App\Support\RussianRegions) либо
* null, если для диапазона он не был промаплен при импорте.
*/
final readonly class RossvyazRecord
{
public function __construct(
public ?int $subjectCode,
public string $region,
public string $operator,
) {}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\SupplierLead;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataPhoneResponse;
use App\Services\Dto\RegionResolution;
use App\Support\DaDataRegionMap;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Оркестратор резолва региона лида: DaData Россвязь tag-fallback (spec §3.3, §3.4).
*
* Каскад решений по qc:
* qc 0/3 + region не-ambiguous и маппится source=dadata;
* qc 0/3 + region ambiguous/null/не-маппится Россвязь (оператор от DaData сохраняем, §3.4.1);
* qc 1 / таймаут / 5xx / бюджет исчерпан Россвязь;
* qc 2/7 tag (Россвязь бессмысленна).
* Если ничего не дало код source=tag (или unknown при пустом теге).
*
* Кэш по sha256(phone) (без сырого номера в ключе/значении, §7.3). Persistent-idempotency
* по supplier_leads.resolved_subject_code (защита от двойной оплаты DaData на retry, §3.11).
* Feature-flag services.dadata.enabled=false сразу tag (текущее поведение, §6.5).
*/
class LeadRegionResolver
{
public function __construct(
private readonly DaDataPhoneClient $dadataClient,
private readonly DaDataBudgetGuard $budgetGuard,
private readonly RossvyazPrefixLookup $rossvyazLookup,
private readonly RegionTagResolver $tagResolver,
private readonly CacheRepository $cache,
) {}
public function resolve(SupplierLead $lead): RegionResolution
{
// Feature-flag: резолвер выключен → текущее tag-поведение.
if (! (bool) config('services.dadata.enabled', false)) {
return $this->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<string, mixed> сырой ответ 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);
}
}
+171 -81
View File
@@ -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<int, Project>
*/
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<int> $excludeTenantIds
* @return Collection<int, Project>
*/
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 = <<<SQL
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit
snap.daily_limit AS snapshot_daily_limit,
snap.regions AS snapshot_regions
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id
AND psl.supplier_project_id = ?
)
AND $sourceWhere
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
)
$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<int, Project> $candidates
* @return Collection<int, Project>
*/
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<int, Project> $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<int, object> $rows
* @param array<int, mixed> $rows
*/
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
{
@@ -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',
];
/**
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Services\Dto\RossvyazRecord;
use Illuminate\Support\Facades\DB;
/**
* Локальный fallback резолва региона/оператора по телефону через реестр
* нумерации Россвязи (`phone_ranges`).
*
* Используется LeadRegionResolver когда DaData недоступна/неуверена (qc=1,
* timeout, бюджет исчерпан). Алгоритм (spec §3.7):
* - def_code = 3 цифры кода ABC/DEF (позиции 1..3 нормализованного номера);
* - subscriber = остаток номера как BIGINT;
* - выбираем самый УЗКИЙ диапазон, накрывающий номер (ORDER BY width ASC),
* т.к. узкие переопределения операторов точнее широких региональных блоков.
*
* Запрос идёт через `pgsql_supplier` (BYPASSRLS на проде, как LeadRouter):
* `phone_ranges` SaaS-level публичные данные без RLS.
*/
class RossvyazPrefixLookup
{
/** Connection для чтения реестра (на проде BYPASSRLS, на dev/test — superuser fallback). */
public const CONNECTION = 'pgsql_supplier';
public function find(string $phone): ?RossvyazRecord
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
// Российский номер: 7|8 + ABC/DEF (3) + абонент (7) = 11 цифр.
if (strlen($digits) !== 11) {
return null;
}
$defCode = (int) substr($digits, 1, 3);
$subscriber = (int) substr($digits, 4);
$row = DB::connection(self::CONNECTION)->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,
);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Маппинг строки региона из ответа DaData код субъекта РФ (1..89).
*
* DaData возвращает регион в поле `region` (например «Москва», «Московская область»).
* Большинство имён точно совпадают с App\Support\RussianRegions::CODE_TO_NAME;
* расхождения (если найдутся на staging) кладутся в OVERRIDES.
*
* «Объединённые» агломерации («Санкт-Петербург и область») DaData не различает
* город и область внутри поля region. Такие строки помечаются isAmbiguous()
* LeadRegionResolver уходит за точным subject_code в Россвязь (spec §3.4.1).
*/
final class DaDataRegionMap
{
/**
* Строки-агломерации, по которым нельзя однозначно определить субъект.
* Расширяется по реальным наблюдениям на staging (spec §3.4.1).
*
* @var list<string>
*/
public const AMBIGUOUS_REGIONS = [
'Санкт-Петербург и область',
'Москва и область',
];
/**
* Ручные переопределения для имён DaData, не совпадающих с RussianRegions.
* На старте пуст заполняется по findings со staging-smoke.
*
* @var array<string, int>
*/
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);
}
}
+13
View File
@@ -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),
],
];
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// SET ROLE crm_migrator на проде (postgres superuser может SET ROLE).
// На dev/testing crm_migrator не имеет GRANT на public schema → RESET ROLE
// и продолжаем как postgres superuser.
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'
-- 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();
}
};
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function rossvyazFixture(): string
{
return base_path('tests/Fixtures/rossvyaz/sample.csv');
}
it('dry-run parses csv, maps regions to subject_code, builds staging, does not swap', function (): void {
$this->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);
});
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
config([
'services.dadata.api_key' => '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();
});
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->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 → регион не тронут
});
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(SharesSupplierPdo::class);
it('creates phone_ranges with lookup columns', function (): void {
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->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');
});
@@ -76,10 +76,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(\App\Services\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 () {
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataBudgetGuard;
it('allows spend while under the daily cap', function (): void {
config(['services.dadata.daily_cap_rub' => 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);
});
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataTimeoutException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
it('parses qc=0 mobile response into DTO', function (): void {
Http::fake(['cleaner.dadata.ru/*' => 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);
});
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
config([
'services.dadata.enabled' => 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();
});
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/** Детерминированный роутер с засеянным жребием (вариант В). */
function seededRouter(int $seed = 42): LeadRouter
{
return new LeadRouter(new Randomizer(new Mt19937($seed)));
}
/**
* Создаёт tenant + project + pivot/snapshot для каскад-тестов.
* regions PG-массив-литерал ('{82}' / '{}'); remaining лимита = dailyLimit - deliveredToday.
*/
function makeCascadeProject(
SupplierProject $sp,
string $regions,
int $dailyLimit = 100,
int $deliveredToday = 0,
): Project {
$tenant = Tenant::factory()->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);
});
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Services\Dto\RegionResolution;
it('exposes the source rank ordering dadata>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('МТС');
});
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Services\Dto\RossvyazRecord;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Вставляет строку-журнал импорта и возвращает её id (import_id для phone_ranges).
*/
function seedRossvyazImport(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('a', 64),
'status' => 'completed',
'imported_at' => now(),
]);
}
/**
* @param array<string, mixed> $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();
});
+2 -1
View File
@@ -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,
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
it('knows lead_region_resolution_log partition key', function (): void {
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
});
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Support\DaDataRegionMap;
use App\Support\RussianRegions;
it('maps exact official names via RussianRegions', function (): void {
expect(DaDataRegionMap::toSubjectCode('Москва'))->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);
}
});
+4
View File
@@ -0,0 +1,4 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;Москва
921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
999;0000000;0009999;10000;Тест Оператор;Атлантида
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС Москва
3 921 5550000 5559999 10000 ПАО МегаФон Санкт-Петербург
4 999 0000000 0009999 10000 Тест Оператор Атлантида
+8
View File
@@ -1974,3 +1974,11 @@ monitorится
guillemets
mirror'ящий
plan'овский
# Lead region resolution (2026-05-31) — DaData / Rossvyaz region detection
rossvyaz
россвязь
россвязи
dadata
kopecks
qc
+55 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+7 -2
View File
@@ -31,9 +31,14 @@ paths:
keyset (cursor) — O(1) глубины; offset-based — backward-совместимость.
При count_only=true возвращает только {"total": N} без строк.
parameters:
- name: status_in[]
- name: status_in
in: query
description: Фильтр по статусам (можно несколько)
description: >
Фильтр по статусам (можно несколько). На проводе сериализуется
Laravel array-binding: status_in[]=NEW&status_in[]=WON. Имя параметра
в спецификации — без скобок: ключи свойств MCP-инструмента обязаны
матчить ^[a-zA-Z0-9_.-]{1,64}$ (скобки запрещены, иначе Anthropic
tools-схема падает с 400).
required: false
schema:
type: array
@@ -0,0 +1,118 @@
# Lead Region Resolution — прогресс автономного прогона (ночь 31.05.2026)
> Хендофф после автономной ночной сессии. Вся работа **на диске в worktree
> `worktree-feat+lead-region-resolution`, НЕ закоммичена** (git commit/push требуют
> approval владельца через гейт — владелец спал). Утром: ревью → коммиты → продолжение.
## Что сделано (Сессии 1–4 — весь движок резолва региона, TDD-зелёный)
| Сессия | Статус | Тесты |
|---|---|---|
| **1** Схема (миграция + партиции + schema.sql sync) | ✅ на диске | 9 passed / 27 assert |
| **2** Россвязь (lookup + DTO + import-команда) | ✅ на диске | 9 passed / 27 assert |
| **3** DaData (region map + config + enum + client + budget guard) | ✅ на диске | 16 passed / 119 assert |
| **4** LeadRegionResolver (оркестратор, 16 кейсов каскада) | ✅ на диске | 16 passed / 46 assert |
| **Консолидированная регрессия** (все файлы вместе) | ✅ | **53 passed / 238 assert** |
### Новые/изменённые файлы
**Создано:**
- `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
- `app/app/Services/RossvyazPrefixLookup.php` + `app/app/Services/Dto/RossvyazRecord.php`
- `app/app/Console/Commands/PhoneRangesImportCommand.php`
- `app/app/Support/DaDataRegionMap.php`
- `app/app/Services/DaData/{DaDataQualityCode,DaDataException,DaDataTimeoutException,DaDataPhoneResponse,DaDataPhoneClient,DaDataBudgetGuard}.php`
- `app/app/Services/Dto/RegionResolution.php`
- `app/app/Services/LeadRegionResolver.php`
- Тесты: `tests/Feature/Migrations/PhoneRangesMigrationTest.php`, `tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`, `tests/Feature/Services/RossvyazPrefixLookupTest.php`, `tests/Feature/Console/PhoneRangesImportCommandTest.php`, `tests/Unit/Support/DaDataRegionMapTest.php`, `tests/Feature/Services/DaData/{DaDataPhoneClientTest,DaDataBudgetGuardTest}.php`, `tests/Feature/Services/{RegionResolutionTest,LeadRegionResolverTest}.php`
- `tests/Fixtures/rossvyaz/sample.csv`
**Изменено:**
- `app/app/Services/MonthlyPartitionManager.php` — +entry `'lead_region_resolution_log' => 'received_at'`
- `app/app/Models/SupplierLead.php` — +4 колонки в fillable + 2 int-cast
- `app/config/services.php` — +блок `dadata`
- `app/tests/Feature/PartitionsCreateMonthsTest.php` — хрупкий хардкод «48 skipped» → динамический `count(PARTITIONED_TABLES) * 6`
- `db/schema.sql` (v8.39 → **v8.40**, только заголовок) + `db/CHANGELOG_schema.md` (+v8.40)
## Решения, принятые по ходу (для ревью)
1. **Коды субъектов** — по `RussianRegions` (Москва=82, СПб=83, МО=56, ЛО=53), НЕ по спеке (там были авто-коды 77/78/50/47 — неверно).
2. **GRANT'ы миграции**`crm_app_user` + `crm_supplier_worker` (роли `crm_readonly` из плана **не существует**).
3. **`schema.sql`** — только заголовок + CHANGELOG, без тела (как v8.39 project_routing_snapshots): иначе двойной `CREATE TABLE` (0001 грузит schema.sql + дельта-миграция) сломал бы `migrate`.
4. **Размещение тестов** — app/DB-зависимые тесты (DaData-клиент, budget, resolver, DTO с моделью) лежат в **`tests/Feature/...`, не `tests/Unit/...`** как в плане: в проекте `tests/Unit` не бутит Laravel (нет `Http::fake`/`app()`/`Cache`). Чистый `DaDataRegionMap` остался в Unit.
5. **`PhoneRangesImportCommand` swap** — atomic RENAME реализован по спеке, но **committing-swap НЕ покрыт автотестом** (RENAME коммитит и сломал бы общую `liderra_testing`, которую ночью без терминала владельца не пересоздать). Тесты покрывают parse/map/dry-run/idempotency/force. **Свап проверяется первым реальным импортом оператора (Session 6 runbook).** Косметика: lookup-индекс на новой таблице после свапа носит имя `idx_phone_ranges_staging_lookup` (имя `idx_phone_ranges_lookup` занято `phone_ranges_old`).
6. **DaData call cost**`services.dadata.call_cost_kopecks` дефолт 60 (≈0.60 ₽/вызов) — **прикидка, откалибровать по тарифу DaData**.
7. **CSV-парсер импорта** — нативный `str_getcsv(';')` (как проект читает файлы); реальный формат Россвязи (заголовки `АВС/ DEF;От;До;Емкость;Оператор;Регион`, возможно cp1251) уточняется оператором на реальном пакете. XLSX-ветка через openspout — **не протестирована**.
## Что осталось (требует владельца)
### Коммиты (утром, через git-approval)
Предлагаемая разбивка (conventional commits, ветка `worktree-feat+lead-region-resolution`):
- `feat(region): schema migration + MonthlyPartitionManager registration` (миграция, partition manager, PartitionsCreateMonths fix, SupplierLead model, тесты Session 1)
- `chore(region): sync db/schema.sql + CHANGELOG (v8.40)`
- `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`
- `feat(region): phone-ranges:import command (parse/map/dry-run/idempotency)`
- `feat(region): DaData layer (region map, config, enum, client, budget guard)`
- `feat(region): LeadRegionResolver orchestrator (full qc cascade)`
> NB: коммит-сообщения **без** trailer `Co-Authored-By` — гейт блокирует символ `<` (угловые скобки email). Зафиксировано в `docs/bugs.md`.
### D1 — продуктовое решение ДО Session 5
Сейчас при >3 кандидатах лид раздаётся **3 случайным** клиентам. Каскад (Session 5) раздаёт 3 клиентам с **наибольшим остатком дневного лимита** (детерминированно) — клиент с большим остатком систематически получает больше лидов. Каскад по конструкции (роутер режет до 3 упорядоченно → `LeadDistributor` не шаффлит) **и есть** эта смена. Нужно подтверждение: убрать random — ок? (Если хочешь сохранить случайность внутри региона — это +1 задача: shuffle внутри каждой фазы перед cap.)
### Session 5 (каскад LeadRouter) + Session 6 (интеграция в Job) — после D1
- Зависят от D1 + трогают прод-критичный `RouteSupplierLeadJob` (30k лидов/сутки) → делать с ревью, не вслепую.
- Session 6 Task 6.4 (smoke-команда `phone-region:smoke`) + метрики §8 — отдельно.
### Pre-existing tech debt (не моё, флагую)
- `tests/Feature/Import/MonthlyPartitionManagerTest.php::ensureMonth создаёт партицию webhook_log`**красный независимо от меня**: `webhook_log` удалён из проекта 24.05 (миграция `2026_05_24_140000`), тест не обновили. Можно убрать как наследие отдельным мелким фиксом — на твоё усмотрение.
- `migrate:fresh` на проекте **сломан** (cross-PDO `auth_log` в миграции `0001`): миграция грузит schema.sql на `pgsql`, затем зовёт `partitions:create-months` на `pgsql_supplier` в той же транзакции → невидимость. Тестовая база `liderra_testing` собрана клоном dev (`CREATE DATABASE ... WITH TEMPLATE liderra`), а не через migrate:fresh. Отдельная проблема, вне фичи.
## Как прогнать (из `app/`)
```
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php
```
→ 53 passed / 238 assertions.
---
## ОБНОВЛЕНИЕ 01.06.2026 — Сессии 5–6 реализованы, фича функционально завершена
**D1 решён заказчиком — вариант В** (взвешенный жребий по остатку лимита; мелкие клиенты не отрезаются, вес ≥ 1 у каждого).
| Сессия | Что сделано | Тесты |
|---|---|---|
| **5** LeadRouter каскад (exact→all-RF→fallback) + взвешенный жребий (В) + `routing_step` | `LeadRouter` переписан: `matchEligibleProjects($sp, ?int $resolvedSubjectCode)`, `queryCandidates` (region-фильтр + `snap.regions`), `weightedPick`, инъекция `Randomizer`. Хелпер `createRoutingSnapshotFromProject(+regions)`. | 9 cascade + 10 regression |
| **6.1** Резолв до tx + persist + лог в `RouteSupplierLeadJob` | `app(LeadRegionResolver)->resolve()` (НЕ 7-й параметр handle — чтобы не ломать сигнатуру/тесты), persist 4 колонки, `logRegionResolution` (fail-safe INSERT в журнал через pgsql_supplier, маскированный телефон). | в наборе из 8 |
| **6.2** Подмена subject_code на шаге 3 + `region_substituted` | `createDealCopyForProject(RegionResolution)`, `routing_step` захватывается до `$lockedProject`, `pickSubstituteRegion(snapshot.regions)`. Deal +`phone_operator`/`region_substituted` (model fillable+cast). | в наборе из 8 |
| **6.3** CSV-merge по рангу источника | merge-блок обновляет subject_code/phone_operator если webhook-резолв dadata/rossvyaz (выше tag CSV). **Эвристика**`deals.region_source` нет (документировано). | 2 |
| **6.4** Smoke-команда `phone-region:smoke` | резолв по телефону без записи в БД. **Метрики §8.1 отложены** (нет механизма Prometheus/StatsD в проекте). | 2 |
| **6.5** Финальная регрессия + runbook | **101 passed / 509 assertions** (вся фича + регрессия Job ×3 / Router ×2). Runbook раскатки: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`. | 101 |
### Новые/изменённые файлы Сессий 5–6 (в worktree, не закоммичено)
- Изменено: `app/app/Services/LeadRouter.php` (каскад + weighted pick + Randomizer), `app/app/Jobs/RouteSupplierLeadJob.php` (resolve+persist+log+substitution+CSV-merge), `app/app/Models/Deal.php` (+2 fillable, +1 cast), `app/tests/Pest.php` (helper +regions).
- Создано: `app/app/Console/Commands/PhoneRegionSmokeCommand.php`; тесты `LeadRouterCascadeTest.php`, `RouteSupplierLeadJobRegionResolutionTest.php`, `PhoneRegionSmokeCommandTest.php`; runbook.
### Решения Сессий 5–6 (для ревью)
1. **D1=В** — взвешенный жребий, мелкие не отрезаны (доказано тестом `variant В: weighted pick` — 120 seed'ов, мелкий выигрывает >0 раз, крупный чаще).
2. **LeadRegionResolver через `app()` внутри `handle()`**, не 7-м параметром — иначе ломались бы сигнатура + 3 существующих Job-теста.
3. **Лог резолва fail-safe** — сбой записи аудит-лога не роняет доставку лида (30k/сутки).
4. **`deals.region_source` НЕ добавлялась** — CSV-merge по рангу через эвристику (dadata/rossvyaz > CSV-tag). Отклонение от плана Task 6.3 (план предполагал колонку), задокументировано.
5. **Метрики §8.1 отложены** — нет механизма метрик в проекте.
### Коммиты Сессий 5–6 (предложение, ветка `worktree-feat+lead-region-resolution`)
- `test(region): createRoutingSnapshotFromProject accepts regions param`
- `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) + weighted pick variant В + routing_step`
- `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist + fail-safe log`
- `feat(region): step-3 region substitution + CSV-merge by source rank`
- `feat(region): phone-region:smoke staging command`
- `docs(region): rollout runbook + session progress`
### Пре-существующий долг (флагую, не моё)
- `tests/Feature/Console/{BillingMigrateLeadsToRub,IncidentsWatchFailures,SnapshotBackfillCommand}Test`**взаимно загрязняются** при прогоне в одном процессе (счётчики растут: ожидал 1, получил 4-5). Падают и БЕЗ моих файлов. В реальном CI (`pest --parallel`, файл = процесс) проходят. Тест-изоляция этих команд хрупкая — отдельная задача.
### Команда финальной регрессии (явный список, из `app/`)
```
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Feature/Console/PhoneRegionSmokeCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
```
→ 101 passed / 509 assertions.
@@ -0,0 +1,81 @@
# Lead Region Resolution — runbook раскатки на прод
> Фича: определение настоящего региона лида по телефону (DaData → реестр Россвязи →
> tag-fallback) + каскадная маршрутизация по региону. Код реализован и зелёный
> (Сессии 1-6, TDD). Этот runbook — порядок выкатки оператором на `liderra.ru`.
> Spec: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
> Plan: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
## Решение D1 (зафиксировано заказчиком 01.06.2026)
**Вариант В** — внутри каждой ступени каскада при >3 претендентах лид раздаётся
**взвешенным жребием по остатку дневного лимита**: шанс ∝ остатку, но у каждого
кандидата шанс > 0 (вес ≥ 1) — маленькие клиенты не отрезаются. Реализовано в
`LeadRouter::weightedPick` (вес `max(1, snapshot_daily_limit delivered_today)`).
## Предусловия
- `DADATA_API_KEY` + `DADATA_SECRET` — завести в **YC Lockbox** (НЕ в git/.env репозитория).
Прокинуть в окружение прод-воркеров (`DADATA_API_KEY`, `DADATA_SECRET`).
- Feature-flag `LEAD_REGION_RESOLVER_ENABLED` (по умолчанию `false` → текущее tag-поведение).
- Бюджет: `DADATA_DAILY_CAP_RUB` (дефолт 10000), `DADATA_CALL_COST_KOPECKS` (дефолт 60 —
**откалибровать по фактическому тарифу DaData** после первого дня).
## Порядок выкатки
1. **Миграция БД.** Накатить `2026_05_31_100000_create_phone_ranges_and_resolution_log`
(создаёт `phone_ranges`, `phone_ranges_imports`, `lead_region_resolution_log` +
колонки в `supplier_leads`/`deals`). Партиции журнала на старте — m05/m06; далее
их подхватывает `partitions:create-months` (уже зарегистрирован в `MonthlyPartitionManager`).
- На проде миграция делает `SET ROLE crm_migrator` (паттерн проекта).
2. **Импорт реестра Россвязи.** Скачать пакет выписок с
`rossvyaz.gov.ru/deyatelnost/resurs-numeracii/...` (~500-600 файлов) в каталог,
затем `php artisan phone-ranges:import --dir=<каталог>`.
- **NB парсер:** ожидает CSV `;`-разделитель, колонки `АВС/ DEF;От;До;Емкость;Оператор;Регион`.
Реальные файлы Россвязи могут быть в cp1251 / иметь другие заголовки — сверить на
первом импорте; при расхождении поправить `resolveColumns()` (это и есть первая
боевая валидация — автотест покрывает CSV-фикстуру, не реальный формат).
- **NB swap:** atomic RENAME (`phone_ranges``_old`, staging → `phone_ranges`) НЕ
покрыт автотестом (коммитящий RENAME сломал бы общую тестовую БД). **Этот импорт —
первая боевая проверка свапа.** Сначала прогнать `--dry-run` (staging без свапа),
проверить `phone_ranges_staging` глазами, потом без `--dry-run`. Откат:
`phone-ranges:rollback` (см. spec §6.4 — команда отката пока не реализована,
при необходимости — ручной RENAME `phone_ranges_old` обратно).
3. **Деплой кода с `LEAD_REGION_RESOLVER_ENABLED=false`.** Резолвер выключен →
поведение идентично текущему (tag-fallback). Каскад работает (но без точного
региона, т.к. `resolved_subject_code=null` → шаг 2 «вся РФ» как раньше).
4. **Smoke на staging/проде:** `php artisan phone-region:smoke --phone=79161234567`
(с реальным ключом — платный вызов, в БД не пишет). Проверить, что DaData отвечает,
регион/оператор резолвятся, Россвязь-fallback находит префиксы. Прогнать §9.4 — ~100
реальных prod-номеров, сверить распределение источников.
5. **Включить флаг (сразу 100%):** `LEAD_REGION_RESOLVER_ENABLED=true`. Рубильник
глобальный — резолвер включается сразу для **всего** потока лидов. **Долевую
(постепенную) раскатку НЕ делаем** (решение заказчика 01.06.2026): никакого
`hash(phone) % 100`-гейта не вводим, фича идёт на 100% с первого включения.
6. **Мониторинг 1 день:** `lead_region_resolution_log` — распределение `region_source`
(ожидание: dadata большинство, tag < 20%, unknown < 5% — spec §8.2). Проверить
`DADATA_DAILY_CAP_RUB` не упирается. Откалибровать `DADATA_CALL_COST_KOPECKS`.
7. **Штатный режим:** фича уже работает на 100% потока (с шага 5) — долевого гейта нет,
убирать нечего. Единственный рычаг управления — флаг `LEAD_REGION_RESOLVER_ENABLED`.
8. **Ежемесячный cron** импорта реестра (`phone-ranges:import`, 4-е число 03:00 МСК —
spec §6.3) — добавить в планировщик/`artisan-run`.
## Откат
- Мгновенный: `LEAD_REGION_RESOLVER_ENABLED=false` → резолвер возвращает tag-fallback,
каскад ведёт себя как до фичи. Код деплоить заново не нужно.
- Реестр: `phone_ranges_old` хранит предыдущую версию (ручной RENAME при проблеме импорта).
## Что отложено (followups, не блокируют ядро)
- **Метрики §8.1** (`phone_resolution.source.*` и т.д.) — в проекте нет механизма
Prometheus/StatsD; отложено до его появления.
- **Долевая (постепенная) раскатка****НЕ делаем** (решение заказчика 01.06.2026):
фича включается сразу на 100%, `hash(phone)%100`-гейт не вводится.
- **`phone-ranges:rollback`** — команда отката свапа (spec §6.4) не реализована.
- **`deals.region_source`** — не добавлялась (по спеке регион-источник живёт на
`supplier_leads` + в журнале). CSV-merge (§3.12) обновляет регион сделки по
эвристике «webhook dadata/rossvyaz > CSV-tag», без хранения source на сделке.
- **pg_anonymizer-маски (§7.2)** на `lead_region_resolution_log` — при настройке масок дампов.
- **152-ФЗ:** телефон в журнале маскирован (`7XXX***YYYY`), `dadata_response_masked`
без сырого номера — базовое покрытие есть; полный аудит ПДн — через `pdn-152fz-audit`.