ec026f1b7b
Найдено приёмкой на живом справочнике боевого сервера. Оператор определялся верно, а часовой пояс у Москвы и Санкт-Петербурга — нет: у 24 063 диапазонов из 453 080 кода субъекта нет вовсе, потому что регион там записан ПАРОЙ — «г. Москва и Московская область», «г. Санкт-Петербург и Ленинградская область», «Архангельская область * Ненецкий автономный округ». Однозначного субъекта у такой строки действительно не существует. Это самые крупные по числу абонентов диапазоны страны. Без разбора пары оператор проставлялся, а пояс нет — и сообщение всё равно не уходило, потому что без пояса номер ждёт уточнения региона, решение владельца В-85. Нам, в отличие от приёма лидов, точный субъект не нужен — нужен ЧАС, а у обеих половин каждой такой пары он общий. Поэтому пара разбирается на половины по разделителям « и » и « * », и час берётся ТОЛЬКО если все узнанные половины сошлись. Не сошлись или ни одна не узнана — честное «не знаю»: молча выбрать одну из двух значит однажды отправить человеку СМС ночью. Заодно поправлено утверждение в шапке класса: ключ ДаДаты на боевом сервере ЕСТЬ, это проверено командой. Почему обращение к ней не дало оператора, установить не удалось — падений в журнале нет ни одного. Модуль СМС 402 из 402, статанализ 0.
411 lines
21 KiB
PHP
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');
|
|
});
|