Files
portal/app/tests/Feature/ClientSms/MtsReconcileTest.php
T
Дмитрий 19d9eda368 fix сверка: не кричим про расхождение, пока оператор просто ещё не ответил
Находка приёмки Этапа 5. Сверка считала счёт оператора «известным», если отчиталось
хотя бы ОДНО сообщение из всей рассылки, а разницу брала против нашего расхода по
ВСЕМ. Живой замер на стенде: оператор отчитался по 10 сообщениям из 120 — экран
написал «наш расход 360.00 руб, счёт оператора 60.00 руб, разница минус 300.00 руб».

Человек прочитает это как «МТС недосчитал 300 рублей». Правда другая: МТС ещё не
отчитался по 110 сообщениям. Отчёты приходят постепенно, опрос ходит раз в десять
минут — значит такое состояние было бы у КАЖДОЙ свежей рассылки.

Это тот же класс, что уже ловили на вебхуке поставщика: сторож, не умеющий отличить
«потеряно» от «ещё в пути», штампует ложные тревоги и заставляет чинить несломанное.

Что сделано:
- разница считается от нашего расхода ПО ОТЧИТАННОМУ, то есть сравнимое со
  сравнимым. Для этого запрос отдельно складывает наши части только по тем
  сообщениям, за которые оператор назвал цену;
- «наш расход» слева остался прежним — это сколько мы должны за всю рассылку, и
  число полезное. Рядом с разницей экран пишет охват: «оператор отчитался по 10
  из 120». Без охвата разница непонятна: не видно, по всей ли рассылке она;
- охват пишется ТОЛЬКО когда отчитались не по всем, иначе строка шумела бы всегда;
- настоящее расхождение по деньгам видно и при неполном отчёте — ждать полного
  отчёта, чтобы заметить, что оператор считает дороже, было бы хуже исходной беды.

Порядок работы соблюдён. Четыре теста сервера и два теста экрана написаны ДО правки
и покраснели на отсутствующих полях. Каждый доказан вырезом, вырезов четыре:
вернул разницу к расходу по всей рассылке - покраснели 2; убрал охват из ответа -
покраснели 3; убрал строку охвата с экрана - покраснел 1; показал охват всегда -
покраснел другой 1. Это пара: один тест стережёт «видно», второй «не шумит».

Прогоны: модуль 387 из 387, фронт 222 файла и 1722 зелёных при 3 намеренно
пропущенных, формат чист, типы - 5 ошибок и до правки, и после, все в чужих файлах.

Статанализ добавил 4 замечания одного ложного класса: анализатор не понимает $this
внутри тестов Pest и не видит getJson. Доказательство ложности прямое - эти самые
тесты проходят, не будь метода, они бы падали.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:22:08 +03:00

323 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
});