65f3785bf3
Строка листа 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) выделено красным. Стенд возвращён. Статанализ поймал настоящую ошибку: сводные числа читались как поля модели сообщения, которых у неё нет. Починено — итоги берутся голыми строками, а не моделями.
233 lines
9.1 KiB
PHP
233 lines
9.1 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);
|
||
});
|