65e270583c
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или виртуального оператора в рассылку не берётся, и человек видит честную причину, а не молчаливую пропажу. Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём. Список — настройкой, а не в коде: client_sms_settings.allowed_operators (схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию. Правило живёт в одном месте — AllowedSmsOperators. Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок и своей базы оператор известен в момент заказа, у номеров, вписанных руками, его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали». Плата за имя не тронута (В-150): в коде два похожих списка операторов, и связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей. Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём только по МТС)» — универсальный канал возит всех. Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка 24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн». ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
10 KiB
PHP
229 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\ClientSmsMessage;
|
||
use App\Models\ClientSmsOptout;
|
||
use App\Models\SmsGlobalOptout;
|
||
use App\Models\Tenant;
|
||
use App\Services\ClientSms\AllowedSmsOperators;
|
||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||
use App\Services\Sms\OperatorNormalizer;
|
||
use App\Services\Sms\Providers\FakeSmsProvider;
|
||
use App\Services\Sms\Providers\MtsSmsProvider;
|
||
use App\Services\Sms\SmsRouter;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
||
/**
|
||
* Отбор получателей клиентской СМС-рассылки (Task 5). Получатели уже несут
|
||
* оператора (его определил ClientSmsAudienceBuilder) — здесь только стоп-лист,
|
||
* дубли внутри рассылки и маршрут. Никакого отсева по каналу отправителя —
|
||
* Фаза 1 всегда шлёт от имени liderra.ru.
|
||
*
|
||
* Роутер собран руками (не app()-синглтон): в песочнице singleton — заглушка,
|
||
* которая маршрутизирует всё подряд, и тест ничего бы не проверил.
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
|
||
function client_sms_selector(): ClientSmsRecipientSelector
|
||
{
|
||
$router = new SmsRouter([
|
||
new MtsSmsProvider(token: 'test', serves: ['mts'], priceKopecks: ['*' => 0]),
|
||
]);
|
||
|
||
return new ClientSmsRecipientSelector($router, new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
|
||
}
|
||
|
||
it('стоп-лист побеждает даже подходящий маршрут', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
ClientSmsOptout::create([
|
||
'tenant_id' => $tenant->id,
|
||
'phone' => '79990000001',
|
||
]);
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000001', 'operator' => 'mts']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->sendable)->toBeEmpty()
|
||
->and($plan->skipped)->toBe(['79990000001' => ClientSmsMessage::SKIP_OPTOUT]);
|
||
});
|
||
|
||
it('номер МТС уходит в отправку через МТС-провайдера', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000002', 'operator' => 'mts']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->skipped)->toBeEmpty()
|
||
->and($plan->sendable)->toHaveCount(1)
|
||
->and($plan->sendable[0]['phone'])->toBe('79990000002')
|
||
->and($plan->sendable[0]['operator'])->toBe('mts')
|
||
->and($plan->sendable[0]['provider'])->not->toBeNull()
|
||
->and($plan->sendable[0]['provider']->key())->toBe('mts');
|
||
});
|
||
|
||
it('оператор без маршрута помечается skipped_no_route', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000003', 'operator' => 'beeline']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->sendable)->toBeEmpty()
|
||
->and($plan->skipped)->toBe(['79990000003' => ClientSmsMessage::SKIP_NO_ROUTE]);
|
||
});
|
||
|
||
it('2.3 — номер без оператора получает причину «не определён оператор», а не «не из МТС»', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
// Оператора в данных НЕТ: так приходят номера, вписанные руками, и номера
|
||
// «своей базы», которым ДаДата ещё не проставила оператора. Раньше им
|
||
// писалось «не из МТС» — а мы не знаем, чей это номер (строка листа 2.3).
|
||
$planNull = client_sms_selector()->build(
|
||
[['phone' => '79990000005', 'operator' => null]],
|
||
$tenant->id,
|
||
);
|
||
$planEmpty = client_sms_selector()->build(
|
||
[['phone' => '79990000007', 'operator' => ' ']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($planNull->skipped)->toBe(['79990000005' => ClientSmsMessage::SKIP_UNKNOWN_OPERATOR])
|
||
->and($planEmpty->skipped)->toBe(['79990000007' => ClientSmsMessage::SKIP_UNKNOWN_OPERATOR]);
|
||
});
|
||
|
||
it('2.3 — оператор назван, но канала для него нет: это «не из МТС», а не «не определён»', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
// 🪤 В-68: `canonical()` отдаёт пустую строку И когда оператора нет, И когда
|
||
// имя есть, но нашего словаря он не знает («Тинькофф Мобайл», МВНО). Делить
|
||
// по этой пустоте нельзя: про названного оператора «не определён» — новое
|
||
// враньё, только в другую сторону. Делим по СЫРОМУ значению.
|
||
//
|
||
// 🔴 Изменение 30.07 (журнал В-155, строка листа 4.14): мелкий оператор
|
||
// теперь выбывает РАНЬШЕ — на списке разрешённых, а не на маршруте, и
|
||
// причина у него своя. «Нет маршрута» осталось для оператора ИЗ четвёрки,
|
||
// которого сейчас некому везти: здесь роутер собран без универсального
|
||
// канала, и билайновский номер до него не доезжает.
|
||
$planKnown = client_sms_selector()->build(
|
||
[['phone' => '79990000006', 'operator' => 'beeline']],
|
||
$tenant->id,
|
||
);
|
||
$planMvno = client_sms_selector()->build(
|
||
[['phone' => '79990000008', 'operator' => 'Тинькофф Мобайл']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($planKnown->skipped)->toBe(['79990000006' => ClientSmsMessage::SKIP_NO_ROUTE])
|
||
->and($planMvno->skipped)->toBe(['79990000008' => ClientSmsMessage::SKIP_OPERATOR_DENIED]);
|
||
});
|
||
|
||
it('2.3 — сторож: при универсальном канале номер без оператора УХОДИТ, а не отсеивается', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
// 🪤 В-67, сторож к порядку проверок. Универсальный канал (СМС-центр,
|
||
// `serves: ['*']`, в песочнице — заглушка) берёт любой номер, в том числе без
|
||
// оператора. Если отсеивать «оператор не распознан» ДО вопроса маршрутизатору
|
||
// — как предлагал план Task 7 — такие номера перестанут уходить, и рассылка
|
||
// молча уменьшится. Сначала спрашиваем маршрут, причину называем только когда
|
||
// он отказал.
|
||
$universal = new ClientSmsRecipientSelector(
|
||
new SmsRouter([new FakeSmsProvider]),
|
||
new OperatorNormalizer,
|
||
new AllowedSmsOperators(new OperatorNormalizer),
|
||
);
|
||
|
||
$plan = $universal->build(
|
||
[['phone' => '79990000009', 'operator' => null]],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->skipped)->toBeEmpty()
|
||
->and($plan->sendable)->toHaveCount(1)
|
||
->and($plan->sendable[0]['provider']->key())->toBe('fake');
|
||
});
|
||
|
||
it('дубль внутри одной рассылки считается один раз', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[
|
||
['phone' => '79990000004', 'operator' => 'mts'],
|
||
['phone' => '79990000004', 'operator' => 'mts'],
|
||
],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->sendable)->toHaveCount(1)
|
||
->and($plan->skipped)->toBe(['79990000004' => ClientSmsMessage::SKIP_DUPLICATE]);
|
||
});
|
||
|
||
it('номер из общего стоп-листа портала не получает рассылку', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
SmsGlobalOptout::create(['phone' => '79990000010', 'reason' => 'жалоба оператору']);
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000010', 'operator' => 'mts']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->sendable)->toBeEmpty()
|
||
->and($plan->skipped)->toBe(['79990000010' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
|
||
});
|
||
|
||
it('общий стоп-лист закрывает номер у ВСЕХ тенантов сразу', function () {
|
||
$tenantA = Tenant::factory()->create();
|
||
$tenantB = Tenant::factory()->create();
|
||
|
||
SmsGlobalOptout::create(['phone' => '79990000011']);
|
||
|
||
foreach ([$tenantA->id, $tenantB->id] as $tenantId) {
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000011', 'operator' => 'mts']],
|
||
$tenantId,
|
||
);
|
||
|
||
expect($plan->sendable)->toBeEmpty()
|
||
->and($plan->skipped)->toBe(['79990000011' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
|
||
}
|
||
});
|
||
|
||
it('общий стоп-лист проверяется РАНЬШЕ тенантского — причина видна именно общая', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
SmsGlobalOptout::create(['phone' => '79990000012']);
|
||
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000012']);
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000012', 'operator' => 'mts']],
|
||
$tenant->id,
|
||
);
|
||
|
||
expect($plan->skipped)->toBe(['79990000012' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
|
||
});
|
||
|
||
it('стоп-лист чужого тенанта не глушит тот же номер у своего', function () {
|
||
$tenantA = Tenant::factory()->create();
|
||
$tenantB = Tenant::factory()->create();
|
||
|
||
ClientSmsOptout::create([
|
||
'tenant_id' => $tenantB->id,
|
||
'phone' => '79990000007',
|
||
]);
|
||
|
||
$plan = client_sms_selector()->build(
|
||
[['phone' => '79990000007', 'operator' => 'mts']],
|
||
$tenantA->id,
|
||
);
|
||
|
||
expect($plan->skipped)->toBeEmpty()
|
||
->and($plan->sendable)->toHaveCount(1);
|
||
});
|