feat(region): RossvyazPrefixLookup + RossvyazRecord DTO
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
АВС/ DEF;От;До;Емкость;Оператор;Регион
|
||||
495;2000000;2009999;10000;ОАО МГТС;Москва
|
||||
921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
|
||||
999;0000000;0009999;10000;Тест Оператор;Атлантида
|
||||
|
Reference in New Issue
Block a user