diff --git a/app/app/Http/Controllers/Api/Client/ClientSmsController.php b/app/app/Http/Controllers/Api/Client/ClientSmsController.php index a5c6a617..7ce0b590 100644 --- a/app/app/Http/Controllers/Api/Client/ClientSmsController.php +++ b/app/app/Http/Controllers/Api/Client/ClientSmsController.php @@ -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), ]); } diff --git a/app/app/Services/ClientSms/ClientSmsDeliveryCounter.php b/app/app/Services/ClientSms/ClientSmsDeliveryCounter.php new file mode 100644 index 00000000..c5fd5b53 --- /dev/null +++ b/app/app/Services/ClientSms/ClientSmsDeliveryCounter.php @@ -0,0 +1,100 @@ + 0, 'not_delivered' => 0, 'not_sent' => 0, 'on_the_way' => 0]; + + /** + * Итог сразу по нескольким рассылкам — одним запросом. + * + * Списку рассылок на экране их до пятидесяти: спрашивать по одной означало бы полсотни + * запросов на открытие страницы (тот же довод, что у счёта ждущих и у остатка продолжения). + * + * @param list $campaignIds + * @return array + */ + 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; + } +} diff --git a/app/tests/Feature/ClientSms/DeliveryTotalsTest.php b/app/tests/Feature/ClientSms/DeliveryTotalsTest.php new file mode 100644 index 00000000..63b9e92e --- /dev/null +++ b/app/tests/Feature/ClientSms/DeliveryTotalsTest.php @@ -0,0 +1,166 @@ + $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); +});