Files
portal/app/tests/Feature/ClientSms/WindowCampaignTest.php
T
Дмитрий 026e71a283 feat(смс-клиент): цена по накоплению за месяц, а не по объёму одного заказа
Этап 3 «Время и цена», Task 6. Строки листа 3.8, 3.9, 3.10, 3.14.

Клиент, разбивший месяц на десять рассылок по сто номеров, платил по самой дорогой
ступени, хотя отправил тысячу. Авто-СМС и вовсе всегда считалась по самой дорогой:
в ступень уезжало число сегментов одного сообщения. Теперь ступень берётся для
«сколько отправлено в этом месяце плюс объём самого заказа», и спрашивают об этом
все три места сразу — предпросмотр, создание рассылки и авто-СМС на новый лид.

Счётчик живёт в одном месте (ClientSmsVolumeCounter): его зовёт цена, а в Task 7 позовёт
экран. Отдельного накопительного счётчика в базе не завожу намеренно — счётчик,
разъехавшийся с журналом, опаснее лишнего запроса; журнал правдив, потому что деньги
списываются той же записью.

Считаем в СМС, а не в сообщениях (В-108). Длинное письмо — это два СМС, и платит клиент
за два; считая строки журнала, мы держали бы его на дорогой ступени дольше обещанного, а
цифра на экране «в этом месяце отправлено N СМС» разошлась бы со списанными деньгами.
Ради этого в журнале появилась колонка segments (схема v9.14) и частичный индекс под
единственный запрос счётчика. Пусто у старых записей = одно СМС (В-109).

Граница месяца — по Москве, а не по Гринвичу (В-110): 31 июля 21:30 UTC это уже 1 августа
в Москве. Не путать с окном 10–20 — там время местное у получателя, здесь московское у
клиента.

Цена по-прежнему фиксируется в момент создания и джобом не пересчитывается (3.14).

🔴 Мина, найденная самопроверкой (В-114): авто-СМС считала бы объём месяца ВНЕ изоляции по
клиенту. В запросе экрана контекст ставит middleware, а в очереди — никто, и на бою
счётчик вернул бы честный ноль: клиента молча посчитали бы по самой дорогой ступени, без
единой ошибки в журнале. Тестами не ловится — на стенде изоляция не применяется. Счёт
переехал внутрь tenant-транзакции, как и деньги в том же джобе.

🧹 Убран прежний estimateRub (В-113): он считал смету по ступени для объёма одного заказа,
без накопленного, и больше не звался. Оставленный «на всякий случай» второй расчёт цены —
это место, которое однажды позовут, и цифры разъедутся.

Прогоны: СМС 229/229 (пачками по 3–4 файла — целиком локальная база уже не тянет, В-112),
приём лидов 17/17, фронт 1663 зелёных, phpstan 0 своих, pint чисто.

Вырезанием проверено четырежды: вернул старый расчёт в контроллер — покраснел тест через
настоящий запрос экрана; убрал запись числа СМС в журнал — покраснел тест отправки;
засчитал песочные — счётчик дал 12 вместо 1; перенёс границу месяца на Гринвич — покраснел
тест границы.

Живьём на локальной базе (20:17 МСК, песочница, ДаДата заглушена): предпросмотр до
накопления 9.00 ₽, после 5 000 отправленных — 8.00 ₽; песочная рассылка ушла, в журнале
«СМС=1», счётчик месяца остался нулём; у прежней рассылки цена так и осталась 9.00 ₽, а
новый заказ уже шёл бы по 8.00 ₽. Стенд возвращён как был. Реальное списание по
накопленной ступени доказано тестом, а не живьём: в песочнице деньги не двигаются (В-81).

🪤 Урок В-111: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в
тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку.
2026-07-28 20:28:00 +03:00

353 lines
18 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\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsMessage;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsSnapshotReader;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строки листа 3.1–3.3: сообщение отдаётся оператору только в окно 10:00–20:00
* по МЕСТНОМУ времени получателя, и в одной рассылке Калининград с Камчаткой
* уходят каждый в своё утро.
*
* Почему тесты парные (урок В-54): прогон, доказывающий «не ушло», сам по себе
* ничего не доказывает — ноль мог получиться от поломки. Рядом обязан стоять
* прогон «а вот теперь ушло», где всё то же самое, кроме времени суток.
*
* Помощники с префиксом window* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Синтетические номера 7999… — реальные НИКОГДА.
*/
// 🪤 SharesSupplierPdo — команда добора перечисляет рассылки служебным соединением
// (BYPASSRLS): без общего PDO оно не видит незакоммиченные данные теста и находит
// НОЛЬ. Прогон при этом зелёный там, где ждёшь ноль, — то есть врёт в обе стороны.
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Момент UTC по человеческой записи. */
function windowUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
/** Сделка с регионом. 🪤 Регион живёт в subject_code (1..89), НЕ в region_code (В-82). */
function windowDeal(int $tenantId, string $phone, int $subjectCode): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => 'МТС',
'subject_code' => $subjectCode,
'received_at' => now()->subDay(),
]));
}
/** Заказ рассылки по сделкам. */
function windowPayload(array $extra = []): array
{
return $extra + [
'title' => 'Окно',
'source' => 'deals',
'body' => 'Здравствуйте!',
'audience_days' => 30,
];
}
/**
* Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги.
* 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при
* создании рассылки и читателем снимка при отправке (В-46).
*/
function windowRealRoute(): void
{
$router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
app()->instance(SmsRouter::class, $router);
app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
$router,
new OperatorNormalizer,
));
}
function windowRunJob(int $campaignId, int $tenantId): void
{
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
);
}
it('в московский полдень камчатский номер ждёт своего утра', function () {
$this->travelTo(windowUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке
windowDeal($this->tenant->id, '79995550001', 82); // Москва, UTC+3
windowDeal($this->tenant->id, '79995550002', 27); // Камчатский край, UTC+12
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
expect($rows['79995550001']->tz_offset_minutes)->toBe(180)
->and($rows['79995550001']->send_after)->toBeNull() // Москве — прямо сейчас
->and($rows['79995550002']->tz_offset_minutes)->toBe(720)
->and($rows['79995550002']->send_after)->not->toBeNull(); // Камчатке — её утром
// Ушёл ровно один номер, и рассылка честно висит «ждёт утра», а не «готово».
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
});
it('контрольный: камчатским утром уходит камчатский, а Москва ждёт', function () {
// Тот же расклад, что выше, но время другое: 22:00 UTC = 10:00 утра на Камчатке
// и полночь в Москве. Без этого прогона «ушёл один» ничего не доказывает (В-54).
$this->travelTo(windowUtc('2026-08-02 22:00'));
windowDeal($this->tenant->id, '79995550001', 82); // Москва
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550002')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
});
it('днём в рабочее время уходят оба — окно никого не задерживает зря', function () {
// 07:00 UTC = 10:00 в Москве и… 19:00 на Камчатке: окно открыто у обоих.
$this->travelTo(windowUtc('2026-08-03 07:00'));
windowDeal($this->tenant->id, '79995550001', 82);
windowDeal($this->tenant->id, '79995550002', 27);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('номер без региона не уходит вовсе и ждёт уточнения — решение владельца В-85', function () {
$this->travelTo(windowUtc('2026-08-03 09:00')); // рабочий день в Москве
// Сделка, заведённая в кабинете руками: региона у неё нет вообще.
windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]);
windowDeal($this->tenant->id, '79995550001', 82);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
// Он не пропущен (причины нет) и не отправлен — он ждёт, когда узнаем регион.
expect($rows['79995550003']->tz_offset_minutes)->toBeNull()
->and($rows['79995550003']->skip_reason)->toBeNull();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001');
// И в журнале рассылки про него НЕ написано «нет маршрута»: мы его даже не пробовали.
expect(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550003')->exists())
->toBeFalse();
});
it('ждущий утра номер не съедает заморозку и не считается отправленным', function () {
$this->travelTo(windowUtc('2026-08-03 09:00'));
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт
Queue::fake(); // отправку запускаем сами, чтобы посмотреть снимок до неё
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$campaign = ClientSmsCampaign::find($id);
expect($campaign->planned_count)->toBe(2); // смета считалась на ОБОИХ
windowRunJob($id, $this->tenant->id);
$campaign->refresh();
expect($campaign->sent_count)->toBe(1)
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW)
->and($campaign->stop_reason)->toBeNull();
});
/*
|--------------------------------------------------------------------------
| Строки листа 3.4–3.5: созревшие дошлются САМИ, без человека.
|--------------------------------------------------------------------------
| Раз в четверть часа команда `client-sms:resume-waiting` обходит рассылки,
| висящие «ждёт утра», и заново кладёт в очередь те, у которых что-то созрело.
| Повторный запуск безопасен: джоб пропускает номера, по которым уже есть запись
| в журнале, а списание идёт по ключу на номер.
*/
it('созревший номер дошлётся сам — команда добора доводит рассылку до конца', function () {
$this->travelTo(windowUtc('2026-08-03 09:00')); // полдень в Москве, 21:00 на Камчатке
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт до своего утра
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// Наступило камчатское утро. Человек ничего не нажимает — работает расписание.
$this->travelTo(windowUtc('2026-08-03 22:00')); // 10:00 на Камчатке
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$campaign = ClientSmsCampaign::find($id);
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
->and(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550002')->exists())->toBeTrue()
->and($campaign->sent_count)->toBe(2)
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('контрольный: пока утро не наступило, команда добора рассылку не трогает', function () {
// Без этого прогона предыдущий ничего не доказывает: «дошлось» могло бы означать
// «команда шлёт всем подряд, не глядя на часы» (урок В-54).
$this->travelTo(windowUtc('2026-08-03 09:00'));
windowDeal($this->tenant->id, '79995550001', 82);
windowDeal($this->tenant->id, '79995550002', 27);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
// Очередь глушим ПОСЛЕ создания: сама рассылка уже отработала и висит «ждёт утра»,
// а вот тронет ли её команда — как раз то, что проверяем.
Queue::fake();
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
Queue::assertNothingPushed();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
});
it('список рассылок различает «ждут утра» и «ждут уточнения региона»', function () {
// Строка листа 3.4. Свалить два ожидания в одно число нельзя: первое пройдёт само
// к утру, второе может кончиться тем, что номер не уйдёт вовсе (следствие В-85).
$this->travelTo(windowUtc('2026-08-03 09:00'));
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — ждёт утра
windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]); // ждёт региона
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$row = collect($this->getJson('/api/sms/campaigns')->assertOk()->json('campaigns'))
->firstWhere('id', $id);
expect($row['status'])->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW)
->and($row['sent_count'])->toBe(1)
->and($row['planned_count'])->toBe(3)
->and($row['waiting_window_count'])->toBe(1)
->and($row['waiting_region_count'])->toBe(1);
});
it('деньги: заморозка держится всё ожидание и снимается, когда дошёл последний номер', function () {
config(['services.sms.sandbox' => false]);
windowRealRoute();
Http::fake(['*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200)]);
$this->travelTo(windowUtc('2026-08-03 09:00'));
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
windowDeal($this->tenant->id, '79995550001', 82);
windowDeal($this->tenant->id, '79995550002', 27);
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
// Пока ждём камчатское утро, смета остаётся замороженной целиком: деньги на
// оставшийся номер ещё понадобятся, и отдавать их клиенту обратно нельзя.
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->not->toBe('0.00');
$this->travelTo(windowUtc('2026-08-03 22:00'));
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$campaign = ClientSmsCampaign::find($id);
$wallet->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and((string) $wallet->frozen_rub)->toBe('0.00')
// Факт — сумма по журналу, а не смета: «записано» ⟺ «оплачено».
->and((string) $campaign->actual_cost_rub)->toBe(
bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2)
)
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00'); // иначе тест пустой
});
it('3.14 — цена зафиксирована при создании: объём вырос, а списано по старой цене', function () {
// Рассылка растянулась на сутки, за это время клиент отправил ещё 5 000 СМС и
// перешёл на дешёвую ступень. Досланному номеру списываем ТУ ЖЕ цену, которую
// клиент видел при заказе, — пересчёт задним числом был бы обманом в обе стороны.
config(['services.sms.sandbox' => false]);
windowRealRoute();
Http::fake(['*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200)]);
$this->travelTo(windowUtc('2026-08-03 09:00'));
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test');
windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу
windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — дождётся утра
$id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id');
$campaign = ClientSmsCampaign::find($id);
$fixedPrice = (string) $campaign->price_rub_per_sms;
expect($fixedPrice)->toBe('9.00'); // объём заказа 2 СМС — самая дорогая ступень
// Пока камчатский номер ждёт утра, клиент отправил ещё 5 000 СМС другой рассылкой.
ClientSmsMessage::create([
'tenant_id' => $this->tenant->id,
'phone' => '79995559999',
'status' => ClientSmsMessage::STATUS_SENT,
'segments' => 5000,
'cost_rub' => '0.00',
]);
// Новый заказ уже пошёл бы по дешёвой ступени — иначе тест ничего не доказывает.
expect(app(ClientSmsPricing::class)->pricePerSmsRubFor((int) $this->tenant->id, 1))->toBe('8.00');
$this->travelTo(windowUtc('2026-08-03 22:00'));
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$kamchatka = ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550002')->firstOrFail();
expect($kamchatka->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and((string) $kamchatka->cost_rub)->toBe($fixedPrice);
});