diff --git a/app/app/Console/Commands/PhoneRangesImportCommand.php b/app/app/Console/Commands/PhoneRangesImportCommand.php new file mode 100644 index 00000000..dbde4860 --- /dev/null +++ b/app/app/Console/Commands/PhoneRangesImportCommand.php @@ -0,0 +1,429 @@ + [--force] [--dry-run] + * php artisan phone-ranges:import --dir= [...] + * + * Алгоритм: + * 1. Резолв входных файлов (--file | --dir; --url отложен — оператор качает пакет вручную). + * 2. Checksum-идемпотентность: совпал с предыдущим `completed` → status='rolled_back', выход. + * 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) → нормализованные строки. + * 4. Маппинг region → subject_code через RussianRegions::nameToCode(). Несматчившиеся → лог в error. + * 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT. + * 6. --dry-run → staging остаётся для инспекции, swap НЕ делается, status='rolled_back'. + * Иначе → atomic RENAME swap + status='completed'. + * + * Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker — член crm_migrator, + * INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test — postgres superuser). + * + * NB (swap — operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом — + * RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным + * импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency. + */ +class PhoneRangesImportCommand extends Command +{ + /** @var string */ + protected $signature = 'phone-ranges:import + {--file= : Путь к одному CSV/XLSX файлу реестра} + {--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)} + {--url= : (отложено) URL пакета — скачать вручную и использовать --dir} + {--force : Игнорировать checksum-идемпотентность} + {--dry-run : Распарсить и собрать staging, но не делать atomic swap}'; + + /** @var string */ + protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)'; + + /** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */ + private const DDL_CONNECTION = 'pgsql_supplier'; + + /** Размер пачки для bulk INSERT в staging. */ + private const INSERT_CHUNK = 1000; + + public function handle(): int + { + $files = $this->resolveFiles(); + if ($files === null) { + return self::FAILURE; + } + + $checksum = $this->computeChecksum($files); + $dryRun = (bool) $this->option('dry-run'); + $force = (bool) $this->option('force'); + + // 2. Идемпотентность по checksum (если не --force). + if (! $force) { + $prev = DB::table('phone_ranges_imports') + ->where('checksum_sha256', $checksum) + ->where('status', 'completed') + ->orderByDesc('id') + ->first(); + + if ($prev !== null) { + DB::table('phone_ranges_imports')->insert([ + 'source_url' => $this->sourceLabel($files), + 'checksum_sha256' => $checksum, + 'status' => 'rolled_back', + 'rows_inserted' => 0, + 'rows_updated' => 0, + 'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.", + 'imported_at' => now(), + 'completed_at' => now(), + ]); + $this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта)."); + + return self::SUCCESS; + } + } + + // 3. Журнал импорта (in_progress). + $importId = (int) DB::table('phone_ranges_imports')->insertGetId([ + 'source_url' => $this->sourceLabel($files), + 'checksum_sha256' => $checksum, + 'status' => 'in_progress', + 'imported_at' => now(), + ]); + + try { + // 4. Парсинг + маппинг. + $unmatched = []; + $rows = []; + foreach ($files as $file) { + foreach ($this->parseFile($file) as $rec) { + $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|null Список файлов или null при ошибке валидации опций. + */ + private function resolveFiles(): ?array + { + $file = $this->option('file'); + $dir = $this->option('dir'); + $url = $this->option('url'); + + if ($url !== null) { + $this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.'); + + return null; + } + + if ($file !== null) { + if (! is_file($file)) { + $this->error("Файл не найден: {$file}"); + + return null; + } + + return [$file]; + } + + if ($dir !== null) { + if (! is_dir($dir)) { + $this->error("Каталог не найден: {$dir}"); + + return null; + } + $found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: []; + if ($found === []) { + $this->error("В каталоге нет *.csv / *.xlsx: {$dir}"); + + return null; + } + sort($found); + + return array_values($found); + } + + $this->error('Укажите --file=<путь> или --dir=<каталог>.'); + + return null; + } + + /** + * @param list $files + */ + private function computeChecksum(array $files): string + { + if (count($files) === 1) { + return (string) hash_file('sha256', $files[0]); + } + + $hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files); + sort($hashes); + + return hash('sha256', implode('|', $hashes)); + } + + /** + * @param list $files + */ + private function sourceLabel(array $files): string + { + return $this->option('url') + ?? $this->option('dir') + ?? ($files[0] ?? 'unknown'); + } + + /** + * Парсит один файл реестра в нормализованные строки. + * + * @return list + */ + private function parseFile(string $path): array + { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + return $ext === 'xlsx' + ? $this->parseXlsx($path) + : $this->parseCsv($path); + } + + /** + * @return list + */ + private function parseCsv(string $path): array + { + $content = (string) file_get_contents($path); + // BOM strip + split строк (CRLF/CR/LF). + $content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content; + $lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: []; + if ($lines === []) { + return []; + } + + $header = str_getcsv((string) array_shift($lines), ';'); + $cols = $this->resolveColumns($header); + + $out = []; + foreach ($lines as $line) { + if (trim($line) === '') { + continue; + } + $cells = str_getcsv($line, ';'); + $rec = $this->mapCells($cells, $cols); + if ($rec !== null) { + $out[] = $rec; + } + } + + return $out; + } + + /** + * Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом). + * + * @return list + */ + private function parseXlsx(string $path): array + { + $reader = new XlsxReader(); + $reader->open($path); + + $out = []; + $cols = null; + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $row) { + $cells = array_map(static fn ($c): string => (string) $c, $row->toArray()); + if ($cols === null) { + $cols = $this->resolveColumns($cells); + + continue; + } + $rec = $this->mapCells($cells, $cols); + if ($rec !== null) { + $out[] = $rec; + } + } + break; // только первый лист + } + $reader->close(); + + return $out; + } + + /** + * Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback. + * + * @param list $header + * @return array{def:int, from:int, to:int, operator:int, region:int} + */ + private function resolveColumns(array $header): array + { + $cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5]; + + foreach ($header as $i => $cell) { + $n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? ''; + if (str_contains($n, 'def') || str_contains($n, 'авс')) { + $cols['def'] = $i; + } elseif ($n === 'от') { + $cols['from'] = $i; + } elseif ($n === 'до') { + $cols['to'] = $i; + } elseif (str_contains($n, 'оператор')) { + $cols['operator'] = $i; + } elseif (str_contains($n, 'регион')) { + $cols['region'] = $i; + } + } + + return $cols; + } + + /** + * @param list $cells + * @param array{def:int, from:int, to:int, operator:int, region:int} $cols + * @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null + */ + private function mapCells(array $cells, array $cols): ?array + { + $def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? ''); + if ($def === 0) { + return null; // пустая/битая строка + } + + return [ + 'def_code' => $def, + 'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'), + 'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'), + 'operator' => trim((string) ($cells[$cols['operator']] ?? '')), + 'region' => trim((string) ($cells[$cols['region']] ?? '')), + ]; + } + + /** + * Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки. + * + * @param list> $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 + } + } +} diff --git a/app/app/Console/Commands/PhoneRegionSmokeCommand.php b/app/app/Console/Commands/PhoneRegionSmokeCommand.php new file mode 100644 index 00000000..a80fa532 --- /dev/null +++ b/app/app/Console/Commands/PhoneRegionSmokeCommand.php @@ -0,0 +1,78 @@ +option('phone'); + if ($phone === '') { + $this->error('Укажите --phone=7XXXXXXXXXX'); + + return self::FAILURE; + } + + // Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен. + config(['services.dadata.enabled' => true]); + + $lead = new SupplierLead([ + 'phone' => $phone, + 'raw_payload' => ['tag' => (string) $this->option('tag')], + ]); + + $r = $resolver->resolve($lead); + + $region = $r->subjectCode !== null + ? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?') + : '—'; + + $this->info('Телефон: '.$this->maskPhone($phone)); + $this->line('Источник: '.$r->source); + $this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')'); + $this->line('Оператор: '.($r->phoneOperator ?? '—')); + $this->line('DaData qc: '.($r->qc ?? '—')); + $this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет')); + $this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет')); + $this->line('Длит., мс: '.($r->durationMs ?? '—')); + $this->newLine(); + $this->comment('NB: запись в БД НЕ выполнялась (smoke).'); + + return self::SUCCESS; + } + + private function maskPhone(string $phone): string + { + $digits = preg_replace('/\D+/', '', $phone) ?? ''; + if (strlen($digits) < 8) { + return '***'; + } + + return substr($digits, 0, 4).'***'.substr($digits, -4); + } +} diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index 97b0f61b..39660d6a 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -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 $selected + */ + private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void + { + try { + $first = $selected->first(); + $routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null; + $substituted = ($routingStep === 3 && $first !== null) + ? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode) + : null; + + $tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? '')); + + DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([ + 'supplier_lead_id' => $lead->id, + 'received_at' => $lead->received_at ?? now(), + 'phone_masked' => $this->maskPhone((string) $lead->phone), + 'subject_code_resolved' => $resolution->subjectCode, + 'subject_code_from_tag' => $tagCode, + 'region_source' => $resolution->source, + 'dadata_qc' => $resolution->qc, + 'dadata_provider' => $resolution->phoneOperator, + 'dadata_type' => null, + 'dadata_response_masked' => $resolution->dadataResponseMasked !== null + ? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE) + : null, + 'rossvyaz_matched' => $resolution->rossvyazMatched, + 'actual_subject_code' => $resolution->actualSubjectCode, + 'substituted_subject_code' => $substituted, + 'routing_step' => $routingStep, + 'phone_operator' => $resolution->phoneOperator, + 'cache_hit' => $resolution->cacheHit, + 'duration_ms' => $resolution->durationMs, + ]); + } catch (Throwable $e) { + Log::warning('lead_region_resolution.log_write_failed', [ + 'supplier_lead_id' => $lead->id, + 'exception' => $e->getMessage(), + ]); + } + } + + /** + * Первый код субъекта из PG INT[]-литерала ('{82,83}' → 82; '{}' → null) — регион клиента + * для подмены на запасном канале (§3.10). + */ + private function pickSubstituteRegion(string $regionsLiteral): ?int + { + return $this->parseSubjectCodes($regionsLiteral)[0] ?? null; + } + + /** + * @return list '{82,83}' → [82,83]; '{}'/'' → [] + */ + private function parseSubjectCodes(string $regionsLiteral): array + { + $inner = trim($regionsLiteral, '{}'); + if ($inner === '') { + return []; + } + + return array_values(array_map('intval', explode(',', $inner))); + } + + /** + * Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567). + */ + private function maskPhone(string $phone): string + { + $digits = preg_replace('/\D+/', '', $phone) ?? ''; + if (strlen($digits) < 8) { + return '***'; + } + + return substr($digits, 0, 4).'***'.substr($digits, -4); + } + /** * Финальный callback после исчерпания всех ретраев ($tries=3). * diff --git a/app/app/Models/Deal.php b/app/app/Models/Deal.php index fe544b06..4bde92a3 100644 --- a/app/app/Models/Deal.php +++ b/app/app/Models/Deal.php @@ -61,6 +61,9 @@ class Deal extends Model 'is_test', 'received_at', 'deleted_at', + // Lead region resolution (Session 1, 31.05.2026). + 'phone_operator', + 'region_substituted', ]; protected function casts(): array @@ -77,6 +80,7 @@ class Deal extends Model 'lead_score' => 'decimal:2', 'phones' => 'array', 'is_test' => 'boolean', + 'region_substituted' => 'boolean', 'assigned_at' => 'datetime', 'received_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/app/Models/SupplierLead.php b/app/app/Models/SupplierLead.php index 92aeaaab..468dc761 100644 --- a/app/app/Models/SupplierLead.php +++ b/app/app/Models/SupplierLead.php @@ -41,6 +41,11 @@ class SupplierLead extends Model 'recovered_from_csv_at', 'deals_created_count', 'error', + // Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display. + 'resolved_subject_code', + 'region_source', + 'dadata_qc', + 'phone_operator', ]; protected function casts(): array @@ -52,6 +57,8 @@ class SupplierLead extends Model 'recovered_from_csv_at' => 'datetime', 'vid' => 'integer', 'deals_created_count' => 'integer', + 'resolved_subject_code' => 'integer', + 'dadata_qc' => 'integer', ]; } diff --git a/app/app/Services/DaData/DaDataBudgetGuard.php b/app/app/Services/DaData/DaDataBudgetGuard.php new file mode 100644 index 00000000..8d66ef1d --- /dev/null +++ b/app/app/Services/DaData/DaDataBudgetGuard.php @@ -0,0 +1,47 @@ +`. + * `Cache::increment` на redis-сторе атомарен (INCRBY) — корректно при параллельных + * RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые. + * + * При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3). + */ +class DaDataBudgetGuard +{ + public function canSpend(): bool + { + $capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100; + + return $this->spentTodayKopecks() < $capKopecks; + } + + public function recordSpend(int $kopecks): void + { + if ($kopecks <= 0) { + return; + } + + $key = $this->dailyKey(); + Cache::add($key, 0, now()->addDays(2)); + Cache::increment($key, $kopecks); + } + + public function spentTodayKopecks(): int + { + return (int) Cache::get($this->dailyKey(), 0); + } + + private function dailyKey(): string + { + return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d'); + } +} diff --git a/app/app/Services/DaData/DaDataException.php b/app/app/Services/DaData/DaDataException.php new file mode 100644 index 00000000..b67eb35f --- /dev/null +++ b/app/app/Services/DaData/DaDataException.php @@ -0,0 +1,13 @@ + ; X-Secret: ; body [""] + * + * Retry — только на сетевые ошибки и 5xx (4xx → сразу DaDataException, без retry). + * Сеть/таймаут после исчерпания retry → DaDataTimeoutException; 5xx → DaDataException. + * Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory). + */ +class DaDataPhoneClient +{ + private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone'; + + public function __construct( + private readonly HttpFactory $http, + ) {} + + public function cleanPhone(string $phone): DaDataPhoneResponse + { + $cfg = (array) config('services.dadata'); + $timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000)); + $attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1); + $apiKey = (string) ($cfg['api_key'] ?? ''); + $secret = (string) ($cfg['secret'] ?? ''); + + $lastException = null; + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + try { + $response = $this->http + ->asJson() + ->acceptJson() + ->timeout($timeoutSec) + ->withHeaders([ + 'Authorization' => 'Token '.$apiKey, + 'X-Secret' => $secret, + ]) + ->post(self::URL, [$phone]); + } catch (ConnectionException $e) { + $lastException = new DaDataTimeoutException( + 'DaData connection failed: '.$e->getMessage(), 0, $e, + ); + + continue; // сеть → retry + } + + if ($response->serverError()) { + $lastException = new DaDataException('DaData 5xx: HTTP '.$response->status()); + + continue; // 5xx → retry + } + + if (! $response->successful()) { + // 4xx — клиентская ошибка, retry бессмыслен. + throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body()); + } + + return $this->parse($response->json()); + } + + throw $lastException ?? new DaDataException('DaData failed without a response'); + } + + /** + * @param mixed $body декодированный JSON (ожидается массив с одним объектом) + */ + private function parse($body): DaDataPhoneResponse + { + $row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : []; + + return new DaDataPhoneResponse( + qc: isset($row['qc']) ? (int) $row['qc'] : null, + qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null, + type: isset($row['type']) ? (string) $row['type'] : null, + phone: isset($row['phone']) ? (string) $row['phone'] : null, + provider: isset($row['provider']) ? (string) $row['provider'] : null, + region: isset($row['region']) ? (string) $row['region'] : null, + city: isset($row['city']) ? (string) $row['city'] : null, + timezone: isset($row['timezone']) ? (string) $row['timezone'] : null, + raw: $row, + ); + } +} diff --git a/app/app/Services/DaData/DaDataPhoneResponse.php b/app/app/Services/DaData/DaDataPhoneResponse.php new file mode 100644 index 00000000..3135c095 --- /dev/null +++ b/app/app/Services/DaData/DaDataPhoneResponse.php @@ -0,0 +1,26 @@ + $raw полный сырой объект ответа (для маскированного лога) + */ + public function __construct( + public readonly ?int $qc, + public readonly ?int $qcConflict, + public readonly ?string $type, + public readonly ?string $phone, + public readonly ?string $provider, + public readonly ?string $region, + public readonly ?string $city, + public readonly ?string $timezone, + public readonly array $raw, + ) {} +} diff --git a/app/app/Services/DaData/DaDataQualityCode.php b/app/app/Services/DaData/DaDataQualityCode.php new file mode 100644 index 00000000..7cc7ae39 --- /dev/null +++ b/app/app/Services/DaData/DaDataQualityCode.php @@ -0,0 +1,27 @@ + ранг источника для CSV-merge (выше = достовернее) */ + public const SOURCE_RANK = [ + 'dadata' => 4, + 'rossvyaz' => 3, + 'tag' => 2, + 'unknown' => 1, + ]; + + /** + * @param array|null $dadataResponseMasked + */ + public function __construct( + public ?int $subjectCode, + public ?int $actualSubjectCode, + public string $source, + public ?string $phoneOperator, + public ?int $qc, + public bool $cacheHit, + public ?array $dadataResponseMasked, + public ?int $durationMs, + public bool $rossvyazMatched, + ) {} + + /** + * @param array|null $dadataMasked + */ + public static function make( + ?int $subjectCode, + string $source, + ?string $operator = null, + ?int $qc = null, + bool $cacheHit = false, + ?array $dadataMasked = null, + ?int $durationMs = null, + bool $rossvyazMatched = false, + ): self { + return new self( + subjectCode: $subjectCode, + actualSubjectCode: $subjectCode, + source: $source, + phoneOperator: $operator, + qc: $qc, + cacheHit: $cacheHit, + dadataResponseMasked: $dadataMasked, + durationMs: $durationMs, + rossvyazMatched: $rossvyazMatched, + ); + } + + public static function fromTag(?int $subjectCode): self + { + return self::make($subjectCode, $subjectCode !== null ? 'tag' : 'unknown'); + } + + /** + * Восстановление из persistent state лида (retry-идемпотентность §3.11) — без DaData-вызова. + */ + public static function fromSupplierLead(SupplierLead $lead): self + { + return self::make( + subjectCode: $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null, + source: (string) ($lead->region_source ?? 'unknown'), + operator: $lead->phone_operator, + qc: $lead->dadata_qc !== null ? (int) $lead->dadata_qc : null, + ); + } + + public function withCacheHit(bool $hit): self + { + return new self( + subjectCode: $this->subjectCode, + actualSubjectCode: $this->actualSubjectCode, + source: $this->source, + phoneOperator: $this->phoneOperator, + qc: $this->qc, + cacheHit: $hit, + dadataResponseMasked: null, // §3.11: cache-hit лог не несёт masked-ответ + durationMs: $this->durationMs, + rossvyazMatched: $this->rossvyazMatched, + ); + } + + /** + * Версия для записи в кэш (§7.3): без per-call полей (masked-ответ, длительность, cache-флаг). + */ + public function forCache(): self + { + return new self( + subjectCode: $this->subjectCode, + actualSubjectCode: $this->actualSubjectCode, + source: $this->source, + phoneOperator: $this->phoneOperator, + qc: $this->qc, + cacheHit: false, + dadataResponseMasked: null, + durationMs: null, + rossvyazMatched: $this->rossvyazMatched, + ); + } +} diff --git a/app/app/Services/Dto/RossvyazRecord.php b/app/app/Services/Dto/RossvyazRecord.php new file mode 100644 index 00000000..ca4d0899 --- /dev/null +++ b/app/app/Services/Dto/RossvyazRecord.php @@ -0,0 +1,20 @@ +tagFallback($lead, provider: null, qc: null, masked: null, start: null); + } + + // Persistent-idempotency: уже резолвили на предыдущем try → без DaData. + if ($lead->resolved_subject_code !== null || $lead->region_source !== null) { + return RegionResolution::fromSupplierLead($lead); + } + + $phone = (string) $lead->phone; + if (! preg_match('/^7\d{10}$/', $phone)) { + return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null); + } + + $cacheKey = $this->cacheKey($phone); + $cached = $this->cache->get($cacheKey); + if ($cached instanceof RegionResolution) { + return $cached->withCacheHit(true); + } + + $resolution = $this->doResolve($lead, $phone); + + $ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30)); + $this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays)); + + return $resolution; + } + + private function doResolve(SupplierLead $lead, string $phone): RegionResolution + { + $start = microtime(true); + $provider = null; + $qc = null; + $masked = null; + + // 1. DaData (под дневным бюджетом). + if ($this->budgetGuard->canSpend()) { + try { + $dadata = $this->dadataClient->cleanPhone($phone); + $this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60)); + $qc = $dadata->qc; + $provider = $dadata->provider; + $masked = $this->maskResponse($dadata); + + if (in_array($dadata->qc, [0, 3], true)) { + $region = (string) ($dadata->region ?? ''); + if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) { + $code = DaDataRegionMap::toSubjectCode($region); + if ($code !== null) { + return RegionResolution::make( + $code, 'dadata', + operator: $provider, qc: $qc, + dadataMasked: $masked, durationMs: $this->ms($start), + ); + } + // qc=0/3, но регион не маппится → страховка Россвязью. + } + // ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем). + } elseif ($dadata->qc === 2 || $dadata->qc === 7) { + // Мусор / иностранец → Россвязь не поможет, сразу tag. + return $this->tagFallback($lead, $provider, $qc, $masked, $start); + } + // qc=1 → Россвязь. + } catch (DaDataException) { + // Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем. + } + } + + // 2. Россвязь. + $rossvyaz = $this->rossvyazLookup->find($phone); + if ($rossvyaz !== null) { + $code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region); + if ($code !== null) { + return RegionResolution::make( + $code, 'rossvyaz', + operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP) + qc: $qc, dadataMasked: $masked, + durationMs: $this->ms($start), rossvyazMatched: true, + ); + } + } + + // 3. Tag-fallback. + return $this->tagFallback($lead, $provider, $qc, $masked, $start); + } + + private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution + { + $tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : ''); + $tagCode = $this->tagResolver->resolve($tag); + + return RegionResolution::make( + $tagCode, + $tagCode !== null ? 'tag' : 'unknown', + operator: $provider, + qc: $qc, + dadataMasked: $masked, + durationMs: $start !== null ? $this->ms($start) : null, + ); + } + + private function cacheKey(string $phone): string + { + return 'phone-region:'.hash('sha256', $phone); + } + + private function ms(float $start): int + { + return (int) round((microtime(true) - $start) * 1000); + } + + /** + * @return array сырой ответ DaData с маскированным телефоном (§7.1) + */ + private function maskResponse(DaDataPhoneResponse $response): array + { + $raw = $response->raw; + if (isset($raw['phone']) && is_string($raw['phone'])) { + $raw['phone'] = $this->maskPhone($raw['phone']); + } + + return $raw; + } + + private function maskPhone(string $phone): string + { + $digits = preg_replace('/\D+/', '', $phone) ?? ''; + if (strlen($digits) < 8) { + return '***'; + } + + return substr($digits, 0, 4).'***'.substr($digits, -4); + } +} diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php index a651627d..762c32a3 100644 --- a/app/app/Services/LeadRouter.php +++ b/app/app/Services/LeadRouter.php @@ -10,129 +10,219 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Random\Randomizer; /** - * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6). + * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с + * каскадной маршрутизацией по региону (lead region resolution §3.9). * * Eligibility — структурно через snapshot `project_routing_snapshots` за активную * дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты, - * с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации - * (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.) - * берутся из snapshot. Из live `projects` — только `delivered_today` (счётчик - * остатка лимита, обновляется в течение дня) и из `tenants` — `balance_rub` - * (live auto-pause при нулевом балансе). + * с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации берутся из + * snapshot; из live `projects` — только `delivered_today` (остаток лимита), + * из `tenants` — `balance_rub` + `frozen_by_balance_at` (live auto-pause). * - * Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) — клиент Лидерры, - * который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно - * получает свои оплаченные лиды по уже зафиксированному slepok'у. + * Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона: + * 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`); + * 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов; + * 3) запасной канал (без фильтра региона) — только если первые две пусты; + * сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10). + * Каждый Project помечается атрибутом `routing_step` (1/2/3). * - * Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix - * фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион - * гарантирован тем, через какой supplier_project пришёл лид. + * Отбор внутри фазы при кандидатах > cap — **взвешенный жребий по остатку лимита** + * (вариант D1=В): шанс ∝ остатку, но у каждого кандидата шанс > 0 (вес ≥ 1) — + * маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз). + * Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937). * * Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в * sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов. * - * Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3. + * Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3 + * + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9. */ class LeadRouter { + public function __construct( + private readonly Randomizer $randomizer = new Randomizer, + ) {} + /** - * Возвращает ONE project per tenant_id — тот, у которого наибольший остаток - * дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id). - * - * Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам - * (клиентам), каждый тенант получает ровно ОДИН проект — с наибольшим остатком. - * LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов, - * а не число проектов, потому что входные данные уже one-per-tenant. - * - * Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) — tenant ещё не - * определён, SELECT видит проекты всех tenant'ов. + * Возвращает ≤ cap проектов (по одному на tenant), отобранных каскадом + * по региону + взвешенным жребием. Каждый Project несёт `routing_step`. * * @return Collection */ - public function matchEligibleProjects(SupplierProject $supplierProject): Collection + public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection { - // Активная дата слепка вычисляется в PHP — детерминирована для всего запроса, - // тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами. $activeDate = $this->activeSnapshotDate(); + $cap = LeadDistributor::CAP; - // Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier - // match с Лидерра-проектами через snapshot (project_supplier_links для - // DIRECT-row'ов не создаются — DIRECT supplier_projects создаются автоматически - // при получении webhook'а без B-префикса). - if ($supplierProject->platform === 'DIRECT') { - $directSql = <<<'SQL' - SELECT DISTINCT ON (snap.tenant_id) - projects.*, - snap.daily_limit AS snapshot_daily_limit - FROM project_routing_snapshots snap - INNER JOIN projects ON projects.id = snap.project_id - WHERE snap.snapshot_date = ?::date - AND snap.signal_type = ? - AND LOWER(snap.signal_identifier) = LOWER(?) - AND projects.delivered_today < snap.daily_limit - AND EXISTS ( - SELECT 1 FROM tenants - WHERE tenants.id = snap.tenant_id - AND tenants.balance_rub > 0 - -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1) - AND tenants.frozen_by_balance_at IS NULL - ) - ORDER BY snap.tenant_id, - (snap.daily_limit - projects.delivered_today) DESC, - projects.created_at, - projects.id - SQL; - $directRows = DB::connection('pgsql_supplier')->select( - $directSql, - [$activeDate, $supplierProject->signal_type, $supplierProject->unique_key] - ); + // Фаза 1: точное совпадение региона (только если резолвер дал subject_code). + $exact = $resolvedSubjectCode !== null + ? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, []) + : collect(); + $selected = $this->weightedPick($exact, $cap); + $this->tagStep($selected, 1); - $this->logIfNoSnapshot($directRows, $supplierProject, $activeDate); - - return Project::hydrate($directRows)->values(); + if ($selected->count() >= $cap) { + return $selected->take($cap)->values(); } - // Existing B1/B2/B3 path — explicit project_supplier_links pivot. - $sql = <<<'SQL' + // Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов). + $allRu = $this->queryCandidates( + $activeDate, $supplierProject, 'all_ru', null, + $selected->pluck('tenant_id')->all(), + ); + $pickedRu = $this->weightedPick($allRu, $cap - $selected->count()); + $this->tagStep($pickedRu, 2); + $combined = $selected->concat($pickedRu); + + if ($combined->isNotEmpty()) { + return $combined->take($cap)->values(); + } + + // Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»). + $fallback = $this->weightedPick( + $this->queryCandidates($activeDate, $supplierProject, 'any', null, []), + $cap, + ); + $this->tagStep($fallback, 3); + + $this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate); + + return $fallback->take($cap)->values(); + } + + /** + * Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона. + * regionFilter ∈ exact|all_ru|any. Возвращает всех eligible (по одному на tenant), + * упорядоченных по остатку лимита DESC, created_at, id; жребий — поверх в PHP. + * + * @param list $excludeTenantIds + * @return Collection + */ + private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection + { + $bindings = [$activeDate]; + + if ($sp->platform === 'DIRECT') { + // DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier. + $sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)'; + $bindings[] = $sp->signal_type; + $bindings[] = $sp->unique_key; + } else { + $sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl + WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)'; + $bindings[] = $sp->id; + } + + $regionWhere = ''; + if ($regionFilter === 'exact') { + $regionWhere = 'AND ?::int = ANY(snap.regions)'; + $bindings[] = $code; + } elseif ($regionFilter === 'all_ru') { + $regionWhere = "AND snap.regions = '{}'::int[]"; + } + + $excludeWhere = ''; + if ($excludeTenantIds !== []) { + $placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?')); + $excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)"; + foreach ($excludeTenantIds as $tid) { + $bindings[] = $tid; + } + } + + $sql = << 0 - -- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1) AND tenants.frozen_by_balance_at IS NULL ) + $regionWhere + $excludeWhere ORDER BY snap.tenant_id, (snap.daily_limit - projects.delivered_today) DESC, projects.created_at, projects.id SQL; - $rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]); - $this->logIfNoSnapshot($rows, $supplierProject, $activeDate); - - return Project::hydrate($rows)->values(); + return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings)); } /** - * Активная дата слепка по правилу slepok-инварианта: - * до 21:00 МСК — сегодняшняя дата; - * с 21:00 МСК — завтрашняя. + * Взвешенный жребий без возврата (вариант D1=В): отбирает ≤ $n кандидатов, + * вероятность ∝ остатку лимита, вес ≥ 1 у каждого (мелкие не отрезаются). + * При кандидатах ≤ $n — возвращает всех в исходном SQL-порядке (детерминизм). * - * Spec §4.2.3. + * @param Collection $candidates + * @return Collection + */ + private function weightedPick(Collection $candidates, int $n): Collection + { + if ($n <= 0) { + return collect(); + } + + $pool = $candidates->values()->all(); + if (count($pool) <= $n) { + return collect($pool); + } + + $picked = []; + for ($i = 0; $i < $n && $pool !== []; $i++) { + $total = 0; + foreach ($pool as $p) { + $total += $this->weightOf($p); + } + + $roll = $this->randomizer->getInt(1, $total); + $acc = 0; + $winner = 0; + foreach ($pool as $idx => $p) { + $acc += $this->weightOf($p); + if ($roll <= $acc) { + $winner = $idx; + break; + } + } + + $picked[] = $pool[$winner]; + array_splice($pool, $winner, 1); + } + + return collect($picked); + } + + private function weightOf(Project $project): int + { + $remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today; + + return max(1, $remaining); + } + + /** + * @param Collection $projects + */ + private function tagStep(Collection $projects, int $step): void + { + foreach ($projects as $project) { + $project->setAttribute('routing_step', $step); + } + } + + /** + * Активная дата слепка: до 21:00 МСК — сегодня, с 21:00 МСК — завтра (§4.2.3). */ private function activeSnapshotDate(): string { @@ -144,11 +234,11 @@ class LeadRouter } /** - * Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки - * snapshot'а — это значит, что cron `SnapshotProjectRoutingJob` не отработал. - * (Если строки есть, но ни одна не сматчилась — это валидный 0-результат, не алерт.) + * Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки + * snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный + * результат при наличии snapshot'ов — не алерт. * - * @param array $rows + * @param array $rows */ private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void { diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php index a975b481..270a537d 100644 --- a/app/app/Services/MonthlyPartitionManager.php +++ b/app/app/Services/MonthlyPartitionManager.php @@ -59,6 +59,8 @@ class MonthlyPartitionManager 'saas_admin_audit_log' => 'created_at', // Slepok routing (Этап 2, 27.05.2026) 'project_routing_snapshots' => 'snapshot_date', + // Lead region resolution (Session 1, 31.05.2026) + 'lead_region_resolution_log' => 'received_at', ]; /** diff --git a/app/app/Services/RossvyazPrefixLookup.php b/app/app/Services/RossvyazPrefixLookup.php new file mode 100644 index 00000000..c465973a --- /dev/null +++ b/app/app/Services/RossvyazPrefixLookup.php @@ -0,0 +1,60 @@ +selectOne( + 'SELECT region, operator, subject_code + FROM phone_ranges + WHERE def_code = ? AND from_num <= ? AND to_num >= ? + ORDER BY (to_num - from_num) ASC + LIMIT 1', + [$defCode, $subscriber, $subscriber], + ); + + if ($row === null) { + return null; + } + + return new RossvyazRecord( + subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null, + region: (string) $row->region, + operator: (string) $row->operator, + ); + } +} diff --git a/app/app/Support/DaDataRegionMap.php b/app/app/Support/DaDataRegionMap.php new file mode 100644 index 00000000..205091d7 --- /dev/null +++ b/app/app/Support/DaDataRegionMap.php @@ -0,0 +1,53 @@ + + */ + public const AMBIGUOUS_REGIONS = [ + 'Санкт-Петербург и область', + 'Москва и область', + ]; + + /** + * Ручные переопределения для имён DaData, не совпадающих с RussianRegions. + * На старте пуст — заполняется по findings со staging-smoke. + * + * @var array + */ + public const OVERRIDES = []; + + public static function toSubjectCode(string $name): ?int + { + $name = trim($name); + if ($name === '') { + return null; + } + + return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null; + } + + public static function isAmbiguous(string $name): bool + { + return in_array(trim($name), self::AMBIGUOUS_REGIONS, true); + } +} diff --git a/app/config/services.php b/app/config/services.php index 20f1e7e9..1f046757 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -42,4 +42,17 @@ return [ 'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'), ], + // DaData phone cleaner — резолв региона лида по телефону (lead region resolution). + // Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки. + 'dadata' => [ + 'api_key' => env('DADATA_API_KEY'), + 'secret' => env('DADATA_SECRET'), + 'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000), + 'retries' => (int) env('DADATA_RETRIES', 1), + 'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000), + 'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу + 'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL), + 'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30), + ], + ]; diff --git a/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php b/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php new file mode 100644 index 00000000..1d73d941 --- /dev/null +++ b/app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php @@ -0,0 +1,169 @@ +ok) { + DB::statement('RESET ROLE'); + } + } catch (\Throwable) { + // окружение без роли — продолжаем как superuser + } + + DB::unprepared(<<<'SQL' + -- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым) + CREATE TABLE phone_ranges_imports ( + id BIGSERIAL PRIMARY KEY, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source_url TEXT NOT NULL, + rows_inserted INTEGER NOT NULL DEFAULT 0, + rows_updated INTEGER NOT NULL DEFAULT 0, + checksum_sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'in_progress' + CHECK (status IN ('in_progress','completed','failed','rolled_back')), + error TEXT, + completed_at TIMESTAMPTZ + ); + COMMENT ON TABLE phone_ranges_imports IS + 'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).'; + + -- 2. phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS — публичные данные) + CREATE TABLE phone_ranges ( + id BIGSERIAL PRIMARY KEY, + def_code SMALLINT NOT NULL, + from_num BIGINT NOT NULL, + to_num BIGINT NOT NULL, + operator TEXT NOT NULL, + region TEXT NOT NULL, + region_normalized TEXT, + subject_code SMALLINT, + imported_at TIMESTAMPTZ NOT NULL, + import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id), + CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999), + CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89), + CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num) + ); + CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num); + COMMENT ON TABLE phone_ranges IS + 'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.'; + + GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker; + + -- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log) + CREATE TABLE lead_region_resolution_log ( + id BIGSERIAL, + supplier_lead_id BIGINT NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + phone_masked TEXT NOT NULL, + subject_code_resolved SMALLINT, + subject_code_from_tag SMALLINT, + region_source TEXT NOT NULL + CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), + dadata_qc SMALLINT, + dadata_provider TEXT, + dadata_type TEXT, + dadata_response_masked JSONB, + rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE, + actual_subject_code SMALLINT + CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89), + substituted_subject_code SMALLINT + CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89), + routing_step SMALLINT + CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3), + phone_operator TEXT, + cache_hit BOOLEAN NOT NULL DEFAULT FALSE, + duration_ms INTEGER, + resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (id, received_at) + ) PARTITION BY RANGE (received_at); + + CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id); + CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at); + COMMENT ON TABLE lead_region_resolution_log IS + 'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).'; + + GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker; + GRANT SELECT ON lead_region_resolution_log TO crm_app_user; + + -- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2). + CREATE TABLE lead_region_resolution_log_y2026_m05 + PARTITION OF lead_region_resolution_log + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); + CREATE TABLE lead_region_resolution_log_y2026_m06 + PARTITION OF lead_region_resolution_log + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); + + -- 4. supplier_leads: +4 колонки (denormalized display + persistent idempotency для retry). + ALTER TABLE supplier_leads + ADD COLUMN resolved_subject_code SMALLINT + CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89), + ADD COLUMN region_source TEXT + CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), + ADD COLUMN dadata_qc SMALLINT, + ADD COLUMN phone_operator TEXT; + + -- 5. deals: +2 колонки (UI-карточка + флаг подмены региона). + ALTER TABLE deals + ADD COLUMN phone_operator TEXT, + ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE; + SQL); + + // Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней). + $exists = DB::table('system_settings') + ->where('key', 'partition_retention_months_lead_region_resolution_log') + ->exists(); + if (! $exists) { + DB::table('system_settings')->insert([ + 'key' => 'partition_retention_months_lead_region_resolution_log', + 'value' => '12', + 'type' => 'int', + 'description' => 'Retention в месяцах для lead_region_resolution_log (~365 дней)', + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + try { + DB::statement('SET ROLE crm_migrator'); + $canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok"); + if (!$canCreate || !$canCreate->ok) { + DB::statement('RESET ROLE'); + } + } catch (\Throwable) { + // окружение без роли — продолжаем как superuser + } + + DB::unprepared(<<<'SQL' + ALTER TABLE deals + DROP COLUMN IF EXISTS phone_operator, + DROP COLUMN IF EXISTS region_substituted; + + ALTER TABLE supplier_leads + DROP COLUMN IF EXISTS resolved_subject_code, + DROP COLUMN IF EXISTS region_source, + DROP COLUMN IF EXISTS dadata_qc, + DROP COLUMN IF EXISTS phone_operator; + + DROP TABLE IF EXISTS lead_region_resolution_log CASCADE; + DROP TABLE IF EXISTS phone_ranges CASCADE; + DROP TABLE IF EXISTS phone_ranges_imports CASCADE; + SQL); + + DB::table('system_settings') + ->where('key', 'partition_retention_months_lead_region_resolution_log') + ->delete(); + } +}; diff --git a/app/tests/Feature/Console/PhoneRangesImportCommandTest.php b/app/tests/Feature/Console/PhoneRangesImportCommandTest.php new file mode 100644 index 00000000..6ec3ad2c --- /dev/null +++ b/app/tests/Feature/Console/PhoneRangesImportCommandTest.php @@ -0,0 +1,88 @@ +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); +}); diff --git a/app/tests/Feature/Console/PhoneRegionSmokeCommandTest.php b/app/tests/Feature/Console/PhoneRegionSmokeCommandTest.php new file mode 100644 index 00000000..f2bb89d2 --- /dev/null +++ b/app/tests/Feature/Console/PhoneRegionSmokeCommandTest.php @@ -0,0 +1,38 @@ + 'k', + 'services.dadata.secret' => 's', + 'services.dadata.daily_cap_rub' => 100000, + ]); +}); + +it('phone-region:smoke prints the resolution and writes nothing to DB', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', + ]], 200)]); + + $this->artisan('phone-region:smoke', ['--phone' => '79161234567']) + ->assertSuccessful() + ->expectsOutputToContain('dadata') + ->expectsOutputToContain('Москва'); + + // Smoke не пишет в БД. + expect(DB::table('lead_region_resolution_log')->count())->toBe(0); + expect(DB::table('deals')->count())->toBe(0); +}); + +it('phone-region:smoke fails without --phone', function (): void { + $this->artisan('phone-region:smoke')->assertFailed(); +}); diff --git a/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php b/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php new file mode 100644 index 00000000..7af05a10 --- /dev/null +++ b/app/tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php @@ -0,0 +1,229 @@ +seed(PricingTierSeeder::class); + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + config([ + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'k', + 'services.dadata.secret' => 's', + 'services.dadata.daily_cap_rub' => 100000, + ]); +}); + +function runRegionJob(int $supplierLeadId): void +{ + (new RouteSupplierLeadJob($supplierLeadId))->handle( + app(LeadRouter::class), + app(SupplierProjectResolver::class), + app(NotificationService::class), + app(LedgerService::class), + app(LeadDistributor::class), + app(RegionTagResolver::class), + ); +} + +/** + * Создаёт маршрутизируемый лид: supplier B1 site + tenant с балансом + project + snapshot. + * + * @return array{0: SupplierLead, 1: Project, 2: Tenant, 3: SupplierProject} + */ +function seedRoutableLead(string $regions, string $tag, string $phone, string $key = 'vashinvestor.ru'): array +{ + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key, + ]); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'site', 'signal_identifier' => $key, + 'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0, + 'daily_limit_target' => 100, + ]); + linkProjectToSupplier($project, $supplier); + createRoutingSnapshotFromProject($project, dailyLimit: 100, regions: $regions); + + $vid = 432176649; + $lead = SupplierLead::factory()->create([ + 'supplier_project_id' => null, + 'platform' => 'B1', + 'vid' => $vid, + 'phone' => $phone, + 'received_at' => now(), + 'raw_payload' => [ + 'vid' => $vid, 'project' => "B1_{$key}", 'tag' => $tag, + 'phone' => $phone, 'phones' => [$phone], 'time' => now()->getTimestamp(), + ], + ]); + + return [$lead, $project, $tenant, $supplier]; +} + +function dealFor(int $tenantId, int $projectId): ?Deal +{ + DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'"); + $deal = Deal::query()->where('project_id', $projectId)->first(); + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + return $deal; +} + +it('lead with phone uses dadata region, not the tag', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', 'phone' => '+7 916 123-45-67', + ]], 200)]); + // tag='Санкт-Петербург' (дал бы 83), но телефон резолвится в Москву (82). + [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Санкт-Петербург', phone: '79161234567'); + + runRegionJob($lead->id); + + $lead->refresh(); + expect($lead->resolved_subject_code)->toBe(82) + ->and($lead->region_source)->toBe('dadata') + ->and($lead->phone_operator)->toBe('МТС'); + + $deal = dealFor($tenant->id, $project->id); + expect($deal)->not->toBeNull() + ->and((int) $deal->subject_code)->toBe(82) // регион из DaData, не из тега (83) + ->and((bool) $deal->region_substituted)->toBeFalse() + ->and($deal->phone_operator)->toBe('МТС'); +}); + +it('logs exactly one region resolution row per lead', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', + ]], 200)]); + [$lead] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567'); + + runRegionJob($lead->id); + + $rows = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->get(); + expect($rows)->toHaveCount(1); + expect($rows->first()->region_source)->toBe('dadata'); + // Телефон в логе маскирован (не сырой номер) — §7.1. + expect($rows->first()->phone_masked)->not->toBe('79161234567'); +}); + +it('lead with invalid phone falls back to tag', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); + // Невалидный телефон → DaData не дёргается → tag (Москва=82). + [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '123'); + + runRegionJob($lead->id); + + $lead->refresh(); + expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82); + Http::assertNothingSent(); +}); + +it('lead with resolver disabled via flag uses tag', function (): void { + config(['services.dadata.enabled' => false]); + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); + [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567'); + + runRegionJob($lead->id); + + $lead->refresh(); + expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82); + Http::assertNothingSent(); +}); + +it('persistent idempotency: pre-resolved lead does not re-call dadata', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]); + [$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567'); + // Эмулируем предыдущий try: резолв уже персистнут. + $lead->update(['resolved_subject_code' => 83, 'region_source' => 'rossvyaz', 'phone_operator' => 'МегаФон']); + + runRegionJob($lead->id); + + Http::assertNothingSent(); // §3.11 — нет двойной оплаты DaData + $lead->refresh(); + expect($lead->resolved_subject_code)->toBe(83)->and($lead->region_source)->toBe('rossvyaz'); +}); + +it('step-3 fallback substitutes subject_code to client region and flags region_substituted', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', + ]], 200)]); + // Лид по Москве (82), но клиент подписан только на Питер (83): точных нет, «вся РФ» нет → шаг 3. + [$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567'); + + runRegionJob($lead->id); + + $deal = dealFor($tenant->id, $project->id); + expect($deal)->not->toBeNull() + ->and((int) $deal->subject_code)->toBe(83) // подменён на регион клиента (Питер) + ->and((bool) $deal->region_substituted)->toBeTrue(); + + // Настоящий регион (Москва=82) сохранён в журнале как actual_subject_code. + $log = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->first(); + expect((int) $log->actual_subject_code)->toBe(82) + ->and((int) $log->substituted_subject_code)->toBe(83); +}); + +it('csv-merge updates subject_code and operator when webhook resolution outranks tag (dadata)', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]); + [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567'); + + // CSV-recovered сделка: source_crm_id=null, регион из тега «неправильный» (53 = ЛО). + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + $csvDeal = Deal::create([ + 'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id, + 'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new', + 'received_at' => now(), 'subject_code' => 53, + ]); + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + runRegionJob($lead->id); + + $merged = dealFor($tenant->id, $project->id); + expect((int) $merged->id)->toBe($csvDeal->id) // merge в существующую, не новая + ->and((int) $merged->subject_code)->toBe(82) // обновлено DaData (82) поверх tag (53) + ->and($merged->phone_operator)->toBe('МТС') + ->and((int) $merged->source_crm_id)->toBe($lead->vid); + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + expect(Deal::query()->where('project_id', $project->id)->count())->toBe(1); // второй сделки нет + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); +}); + +it('csv-merge does not overwrite subject_code when webhook resolution is tag-level', function (): void { + config(['services.dadata.enabled' => false]); // резолвер выключен → source='tag' (rank не выше CSV-tag) + [$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567'); + + DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); + Deal::create([ + 'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id, + 'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new', + 'received_at' => now(), 'subject_code' => 53, + ]); + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + runRegionJob($lead->id); + + $merged = dealFor($tenant->id, $project->id); + expect((int) $merged->subject_code)->toBe(53); // tag не выше tag → регион не тронут +}); diff --git a/app/tests/Feature/Migrations/PhoneRangesMigrationTest.php b/app/tests/Feature/Migrations/PhoneRangesMigrationTest.php new file mode 100644 index 00000000..2168d4f1 --- /dev/null +++ b/app/tests/Feature/Migrations/PhoneRangesMigrationTest.php @@ -0,0 +1,51 @@ +t)->not->toBeNull(); + + $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges'")) + ->pluck('column_name')->all(); + + expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id'); +}); + +it('creates phone_ranges_imports journal table', function (): void { + expect(DB::selectOne("SELECT to_regclass('public.phone_ranges_imports') AS t")->t)->not->toBeNull(); + + $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges_imports'")) + ->pluck('column_name')->all(); + + expect($cols)->toContain('source_url', 'checksum_sha256', 'status', 'rows_inserted', 'rows_updated'); +}); + +it('creates lead_region_resolution_log as a partitioned table', function (): void { + $partitioned = DB::selectOne( + "SELECT 1 AS ok + FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'lead_region_resolution_log'" + ); + + expect($partitioned)->not->toBeNull(); +}); + +it('adds resolution columns to supplier_leads', function (): void { + $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_leads'")) + ->pluck('column_name')->all(); + + expect($cols)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator'); +}); + +it('adds resolution columns to deals', function (): void { + $cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'deals'")) + ->pluck('column_name')->all(); + + expect($cols)->toContain('phone_operator', 'region_substituted'); +}); diff --git a/app/tests/Feature/PartitionsCreateMonthsTest.php b/app/tests/Feature/PartitionsCreateMonthsTest.php index 6f412aa9..8dbc11f9 100644 --- a/app/tests/Feature/PartitionsCreateMonthsTest.php +++ b/app/tests/Feature/PartitionsCreateMonthsTest.php @@ -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 () { diff --git a/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php b/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php new file mode 100644 index 00000000..e1939d56 --- /dev/null +++ b/app/tests/Feature/Services/DaData/DaDataBudgetGuardTest.php @@ -0,0 +1,42 @@ + 10]); // 1000 копеек + $guard = app(DaDataBudgetGuard::class); + + expect($guard->canSpend())->toBeTrue(); + + $guard->recordSpend(500); + + expect($guard->canSpend())->toBeTrue() + ->and($guard->spentTodayKopecks())->toBe(500); +}); + +it('blocks spend once the daily cap is reached', function (): void { + config(['services.dadata.daily_cap_rub' => 1]); // 100 копеек + $guard = app(DaDataBudgetGuard::class); + + $guard->recordSpend(100); + + expect($guard->canSpend())->toBeFalse(); +}); + +it('accumulates spend across multiple calls', function (): void { + config(['services.dadata.daily_cap_rub' => 100]); + $guard = app(DaDataBudgetGuard::class); + + $guard->recordSpend(30); + $guard->recordSpend(70); + + expect($guard->spentTodayKopecks())->toBe(100); +}); + +it('starts at zero spend for a fresh day', function (): void { + $guard = app(DaDataBudgetGuard::class); + + expect($guard->spentTodayKopecks())->toBe(0); +}); diff --git a/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php b/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php new file mode 100644 index 00000000..262196d3 --- /dev/null +++ b/app/tests/Feature/Services/DaData/DaDataPhoneClientTest.php @@ -0,0 +1,80 @@ + Http::response([[ + 'qc' => 0, 'qc_conflict' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34', + 'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', 'city' => null, 'timezone' => 'UTC+3', + ]], 200)]); + + $resp = app(DaDataPhoneClient::class)->cleanPhone('79215551234'); + + expect($resp->qc)->toBe(0) + ->and($resp->provider)->toBe('МегаФон') + ->and($resp->region)->toBe('Санкт-Петербург и область') + ->and($resp->type)->toBe('Мобильный') + ->and($resp->raw)->toBeArray(); +}); + +it('parses qc=3 multiple response', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([[ + 'qc' => 3, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', + ]], 200)]); + + expect(app(DaDataPhoneClient::class)->cleanPhone('79991234567')->qc)->toBe(3); +}); + +it('sends Token auth, X-Secret header and json-array body', function (): void { + config(['services.dadata.api_key' => 'KEY', 'services.dadata.secret' => 'SEC']); + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); + + app(DaDataPhoneClient::class)->cleanPhone('79161234567'); + + Http::assertSent(function ($request): bool { + return $request->url() === 'https://cleaner.dadata.ru/api/v1/clean/phone' + && $request->hasHeader('Authorization', 'Token KEY') + && $request->hasHeader('X-Secret', 'SEC') + && $request->body() === '["79161234567"]'; + }); +}); + +it('throws DaDataTimeoutException on connection error', function (): void { + Http::fake(fn () => throw new ConnectionException('timeout')); + + expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234')) + ->toThrow(DaDataTimeoutException::class); +}); + +it('throws DaDataException on persistent 5xx', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response('upstream error', 500)]); + + expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234')) + ->toThrow(DaDataException::class); +}); + +it('retries once on 5xx then succeeds', function (): void { + Http::fakeSequence('cleaner.dadata.ru/*') + ->push('upstream error', 500) + ->push([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200); + + $resp = app(DaDataPhoneClient::class)->cleanPhone('79161234567'); + + expect($resp->qc)->toBe(0); + Http::assertSentCount(2); +}); + +it('does not retry on 4xx client error', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response('bad request', 400)]); + + expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79161234567')) + ->toThrow(DaDataException::class); + + Http::assertSentCount(1); +}); diff --git a/app/tests/Feature/Services/LeadRegionResolverTest.php b/app/tests/Feature/Services/LeadRegionResolverTest.php new file mode 100644 index 00000000..00b947c0 --- /dev/null +++ b/app/tests/Feature/Services/LeadRegionResolverTest.php @@ -0,0 +1,215 @@ + true, + 'services.dadata.api_key' => 'k', + 'services.dadata.secret' => 's', + 'services.dadata.daily_cap_rub' => 10000, + ]); +}); + +function resolverSeedImport(): int +{ + return (int) DB::table('phone_ranges_imports')->insertGetId([ + 'source_url' => 'test', 'checksum_sha256' => str_repeat('b', 64), + 'status' => 'completed', 'imported_at' => now(), + ]); +} + +function resolverSeedRange(int $subject, string $region = 'Москва', int $def = 916, string $operator = 'Ростелеком'): void +{ + DB::table('phone_ranges')->insert([ + 'def_code' => $def, 'from_num' => 0, 'to_num' => 9999999, + 'operator' => $operator, 'region' => $region, 'subject_code' => $subject, + 'imported_at' => now(), 'import_id' => resolverSeedImport(), + ]); +} + +function resolverLead(string $phone = '79161234567', string $tag = ''): SupplierLead +{ + return new SupplierLead([ + 'phone' => $phone, + 'raw_payload' => ['tag' => $tag], + 'received_at' => now(), + ]); +} + +function fakeDadata(array $row): void +{ + Http::fake(['cleaner.dadata.ru/*' => Http::response([$row], 200)]); +} + +it('dadata qc 0 returns dadata source', function (): void { + fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный']); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('dadata') + ->and($r->subjectCode)->toBe(82) + ->and($r->phoneOperator)->toBe('МТС') + ->and($r->qc)->toBe(0) + ->and($r->cacheHit)->toBeFalse(); +}); + +it('dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider', function (): void { + fakeDadata(['qc' => 0, 'region' => 'Санкт-Петербург и область', 'provider' => 'МегаФон']); + resolverSeedRange(subject: 83, region: 'Санкт-Петербург'); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz') + ->and($r->subjectCode)->toBe(83) + ->and($r->phoneOperator)->toBe('МегаФон') // оператор от DaData (MNP), §3.4.1 + ->and($r->rossvyazMatched)->toBeTrue(); +}); + +it('dadata qc 3 returns dadata with multiple flag', function (): void { + fakeDadata(['qc' => 3, 'region' => 'Москва', 'provider' => 'МТС']); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('dadata')->and($r->subjectCode)->toBe(82)->and($r->qc)->toBe(3); +}); + +it('dadata qc 1 falls back to rossvyaz', function (): void { + fakeDadata(['qc' => 1, 'region' => 'Москва', 'provider' => 'Билайн']); + resolverSeedRange(subject: 82); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82); +}); + +it('dadata qc 2 falls back to tag skipping rossvyaz', function (): void { + fakeDadata(['qc' => 2]); + resolverSeedRange(subject: 83); // если бы Россвязь дёрнули — был бы 83 + + $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва')); + + expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82)->and($r->rossvyazMatched)->toBeFalse(); +}); + +it('dadata qc 7 falls back to tag skipping rossvyaz', function (): void { + fakeDadata(['qc' => 7]); + resolverSeedRange(subject: 83); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва')); + + expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82); +}); + +it('dadata timeout falls back to rossvyaz', function (): void { + Http::fake(fn () => throw new ConnectionException('timeout')); + resolverSeedRange(subject: 82); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82); +}); + +it('dadata network error 5xx falls back to rossvyaz', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response('err', 500)]); + resolverSeedRange(subject: 82); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82); +}); + +it('budget cap exceeded skips dadata directly to rossvyaz', function (): void { + config(['services.dadata.daily_cap_rub' => 0]); // canSpend() → false + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); + resolverSeedRange(subject: 82); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82); + Http::assertNothingSent(); +}); + +it('cache hit skips dadata and rossvyaz on the second call', function (): void { + fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']); + $resolver = app(LeadRegionResolver::class); + + $first = $resolver->resolve(resolverLead()); + $second = $resolver->resolve(resolverLead()); + + expect($first->cacheHit)->toBeFalse() + ->and($second->cacheHit)->toBeTrue() + ->and($second->subjectCode)->toBe(82); + Http::assertSentCount(1); +}); + +it('invalid phone skips dadata returns tag', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead(phone: '123', tag: 'Москва')); + + expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82); + Http::assertNothingSent(); +}); + +it('qc 0 region null falls through to rossvyaz', function (): void { + fakeDadata(['qc' => 0, 'region' => null, 'provider' => 'Tele2']); + resolverSeedRange(subject: 82); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82)->and($r->phoneOperator)->toBe('Tele2'); +}); + +it('unmappable dadata region falls through to rossvyaz', function (): void { + fakeDadata(['qc' => 0, 'region' => 'Несуществующий край', 'provider' => 'МТС']); + resolverSeedRange(subject: 82); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead()); + + expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82); +}); + +it('all three layers fail returns unknown with null subject_code', function (): void { + fakeDadata(['qc' => 1]); // → rossvyaz + // no phone_ranges seeded → rossvyaz miss; tag empty → null + + $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: '')); + + expect($r->source)->toBe('unknown')->and($r->subjectCode)->toBeNull(); +}); + +it('disabled feature flag returns tag without any dadata call', function (): void { + config(['services.dadata.enabled' => false]); + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]); + + $r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва')); + + expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82); + Http::assertNothingSent(); +}); + +it('persistent idempotency: already-resolved lead skips dadata', function (): void { + Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]); + $lead = resolverLead(); + $lead->resolved_subject_code = 83; + $lead->region_source = 'dadata'; + $lead->dadata_qc = 0; + $lead->phone_operator = 'МегаФон'; + + $r = app(LeadRegionResolver::class)->resolve($lead); + + expect($r->subjectCode)->toBe(83)->and($r->source)->toBe('dadata'); + Http::assertNothingSent(); +}); diff --git a/app/tests/Feature/Services/LeadRouterCascadeTest.php b/app/tests/Feature/Services/LeadRouterCascadeTest.php new file mode 100644 index 00000000..c55d6b55 --- /dev/null +++ b/app/tests/Feature/Services/LeadRouterCascadeTest.php @@ -0,0 +1,189 @@ +create(['balance_leads' => 100, 'balance_rub' => '1000.00']); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'daily_limit_target' => $dailyLimit, + 'delivered_today' => $deliveredToday, + 'delivery_days_mask' => 127, + 'signal_type' => $sp->signal_type, + 'signal_identifier' => $sp->unique_key, + ]); + linkProjectToSupplier($project, $sp); + createRoutingSnapshotFromProject( + $project, + signalType: $sp->signal_type, + signalIdentifier: $sp->unique_key, + dailyLimit: $dailyLimit, + regions: $regions, + ); + + return $project; +} + +function b1Supplier(string $key = 'ex.ru'): SupplierProject +{ + return SupplierProject::query()->create([ + 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key, + 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', + ]); +} + +it('step 1: exact region match wins, others excluded', function (): void { + $sp = b1Supplier(); + $spb = makeCascadeProject($sp, regions: '{83}'); // Питер + $msk = makeCascadeProject($sp, regions: '{82}'); // Москва + + $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); + + expect($matched->pluck('id')->all())->toBe([$msk->id]) + ->and($matched->first()->routing_step)->toBe(1); +}); + +it('step 2: falls to all-RF when no exact match', function (): void { + $sp = b1Supplier('s2.ru'); + $allRu = makeCascadeProject($sp, regions: '{}'); // вся РФ + + $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); + + expect($matched->pluck('id')->all())->toBe([$allRu->id]) + ->and($matched->first()->routing_step)->toBe(2); +}); + +it('step 3: fallback channel when nobody subscribed to region and no all-RF', function (): void { + $sp = b1Supplier('s3.ru'); + $spb = makeCascadeProject($sp, regions: '{83}'); // только Питер подписан + + // resolvedSubjectCode=82 (Москва): точных нет, «вся РФ» нет → запасной канал. + $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); + + expect($matched->pluck('id')->all())->toBe([$spb->id]) + ->and($matched->first()->routing_step)->toBe(3); +}); + +it('exact + all-RF combine up to cap=3, exact taking priority', function (): void { + $sp = b1Supplier('s4.ru'); + $e1 = makeCascadeProject($sp, regions: '{82}'); + $e2 = makeCascadeProject($sp, regions: '{82}'); + $r1 = makeCascadeProject($sp, regions: '{}'); + $r2 = makeCascadeProject($sp, regions: '{}'); + + $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); + + // Всего 3 (cap). Оба точных (step 1) обязаны быть; добор — ровно 1 «вся РФ» (step 2). + expect($matched)->toHaveCount(3); + $byStep = $matched->groupBy(fn ($p) => $p->routing_step); + expect($byStep->get(1)->pluck('id')->sort()->values()->all())->toBe(collect([$e1->id, $e2->id])->sort()->values()->all()) + ->and($byStep->get(2))->toHaveCount(1); + expect(in_array($byStep->get(2)->first()->id, [$r1->id, $r2->id], true))->toBeTrue(); +}); + +it('null resolvedSubjectCode skips exact, uses all-RF', function (): void { + $sp = b1Supplier('s5.ru'); + $allRu = makeCascadeProject($sp, regions: '{}'); + $exact = makeCascadeProject($sp, regions: '{82}'); + + // Резолвер не сработал → шаг 1 пропускается; матчит только «вся РФ». + $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: null); + + expect($matched->pluck('id')->all())->toBe([$allRu->id]) + ->and($matched->first()->routing_step)->toBe(2); +}); + +it('cascade works for DIRECT supplier_project path too', function (): void { + $sp = SupplierProject::query()->create([ + 'platform' => 'DIRECT', 'signal_type' => 'site', 'unique_key' => 'cashmotor.ru', + 'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', + ]); + $msk = makeCascadeProject($sp, regions: '{82}'); + $spb = makeCascadeProject($sp, regions: '{83}'); + + $matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82); + + expect($matched->pluck('id')->all())->toBe([$msk->id]) + ->and($matched->first()->routing_step)->toBe(1); +}); + +it('backward compat: no second arg behaves as all-RF/any (existing call shape)', function (): void { + $sp = b1Supplier('s7.ru'); + $allRu = makeCascadeProject($sp, regions: '{}'); + + // Старая сигнатура (без 2-го аргумента) — дефолт null → шаг 2 all-RF матчит '{}'. + $matched = seededRouter()->matchEligibleProjects($sp); + + expect($matched->pluck('id')->all())->toBe([$allRu->id]); +}); + +it('variant В: weighted pick — small client never starved, big client wins more often', function (): void { + $sp = b1Supplier('fair.ru'); + // 5 клиентов на Москву, разный остаток лимита. + $a = makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); // остаток 100 + $b = makeCascadeProject($sp, regions: '{82}', dailyLimit: 50); + $c = makeCascadeProject($sp, regions: '{82}', dailyLimit: 30); + $d = makeCascadeProject($sp, regions: '{82}', dailyLimit: 20); + $e = makeCascadeProject($sp, regions: '{82}', dailyLimit: 10); // остаток 10 — самый маленький + + $wins = []; + $seedCount = 120; + for ($seed = 0; $seed < $seedCount; $seed++) { + $matched = seededRouter($seed)->matchEligibleProjects($sp, resolvedSubjectCode: 82); + expect($matched)->toHaveCount(3); // лид всегда раздаётся ровно троим + foreach ($matched as $p) { + $wins[$p->id] = ($wins[$p->id] ?? 0) + 1; + } + } + + // (1) Мелкого не отрезаем: за 120 розыгрышей хотя бы раз получил лид. + expect($wins[$e->id] ?? 0)->toBeGreaterThan(0); + // (2) Вес уважается: крупный клиент выигрывает строго чаще мелкого. + expect($wins[$a->id] ?? 0)->toBeGreaterThan($wins[$e->id] ?? 0); +}); + +it('variant В: deterministic — same seed yields same recipients', function (): void { + $sp = b1Supplier('det.ru'); + makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); + makeCascadeProject($sp, regions: '{82}', dailyLimit: 50); + makeCascadeProject($sp, regions: '{82}', dailyLimit: 30); + makeCascadeProject($sp, regions: '{82}', dailyLimit: 20); + + $first = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all(); + $second = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all(); + + expect($first)->toBe($second)->and($first)->toHaveCount(3); +}); diff --git a/app/tests/Feature/Services/RegionResolutionTest.php b/app/tests/Feature/Services/RegionResolutionTest.php new file mode 100644 index 00000000..3f7ca9d9 --- /dev/null +++ b/app/tests/Feature/Services/RegionResolutionTest.php @@ -0,0 +1,70 @@ +rossvyaz>tag>unknown', function (): void { + expect(RegionResolution::SOURCE_RANK)->toBe([ + 'dadata' => 4, 'rossvyaz' => 3, 'tag' => 2, 'unknown' => 1, + ]); +}); + +it('make sets actualSubjectCode equal to subjectCode', function (): void { + $r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0); + + expect($r->subjectCode)->toBe(82) + ->and($r->actualSubjectCode)->toBe(82) + ->and($r->source)->toBe('dadata') + ->and($r->phoneOperator)->toBe('МТС') + ->and($r->qc)->toBe(0) + ->and($r->cacheHit)->toBeFalse() + ->and($r->rossvyazMatched)->toBeFalse(); +}); + +it('fromTag builds a tag-sourced resolution', function (): void { + $r = RegionResolution::fromTag(82); + + expect($r->subjectCode)->toBe(82) + ->and($r->source)->toBe('tag') + ->and($r->phoneOperator)->toBeNull(); +}); + +it('fromSupplierLead reconstructs a persisted resolution (idempotency)', function (): void { + $lead = new SupplierLead([ + 'resolved_subject_code' => 83, + 'region_source' => 'dadata', + 'dadata_qc' => 0, + 'phone_operator' => 'МегаФон', + ]); + + $r = RegionResolution::fromSupplierLead($lead); + + expect($r->subjectCode)->toBe(83) + ->and($r->source)->toBe('dadata') + ->and($r->phoneOperator)->toBe('МегаФон') + ->and($r->qc)->toBe(0); +}); + +it('withCacheHit flips the flag and clears the per-call masked response', function (): void { + $r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => '7916***4567']); + + $hit = $r->withCacheHit(true); + + expect($hit->cacheHit)->toBeTrue() + ->and($hit->subjectCode)->toBe(82) + ->and($hit->dadataResponseMasked)->toBeNull(); +}); + +it('forCache strips per-call fields before storing', function (): void { + $r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => 'x'], durationMs: 120); + + $c = $r->forCache(); + + expect($c->dadataResponseMasked)->toBeNull() + ->and($c->durationMs)->toBeNull() + ->and($c->cacheHit)->toBeFalse() + ->and($c->subjectCode)->toBe(82) + ->and($c->phoneOperator)->toBe('МТС'); +}); diff --git a/app/tests/Feature/Services/RossvyazPrefixLookupTest.php b/app/tests/Feature/Services/RossvyazPrefixLookupTest.php new file mode 100644 index 00000000..d49efc1e --- /dev/null +++ b/app/tests/Feature/Services/RossvyazPrefixLookupTest.php @@ -0,0 +1,94 @@ +insertGetId([ + 'source_url' => 'https://rossvyaz.gov.ru/test', + 'checksum_sha256' => str_repeat('a', 64), + 'status' => 'completed', + 'imported_at' => now(), + ]); +} + +/** + * @param array $overrides + */ +function seedPhoneRange(array $overrides = []): void +{ + DB::table('phone_ranges')->insert(array_merge([ + 'def_code' => 921, + 'from_num' => 5550000, + 'to_num' => 5559999, + 'operator' => 'МегаФон', + 'region' => 'Санкт-Петербург', + 'subject_code' => 83, + 'imported_at' => now(), + 'import_id' => seedRossvyazImport(), + ], $overrides)); +} + +it('mobile prefix returns correct region and operator', function (): void { + seedPhoneRange(); + + $rec = app(RossvyazPrefixLookup::class)->find('79215555123'); + + expect($rec)->toBeInstanceOf(RossvyazRecord::class) + ->and($rec->subjectCode)->toBe(83) + ->and($rec->region)->toBe('Санкт-Петербург') + ->and($rec->operator)->toBe('МегаФон'); +}); + +it('prefers narrower range when two ranges overlap', function (): void { + $importId = seedRossvyazImport(); + // Широкий диапазон (вся 495-зона) — Московская область (56). + seedPhoneRange([ + 'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999, + 'operator' => 'Ростелеком', 'region' => 'Московская область', + 'subject_code' => 56, 'import_id' => $importId, + ]); + // Узкий диапазон внутри — Москва (82). Должен выиграть (ORDER BY width ASC). + seedPhoneRange([ + 'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999, + 'operator' => 'МГТС', 'region' => 'Москва', + 'subject_code' => 82, 'import_id' => $importId, + ]); + + $rec = app(RossvyazPrefixLookup::class)->find('74952005000'); + + expect($rec)->not->toBeNull() + ->and($rec->subjectCode)->toBe(82) + ->and($rec->region)->toBe('Москва'); +}); + +it('returns null for unknown prefix', function (): void { + seedPhoneRange(); // только def_code=921 + + expect(app(RossvyazPrefixLookup::class)->find('79991234567'))->toBeNull(); +}); + +it('returns null when subscriber number is outside any range', function (): void { + seedPhoneRange(['def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999]); + + // def_code совпадает (921), но subscriber 4440000 вне [5550000, 5559999] + expect(app(RossvyazPrefixLookup::class)->find('79214440000'))->toBeNull(); +}); + +it('returns null for malformed phone', function (): void { + seedPhoneRange(); + + expect(app(RossvyazPrefixLookup::class)->find('123'))->toBeNull(); +}); diff --git a/app/tests/Pest.php b/app/tests/Pest.php index 1812e7f2..021b10e1 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -131,6 +131,7 @@ function createRoutingSnapshotFromProject( string $signalType = 'call', ?string $signalIdentifier = null, ?int $dailyLimit = null, + string $regions = '{}', ): void { DB::table('project_routing_snapshots')->insert([ 'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(), @@ -138,7 +139,7 @@ function createRoutingSnapshotFromProject( 'tenant_id' => $project->tenant_id, 'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target), 'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127), - 'regions' => '{}', + 'regions' => $regions, 'signal_type' => $signalType, 'signal_identifier' => $signalIdentifier, 'sms_senders' => null, diff --git a/app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php b/app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php new file mode 100644 index 00000000..1ff66d8c --- /dev/null +++ b/app/tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php @@ -0,0 +1,10 @@ +toHaveKey('lead_region_resolution_log'); + expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at'); +}); diff --git a/app/tests/Unit/Support/DaDataRegionMapTest.php b/app/tests/Unit/Support/DaDataRegionMapTest.php new file mode 100644 index 00000000..3fec0588 --- /dev/null +++ b/app/tests/Unit/Support/DaDataRegionMapTest.php @@ -0,0 +1,35 @@ +toBe(82) + ->and(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56) + ->and(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83) + ->and(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53); +}); + +it('trims surrounding whitespace before mapping', function (): void { + expect(DaDataRegionMap::toSubjectCode(' Москва '))->toBe(82); +}); + +it('flags ambiguous agglomeration strings', function (): void { + expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue() + ->and(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue() + ->and(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse() + ->and(DaDataRegionMap::isAmbiguous('Санкт-Петербург'))->toBeFalse(); +}); + +it('returns null for unmappable region', function (): void { + expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull() + ->and(DaDataRegionMap::toSubjectCode(''))->toBeNull(); +}); + +it('resolves all 89 RussianRegions names', function (): void { + foreach (RussianRegions::CODE_TO_NAME as $code => $name) { + expect(DaDataRegionMap::toSubjectCode($name))->toBe($code); + } +}); diff --git a/app/tests/fixtures/rossvyaz/sample.csv b/app/tests/fixtures/rossvyaz/sample.csv new file mode 100644 index 00000000..2fae21e4 --- /dev/null +++ b/app/tests/fixtures/rossvyaz/sample.csv @@ -0,0 +1,4 @@ +АВС/ DEF;От;До;Емкость;Оператор;Регион +495;2000000;2009999;10000;ОАО МГТС;Москва +921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург +999;0000000;0009999;10000;Тест Оператор;Атлантида diff --git a/cspell-words.txt b/cspell-words.txt index 572aa886..7b56ffbc 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1974,3 +1974,11 @@ monitorится guillemets mirror'ящий plan'овский + +# Lead region resolution (2026-05-31) — DaData / Rossvyaz region detection +rossvyaz +россвязь +россвязи +dadata +kopecks +qc diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 8420da54..42d93609 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,61 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.39, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.40, консолидированная — разворачивает БД с нуля). + +## v8.40 (2026-05-31) — lead region resolution (phone_ranges + resolution_log + supplier_leads/deals columns) + +Резолюция настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback) +и переключение `LeadRouter` на каскадную маршрутизацию по региону. Эта запись покрывает +только схемные изменения Session 1 (таблицы и колонки); бизнес-логика — в последующих сессиях. + +Спека: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5. +План: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`. +Миграция: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`. + +**Добавлено:** + +- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (SaaS-level, без RLS). + Поля: `source_url`, `rows_inserted`/`rows_updated`, `checksum_sha256`, `status` + (`in_progress`/`completed`/`failed`/`rolled_back`), `error`, `completed_at`. + GRANT SELECT `crm_app_user` + `crm_supplier_worker`. +- **`phone_ranges`** — реестр диапазонов нумерации Россвязи (SaaS-level, без RLS — публичные данные). + Поля: `def_code` (код ABC/DEF), `from_num`/`to_num`, `operator`, `region`, `region_normalized`, + `subject_code` (1..89), `imported_at`, `import_id`→`phone_ranges_imports`. 3 CHECK + (`def_code` 300..999, `subject_code` 1..89, `from_num` ≤ `to_num`). Индекс + `idx_phone_ranges_lookup (def_code, from_num, to_num)`. GRANT SELECT `crm_app_user` + `crm_supplier_worker`. +- **`lead_region_resolution_log`** — PARTITION BY RANGE (`received_at`), composite PK + `(id, received_at)`. Аудит резолва региона на лид: `phone_masked`, `subject_code_resolved`/ + `subject_code_from_tag`, `region_source` (`dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`/ + `dadata_provider`/`dadata_type`/`dadata_response_masked` (JSONB), `rossvyaz_matched`, + `actual_subject_code`/`substituted_subject_code` (1..89), `routing_step` (1..3), + `phone_operator`, `cache_hit`, `duration_ms`, `resolved_at`. Индексы `idx_lrrl_lead_id` + + `idx_lrrl_source (region_source, received_at)`. GRANT SELECT,INSERT `crm_supplier_worker` / + SELECT `crm_app_user`. Стартовые партиции `lead_region_resolution_log_y2026_m05`, `_y2026_m06`. +- **`MonthlyPartitionManager::PARTITIONED_TABLES`** +entry `'lead_region_resolution_log' => 'received_at'`. +- **`system_settings`** +key `partition_retention_months_lead_region_resolution_log = '12'` (retention ~365 дней). + +**Изменено:** + +- **`supplier_leads`** +4 колонки: `resolved_subject_code` (CHECK 1..89), `region_source` + (CHECK `dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`, `phone_operator`. Persistent-idempotency + резолва (retry не повторяет DaData-вызов). +- **`deals`** +2 колонки: `phone_operator`, `region_substituted` BOOLEAN NOT NULL DEFAULT FALSE + (флаг подмены региона на запасном канале — `routing_step` 3). + +**NB консолидация:** как и v8.39 (`project_routing_snapshots`), полный DDL живёт в дельта-миграции, +а не в теле `schema.sql` — тело отражает последнюю точку консолидации, заголовок/CHANGELOG ведут +дельты. Свежий деплой: миграция `0001` грузит `schema.sql` → дельта-миграция `2026_05_31` добавляет +эти объекты. Иначе был бы двойной `CREATE TABLE` (0001 + дельта) и `migrate` упал бы. + +**NB GRANT'ы:** план Task 1.3 указывал `crm_readonly`, но этой роли на dev/прод нет — +фактические GRANT'ы выданы `crm_app_user` + `crm_supplier_worker` (проверено по `pg_roles`). + +**NB 152-ФЗ:** `phone_masked` в логе — маскированный телефон (`7XXX***YYYY`), `dadata_response_masked` +хранит ответ DaData без сырого номера (spec §7.1). Полное `pg_anonymizer`-маскирование — +шаг раскатки (spec §7.2), вне Session 1. + +--- ## v8.39 (2026-05-27) — project_routing_snapshots (Slepok routing Этап 2) diff --git a/db/schema.sql b/db/schema.sql index 8932d639..7ef7f949 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,7 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2) +-- Версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39)) +-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2) -- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md) -- Базовая версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end) -- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7a36bebd..da5d90a9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/superpowers/plans/2026-05-31-lead-region-session-progress.md b/docs/superpowers/plans/2026-05-31-lead-region-session-progress.md new file mode 100644 index 00000000..77ce3f1b --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-lead-region-session-progress.md @@ -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. diff --git a/docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md b/docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md new file mode 100644 index 00000000..ff0a8522 --- /dev/null +++ b/docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md @@ -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`.