Compare commits

...

4 Commits

Author SHA1 Message Date
Дмитрий 0bf69ce6b5 chore(lead-region-ops): add force input for phone-ranges:import
Re-import skips on identical checksum without --force. Adds a 'force'
boolean dispatch input wired into the import op so the registry can be
re-mapped after the region-normalization fix (PR #44).
2026-06-03 06:12:43 +03:00
CoralMinister 07747713f0 Merge pull request #44 from CoralMinister/feat/rossvyaz-region-mapping
Feat/rossvyaz region mapping
2026-06-02 15:42:37 +03:00
Дмитрий c6d2df908a feat(rossvyaz): wire region normalizer into import + fill region_normalized
PhoneRangesImportCommand now resolves subject_code via
RussianRegions::canonicalRegionName (pipe segment + обл./alias normalization)
and persists region_normalized. messy.csv fixture covers real prod formats
(3-digit DEF codes per chk_phone_ranges_def_code). 5/5 command tests GREEN.
2026-06-02 15:39:35 +03:00
Дмитрий d4ade05446 feat(rossvyaz): normalize registry region names to subject_code
RussianRegions::canonicalRegionName + resolveSubjectCode: take last pipe
segment, expand обл.->область, alias federal cities / Удмуртская / Кузбасс.
Fixes 98% unmapped phone_ranges (exact-match -> normalized). Unit-tested.
2026-06-02 15:22:24 +03:00
7 changed files with 219 additions and 4 deletions
+11 -3
View File
@@ -53,6 +53,11 @@ on:
required: false
default: true
type: boolean
force:
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
required: false
default: false
type: boolean
phone:
description: 'smoke: телефон'
required: false
@@ -77,6 +82,7 @@ jobs:
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
FORCE: ${{ github.event.inputs.force }}
PHONE: ${{ github.event.inputs.phone }}
steps:
@@ -344,12 +350,14 @@ jobs:
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
FORCE_FLAG=""
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_FLAG 2>&1
echo "=== Счётчики ==="
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
@@ -100,7 +100,10 @@ class PhoneRangesImportCommand extends Command
$rows = [];
foreach ($files as $file) {
foreach ($this->parseFile($file) as $rec) {
$subjectCode = RussianRegions::nameToCode()[trim($rec['region'])] ?? null;
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
$subjectCode = $regionNormalized === null
? null
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
if ($subjectCode === null && trim($rec['region']) !== '') {
$unmatched[trim($rec['region'])] = true;
}
@@ -110,6 +113,7 @@ class PhoneRangesImportCommand extends Command
'to_num' => $rec['to_num'],
'operator' => $rec['operator'],
'region' => $rec['region'],
'region_normalized' => $regionNormalized,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
+54
View File
@@ -114,9 +114,63 @@ final class RussianRegions
89 => 'Ямало-Ненецкий автономный округ',
];
/**
* Алиасы нестандартных форм реестра Россвязи каноничное имя субъекта.
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская»
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» спец-форма.
*
* @var array<string, string>
*/
private const REGION_ALIASES = [
'г. Москва' => 'Москва',
'г. Санкт-Петербург' => 'Санкт-Петербург',
'г. Севастополь' => 'Севастополь',
'Республика Удмуртская' => 'Удмуртская Республика',
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
'Кемеровская область - Кузбасс' => 'Кемеровская область',
];
/** @return array<string, int> name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
/**
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
*
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» «Московская обл.»),
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
* «Москва и Московская область», «г.о. Тольятти») null.
*/
public static function canonicalRegionName(string $raw): ?string
{
$segment = self::lastRegionSegment($raw);
if ($segment === '') {
return null;
}
$name = self::REGION_ALIASES[$segment]
?? (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
return isset(self::nameToCode()[$name]) ? $name : null;
}
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
public static function resolveSubjectCode(string $raw): ?int
{
$name = self::canonicalRegionName($raw);
return $name === null ? null : (self::nameToCode()[$name] ?? null);
}
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
private static function lastRegionSegment(string $raw): string
{
$parts = explode('|', $raw);
return trim((string) end($parts));
}
}
@@ -86,3 +86,25 @@ it('force flag bypasses idempotency note even with matching checksum', function
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
expect(DB::table('phone_ranges')->count())->toBe(0);
});
it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
// Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
// pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
// и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
$this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
->assertSuccessful();
$moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
$orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
$udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
$togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
expect((int) $moscow->subject_code)->toBe(82)
->and($moscow->region_normalized)->toBe('Москва')
->and((int) $orenburg->subject_code)->toBe(62)
->and($orenburg->region_normalized)->toBe('Оренбургская область')
->and((int) $udmurtia->subject_code)->toBe(21)
->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
->and($togliatti->subject_code)->toBeNull()
->and($togliatti->region_normalized)->toBeNull();
});
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Support\RussianRegions;
/**
* Нормализация регионов реестра Россвязи subject_code.
* Кейсы взяты из реальных топ-50 unmapped-форматов прод-реестра (02.06.2026).
*/
it('maps cities of federal significance with the г. prefix', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
});
it('still maps a plain canonical federal-city name', function (): void {
expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
});
it('takes the last pipe segment as the subject region', function (): void {
// регион = последний сегмент после |
expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
});
it('expands the обл. abbreviation to область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
});
it('keeps already-canonical край/республика segments', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
});
it('reorders the Удмуртская Республика inverted form', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
});
it('maps the Кузбасс special form to Кемеровская область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
});
it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
})->with([
'-',
'Российская Федерация',
'Москва и Московская область', // неоднозначно — два субъекта
'г.о. Тольятти', // нет региона в строке
'г.о. город Уфа',
'',
' ',
]);
it('exposes the canonical name via canonicalRegionName', function (): void {
expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
});
+5
View File
@@ -0,0 +1,5 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;г. Москва
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС г. Москва
3 922 1000000 1099999 100000 ПАО Ростелеком г. Оренбург|Оренбургская обл.
4 987 5000000 5099999 100000 ПАО Ростелеком г. Ижевск|Республика Удмуртская
5 902 7000000 7009999 10000 ООО Оператор г.о. Тольятти
@@ -0,0 +1,61 @@
# Россвязь region→subject_code mapping fix — Implementation Plan
> **For agentic workers:** TDD, bite-sized steps. Steps use `- [ ]`.
**Goal:** Маппить регион из реестра Россвязи в `subject_code` через нормализацию форматов, чтобы перестать терять ~98% диапазонов (444904/453080 были NULL из-за exact-match).
**Architecture:** Чистый нормализатор в `App\Support\RussianRegions` (`canonicalRegionName` + `resolveSubjectCode`), unit-тестируемый без БД. `PhoneRangesImportCommand` зовёт его и заполняет `region_normalized`. Прод перечитывает реестр командой `phone-ranges:import` после мержа.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16.
---
## Корень проблемы (systematic-debugging Phase 1, подтверждён прод-данными)
`PhoneRangesImportCommand` делал `RussianRegions::nameToCode()[trim($rec['region'])]` — exact match. Реальные строки реестра (топ-50 unmapped, прод 02.06.2026):
- `г. Москва` (253342) / `г. Санкт-Петербург` (34573) — города фед. значения с префиксом `г. `
- `г. Оренбург|Оренбургская обл.` — регион = **последний** сегмент после `|`, область сокращена `обл.`
- `г. Воскресенск|р-н Воскресенский|Московская обл.` — 3 сегмента, регион = последний
- `г. Ижевск|Республика Удмуртская` — порядок слов перевёрнут (канон `Удмуртская Республика`)
- `г. Кемерово|Кемеровская область - Кузбасс обл.` — спец-форма
- Безнадёжные (меньшинство, остаются NULL): `-`, `Российская Федерация`, `Москва и Московская область` (неоднозначно), `г.о. Тольятти` / `г.о. город Уфа` (нет региона в строке)
## Правила нормализации
1. Взять последний сегмент после `|`, trim.
2. Прямые алиасы (приоритет): `г. Москва``Москва`, `г. Санкт-Петербург``Санкт-Петербург`, `г. Севастополь``Севастополь`, `Республика Удмуртская``Удмуртская Республика`, `Кемеровская область - Кузбасс обл.``Кемеровская область`.
3. Иначе: суффикс ` обл.`` область`.
4. Результат искать в `nameToCode()`. Нет → `null` (диапазон остаётся unmapped — корректно).
---
## Task 1: `RussianRegions::canonicalRegionName` + `resolveSubjectCode`
**Files:**
- Modify: `app/app/Support/RussianRegions.php`
- Test: `app/tests/Unit/Support/RussianRegionsTest.php`
- [ ] Step 1: написать падающий unit-тест (кейсы: фед.города с `г. `, `обл.``область`, многосегментный pipe, переворот Удмуртии, Кузбасс-алиас, безнадёжные→null, чистое каноничное имя).
- [ ] Step 2: запустить pest → RED (метод не существует).
- [ ] Step 3: реализовать `lastSegment` (private), `ALIASES` (const), `canonicalRegionName(string): ?string`, `resolveSubjectCode(string): ?int`.
- [ ] Step 4: pest → GREEN.
- [ ] Step 5: commit.
## Task 2: wire команды импорта + `region_normalized`
**Files:**
- Modify: `app/app/Console/Commands/PhoneRangesImportCommand.php:103-116`
- Modify: `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
- Modify: `app/tests/Fixtures/rossvyaz/sample.csv` (добавить грязные строки)
- [ ] Step 1: добавить в fixture строки с реальными форматами (`г. Москва`, `г. Оренбург|Оренбургская обл.`, `г. Ижевск|Республика Удмуртская`, `г.о. Тольятти`).
- [ ] Step 2: расширить command-тест: проверить, что грязные строки маппятся в правильные коды, безнадёжные → NULL, `region_normalized` заполнен.
- [ ] Step 3: pest → RED.
- [ ] Step 4: команда зовёт `RussianRegions::canonicalRegionName` + `nameToCode`, пишет `region_normalized`.
- [ ] Step 5: pest → GREEN (весь файл).
- [ ] Step 6: commit + push + PR.
## После мержа
Владелец запускает на проде через `artisan-run.yml` (mutating, confirm_apply): `phone-ranges:import --dir=<пакет> --force` — перечитывает реестр с новым маппингом. Будущие лиды резолвятся через Россвязь-fallback → меньше пустого «Город».