2026-08-01 01:08:51 +03:00
|
|
|
|
<?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);
|
|
|
|
|
|
});
|
2026-08-01 14:22:08 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 Находка приёмки Этапа 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);
|
|
|
|
|
|
});
|