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: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку.
122 lines
5.4 KiB
PHP
122 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ClientSmsMessage;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\ClientSms\ClientSmsPricing;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('ступень выбирается по объёму (границы)', function () {
|
|
$p = app(ClientSmsPricing::class);
|
|
expect($p->pricePerSmsRub(99))->toBe('9.00')
|
|
->and($p->pricePerSmsRub(100))->toBe('8.50')
|
|
->and($p->pricePerSmsRub(249))->toBe('8.50')
|
|
->and($p->pricePerSmsRub(250))->toBe('8.30')
|
|
->and($p->pricePerSmsRub(500))->toBe('8.10')
|
|
->and($p->pricePerSmsRub(999))->toBe('8.10')
|
|
->and($p->pricePerSmsRub(1000))->toBe('8.00')
|
|
->and($p->pricePerSmsRub(50000))->toBe('8.00')
|
|
->and($p->pricePerSmsRub(0))->toBe('9.00'); // volume floored to >=1
|
|
});
|
|
|
|
it('segments() делегирует калькулятору', function () {
|
|
$p = app(ClientSmsPricing::class);
|
|
expect($p->segments('короткий'))->toBe(1);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Строка листа 3.8: ступень берётся с учётом УЖЕ отправленного за месяц.
|
|
|--------------------------------------------------------------------------
|
|
| Прежде цена считалась по объёму одного заказа: клиент, разбивший месяц на
|
|
| десять рассылок по сто номеров, платил по самой дорогой ступени, хотя отправил
|
|
| тысячу. Теперь ступень берётся для «накоплено за месяц + объём этого заказа».
|
|
|
|
|
| Помощники с префиксом price* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
|
| Синтетические номера 7999… — реальные НИКОГДА.
|
|
*/
|
|
|
|
/** Уже отправленные в этом месяце СМС (одна строка журнала на $segments штук). */
|
|
function priceSentThisMonth(int $tenantId, int $segments): void
|
|
{
|
|
ClientSmsMessage::create([
|
|
'tenant_id' => $tenantId,
|
|
'phone' => '79990000001',
|
|
'status' => ClientSmsMessage::STATUS_SENT,
|
|
'segments' => $segments,
|
|
'cost_rub' => '0.00',
|
|
]);
|
|
}
|
|
|
|
it('3.8 — ступень берётся для «накоплено за месяц + объём заказа»', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$p = app(ClientSmsPricing::class);
|
|
|
|
// Заказ сам по себе маленький — 200 СМС, это ступень 8.50.
|
|
expect($p->pricePerSmsRub(200))->toBe('8.50');
|
|
|
|
// Но в этом месяце клиент уже отправил 900 — вместе выходит 1 100, ступень 8.00.
|
|
priceSentThisMonth((int) $tenant->id, 900);
|
|
|
|
expect($p->pricePerSmsRubFor((int) $tenant->id, 200))->toBe('8.00');
|
|
});
|
|
|
|
it('3.8 — контрольный: без накопленного цена та же, что была', function () {
|
|
// Без этого прогона предыдущий ничего не доказывает: «8.00» могло бы означать
|
|
// «теперь всегда самая дешёвая ступень» (урок В-54).
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
expect(app(ClientSmsPricing::class)->pricePerSmsRubFor((int) $tenant->id, 200))->toBe('8.50');
|
|
});
|
|
|
|
it('3.8 — чужие отправки на мою цену не влияют', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$other = Tenant::factory()->create();
|
|
|
|
priceSentThisMonth((int) $other->id, 5000);
|
|
|
|
expect(app(ClientSmsPricing::class)->pricePerSmsRubFor((int) $tenant->id, 200))->toBe('8.50');
|
|
});
|
|
|
|
it('3.8 — предпросмотр отдаёт цену с учётом отправленного за месяц', function () {
|
|
// Проверка НЕ на сервисе, а через настоящий запрос экрана: иначе вырез «вернуть
|
|
// старый расчёт в контроллере» прошёл бы незамеченным — сервис-то остался умным.
|
|
config(['services.sms.sandbox' => true]);
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
$this->actingAs($user);
|
|
|
|
priceSentThisMonth((int) $tenant->id, 5000);
|
|
|
|
$response = $this->postJson('/api/sms/preview', [
|
|
'body' => 'Здравствуйте!',
|
|
'source' => 'manual',
|
|
'phones' => ['79995550001'],
|
|
])->assertOk();
|
|
|
|
expect($response->json('price_rub_per_sms'))->toBe('8.00')
|
|
->and($response->json('estimated_cost_rub'))->toBe('8.00');
|
|
});
|
|
|
|
it('3.8 — контрольный: без отправленного предпросмотр даёт прежнюю цену', function () {
|
|
config(['services.sms.sandbox' => true]);
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
$this->actingAs($user);
|
|
|
|
$response = $this->postJson('/api/sms/preview', [
|
|
'body' => 'Здравствуйте!',
|
|
'source' => 'manual',
|
|
'phones' => ['79995550001'],
|
|
])->assertOk();
|
|
|
|
expect($response->json('price_rub_per_sms'))->toBe('9.00')
|
|
->and($response->json('estimated_cost_rub'))->toBe('9.00');
|
|
});
|