6e771636ef
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст, видит цену ДО отправки и журнал после. Отправки СМС в проекте не было вообще — ни у СМС-центра (только баланс и 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>
128 lines
5.2 KiB
PHP
128 lines
5.2 KiB
PHP
<?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', []);
|
||
});
|