Files
portal/app/tests/Feature/ClientSms/VolumeCounterTest.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

123 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsVolumeCounter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
/**
* Строки листа 3.9 и 3.10: сколько СМС клиент отправил в этом месяце.
*
* Считаем в СМС, а НЕ в сообщениях (решение В-108): длинное письмо — это два СМС,
* и клиент платит за два. Считая сообщения, мы держали бы его на дорогой ступени
* дольше, чем обещано, а цифра на экране разошлась бы со списанными деньгами.
*
* Счётчик ОДИН на обе дороги — и рассылки, и авто-СМС (строка 3.9). Песочные
* отправки не идут в счёт вовсе (строка 3.10): за них не платят.
*
* 🪤 Граница месяца — по МОСКВЕ (В-110), а не по Гринвичу. Не путать с окном 10–20:
* там время местное у ПОЛУЧАТЕЛЯ, здесь — московское у КЛИЕНТА.
*
* Помощники с префиксом volume* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
});
/** Строка журнала: сколько СМС, какой статус, когда (UTC). */
function volumeMessage(int $tenantId, string $status, ?int $segments, string $atUtc, array $extra = []): ClientSmsMessage
{
$at = CarbonImmutable::parse($atUtc, 'UTC');
$message = ClientSmsMessage::create($extra + [
'tenant_id' => $tenantId,
'phone' => '79990000001',
'status' => $status,
'segments' => $segments,
'cost_rub' => '0.00',
]);
// created_at ставим отдельно: модель проставляет своё время при создании.
ClientSmsMessage::where('id', $message->id)->update(['created_at' => $at]);
return $message->refresh();
}
it('3.9 — считает СМС, а не сообщения: длинное письмо это два', function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-10 09:00:00', 'UTC'));
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-08-02 09:00:00');
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 2, '2026-08-03 09:00:00');
expect(app(ClientSmsVolumeCounter::class)->sentThisMonth((int) $this->tenant->id))->toBe(3);
Carbon::setTestNow();
});
it('3.9 — счётчик один на обе дороги: рассылка и авто-СМС складываются', function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-10 09:00:00', 'UTC'));
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-08-02 09:00:00');
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-08-03 09:00:00', ['deal_id' => null]);
expect(app(ClientSmsVolumeCounter::class)->sentThisMonth((int) $this->tenant->id))->toBe(2);
Carbon::setTestNow();
});
it('3.10 — песочные отправки в счёт не идут', function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-10 09:00:00', 'UTC'));
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-08-02 09:00:00');
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_FAKE_SENT, 5, '2026-08-03 09:00:00');
// Не ушедшее — тем более не в счёт: за него не платили.
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_FAILED, 3, '2026-08-04 09:00:00');
volumeMessage($this->tenant->id, ClientSmsMessage::SKIP_OPTOUT, 3, '2026-08-05 09:00:00');
expect(app(ClientSmsVolumeCounter::class)->sentThisMonth((int) $this->tenant->id))->toBe(1);
Carbon::setTestNow();
});
it('3.9 — граница месяца по Москве: полночь 1-го числа уже новый месяц', function () {
// 31 июля 21:30 UTC — это 1 августа 00:30 в Москве, то есть УЖЕ август.
// 31 июля 20:30 UTC — это 31 июля 23:30 в Москве, то есть ещё июль.
Carbon::setTestNow(CarbonImmutable::parse('2026-08-01 06:00:00', 'UTC'));
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-07-31 20:30:00'); // июль
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-07-31 21:30:00'); // август
expect(app(ClientSmsVolumeCounter::class)->sentThisMonth((int) $this->tenant->id))->toBe(1);
Carbon::setTestNow();
});
it('3.9 — старая строка без числа СМС считается за одну (В-109)', function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-10 09:00:00', 'UTC'));
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, null, '2026-08-02 09:00:00');
expect(app(ClientSmsVolumeCounter::class)->sentThisMonth((int) $this->tenant->id))->toBe(1);
Carbon::setTestNow();
});
it('3.9 — чужие отправки в свой счётчик не попадают', function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-10 09:00:00', 'UTC'));
$other = Tenant::factory()->create();
volumeMessage($this->tenant->id, ClientSmsMessage::STATUS_SENT, 1, '2026-08-02 09:00:00');
volumeMessage($other->id, ClientSmsMessage::STATUS_SENT, 7, '2026-08-02 09:00:00');
expect(app(ClientSmsVolumeCounter::class)->sentThisMonth((int) $this->tenant->id))->toBe(1);
Carbon::setTestNow();
});