Files
portal/app/tests/Feature/ClientTg/AdminRazborApiTest.php
T
Дмитрий f83b845658 fix телеграм: экран разбора показывал смету вместо живых денег
Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке
застрявших оказались три кампании, а не одна — и у двух из них заморозку уже
отпустили, денег за ними не осталось.

Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽».
Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы,
что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная
галочка над невыполненной работой.

Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь
список. Когда её нет — так и написано, и кнопка меняет обещание на «просто
закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с
баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот
случай — кабинету уплачено 180 ₽, а заморозки уже нет.

Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:18 +03:00

175 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdWalletHold;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Экран разбора застрявших кампаний (дефект Д-1).
*
* До 06.08.2026 кампания, брошенная роботом на полпути, застревала навсегда вместе с
* замороженными деньгами клиента — снять их можно было только правкой боевой базы.
* Здесь владелец видит все такие кампании и решает по каждой: реклама пошла (списать по
* факту кабинета) или не пошла (вернуть всё).
*
* Зона saas-admin/admin-db (crm_admin_user, srv_bypass) — кампании видны по всем клиентам.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
function arCampaign(int $tenantId, string $status, ?string $mtsCost = null): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => '2237812',
'mts_cost_rub' => $mtsCost,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 400,
'estimated_cost_rub' => '268.80',
'created_by' => 1,
]);
}
function arZastryavshaya(string $status = Campaign::STATUS_NEEDS_REVIEW, ?string $mtsCost = null): array
{
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = arCampaign($tenant->id, $status, $mtsCost);
tgKoshelekTopup($tenant->id, '1000.00');
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
return [$tenant, $campaign];
}
function arHoldActive(int $tenantId, int $campaignId): int
{
return AdWalletHold::where('tenant_id', $tenantId)
->where('channel', 'telegram')
->where('source_type', 'client_tg_campaign')
->where('source_id', $campaignId)
->where('status', AdWalletHold::STATUS_ACTIVE)
->count();
}
it('список показывает застрявшие кампании и НЕ показывает остальные', function () {
[$t1, $zastryala] = arZastryavshaya();
[, $chernovik] = arZastryavshaya(Campaign::STATUS_DRAFT_READY);
[, $namoderacii] = arZastryavshaya(Campaign::STATUS_MODERATING);
$r = $this->getJson('/api/admin/telegram/zastryavshie');
$r->assertStatus(200);
$ids = collect($r->json('campaigns'))->pluck('id')->all();
expect($ids)->toContain($zastryala->id)
->and($ids)->toContain($chernovik->id)
->and($ids)->not->toContain($namoderacii->id);
});
// 🔑 Решение принимает человек, сверяясь с кабинетом МТС. Значит на экране обязаны быть
// три числа: сколько заперто, сколько уже заплачено кабинету и по какому номеру искать
// кампанию в самом кабинете. Без них кнопки — гадание.
it('в списке есть всё, что нужно для решения', function () {
[$tenant, $campaign] = arZastryavshaya(mtsCost: '182.40');
$stroka = collect($this->getJson('/api/admin/telegram/zastryavshie')->json('campaigns'))
->firstWhere('id', $campaign->id);
expect($stroka['mts_campaign_id'])->toBe('2237812')
->and($stroka['estimated_cost_rub'])->toBe('268.80')
->and($stroka['mts_cost_rub'])->toBe('182.40')
->and($stroka['tenant_id'])->toBe($tenant->id)
->and($stroka)->toHaveKey('status');
});
// 🔴 Поймано на боевых данных 06.08.2026, до первого нажатия кнопки. В списке
// застрявших оказались ТРИ кампании, и у двух из них заморозка была уже отпущена —
// денег за ними не осталось. Экран же показывал смету и обещал «заперто 268,80 ₽».
// Владелец нажал бы «вернуть», ничего бы не вернулось, а он считал бы, что вернул.
// Поэтому в списке идёт РЕАЛЬНАЯ живая заморозка, а не смета.
it('в списке видно живую заморозку, а не смету', function () {
[$tenant, $sDengami] = arZastryavshaya();
[, $bezDeneg] = arZastryavshaya();
app(TelegramCampaignChargeService::class)->release($bezDeneg);
$stroki = collect($this->getJson('/api/admin/telegram/zastryavshie')->json('campaigns'));
expect($stroki->firstWhere('id', $sDengami->id)['zamorozheno_rub'])->toBe('268.80')
->and($stroki->firstWhere('id', $bezDeneg->id)['zamorozheno_rub'])->toBe('0.00')
// смета остаётся на месте — по ней видно, во что кампания оценивалась
->and($stroki->firstWhere('id', $bezDeneg->id)['estimated_cost_rub'])->toBe('268.80');
});
it('разбор «вернуть» отпускает заморозку и закрывает кампанию', function () {
[$tenant, $campaign] = arZastryavshaya();
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'vernut'])
->assertStatus(200);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(arHoldActive($tenant->id, $campaign->id))->toBe(0)
->and(tgKoshelekBalance($tenant->id))->toBe('1000.00');
});
it('разбор «списать» списывает по факту кабинета', function () {
[$tenant, $campaign] = arZastryavshaya(mtsCost: '182.40');
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'spisat'])
->assertStatus(200);
$ozhidaem = app(TelegramTariffService::class)->clientPriceFromCostRub('182.40');
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_LAUNCHED)
->and(tgKoshelekBalance($tenant->id))->toBe(bcsub('1000.00', $ozhidaem, 2));
});
// 🔴 Самое опасное нажатие: «списать», когда сумма кабинета неизвестна. Списать смету
// значило бы взять с клиента за размер списка, а не за рекламу. Отбиваем понятной
// фразой и деньги не трогаем.
it('«списать» без суммы кабинета — 422 понятной фразой, деньги целы', function () {
[$tenant, $campaign] = arZastryavshaya();
$r = $this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'spisat']);
$r->assertStatus(422);
expect($r->json('message'))->toContain('вернуть');
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and(arHoldActive($tenant->id, $campaign->id))->toBe(1)
->and(tgKoshelekBalance($tenant->id))->toBe('1000.00');
});
it('незнакомое решение — 422, кампания не тронута', function () {
[, $campaign] = arZastryavshaya();
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'udalit'])
->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
});
it('кампанию не в тупике разбирать нельзя — 422', function () {
[, $campaign] = arZastryavshaya(Campaign::STATUS_MODERATING);
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'vernut'])
->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_MODERATING);
});
it('несуществующая кампания — 422, а не поломка', function () {
$this->postJson('/api/admin/telegram/zastryavshie/999999/razbor', ['reshenie' => 'vernut'])
->assertStatus(422);
});