feat(смс-клиент): итог рассылки — доставлено, не доставлено, ещё в пути
Строка листа 5.3. До этого экран знал только «отправлено 900» — то есть сколько сообщений мы отдали оператору. Дошли ли они до людей, портал не знал вовсе, а платит клиент именно за то, чтобы дошли. Новый счётчик ClientSmsDeliveryCounter — единственный дом этого счёта, как SmsQuietHours для окна 10–20 и ClientSmsVolumeCounter для месячного объёма. Итог отдаётся и в карточке рассылки, и в списке рассылок (одним запросом на все пятьдесят, а не по одному). «Ещё в пути» — это и явное «везу» от оператора, и те, кого мы ещё не спрашивали: для человека это одно состояние «пока не знаем» (В-214). Пробные сообщения песочницы в счёт не идут — за них не платят. Счётчик ставит пометку клиента САМ (В-221): у журнала сообщений изоляция принудительная, и запрос без пометки под рабочей ролью возвращает ноль строк молча — экран написал бы «доставлено 0». На стенде эта дыра невидима в принципе (тесты идут под postgres), поэтому проверена живым прогоном под ролью crm_app_user: со счётчиком 3/1/1/2, тот же запрос без пометки — ноль. Тесты: 5 новых, каждый проверен вырезом. Тест списка рассылок пришлось починить (В-223): со списком из ОДНОЙ рассылки он не отличал «итог своей» от «итог первой попавшейся» и не краснел на такой подмене — теперь в нём две рассылки с разными числами. Отдельная запись про приборы (В-222): мой собственный скрипт вырезов соврал — падение стенда он засчитывал как зелёный тест, а один вырез я забыл вернуть. Обе поломки чинены в скрипте, а не обойдены.
This commit is contained in:
@@ -15,6 +15,7 @@ use App\Models\ClientSmsCampaignPhone;
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientSms\ClientSmsAudienceBuilder;
|
||||
use App\Services\ClientSms\ClientSmsDeliveryCounter;
|
||||
use App\Services\ClientSms\ClientSmsPlan;
|
||||
use App\Services\ClientSms\ClientSmsPricing;
|
||||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||||
@@ -55,6 +56,7 @@ class ClientSmsController extends Controller
|
||||
private readonly ClientSmsSnapshotWriter $snapshot,
|
||||
private readonly ClientSmsSnapshotReader $reader,
|
||||
private readonly SmsQuietHours $quietHours,
|
||||
private readonly ClientSmsDeliveryCounter $delivery,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
@@ -95,12 +97,21 @@ class ClientSmsController extends Controller
|
||||
$resumable->pluck('id')->map(fn ($id) => (int) $id)->values()->all()
|
||||
);
|
||||
|
||||
// Итог по судьбам (строка листа 5.3) сразу на все рассылки списка: по одной было бы
|
||||
// 50 запросов на открытие страницы — тот же довод, что у ждущих и у остатка продолжения.
|
||||
// В списке он нужен затем, чтобы «дошло или нет» человек видел, не открывая карточку.
|
||||
$totals = $this->delivery->forCampaigns(
|
||||
$tenantId,
|
||||
$campaigns->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'campaigns' => $campaigns->map(fn (ClientSmsCampaign $c) => $c->toArray() + [
|
||||
'waiting_window_count' => $waiting[(int) $c->id]['window'] ?? 0,
|
||||
'waiting_region_count' => $waiting[(int) $c->id]['region'] ?? 0,
|
||||
'can_resume' => $resumable->contains(fn (ClientSmsCampaign $r) => (int) $r->id === (int) $c->id),
|
||||
'resume_pending_count' => $pending[(int) $c->id] ?? 0,
|
||||
'delivery' => $totals[(int) $c->id] ?? ['delivered' => 0, 'not_delivered' => 0, 'not_sent' => 0, 'on_the_way' => 0],
|
||||
]),
|
||||
'sandbox' => (bool) config('services.sms.sandbox'),
|
||||
'sender_name' => $this->effectiveSenderName($tenantId),
|
||||
@@ -485,6 +496,9 @@ class ClientSmsController extends Controller
|
||||
return response()->json([
|
||||
'campaign' => $campaign,
|
||||
'messages' => $messages,
|
||||
// Итог по судьбам (строка листа 5.3): человеку нужно не «отправлено 900»,
|
||||
// а «доставлено 812, не доставлено 74, ещё в пути 14».
|
||||
'delivery' => $this->delivery->forCampaign($tenantId, (int) $campaign->id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientSms;
|
||||
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Services\Sms\SmsDeliveryState;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Итог рассылки по судьбам (строка листа 5.3).
|
||||
*
|
||||
* Человеку нужно не «отправлено 900», а «доставлено 812, не доставлено 74, ещё в пути 14»:
|
||||
* первое число говорит лишь о том, что мы отдали сообщения оператору, а платит клиент за то,
|
||||
* чтобы они дошли.
|
||||
*
|
||||
* Единственный дом этого счёта — как `SmsQuietHours` для окна 10–20 и `ClientSmsVolumeCounter`
|
||||
* для месячного объёма. Второе такое же место = карточка рассылки и список рассылок разъедутся,
|
||||
* и человек увидит про одну рассылку разные числа в двух местах.
|
||||
*
|
||||
* 🪤 «Ещё в пути» — это И явное `sending`, И те, кого мы просто ещё не спрашивали (графа
|
||||
* пустая). Для человека это одно состояние «пока не знаем» (моё решение В-214). Считай мы их
|
||||
* по-разному, экран показал бы «доставлено 2 из 5» и промолчал про три.
|
||||
*
|
||||
* 🔴 Считаем только реально отправленное (`sent`) — ровно как месячный объём: за пробные
|
||||
* `fake_sent` никто никуда не ехал и денег не платил, а не ушедшее тем более не в пути.
|
||||
*/
|
||||
final class ClientSmsDeliveryCounter
|
||||
{
|
||||
/** @var array{delivered: int, not_delivered: int, not_sent: int, on_the_way: int} */
|
||||
private const EMPTY = ['delivered' => 0, 'not_delivered' => 0, 'not_sent' => 0, 'on_the_way' => 0];
|
||||
|
||||
/**
|
||||
* Итог сразу по нескольким рассылкам — одним запросом.
|
||||
*
|
||||
* Списку рассылок на экране их до пятидесяти: спрашивать по одной означало бы полсотни
|
||||
* запросов на открытие страницы (тот же довод, что у счёта ждущих и у остатка продолжения).
|
||||
*
|
||||
* @param list<int> $campaignIds
|
||||
* @return array<int, array{delivered: int, not_delivered: int, not_sent: int, on_the_way: int}>
|
||||
*/
|
||||
public function forCampaigns(int $tenantId, array $campaignIds): array
|
||||
{
|
||||
if ($campaignIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 🔴 Пометка клиента ставится ЯВНО, а не берётся «как повезёт» от вызывающего
|
||||
// (приём взят у соседа — `ClientSmsSnapshotReader::underTenant`). У журнала сообщений
|
||||
// изоляция принудительная (`FORCE ROW LEVEL SECURITY`), и запрос без пометки вернёт
|
||||
// НОЛЬ строк молча, без единой ошибки: экран написал бы «доставлено 0». Это тот самый
|
||||
// класс тихого отказа, что и отсутствующая политика `srv_bypass` (В-181). На стенде
|
||||
// такую дыру не видно вовсе — там роль `postgres`, изоляция её не касается.
|
||||
return DB::transaction(function () use ($tenantId, $campaignIds): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// 🪤 Считаем строителем запросов, а НЕ моделью (урок В-174): у модели нет полей
|
||||
// `total`, они рождаются в `count(*)`, и статанализ на этом справедливо ругается.
|
||||
// Пометка клиента здесь уже стоит, плюс явный `where` — как во всём модуле.
|
||||
$rows = DB::table('client_sms_messages')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('campaign_id', $campaignIds)
|
||||
->where('status', ClientSmsMessage::STATUS_SENT)
|
||||
->groupBy('campaign_id', 'delivery_status')
|
||||
->get([
|
||||
'campaign_id',
|
||||
'delivery_status',
|
||||
DB::raw('count(*) as total'),
|
||||
]);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($campaignIds as $id) {
|
||||
$result[$id] = self::EMPTY;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$campaignId = (int) $row->campaign_id;
|
||||
|
||||
$key = match ((string) ($row->delivery_status ?? '')) {
|
||||
SmsDeliveryState::DELIVERED => 'delivered',
|
||||
SmsDeliveryState::NOT_DELIVERED => 'not_delivered',
|
||||
SmsDeliveryState::NOT_SENT => 'not_sent',
|
||||
default => 'on_the_way',
|
||||
};
|
||||
|
||||
$result[$campaignId][$key] += (int) $row->total;
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{delivered: int, not_delivered: int, not_sent: int, on_the_way: int} */
|
||||
public function forCampaign(int $tenantId, int $campaignId): array
|
||||
{
|
||||
return $this->forCampaigns($tenantId, [$campaignId])[$campaignId] ?? self::EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientSmsCampaign;
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Sms\SmsDeliveryState;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Строка листа 5.3: итог рассылки — «доставлено 812, не доставлено 74, ещё в пути 14»,
|
||||
* а не «отправлено 900».
|
||||
*
|
||||
* До Этапа 5 человек видел только «отправлено»: то есть сколько сообщений мы ОТДАЛИ
|
||||
* оператору. Дошли ли они до людей — экран не знал вовсе, а платит клиент именно за
|
||||
* то, чтобы дошли.
|
||||
*
|
||||
* 🪤 «Ещё в пути» — это И явное `sending`, И те, кого мы просто ещё не спрашивали
|
||||
* (графа пустая). Для человека это одно состояние «пока не знаем» (моё решение В-214).
|
||||
*
|
||||
* Помощники с префиксом totals* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
||||
* Номера только синтетические 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Рассылка, у сообщений которой судьбы заданы списком.
|
||||
*
|
||||
* @param array<int, string|null> $states
|
||||
*/
|
||||
function totalsCampaignWith(int $tenantId, array $states, string $status = ClientSmsMessage::STATUS_SENT): ClientSmsCampaign
|
||||
{
|
||||
$campaign = ClientSmsCampaign::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => 'Итог',
|
||||
'body' => 'Текст рассылки',
|
||||
'sender_name' => 'LIDERRA',
|
||||
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
||||
'status' => ClientSmsCampaign::STATUS_DONE,
|
||||
'idempotency_key' => 'totals-'.uniqid(),
|
||||
'segments' => 1,
|
||||
'planned_count' => count($states),
|
||||
'sent_count' => count($states),
|
||||
'total_sms' => count($states),
|
||||
'price_rub_per_sms' => '8.50',
|
||||
'estimated_cost_rub' => '8.50',
|
||||
]);
|
||||
|
||||
$n = 0;
|
||||
|
||||
foreach ($states as $state) {
|
||||
$n++;
|
||||
ClientSmsMessage::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'campaign_id' => $campaign->id,
|
||||
'phone' => '7999'.str_pad((string) $campaign->id, 4, '0', STR_PAD_LEFT).str_pad((string) $n, 3, '0', STR_PAD_LEFT),
|
||||
'provider_key' => 'mts',
|
||||
'status' => $status,
|
||||
'delivery_status' => $state,
|
||||
'cost_rub' => '8.50',
|
||||
'segments' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
it('карточка рассылки показывает доставлено, не доставлено и ещё в пути', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$campaign = totalsCampaignWith((int) $tenant->id, [
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::NOT_DELIVERED,
|
||||
SmsDeliveryState::SENDING,
|
||||
null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('delivery.delivered'))->toBe(2);
|
||||
expect($response->json('delivery.not_delivered'))->toBe(1);
|
||||
// «Ещё в пути» — это и явное `sending`, и те, кого мы ещё не спрашивали.
|
||||
expect($response->json('delivery.on_the_way'))->toBe(2);
|
||||
});
|
||||
|
||||
it('список рассылок несёт итог по КАЖДОЙ — человек видит его не открывая карточку', function () {
|
||||
// 🔴 Рассылок в списке намеренно ДВЕ и с разными числами: со списком из одной
|
||||
// штуки этот тест не отличал бы «итог по своей рассылке» от «итог по первой в
|
||||
// списке» — и не покраснел бы на такой подмене вовсе (урок В-219).
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$first = totalsCampaignWith((int) $tenant->id, [SmsDeliveryState::DELIVERED, SmsDeliveryState::NOT_DELIVERED]);
|
||||
$second = totalsCampaignWith((int) $tenant->id, [
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::SENDING,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sms/campaigns');
|
||||
|
||||
$response->assertOk();
|
||||
// Список идёт от новых к старым: сначала вторая рассылка, потом первая.
|
||||
$rows = collect($response->json('campaigns'))->keyBy('id');
|
||||
expect($rows[$first->id]['delivery']['delivered'])->toBe(1);
|
||||
expect($rows[$first->id]['delivery']['not_delivered'])->toBe(1);
|
||||
expect($rows[$second->id]['delivery']['delivered'])->toBe(3);
|
||||
expect($rows[$second->id]['delivery']['on_the_way'])->toBe(1);
|
||||
});
|
||||
|
||||
it('«оператор не отправил вовсе» считается отдельно от «не доставлено»', function () {
|
||||
// Это разные события, и разговор с оператором по ним разный (В-212): за
|
||||
// «не отправил вовсе» клиент платить не должен тем более. Свалить их в одну
|
||||
// кучу значило бы потерять повод для этого разговора.
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$campaign = totalsCampaignWith((int) $tenant->id, [
|
||||
SmsDeliveryState::NOT_SENT,
|
||||
SmsDeliveryState::NOT_DELIVERED,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('delivery.not_sent'))->toBe(1);
|
||||
expect($response->json('delivery.not_delivered'))->toBe(1);
|
||||
});
|
||||
|
||||
it('пробные сообщения песочницы в итог не идут', function () {
|
||||
// За `fake_sent` никто никуда не ехал и денег не платил — ровно так же их не
|
||||
// считает месячный объём (`ClientSmsVolumeCounter`). Посчитай мы их «ещё в пути»,
|
||||
// рассылка в песочнице никогда не стала бы окончательной.
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$campaign = totalsCampaignWith(
|
||||
(int) $tenant->id,
|
||||
[null, SmsDeliveryState::DELIVERED],
|
||||
ClientSmsMessage::STATUS_FAKE_SENT,
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$campaign->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('delivery.on_the_way'))->toBe(0);
|
||||
expect($response->json('delivery.delivered'))->toBe(0);
|
||||
});
|
||||
|
||||
it('итог соседней рассылки в карточку не подмешивается', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$mine = totalsCampaignWith((int) $tenant->id, [SmsDeliveryState::DELIVERED]);
|
||||
totalsCampaignWith((int) $tenant->id, [
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::DELIVERED,
|
||||
SmsDeliveryState::DELIVERED,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson("/api/sms/campaigns/{$mine->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('delivery.delivered'))->toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user