$rows */ function reconcileCampaign(int $tenantId, array $rows, string $messageStatus = 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' => 'reconcile-'.uniqid(), 'segments' => 1, 'planned_count' => count($rows), 'sent_count' => count($rows), 'total_sms' => count($rows), 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => '8.50', ]); $n = 0; foreach ($rows as $row) { $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' => $messageStatus, 'segments' => $row[0], 'provider_parts' => $row[1], 'provider_cost' => $row[2], 'cost_rub' => '8.50', ]); } return $campaign; } function reconcileTenant(): Tenant { return Tenant::factory()->create(); } /** Цена канала МТС — в копейках за часть, как в config/services.php. */ function reconcilePrice(?int $kopecks): void { config(['services.sms.providers.mts.price_kopecks' => ['*' => $kopecks ?? 0]]); } test('владелец видит по рассылке наш счёт частей и счёт МТС, и когда они сходятся — расхождений ноль', function () { $tenant = reconcileTenant(); reconcilePrice(300); $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], [2, 2, '6.0000'], ]); $r = $this->getJson('/api/admin/sms/reconcile'); $r->assertStatus(200); $row = collect($r->json('rows'))->firstWhere('campaign_id', $campaign->id); expect($row)->not->toBeNull(); expect($row['messages'])->toBe(2); expect($row['our_parts'])->toBe(3); expect($row['provider_parts'])->toBe(3); expect($row['parts_mismatch'])->toBe(0); }); test('расхождение по частям видно и посчитано поимённо', function () { $tenant = reconcileTenant(); reconcilePrice(300); // Второе сообщение: мы насчитали 2 части, МТС — 3. $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], [2, 3, '9.0000'], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_parts'])->toBe(3); expect($row['provider_parts'])->toBe(4); expect($row['parts_mismatch'])->toBe(1); }); test('пустая графа частей у старых записей расхождением НЕ считается', function () { $tenant = reconcileTenant(); reconcilePrice(300); // Оператора не спрашивали: частей у МТС нет вовсе. $campaign = reconcileCampaign($tenant->id, [ [1, null, null], [2, null, null], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_parts'])->toBe(3); expect($row['parts_mismatch'])->toBe(0); expect($row['provider_parts'])->toBeNull(); }); test('пустой наш счёт частей у старой записи читается как одна часть', function () { $tenant = reconcileTenant(); reconcilePrice(300); $campaign = reconcileCampaign($tenant->id, [ [null, 1, '3.0000'], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_parts'])->toBe(1); expect($row['parts_mismatch'])->toBe(0); }); test('пока МТС цену не сообщил — портал честно говорит «цены нет», а не показывает ноль', function () { $tenant = reconcileTenant(); reconcilePrice(300); $campaign = reconcileCampaign($tenant->id, [ [1, 1, null], [1, 1, null], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['provider_cost_known'])->toBeFalse(); expect($row['provider_cost'])->toBeNull(); expect($row['difference'])->toBeNull(); }); test('когда наша цена канала не задана — портал честно говорит об этом, а не считает ноль рублями', function () { $tenant = reconcileTenant(); reconcilePrice(0); $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_price_known'])->toBeFalse(); expect($row['our_cost'])->toBeNull(); expect($row['difference'])->toBeNull(); }); test('когда известны оба числа — считается разница', function () { $tenant = reconcileTenant(); reconcilePrice(300); // Наш расчёт: 3 части × 3.00 ₽ = 9.00. МТС насчитал 10.00 — переплата 1.00. $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], [2, 2, '7.0000'], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_price_known'])->toBeTrue(); expect($row['provider_cost_known'])->toBeTrue(); expect($row['our_cost'])->toBe('9.0000'); expect($row['provider_cost'])->toBe('10.0000'); expect($row['difference'])->toBe('1.0000'); }); test('в сверку идут только ОТПРАВЛЕННЫЕ сообщения', function () { $tenant = reconcileTenant(); reconcilePrice(300); // Сообщения, которые мы оператору не отдавали: их МТС не считал и мы не платим. $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], [5, 9, '99.0000'], ], ClientSmsMessage::STATUS_FAILED); $rows = $this->getJson('/api/admin/sms/reconcile')->json('rows'); expect(collect($rows)->firstWhere('campaign_id', $campaign->id))->toBeNull(); }); test('клиенту этот адрес недоступен вовсе — дверь админки закрыта', function () { config(['admin.basic_auth_gate' => true]); config(['admin.basic_auth_allowlist' => ['admin']]); $this->getJson('/api/admin/sms/reconcile')->assertStatus(403); });