feat(region): RossvyazPrefixLookup + RossvyazRecord DTO

This commit is contained in:
Дмитрий
2026-06-01 07:18:17 +03:00
parent ee932c5178
commit 44cc8a7dbd
4 changed files with 178 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
/**
* Read-only результат поиска по реестру нумерации Россвязи (`phone_ranges`).
*
* `subjectCode` код субъекта РФ 1..89 (см. App\Support\RussianRegions) либо
* null, если для диапазона он не был промаплен при импорте.
*/
final readonly class RossvyazRecord
{
public function __construct(
public ?int $subjectCode,
public string $region,
public string $operator,
) {}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Services\Dto\RossvyazRecord;
use Illuminate\Support\Facades\DB;
/**
* Локальный fallback резолва региона/оператора по телефону через реестр
* нумерации Россвязи (`phone_ranges`).
*
* Используется LeadRegionResolver когда DaData недоступна/неуверена (qc=1,
* timeout, бюджет исчерпан). Алгоритм (spec §3.7):
* - def_code = 3 цифры кода ABC/DEF (позиции 1..3 нормализованного номера);
* - subscriber = остаток номера как BIGINT;
* - выбираем самый УЗКИЙ диапазон, накрывающий номер (ORDER BY width ASC),
* т.к. узкие переопределения операторов точнее широких региональных блоков.
*
* Запрос идёт через `pgsql_supplier` (BYPASSRLS на проде, как LeadRouter):
* `phone_ranges` SaaS-level публичные данные без RLS.
*/
class RossvyazPrefixLookup
{
/** Connection для чтения реестра (на проде BYPASSRLS, на dev/test — superuser fallback). */
public const CONNECTION = 'pgsql_supplier';
public function find(string $phone): ?RossvyazRecord
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
// Российский номер: 7|8 + ABC/DEF (3) + абонент (7) = 11 цифр.
if (strlen($digits) !== 11) {
return null;
}
$defCode = (int) substr($digits, 1, 3);
$subscriber = (int) substr($digits, 4);
$row = DB::connection(self::CONNECTION)->selectOne(
'SELECT region, operator, subject_code
FROM phone_ranges
WHERE def_code = ? AND from_num <= ? AND to_num >= ?
ORDER BY (to_num - from_num) ASC
LIMIT 1',
[$defCode, $subscriber, $subscriber],
);
if ($row === null) {
return null;
}
return new RossvyazRecord(
subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null,
region: (string) $row->region,
operator: (string) $row->operator,
);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Services\Dto\RossvyazRecord;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Вставляет строку-журнал импорта и возвращает её id (import_id для phone_ranges).
*/
function seedRossvyazImport(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('a', 64),
'status' => 'completed',
'imported_at' => now(),
]);
}
/**
* @param array<string, mixed> $overrides
*/
function seedPhoneRange(array $overrides = []): void
{
DB::table('phone_ranges')->insert(array_merge([
'def_code' => 921,
'from_num' => 5550000,
'to_num' => 5559999,
'operator' => 'МегаФон',
'region' => 'Санкт-Петербург',
'subject_code' => 83,
'imported_at' => now(),
'import_id' => seedRossvyazImport(),
], $overrides));
}
it('mobile prefix returns correct region and operator', function (): void {
seedPhoneRange();
$rec = app(RossvyazPrefixLookup::class)->find('79215555123');
expect($rec)->toBeInstanceOf(RossvyazRecord::class)
->and($rec->subjectCode)->toBe(83)
->and($rec->region)->toBe('Санкт-Петербург')
->and($rec->operator)->toBe('МегаФон');
});
it('prefers narrower range when two ranges overlap', function (): void {
$importId = seedRossvyazImport();
// Широкий диапазон (вся 495-зона) — Московская область (56).
seedPhoneRange([
'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'Ростелеком', 'region' => 'Московская область',
'subject_code' => 56, 'import_id' => $importId,
]);
// Узкий диапазон внутри — Москва (82). Должен выиграть (ORDER BY width ASC).
seedPhoneRange([
'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999,
'operator' => 'МГТС', 'region' => 'Москва',
'subject_code' => 82, 'import_id' => $importId,
]);
$rec = app(RossvyazPrefixLookup::class)->find('74952005000');
expect($rec)->not->toBeNull()
->and($rec->subjectCode)->toBe(82)
->and($rec->region)->toBe('Москва');
});
it('returns null for unknown prefix', function (): void {
seedPhoneRange(); // только def_code=921
expect(app(RossvyazPrefixLookup::class)->find('79991234567'))->toBeNull();
});
it('returns null when subscriber number is outside any range', function (): void {
seedPhoneRange(['def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999]);
// def_code совпадает (921), но subscriber 4440000 вне [5550000, 5559999]
expect(app(RossvyazPrefixLookup::class)->find('79214440000'))->toBeNull();
});
it('returns null for malformed phone', function (): void {
seedPhoneRange();
expect(app(RossvyazPrefixLookup::class)->find('123'))->toBeNull();
});
+4
View File
@@ -0,0 +1,4 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;Москва
921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
999;0000000;0009999;10000;Тест Оператор;Атлантида
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС Москва
3 921 5550000 5559999 10000 ПАО МегаФон Санкт-Петербург
4 999 0000000 0009999 10000 Тест Оператор Атлантида