026e71a283
Этап 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: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку.
123 lines
5.8 KiB
PHP
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();
|
|
});
|