Files
portal/app/tests/Feature/Sales/SalesSmsScreenTest.php
T
Дмитрий 6e771636ef feat(смс): модуль «Прогрев СМС» с заделом под мультиклиентность
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл).

Что сделано:
- разъём провайдера SmsProvider: новый оператор подключается одним файлом
- заглушка FakeSmsProvider — модуль работает и проверяется ДО согласования
  имени отправителя у операторов (это недели), иначе разработку не закончить
- маршрутизация по оператору: билайновский номер уходит через Билайн за
  4,75 ₽, прочие через МТС — без ручного выбора канала
- стоп-лист: кто отписался, тому не шлём никогда, проверка перед списанием
- отбор получателей с шестью причинами пропуска, все ДО траты денег
- списание скопировано с AutopodborChargeService; пока клиента нет
  (tenant_id пуст) с баланса не берём — платим оператору напрямую
- оператор номера доезжает из «Поиска клиентов» в прогрев (был известен
  и оплачен ДаДате, но терялся при передаче)

Мультиклиентность в костях: колонка tenant_id во всех четырёх таблицах
СМС с первого дня, NULL = «Лидерра сама». Клиент добавляется строкой,
а не переделкой модуля.

Найдено и закрыто при исполнении:
- замок от двойного списания стоял не на том соединении: кампания на
  pgsql_supplier, деньги на pgsql, lockForUpdate по кампании отпускался
  сразу. На бою два запуска списали бы дважды, обрыв — оставил бы пометку
  «оплачено» при неушедших деньгах. Источник правды перенесён в
  balance_transactions под замок по тенанту. Доказано тестом: старый код
  списывал 700 вместо 850
- приём в портал требовал phones строкой по regex — словарь с оператором
  получал 422, в базу не доезжало ничего. Тесты были зелёные, потому что
  звали сервис МИМО контроллера. Проверка теперь принимает оба формата,
  тест идёт через HTTP
- телефоны директоров в contacts остаются строками (договор
  SalesProspectController), словари — только в верхнем phones

Заодно вылечена мигающая поломка 48 тестов доставки лидов: помощник
createRoutingSnapshotFromProject клал снимок на сегодня, а LeadRouter
после 21:00 МСК ищет завтрашний (вечерний переворот заливки) — вечерние
прогоны падали, дневные проходили. Помощник теперь зеркалит активную дату
роутера в любой час. Регрессия SnapshotHelperTimeOfDayTest замораживает
22:00 МСК и пинит инвариант. Боевой LeadRouter не тронут.

Тесты: 84 бэкенд + фронт по экрану + 397 поисковика, весь набор 3226
зелёный, статанализ чист. Все защиты проверены вырезанием.

План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md
Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 06:35:43 +03:00

128 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Раздел «Прогрев СМС» в кабинете начальника (Task 9, бэк).
*
* GET /api/sales/sms/campaigns — список рассылок + песочница + отправители
* POST /api/sales/sms/campaigns — запуск рассылки
* GET /api/sales/sms/campaigns/{id} — карточка с журналом
* POST /api/sales/sms/preview — цена и причины пропуска ДО отправки
*
* Все методы — ТОЛЬКО начальник (role=head), как у SalesAdAudienceController.
* Менеджер → 403. Аутентификация: $this->actingAs($user, 'sales').
*
* SharesSupplierPdo обязателен: модели sales_sms_* прибиты к pgsql_supplier
* (отдельный PDO), без шаринга DatabaseTransactions откатывал бы только
* соединение по умолчанию — записи текли бы из теста в тест (правило 10 плана).
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ── helpers (уникальный префикс sms_, чтобы не столкнуться с ads_* соседнего файла) ──
//
// SalesUser::factory() в проекте НЕТ (правило 11 плана) — заводим через create()
// с uniqid(), как в AdAudienceScreenTest.php.
function sms_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'smshead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function sms_manager(): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'smsmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
]);
}
it('менеджеру раздел закрыт', function () {
$manager = sms_manager();
$this->actingAs($manager, 'sales')
->getJson('/api/sales/sms/campaigns')
->assertStatus(403);
});
it('начальник видит список рассылок', function () {
$head = sms_head();
$this->actingAs($head, 'sales')
->getJson('/api/sales/sms/campaigns')
->assertOk()
->assertJsonStructure(['campaigns', 'sandbox']);
});
it('предпросмотр показывает цену и причины пропуска до отправки', function () {
$head = sms_head();
$this->actingAs($head, 'sales')
->postJson('/api/sales/sms/preview', [
'body' => str_repeat('а', 80),
'phones' => ['79990000001'],
])
->assertOk()
->assertJsonStructure(['segments', 'sendable_count', 'skipped', 'estimated_cost_kopecks'])
->assertJsonPath('segments', 2);
});
it('не создаёт рассылку с пустым текстом', function () {
$head = sms_head();
$this->actingAs($head, 'sales')
->postJson('/api/sales/sms/campaigns', ['title' => 'Пустая', 'body' => '', 'phones' => ['79990000001']])
->assertStatus(422);
});
// ── Task 10.5: номера отмеченных фирм ─────────────────────────────────────────
//
// Начальник отмечает фирмы галочками, а не переписывает 177 номеров руками.
// POST /api/sales/sms/recipients — по списку id фирм отдаёт их живые номера.
it('отдаёт номера отмеченных фирм', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'ООО Ромашка', 'firm_inn' => '2452108518']);
SalesAdAudiencePhone::create([
'phone' => '79990000010', 'firm_id' => $firm->id, 'firm_name' => 'ООО Ромашка',
'expires_at' => now()->addDays(30), 'state' => 'active',
'operator' => 'МТС', 'phone_type' => 'mobile',
]);
$this->actingAs(sms_head(), 'sales')
->postJson('/api/sales/sms/recipients', ['firm_ids' => [$firm->id]])
->assertOk()
->assertJsonPath('phones.0.phone', '79990000010')
->assertJsonPath('phones.0.firm_name', 'ООО Ромашка');
});
it('не отдаёт погашенные номера', function () {
$firm = SalesAdAudienceFirm::create(['firm_name' => 'ООО Ушедшая', 'firm_inn' => '2452108519']);
SalesAdAudiencePhone::create([
'phone' => '79990000011', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
'removed_at' => now(), 'operator' => 'МТС', 'phone_type' => 'mobile',
]);
$this->actingAs(sms_head(), 'sales')
->postJson('/api/sales/sms/recipients', ['firm_ids' => [$firm->id]])
->assertOk()
->assertJsonPath('phones', []);
});