Files
portal/app/tests/Feature/ClientSms/MtsReconcileTest.php
T

323 lines
14 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Строка листа 5.7: сверка НАШЕГО расчёта расхода перед МТС с тем, что насчитал
* САМ МТС. Видит только владелец в админке (решение В-201) — клиенту сверка не
* показывается вовсе.
*
* 🔴 Живая проба 31.07 (В-235) переопределила устройство этой сверки:
* · `cost` у МТС во ВСЕХ шести пробах пришёл нулём, единицы поля так и не
* доказаны ⇒ денежную половину строим, но НИКОГДА не выдаём отсутствие
* цены за ноль рублей (В-236);
* · `partCount` у МТС совпал с нашим счётом сегментов буква в букву (1 и 2)
* ⇒ сверка ПО ЧАСТЯМ работает уже сегодня и деньги ей не нужны.
* 🔴 На бою не задана и НАША цена канала (В-234) — значит «наш расчёт» тоже
* может быть неизвестен, и это отдельное состояние, а не ноль.
*
* 🪤 Пустые графы у старых записей: `segments` пусто = одна часть (В-108/В-109),
* `provider_parts` пусто = «оператор не сказал» и расхождением НЕ считается.
*
* Помощники с префиксом reconcile* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Номера только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
/**
* Рассылка с сообщениями, заданными списком троек:
* [наших частей, частей у МТС (null = не сказал), цена МТС (null = не сказал)].
*
* @param array<int, array{0: int|null, 1: int|null, 2: string|null}> $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);
});