Files
portal/app/tests/Feature/ClientSms/MtsReconcileTest.php
T
Дмитрий 65f3785bf3 feat(смс-клиент): сверка нашего расчёта с расчётом оператора — в админке у владельца
Строка листа 5.7. В админке «СМС» появился блок «Сверка расчётов с оператором»: по каждой
отправленной рассылке видно, сколько частей насчитали мы и сколько оператор, сколько
сообщений разошлись, наш расход, счёт оператора и разница. Клиенту это не показывается
вовсе (решение В-201) — он платит по своему тарифу, наш расход перед оператором не его дело.

Главное решение здесь — отсутствие числа и ноль это РАЗНЫЕ состояния. Экран пишет «цена
канала не задана», «оператор цену не сообщил», «не спрашивали», «сверять нечем» и никогда
не рисует 0 ₽ вместо неизвестного. Иначе владелец видел бы идеальную сходимость там, где
сверять нечем — ровно тот молчаливый сбой, что стоил четырёх поломок 20.07. Разница
считается только когда известны оба числа.

Порядок работы задала живая проба, а не код. Шесть живых сообщений с боевого (разрешение
и номера дал владелец) с перебором по одному признаку: МТС-номер и Т2-номер, одна часть и
две, три разных имени отправителя, смешанный и чисто русский текст. Все шесть — «не
отправлено», цена 0, отказ в ту же секунду, что и приём. Владелец проверил кабинет: баланс
5010 ₽, имя живое. Значит вывод из памяти проекта «пустой счёт» опровергнут, причина на
стороне оператора (В-235, В-237) и требует разбора с их поддержкой.

Попутно вторично и жёстче подтвердилось В-212: оператор принял даже номер Теле2, которого
наш канал не обслуживает. «Принял» не значит ничего — по-старому портал записал бы
«отправлено» и списал бы деньги за сообщения, которых нет.

Что проба дала положительного: оператор считает ЧАСТИ ровно как мы — 1 на короткое, 2 на
длинное в 118 знаков. На этом сверка по частям и построена, деньги ей не нужны.

Строка 5.7 сужена честно и не молча (В-236): денежная половина построена, но живьём не
доказана — цены нет с обеих сторон. У оператора 0, а у нас цена канала на бою не задана
вовсе (В-234). Дозакрыть можно двумя вещами: числом из договора и одним реально
отправленным сообщением с ненулевой ценой.

План велел править AdminSmsController — такого файла нет вовсе (В-231, четвёртый раз за
проект). Сверка живёт отдельным распорядителем: она ни ценам, ни отказам, ни именам
отправителя не родня.

Тесты: 9 на сервере, 6 на экране. Все проверены вырезом — 15 вырезов, каждый покрасил
именно свои тесты. Один вырез не сработал, и опять ошибалось моё ожидание, а не код
(В-239): база сама считает сравнение с пустотой «неизвестным», поэтому страж оказался
лишним; заменён на вырез с настоящей ошибкой этого места.

Живой прогон в браузере парный: без заданной цены канала — «цена канала не задана» и
«сверять нечем»; с ценой 3 ₽ за часть — 9.00 ₽ против 12.00 ₽, разница 3.00 ₽, а
расхождение по частям (3 против 4) выделено красным. Стенд возвращён.

Статанализ поймал настоящую ошибку: сводные числа читались как поля модели сообщения,
которых у неё нет. Починено — итоги берутся голыми строками, а не моделями.
2026-08-01 01:08:51 +03:00

233 lines
9.1 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);
});