$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); }); /** * 🔴 Находка приёмки Этапа 5 (01.08.2026). Раньше счёт оператора считался * «известным», если отчиталось хотя бы ОДНО сообщение из всей рассылки, а разница * бралась против нашего расхода по ВСЕМ. Живой замер: оператор отчитался по 10 из * 120 — экран написал «разница −300 ₽», что читается как «МТС недосчитал». Правда * была другая: МТС ещё не отчитался по 110. * * Так будет у КАЖДОЙ свежей рассылки: отчёты приходят постепенно, опрос ходит раз * в десять минут. Это тот же класс, что уже ловили на вебхуке поставщика — сторож, * не умеющий отличить «потеряно» от «ещё в пути», штампует ложные тревоги. */ test('разница считается только по тем сообщениям, о которых оператор отчитался', function () { $tenant = reconcileTenant(); reconcilePrice(300); // Три сообщения по одной части. Оператор отчитался только по первому, и по нему // наши числа сходятся: 3 ₽ у нас, 3 ₽ у него. Значит расхождения НЕТ. $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], [1, null, null], [1, null, null], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_cost'])->toBe('9.0000'); // сколько мы должны за всю рассылку expect($row['our_cost_reported'])->toBe('3.0000'); // и сколько — за отчитанное expect($row['provider_cost'])->toBe('3.0000'); expect($row['difference'])->toBe('0.0000'); // сравнимое со сравнимым }); test('видно, по скольким сообщениям оператор отчитался, а по скольким ещё нет', function () { $tenant = reconcileTenant(); reconcilePrice(300); $campaign = reconcileCampaign($tenant->id, [ [1, 1, '3.0000'], [1, null, null], [1, null, null], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['messages'])->toBe(3); expect($row['messages_reported'])->toBe(1); }); test('настоящее расхождение по деньгам видно и при неполном отчёте оператора', function () { $tenant = reconcileTenant(); reconcilePrice(300); // Оператор отчитался по двум из трёх и насчитал за них больше нашего: // у нас 3 + 3 = 6 ₽, у него 4 + 5 = 9 ₽. Это НАСТОЯЩЕЕ расхождение, и оно // обязано быть видно, не дожидаясь отчёта по третьему. $campaign = reconcileCampaign($tenant->id, [ [1, 1, '4.0000'], [1, 1, '5.0000'], [1, null, null], ]); $row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows')) ->firstWhere('campaign_id', $campaign->id); expect($row['our_cost_reported'])->toBe('6.0000'); expect($row['provider_cost'])->toBe('9.0000'); expect($row['difference'])->toBe('3.0000'); expect($row['messages_reported'])->toBe(2); }); test('оператор не отчитался ни по одному — сверять по деньгам нечего', function () { $tenant = reconcileTenant(); reconcilePrice(300); $campaign = reconcileCampaign($tenant->id, [ [1, null, null], [1, null, 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['our_cost_reported'])->toBeNull(); expect($row['difference'])->toBeNull(); expect($row['messages_reported'])->toBe(0); });