19d9eda368
Находка приёмки Этапа 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>
323 lines
14 KiB
PHP
323 lines
14 KiB
PHP
<?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);
|
||
});
|