Files
portal/app/tests/Feature/ClientSms/OperatorFromRossvyazTest.php
T
Дмитрий ec026f1b7b fix,смс: час по названию региона — Москва и Питер записаны в справочнике парой
Найдено приёмкой на живом справочнике боевого сервера. Оператор определялся
верно, а часовой пояс у Москвы и Санкт-Петербурга — нет: у 24 063 диапазонов
из 453 080 кода субъекта нет вовсе, потому что регион там записан ПАРОЙ —
«г. Москва и Московская область», «г. Санкт-Петербург и Ленинградская область»,
«Архангельская область * Ненецкий автономный округ». Однозначного субъекта у
такой строки действительно не существует.

Это самые крупные по числу абонентов диапазоны страны. Без разбора пары
оператор проставлялся, а пояс нет — и сообщение всё равно не уходило, потому
что без пояса номер ждёт уточнения региона, решение владельца В-85.

Нам, в отличие от приёма лидов, точный субъект не нужен — нужен ЧАС, а у обеих
половин каждой такой пары он общий. Поэтому пара разбирается на половины по
разделителям « и » и « * », и час берётся ТОЛЬКО если все узнанные половины
сошлись. Не сошлись или ни одна не узнана — честное «не знаю»: молча выбрать
одну из двух значит однажды отправить человеку СМС ночью.

Заодно поправлено утверждение в шапке класса: ключ ДаДаты на боевом сервере
ЕСТЬ, это проверено командой. Почему обращение к ней не дало оператора,
установить не удалось — падений в журнале нет ни одного.

Модуль СМС 402 из 402, статанализ 0.
2026-08-03 12:28:23 +03:00

411 lines
21 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\EnrichClientSmsContactsOperatorJob;
use App\Jobs\EnrichClientSmsSnapshotRegionJob;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsRossvyazHint;
use App\Services\ClientSms\SmsQuietHours;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Sms\OperatorNormalizer;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
/**
* Оператора и часовой пояс номера модуль спрашивал ТОЛЬКО у платной ДаДаты, а без
* её ключа выходил молча — и на бою 03.08.2026 это убило две трети рассылок:
* «по своей базе» и «списком руками» не отправляли ни одного сообщения (причина
* `skipped_unknown_operator`). Прежние тесты этого не видели, потому что САМИ
* проставляли ключ ДаДаты и оператора руками.
*
* Здесь ДаДаты нет вовсе — ровно как на боевом сервере, — и оператор с поясом
* обязаны приезжать из бесплатного справочника Россвязи (`phone_ranges`), которым
* уже пользуется приём лидов. Тесты ДОХОДЯТ до значения через справочник, а не
* подставляют его.
*
* Каждый прогон «а вот теперь ушло» стоит в паре с контрольным «и без справочника
* по-прежнему не уходит» (урок В-54): иначе зелёный мог бы означать «уходит всегда».
*
* Помощники с префиксом rossvyazSms* — имена функций в Pest ГЛОБАЛЬНЫЕ, и короткие
* имена уже заняты соседними файлами.
*
* 🪤 SharesSupplierPdo — справочник читается служебным соединением `pgsql_supplier`:
* без общего PDO оно не видит незакоммиченные строки теста.
*
* Синтетические номера 7999…/7921… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
// 🔴 Ключа ДаДаты НЕТ — это и есть состояние боевого сервера.
config(['services.dadata.api_key' => '', 'services.dadata.secret' => '']);
Cache::flush(); // чистый дневной бюджет
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Строка-журнал импорта справочника; её id нужен каждому диапазону. */
function rossvyazSmsImportId(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('b', 64),
'status' => 'completed',
'imported_at' => now(),
]);
}
/**
* Диапазон номеров в справочнике Россвязи. По умолчанию — питерский МегаФон
* (субъект 83, это UTC+3, то есть 180 минут).
*
* @param array<string, mixed> $overrides
*/
function rossvyazSmsRange(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' => rossvyazSmsImportId(),
], $overrides));
}
/** Прогон обогащения своей базы номеров. */
function rossvyazSmsEnrichContacts(int $tenantId): void
{
(new EnrichClientSmsContactsOperatorJob($tenantId))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
}
/** Заказ рассылки по списку, вписанному руками. */
function rossvyazSmsOrder(array $phones): int
{
return (int) test()->postJson('/api/sms/campaigns', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => $phones,
])->assertCreated()->json('id');
}
// ─────────────────────────── своя база номеров ───────────────────────────
it('своя база: без ДаДаты оператора и пояс даёт справочник Россвязи', function () {
rossvyazSmsRange();
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79215555123']);
rossvyazSmsEnrichContacts($this->tenant->id);
// Оператор кладётся СЫРЫМ именем — ровно так же, как его клала ДаДата:
// к каноническому ключу его приводит отборщик получателей, а не справочник.
expect($c->fresh()->operator)->toBe('МегаФон')
->and($c->fresh()->tz_offset_minutes)->toBe(180);
});
it('контрольный — своя база: номера нет в справочнике, оператор остаётся пустым', function () {
// Без этого прогона предыдущий ничего не доказывает: «проставился» мог бы
// означать «проставляется всегда, чему бы справочник ни отвечал».
rossvyazSmsRange(); // только def_code=921
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79991234567']);
rossvyazSmsEnrichContacts($this->tenant->id);
expect($c->fresh()->operator)->toBeNull()
->and($c->fresh()->tz_offset_minutes)->toBeNull();
});
it('своя база: уже известного оператора справочник не затирает', function () {
// У номера в справочнике МегаФон, а у нас про него записан МТС — значит номер
// перенесён к другому оператору, и справочник об этом не знает. Знание, за
// которое уже заплачено, сильнее догадки по диапазону.
rossvyazSmsRange();
$c = ClientSmsContact::create([
'tenant_id' => $this->tenant->id,
'phone' => '79215555123',
'operator' => 'МТС',
]);
rossvyazSmsEnrichContacts($this->tenant->id);
expect($c->fresh()->operator)->toBe('МТС')
->and($c->fresh()->tz_offset_minutes)->toBe(180); // а пояса не было — его дополнили
});
it('своя база: ответ ДаДаты сильнее справочника — номер перенесён к другому оператору', function () {
config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'UTC+3']], 200)]);
rossvyazSmsRange(); // справочник говорит «МегаФон»
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79215555123']);
rossvyazSmsEnrichContacts($this->tenant->id);
expect($c->fresh()->operator)->toBe('МТС');
});
// ───────────────────────── список, вписанный руками ─────────────────────────
it('списком руками: без ДаДаты справочник даёт пояс и оператора — номер уходит', function () {
// Полдень в Петербурге: окно 10–20 открыто, ждать номеру нечего.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
rossvyazSmsRange();
$id = rossvyazSmsOrder(['79215555123']);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
expect($row->tz_offset_minutes)->toBe(180)
->and($row->skip_reason)->toBeNull()
->and($row->operator)->toBe('megafon') // в снимке лежит канонический ключ
->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1);
});
it('контрольный — списком руками: номера нет в справочнике, он ждёт и не теряется', function () {
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
rossvyazSmsRange(); // только def_code=921
$id = rossvyazSmsOrder(['79991234567']);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
expect($row->tz_offset_minutes)->toBeNull()
->and($row->skip_reason)->toBeNull() // не потерян и не помечен зря
->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0);
});
it('списком руками: предпросмотр не пугает клиента зря — про известный номер он всё знает сразу', function () {
// Предпросмотр считается ДО создания рассылки, и догону снимка тут взяться
// неоткуда. Пока справочник не спрашивали здесь же, человек видел «ждут
// уточнения региона» про номер, который справочник знает наизусть, — и
// смету на этот номер тоже не видел.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
rossvyazSmsRange();
$body = test()->postJson('/api/sms/preview', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => ['79215555123'],
])->assertOk()->json();
expect($body['sendable_count'])->toBe(1)
->and($body['waiting_region_count'])->toBe(0)
->and($body['skipped'])->toBe([]);
});
it('контрольный — предпросмотр: про номер вне справочника честно говорит, что ждёт региона', function () {
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
rossvyazSmsRange(); // только def_code=921
$body = test()->postJson('/api/sms/preview', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => ['79991234567'],
])->assertOk()->json();
expect($body['waiting_region_count'])->toBe(1);
});
it('пара регионов в справочнике даёт пояс, если обе половины в одном часе', function () {
// 🔴 Найдено приёмкой на бою: у Москвы и Питера в справочнике Россвязи регион
// записан ПАРОЙ — «г. Москва и Московская область», — и кода субъекта у таких
// строк нет вовсе. Это самые крупные по числу абонентов диапазоны страны, и без
// разбора пары оператор определялся, а пояс нет — то есть СМС всё равно не уходила.
//
// Разбирать пару можно потому, что обе её половины живут в ОДНОМ часе. Там, где
// это не так, честнее ответить «не знаю» — про это отдельный прогон ниже.
rossvyazSmsRange([
'def_code' => 916, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'ПАО "МТС"', 'region' => 'г. Москва и Московская область',
'subject_code' => null,
]);
expect(app(ClientSmsRossvyazHint::class)->for('79161234567'))
->toBe(['operator' => 'ПАО "МТС"', 'tz' => 180]);
});
it('пара регионов из РАЗНЫХ часов пояса не даёт — говорим честно «не знаю»', function () {
// Выдуманная пара Москва + Красноярск: полтора часовых пояса между ними. Угадать
// тут нельзя, и молча выбрать один из двух — значит отправить человеку СМС ночью.
rossvyazSmsRange([
'def_code' => 917, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'ПАО "МТС"', 'region' => 'г. Москва и Красноярский край',
'subject_code' => null,
]);
expect(app(ClientSmsRossvyazHint::class)->for('79171234567'))
->toBe(['operator' => 'ПАО "МТС"', 'tz' => null]);
});
it('пара через звёздочку разбирается так же — так пишет сам справочник', function () {
// «Архангельская область * Ненецкий автономный округ» — второй разделитель,
// которым Россвязь склеивает пару. Обе половины в одном часе.
rossvyazSmsRange([
'def_code' => 911, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'ПАО "МегаФон"', 'region' => 'Архангельская область * Ненецкий автономный округ',
'subject_code' => null,
]);
expect(app(ClientSmsRossvyazHint::class)->for('79111234567')['tz'])->toBe(180);
});
it('выбранный дневной лимит ДаДаты не мешает справочнику ответить — он бесплатный', function () {
// Рассылка на 20 000 номеров стоила бы у ДаДаты 12 000 ₽ при дневном потолке
// 10 000 ₽: остаток списка упирается в лимит каждый раз, когда список большой.
// Раньше на этом всё вставало до следующего запуска. Справочник денег не стоит,
// и не спросить его — значит держать людей в очереди без всякой причины.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'UTC+3']], 200)]);
// Дневной лимит выбираем ДО заказа, а справочник пополняем ПОСЛЕ. Тогда номер
// ложится в снимок без пояса и достаётся догону. Сделай наоборот — его обслужил
// бы сам заказ, и тест прошёл бы, ничего не проверив (так и вышло с первой
// попытки этого теста).
config(['services.dadata.daily_cap_rub' => 1]);
app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽
$id = rossvyazSmsOrder(['79215555123']);
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('tz_offset_minutes'))->toBeNull();
rossvyazSmsRange(); // справочник пополнили
(new EnrichClientSmsSnapshotRegionJob($id, (int) $this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(SmsQuietHours::class),
app(OperatorNormalizer::class),
app(AllowedSmsOperators::class),
app(ClientSmsRossvyazHint::class),
);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
// Оператор и пояс — из справочника, ДаДату не дёргали ни разу.
expect($row->tz_offset_minutes)->toBe(180)
->and($row->operator)->toBe('megafon');
Http::assertNothingSent();
});
it('пачкой и по одному справочник отвечает ОДИНАКОВО — в том числе на вложенных диапазонах', function () {
// 🔴 Пачечный запрос — второй экземпляр того же правила выбора («побеждает самый
// узкий накрывающий диапазон»). Два экземпляра одного правила расходятся молча,
// поэтому сторож сравнивает их ответы на том самом краю, где правило и работает.
$importId = rossvyazSmsImportId();
rossvyazSmsRange([
'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'Ростелеком', 'region' => 'Московская область',
'subject_code' => 56, 'import_id' => $importId,
]);
rossvyazSmsRange([
'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999,
'operator' => 'МГТС', 'region' => 'Москва',
'subject_code' => 82, 'import_id' => $importId,
]);
$hint = app(ClientSmsRossvyazHint::class);
$phones = ['74952005000', '74953005000', '79991234567'];
$one = [];
foreach ($phones as $phone) {
$one[$phone] = $hint->for($phone);
}
expect($hint->forMany($phones))->toBe($one)
// И заодно проверяем, что узкий диапазон действительно выиграл, — иначе
// «совпало» могло бы означать «оба ошибаются одинаково».
->and($one['74952005000']['operator'])->toBe('МГТС')
->and($one['74953005000']['operator'])->toBe('Ростелеком')
->and($one['79991234567']['operator'])->toBeNull();
});
it('предпросмотр большого списка спрашивает справочник пачками, а не по номеру', function () {
// Справочник бесплатный, но не даровой: вопрос про КАЖДЫЙ номер отдельно — это
// 20 000 запросов в одном нажатии кнопки, и человек у экрана столько не ждёт.
// 🔑 Прибор — ЗАПРОСЫ, а не время: время на стенде плавает и порогом врало бы.
rossvyazSmsRange();
$phones = [];
for ($i = 0; $i < 20000; $i++) {
$phones[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
}
$queries = 0;
DB::listen(function () use (&$queries): void {
$queries++;
});
$started = microtime(true);
$res = test()->postJson('/api/sms/preview', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => $phones,
]);
$elapsed = microtime(true) - $started;
fwrite(STDERR, sprintf("\n[Н.1] предпросмотр 20 000 номеров списком: %d запросов, %.2f с\n", $queries, $elapsed));
$res->assertOk();
expect($queries)->toBeLessThan(200);
});
it('списком руками: догон снимка тоже работает без ДаДаты', function () {
// Строка снимка могла остаться без пояса — например, справочник пополнили
// уже после заказа. Догон обязан её подобрать, а не ждать ключа ДаДаты.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
$id = rossvyazSmsOrder(['79215555123']); // справочник ещё пуст — номер ждёт
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('tz_offset_minutes'))->toBeNull();
rossvyazSmsRange(); // справочник пополнили
(new EnrichClientSmsSnapshotRegionJob($id, (int) $this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(SmsQuietHours::class),
app(OperatorNormalizer::class),
app(AllowedSmsOperators::class),
app(ClientSmsRossvyazHint::class),
);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
expect($row->tz_offset_minutes)->toBe(180)
->and($row->operator)->toBe('megafon');
});