262 lines
13 KiB
PHP
262 lines
13 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
use App\Models\ClientSmsCampaign;
|
|||
|
|
use App\Models\ClientSmsMessage;
|
|||
|
|
use App\Models\ClientSmsSender;
|
|||
|
|
use App\Models\ClientSmsSettings;
|
|||
|
|
use App\Models\Tenant;
|
|||
|
|
use App\Services\ClientSms\AllowedSmsOperators;
|
|||
|
|
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
|||
|
|
use App\Services\Sms\OperatorNormalizer;
|
|||
|
|
use App\Services\Sms\Providers\BeelineSmsProvider;
|
|||
|
|
use App\Services\Sms\Providers\FakeSmsProvider;
|
|||
|
|
use App\Services\Sms\SmsProvider;
|
|||
|
|
use App\Services\Sms\SmsRouter;
|
|||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Сеть Мотива (задача владельца 10.08.2026 «научить портал отправлять Мотив»).
|
|||
|
|
*
|
|||
|
|
* 🔑 Мотив достался не по выбору: в форме заявки на имя отправителя у Билайна
|
|||
|
|
* галки «Билайн» и «Мотив» прибиты намертво (отмечены, снять нельзя). Раз он
|
|||
|
|
* оплачен — надо им пользоваться.
|
|||
|
|
*
|
|||
|
|
* 🔴 ДО одобрения имени Билайном (заявка 10.08 14:53, срок до ~24.08) канал
|
|||
|
|
* Билайна Мотива брать НЕ должен: отправка вернёт отказ 20230 «имя отправителя
|
|||
|
|
* не одобрено оператором», а он у нас в списке ОКОНЧАТЕЛЬНЫХ — повтора не будет,
|
|||
|
|
* сообщение умрёт. Поэтому маршрут через Билайна стоит на выключателе
|
|||
|
|
* SMS_BEELINE_SERVES_MOTIV, и умолчание у него — ВЫКЛЮЧЕНО.
|
|||
|
|
*
|
|||
|
|
* 🪤 Отдельно от выключателя: сам номер Мотива в рассылку теперь берётся
|
|||
|
|
* (AllowedSmsOperators). Пока Билайн его не возит, такой номер уходит
|
|||
|
|
* универсальным каналом — ровно так же, как сегодня уходят номера Билайна,
|
|||
|
|
* МегаФона и Теле2. Нет и универсала — честный отсев SKIP_NO_ROUTE.
|
|||
|
|
*
|
|||
|
|
* Помощники с префиксом motiv_* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
|||
|
|
* Номера только синтетические 7999… — реальные НИКОГДА.
|
|||
|
|
*/
|
|||
|
|
uses(RefreshDatabase::class);
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Канал Билайна, собранный как в реестре: кого возит и почём.
|
|||
|
|
*
|
|||
|
|
* @param array<int, string> $serves
|
|||
|
|
* @param array<string, int> $price
|
|||
|
|
*/
|
|||
|
|
function motiv_beeline(array $serves, array $price = ['*' => 586]): BeelineSmsProvider
|
|||
|
|
{
|
|||
|
|
return new BeelineSmsProvider(
|
|||
|
|
login: '1834321',
|
|||
|
|
password: 'secret',
|
|||
|
|
serves: $serves,
|
|||
|
|
priceKopecks: $price,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** @param array<int, SmsProvider> $providers */
|
|||
|
|
function motiv_selector(array $providers): ClientSmsRecipientSelector
|
|||
|
|
{
|
|||
|
|
return new ClientSmsRecipientSelector(
|
|||
|
|
new SmsRouter($providers),
|
|||
|
|
new OperatorNormalizer,
|
|||
|
|
new AllowedSmsOperators(new OperatorNormalizer),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Запись канала Билайна из ФАЙЛА настроек при заданном положении выключателя.
|
|||
|
|
* Читаем файл заново, а не config(): проверяем именно то, что соберётся из
|
|||
|
|
* окружения на бою, — иначе выключателя как такового тест не касается.
|
|||
|
|
*
|
|||
|
|
* @return array<string, mixed>
|
|||
|
|
*/
|
|||
|
|
function motiv_reestr(?string $flag): array
|
|||
|
|
{
|
|||
|
|
if ($flag === null) {
|
|||
|
|
unset($_SERVER['SMS_BEELINE_SERVES_MOTIV'], $_ENV['SMS_BEELINE_SERVES_MOTIV']);
|
|||
|
|
} else {
|
|||
|
|
$_SERVER['SMS_BEELINE_SERVES_MOTIV'] = $flag;
|
|||
|
|
$_ENV['SMS_BEELINE_SERVES_MOTIV'] = $flag;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** @var array<string, mixed> $config */
|
|||
|
|
$config = require base_path('config/services.php');
|
|||
|
|
|
|||
|
|
unset($_SERVER['SMS_BEELINE_SERVES_MOTIV'], $_ENV['SMS_BEELINE_SERVES_MOTIV']);
|
|||
|
|
|
|||
|
|
/** @var array<string, mixed> $beeline */
|
|||
|
|
$beeline = $config['sms']['providers']['beeline'];
|
|||
|
|
|
|||
|
|
return $beeline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
it('номер Мотива больше не отсеивается на первой заставе', function (string $raw) {
|
|||
|
|
// Первая застава — «кому мы вообще шлём» (В-149). До этой правки Мотива в
|
|||
|
|
// четвёрке владельца не было, и номер выбывал ЗДЕСЬ, не дойдя до каналов.
|
|||
|
|
expect((new AllowedSmsOperators(new OperatorNormalizer))->accepts($raw))->toBeTrue();
|
|||
|
|
})->with([
|
|||
|
|
['Мотив'],
|
|||
|
|
['ООО "ЕКАТЕРИНБУРГ-2000"'],
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
it('выключатель опущен — Билайн Мотива не возит', function () {
|
|||
|
|
expect(motiv_reestr(null)['serves'])->toBe(['beeline'])
|
|||
|
|
->and(motiv_reestr('false')['serves'])->toBe(['beeline']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('выключатель поднят — Билайн берёт Мотива вдобавок к своим', function () {
|
|||
|
|
expect(motiv_reestr('true')['serves'])->toBe(['beeline', 'motiv']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('у Мотива своя цена в реестре, а не общая цена канала', function () {
|
|||
|
|
// 🔴 6,24 ₽ без НДС по прайсу Билайна × 1,23464 (НДС 22% + надбавка 1,2%)
|
|||
|
|
// = 7,70 ₽. Считай мы Мотива по общей цене канала — занижали бы каждое
|
|||
|
|
// сообщение почти на два рубля, и сверка молчала бы.
|
|||
|
|
expect(motiv_reestr(null)['price_kopecks']['motiv'])->toBe(770);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('маршрутизатор при опущенном выключателе Мотиву канала не даёт', function () {
|
|||
|
|
expect((new SmsRouter([motiv_beeline(['beeline'])]))->pick('motiv'))->toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('маршрутизатор при поднятом выключателе ведёт Мотива в Билайна', function () {
|
|||
|
|
$router = new SmsRouter([motiv_beeline(['beeline', 'motiv'])]);
|
|||
|
|
|
|||
|
|
expect($router->pick('motiv'))->toBeInstanceOf(BeelineSmsProvider::class)
|
|||
|
|
->and($router->pick('beeline'))->toBeInstanceOf(BeelineSmsProvider::class);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('канал называет цену по сети, не сбивая цену соседа', function () {
|
|||
|
|
// Обе цены в ОДНОЙ проверке: иначе не докажешь, что, научив канал Мотиву,
|
|||
|
|
// не сбил цену самому Билайну.
|
|||
|
|
$provider = motiv_beeline(['beeline', 'motiv'], ['motiv' => 770, '*' => 586]);
|
|||
|
|
|
|||
|
|
expect($provider->priceKopecks('motiv'))->toBe(770)
|
|||
|
|
->and($provider->priceKopecks('beeline'))->toBe(586);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('выключатель опущен и универсала нет — отсев честный, «не из наших сетей»', function () {
|
|||
|
|
// 🪤 Причина именно SKIP_NO_ROUTE, а не SKIP_UNKNOWN_OPERATOR: оператор
|
|||
|
|
// НАЗВАН, просто везти его некому. Перепутай их — и в отчёте владельцу
|
|||
|
|
// наша выключенная сеть выглядела бы как «ДаДата не ответила» (В-68).
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
|
|||
|
|
$plan = motiv_selector([motiv_beeline(['beeline'])])->build([
|
|||
|
|
['phone' => '79990000101', 'operator' => 'Мотив'],
|
|||
|
|
], $tenant->id);
|
|||
|
|
|
|||
|
|
expect($plan->sendable)->toBeEmpty()
|
|||
|
|
->and($plan->skipped['79990000101'])->toBe(ClientSmsMessage::SKIP_NO_ROUTE);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('выключатель опущен, но универсал есть — номер Мотива уходит им, как Билайн и МегаФон сегодня', function () {
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
|
|||
|
|
$plan = motiv_selector([motiv_beeline(['beeline']), new FakeSmsProvider])->build([
|
|||
|
|
['phone' => '79990000102', 'operator' => 'ООО "ЕКАТЕРИНБУРГ-2000"'],
|
|||
|
|
], $tenant->id);
|
|||
|
|
|
|||
|
|
expect($plan->skipped)->toBe([])
|
|||
|
|
->and($plan->sendable)->toHaveCount(1)
|
|||
|
|
->and($plan->sendable[0]['operator'])->toBe('motiv');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('выключатель поднят — номер Мотива уходит именно Билайном', function () {
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
|
|||
|
|
$plan = motiv_selector([motiv_beeline(['beeline', 'motiv']), new FakeSmsProvider])->build([
|
|||
|
|
['phone' => '79990000103', 'operator' => 'Мотив'],
|
|||
|
|
], $tenant->id);
|
|||
|
|
|
|||
|
|
expect($plan->skipped)->toBe([])
|
|||
|
|
->and($plan->sendable[0]['provider'])->toBeInstanceOf(BeelineSmsProvider::class);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('своё имя клиента без Мотива в списке — номер не уходит вовсе', function () {
|
|||
|
|
// Поведение НЕ меняем, а закрепляем: у клиента со своим именем имя в сети
|
|||
|
|
// Мотива не согласовано, и подписать его рассылку нашим «Liderra.ru»
|
|||
|
|
// значило бы незаметно подменить бренд, за который он заплатил.
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
|
|||
|
|
ClientSmsSender::create([
|
|||
|
|
'tenant_id' => $tenant->id,
|
|||
|
|
'name' => 'mybrand.ru',
|
|||
|
|
'name_type' => ClientSmsSender::TYPE_WEBSITE,
|
|||
|
|
'status' => ClientSmsSender::STATUS_ACTIVE,
|
|||
|
|
'operators' => ['mts'],
|
|||
|
|
'monthly_fee_rub' => '0.00',
|
|||
|
|
'consent_at' => now(),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
$plan = motiv_selector([motiv_beeline(['beeline', 'motiv'])])->build([
|
|||
|
|
['phone' => '79990000104', 'operator' => 'Мотив'],
|
|||
|
|
], $tenant->id);
|
|||
|
|
|
|||
|
|
expect($plan->sendable)->toBeEmpty()
|
|||
|
|
->and($plan->skipped['79990000104'])->toBe(ClientSmsMessage::SKIP_NO_SENDER);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('сверка считает Мотива по его цене, а не по общей цене канала', function () {
|
|||
|
|
// 🔴 Единственный наш прибор «оператор считает так же, как мы». Читай он и
|
|||
|
|
// дальше только общую цену канала, рассылка из билайновских и мотивовских
|
|||
|
|
// номеров показывала бы заниженный расход — и расхождение со счётом
|
|||
|
|
// Билайна выглядело бы как ошибка ОПЕРАТОРА, а не наша.
|
|||
|
|
$tenant = Tenant::factory()->create();
|
|||
|
|
|
|||
|
|
config(['services.sms.providers.beeline.price_kopecks' => ['motiv' => 770, '*' => 586]]);
|
|||
|
|
|
|||
|
|
$campaign = ClientSmsCampaign::create([
|
|||
|
|
'tenant_id' => $tenant->id,
|
|||
|
|
'title' => 'Мотив и Билайн в одной рассылке',
|
|||
|
|
'body' => 'Текст рассылки',
|
|||
|
|
'sender_name' => 'LIDERRA',
|
|||
|
|
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
|||
|
|
'status' => ClientSmsCampaign::STATUS_DONE,
|
|||
|
|
'idempotency_key' => 'motiv-'.uniqid(),
|
|||
|
|
'segments' => 1,
|
|||
|
|
'planned_count' => 2,
|
|||
|
|
'sent_count' => 2,
|
|||
|
|
'total_sms' => 2,
|
|||
|
|
'price_rub_per_sms' => '8.50',
|
|||
|
|
'estimated_cost_rub' => '17.00',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
foreach ([['79990000105', 'beeline'], ['79990000106', 'motiv']] as [$phone, $operator]) {
|
|||
|
|
ClientSmsMessage::create([
|
|||
|
|
'tenant_id' => $tenant->id,
|
|||
|
|
'campaign_id' => $campaign->id,
|
|||
|
|
'phone' => $phone,
|
|||
|
|
'operator' => $operator,
|
|||
|
|
'provider_key' => 'beeline',
|
|||
|
|
'status' => ClientSmsMessage::STATUS_SENT,
|
|||
|
|
'segments' => 1,
|
|||
|
|
'cost_rub' => '8.50',
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows'))
|
|||
|
|
->firstWhere('campaign_id', $campaign->id);
|
|||
|
|
|
|||
|
|
// 5,86 + 7,70 = 13,56 ₽. По общей цене канала вышло бы 11,72 — на 1,84 меньше.
|
|||
|
|
expect($row)->not->toBeNull()
|
|||
|
|
->and($row['our_cost'])->toBe('13.5600');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('владелец может отметить Мотива в настройках', function () {
|
|||
|
|
// 🔴 Без этого правка кода не даёт НИЧЕГО на бою: если в настройках список
|
|||
|
|
// уже заполнен, значение из кода не читается совсем (AllowedSmsOperators),
|
|||
|
|
// а проверка ввода отвергала бы Мотива с ошибкой — владелец не смог бы его
|
|||
|
|
// добавить ни через экран, ни запросом.
|
|||
|
|
$this->putJson('/api/admin/sms/settings', [
|
|||
|
|
// Два поля обязательны на этом адресе всегда — без них до проверки
|
|||
|
|
// операторов дело не дойдёт вовсе.
|
|||
|
|
'name_fee_rub_per_operator' => '2500.00',
|
|||
|
|
'name_debt_grace_days' => 7,
|
|||
|
|
'allowed_operators' => ['mts', 'beeline', 'megafon', 'tele2', 'motiv'],
|
|||
|
|
])->assertOk();
|
|||
|
|
|
|||
|
|
expect(ClientSmsSettings::first()->allowed_operators)->toContain('motiv');
|
|||
|
|
});
|