fix(supplier): перевод кодов регионов Лидерра→поставщик (конституционный→ГИБДД)

Лидерра нумерует субъекты по конституционному порядку (RussianRegions:
Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
Архангельск=29). Sync слал Лидерра-код как есть → поставщик выбирал ЧУЖОЙ регион
(заказчик выбрал Красноярский край — у поставщика встал Архангельск). На dev не
всплывало: проверяли на «вся РФ» (пустой regions).

Фикс: App\Support\SupplierRegions::mapToSupplier — карта 79 субъектов, построена
сверкой имён RussianRegions ↔ live-дерево формы «Добавить проект» поставщика
(recon 2026-05-21, node-key="id"). Перевод в единственной точке выхода —
SupplierPortalClient::toPayload (покрывает create/update/multiFlag). Тег остаётся
человекочитаемым именем Лидерры.

10 субъектов Лидерры поставщик не предлагает (Московская/Ленинградская/Крым/
Севастополь/ДНР/ЛНР/Запорожская/Херсонская/Ненецкий АО/ЯНАО) — их коды
отбрасываются с warning'ом (георфильтр для них у поставщика недоступен).

Тесты: SupplierRegionsTest (перевод/отброс/dedupe/биекция);
SupplierPortalClientRtProjectTest обновлён (regions [77]→[72] после перевода).

Проверено вживую на тест-сервере: проекты 14/15 пере-синхронизированы, доноры
12742042/12766120 у crm.bp-gr.ru → regions=24 (Красноярский), reverse=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-21 15:50:18 +03:00
parent b29bfe2ac6
commit 1dc696cef6
4 changed files with 219 additions and 2 deletions
@@ -9,6 +9,7 @@ use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Support\SupplierRegions;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
@@ -477,7 +478,10 @@ class SupplierPortalClient
'srcseg' => false,
'limit' => $dto->limit,
'workdays' => $workdays,
'regions' => $dto->regions,
// DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
// свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
// → Архангельск 29). См. App\Support\SupplierRegions.
'regions' => SupplierRegions::mapToSupplier($dto->regions),
'regions_reverse' => $dto->regionsReverse,
'status' => $dto->status === 'active',
'show' => true,
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Illuminate\Support\Facades\Log;
/**
* Перевод кодов регионов: Лидерра поставщик crm.bp-gr.ru.
*
* Лидерра нумерует субъекты РФ по конституционному порядку (ст. 65), 1..89
* см. {@see RussianRegions}: Красноярский край = 29, Архангельская обл. = 35.
* Поставщик нумерует по автомобильным кодам (ГИБДД): Красноярский = 24,
* Архангельская = 29. Без перевода Sync отправлял Лидерра-код «как есть»
* (`regions => [29]` для Красноярского), а поставщик понимал его как СВОЙ 29 =
* Архангельск у поставщика выбирался ЧУЖОЙ регион. На dev не всплывало
* проверяли на «вся РФ» (пустой regions).
*
* Карта построена сверкой имён {@see RussianRegions::CODE_TO_NAME} live-дерево
* регионов формы «Добавить проект» поставщика (recon 2026-05-21: node-key="id",
* 79 субъектов-листьев). Все 79 кодов поставщика покрыты (биекция на 79).
*
* 10 субъектов Лидерры поставщик НЕ предлагает (нет в дереве) их коды
* отбрасываются при переводе (с warning'ом): Московская обл. (56),
* Ленинградская обл. (53), Крым (13), Севастополь (84), ДНР (6), ЛНР (14),
* Запорожская (43), Херсонская (79), Ненецкий АО (86), Ямало-Ненецкий АО (89).
* Если у проекта это был ЕДИНСТВЕННЫЙ регион у поставщика проект окажется без
* георфильтра (вся РФ). Это ограничение покрытия поставщика, не баг перевода.
*/
final class SupplierRegions
{
/**
* Лидерра-код (конституционный 1..89) => код поставщика (ГИБДД).
*
* @var array<int, int>
*/
public const LIDERRA_TO_SUPPLIER = [
// Республики
1 => 1, // Республика Адыгея
2 => 4, // Республика Алтай
3 => 2, // Республика Башкортостан
4 => 3, // Республика Бурятия
5 => 5, // Республика Дагестан
7 => 6, // Республика Ингушетия
8 => 7, // Кабардино-Балкарская Республика
9 => 8, // Республика Калмыкия
10 => 9, // Карачаево-Черкесская Республика
11 => 10, // Республика Карелия
12 => 11, // Республика Коми
15 => 12, // Республика Марий Эл
16 => 13, // Республика Мордовия
17 => 14, // Республика Саха (Якутия)
18 => 15, // Республика Северная Осетия — Алания
19 => 16, // Республика Татарстан
20 => 17, // Республика Тыва
21 => 18, // Удмуртская Республика
22 => 19, // Республика Хакасия
23 => 20, // Чеченская Республика
24 => 21, // Чувашская Республика
// Края
25 => 22, // Алтайский край
26 => 75, // Забайкальский край
27 => 41, // Камчатский край
28 => 23, // Краснодарский край
29 => 24, // Красноярский край
30 => 59, // Пермский край
31 => 25, // Приморский край
32 => 26, // Ставропольский край
33 => 27, // Хабаровский край
// Области
34 => 28, // Амурская область
35 => 29, // Архангельская область
36 => 30, // Астраханская область
37 => 31, // Белгородская область
38 => 32, // Брянская область
39 => 33, // Владимирская область
40 => 34, // Волгоградская область
41 => 35, // Вологодская область
42 => 36, // Воронежская область
44 => 37, // Ивановская область
45 => 38, // Иркутская область
46 => 39, // Калининградская область
47 => 40, // Калужская область
48 => 42, // Кемеровская область
49 => 43, // Кировская область
50 => 44, // Костромская область
51 => 45, // Курганская область
52 => 46, // Курская область
54 => 48, // Липецкая область
55 => 49, // Магаданская область
57 => 51, // Мурманская область
58 => 52, // Нижегородская область
59 => 53, // Новгородская область
60 => 54, // Новосибирская область
61 => 55, // Омская область
62 => 56, // Оренбургская область
63 => 57, // Орловская область
64 => 58, // Пензенская область
65 => 60, // Псковская область
66 => 61, // Ростовская область
67 => 62, // Рязанская область
68 => 63, // Самарская область
69 => 64, // Саратовская область
70 => 65, // Сахалинская область
71 => 66, // Свердловская область
72 => 67, // Смоленская область
73 => 68, // Тамбовская область
74 => 69, // Тверская область
75 => 70, // Томская область
76 => 71, // Тульская область
77 => 72, // Тюменская область
78 => 73, // Ульяновская область
80 => 74, // Челябинская область
81 => 76, // Ярославская область
// Города федерального значения
82 => 77, // Москва
83 => 78, // Санкт-Петербург
// Автономная область / округа
85 => 79, // Еврейская автономная область
87 => 86, // Ханты-Мансийский автономный округ — Югра
88 => 87, // Чукотский автономный округ
];
/**
* Переводит Лидерра-коды регионов в коды поставщика. Неизвестные (нет у
* поставщика) отбрасываются с warning'ом; sentinel 0 («Вся РФ») игнорируется.
* Результат уникальные коды поставщика по возрастанию.
*
* @param list<int>|array<int|string, int|string> $liderraCodes
* @return list<int>
*/
public static function mapToSupplier(array $liderraCodes): array
{
$out = [];
$dropped = [];
foreach ($liderraCodes as $code) {
$code = (int) $code;
if ($code === 0) {
continue; // sentinel «Вся РФ»
}
if (isset(self::LIDERRA_TO_SUPPLIER[$code])) {
$out[self::LIDERRA_TO_SUPPLIER[$code]] = true;
} else {
$dropped[] = $code;
}
}
if ($dropped !== []) {
Log::warning('supplier.regions.unmapped', [
'liderra_codes' => $dropped,
'note' => 'supplier does not offer these subjects — geo-filter dropped for them',
]);
}
$codes = array_keys($out);
sort($codes);
return $codes;
}
}
@@ -99,7 +99,9 @@ it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (sin
&& $request['srcrt'] === false
&& $request['srcbl'] === true
&& $request['srcmt'] === false
&& $request['regions'] === [77]
// Лидерра-код 77 (Тюменская обл., конституционный порядок) переводится
// в код поставщика 72 (ГИБДД). См. App\Support\SupplierRegions.
&& $request['regions'] === [72]
&& $request['regions_reverse'] === true
&& $request['status'] === false;
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Support\SupplierRegions;
use Tests\TestCase;
// Бутстрапим приложение — mapToSupplier() пишет Log::warning при отбросе непереводимых.
uses(TestCase::class);
// Regression: Лидерра нумерует субъекты по конституционному порядку (RussianRegions,
// Красноярский=29), поставщик crm.bp-gr.ru — по автокодам ГИБДД (Красноярский=24,
// Архангельск=29). Sync слал Лидерра-код как есть → у поставщика выбирался ЧУЖОЙ регион.
// SupplierRegions::mapToSupplier переводит Лидерра-код → код поставщика.
it('translates Liderra constitutional codes to supplier (ГИБДД) codes', function (): void {
expect(SupplierRegions::mapToSupplier([29]))->toBe([24]); // Красноярский край
expect(SupplierRegions::mapToSupplier([35]))->toBe([29]); // Архангельская обл.
expect(SupplierRegions::mapToSupplier([24]))->toBe([21]); // Чувашская Республика
expect(SupplierRegions::mapToSupplier([82]))->toBe([77]); // Москва
expect(SupplierRegions::mapToSupplier([83]))->toBe([78]); // Санкт-Петербург
});
it('returns empty for all-Russia (no regions)', function (): void {
expect(SupplierRegions::mapToSupplier([]))->toBe([]);
});
it('ignores sentinel 0 (Вся РФ)', function (): void {
expect(SupplierRegions::mapToSupplier([0]))->toBe([]);
});
it('drops regions the supplier does not offer', function (): void {
// Поставщик НЕ предлагает: Московская (56), Ленинградская (53), Крым (13), новые территории.
expect(SupplierRegions::mapToSupplier([56]))->toBe([]); // Московская обл.
expect(SupplierRegions::mapToSupplier([53]))->toBe([]); // Ленинградская обл.
expect(SupplierRegions::mapToSupplier([13]))->toBe([]); // Крым
// mixed: оставляем переводимые, отбрасываем непереводимые
expect(SupplierRegions::mapToSupplier([29, 56]))->toBe([24]); // Красноярский kept, Московская dropped
});
it('dedupes and sorts supplier codes', function (): void {
// 35→29 (Архангельск), 29→24 (Красноярский), дубль 35 → unique+sorted [24,29]
expect(SupplierRegions::mapToSupplier([35, 29, 35]))->toBe([24, 29]);
});
it('every map entry points to a distinct supplier code (no collisions)', function (): void {
$targets = array_values(SupplierRegions::LIDERRA_TO_SUPPLIER);
expect(count($targets))->toBe(count(array_unique($targets)));
});