Files
portal/app/tests/Feature/ClientTg/RazborZastryavshihTest.php
T
Дмитрий 13ce301ab3 feat телеграм: разбор застрявших кампаний и человеческий язык отказов
Кампания, брошенная роботом на полпути, попадала в needs_review или
draft_ready — статусы, из которых не вело ни одного перехода. Замороженные
деньги клиента запирались навсегда, снять их мог только программист правкой
боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14.

Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер
кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает
сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено;
иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой
заморозку и заводили.

Заодно: клиенту больше не показывают внутренние адреса и команды запуска —
причина отказа переводится на человеческий язык. Портал перестал принимать
пустой отчёт робота как успешный.

Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок.

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

183 lines
8.9 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\TelegramRazborService;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* 🔴 Д-1, поймано в бою 06.08.2026 дважды. Статусы `needs_review` и `draft_ready` были
* ТЕРМИНАЛЬНЫМИ: выходов ноль, экрана разбора нет. Кампания, которую робот довёл до
* кабинета и бросил, застревала навсегда — вместе с замороженными деньгами клиента.
* На боевом так заперты 268,80 ₽ по кампании №14.
*
* Разбор — РУЧНОЙ и по решению человека, который сходил в кабинет МТС и увидел там
* правду. Служба только исполняет решение:
* «вернуть» — рекламы не было → отпускаем всю заморозку, ни рубля не списано;
* «списать» — реклама пошла → списываем ПО ФАКТУ кабинета × наценка, остаток
* отпускаем. Ровно как одобрение модерации.
*
* 🔴 Списывать «по оценке» нельзя ни при каких условиях: это ровно та беда, ради которой
* заморозку и заводили — клиент платил за размер списка, а не за рекламу.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
function rzCampaign(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,
]);
}
/** Держится ли ещё заморозка под кампанию (1 — держится, 0 — отпущена). */
function rzHoldActive(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();
}
/** Кампания в needs_review с живой заморозкой сметы — исходная точка всех разборов. */
function rzZastryavshaya(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 = rzCampaign($tenant->id, $status, $mtsCost);
tgKoshelekTopup($tenant->id, '1000.00');
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
return [$tenant, $campaign];
}
it('«вернуть»: заморозка отпущена целиком, ни рубля не списано', function () {
[$tenant, $campaign] = rzZastryavshaya();
expect(rzHoldActive($tenant->id, $campaign->id))->toBe(1); // до разбора деньги заперты
app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::VERNUT, sandbox: false,
);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and(rzHoldActive($tenant->id, $campaign->id))->toBe(0)
->and(tgKoshelekFrozen($tenant->id))->toBe('0.00')
->and(tgKoshelekBalance($tenant->id))->toBe('1000.00');
});
it('«списать»: списываем факт кабинета × наценка, остаток сметы отпускаем', function () {
// 182,40 ₽ — настоящая сумма, которую робот заплатил МТС 06.08 за кампанию 2237821.
[$tenant, $campaign] = rzZastryavshaya(mtsCost: '182.40');
app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::SPISAT, sandbox: false,
);
$fresh = Campaign::find($campaign->id);
$ozhidaem = app(TelegramTariffService::class)->clientPriceFromCostRub('182.40');
expect($fresh->status)->toBe(Campaign::STATUS_LAUNCHED)
->and($fresh->actual_cost_rub)->toBe($ozhidaem)
->and(rzHoldActive($tenant->id, $campaign->id))->toBe(0) // остаток сметы отпущен
->and(tgKoshelekFrozen($tenant->id))->toBe('0.00')
->and(tgKoshelekBalance($tenant->id))->toBe(bcsub('1000.00', $ozhidaem, 2));
});
it('«списать» БЕЗ суммы кабинета — отказ, деньги не тронуты', function () {
[$tenant, $campaign] = rzZastryavshaya(); // mts_cost_rub пуст
expect(fn () => app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::SPISAT, sandbox: false,
))->toThrow(RuntimeException::class);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW) // разбора не было
->and(rzHoldActive($tenant->id, $campaign->id))->toBe(1) // деньги как были
->and(tgKoshelekBalance($tenant->id))->toBe('1000.00');
});
it('черновик, брошенный у кассы (draft_ready), разбирается так же', function () {
[$tenant, $campaign] = rzZastryavshaya(Campaign::STATUS_DRAFT_READY);
app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::VERNUT, sandbox: false,
);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(tgKoshelekFrozen($tenant->id))->toBe('0.00');
});
it('кампанию, которая ещё на модерации, разбирать нельзя', function () {
[$tenant, $campaign] = rzZastryavshaya(Campaign::STATUS_MODERATING);
expect(fn () => app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::VERNUT, sandbox: false,
))->toThrow(RuntimeException::class);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_MODERATING)
->and(rzHoldActive($tenant->id, $campaign->id))->toBe(1);
});
// 🪤 Кнопку нажмут дважды — на медленном ответе это дело двух секунд. Второй раз деньги
// двигаться не должны: списание по ключу события идемпотентно, но статус уже не тот,
// поэтому до денег вообще не доходим.
it('повторный разбор второй раз деньги не двигает', function () {
[$tenant, $campaign] = rzZastryavshaya(mtsCost: '182.40');
$razbor = app(TelegramRazborService::class);
$razbor->razobrat($tenant->id, $campaign->id, TelegramRazborService::SPISAT, sandbox: false);
$posle = tgKoshelekBalance($tenant->id);
expect(fn () => $razbor->razobrat($tenant->id, $campaign->id, TelegramRazborService::SPISAT, sandbox: false))
->toThrow(RuntimeException::class);
expect(tgKoshelekBalance($tenant->id))->toBe($posle);
});
it('в причине для клиента нет внутренних слов и латиницы', function () {
[$tenant, $campaign] = rzZastryavshaya();
app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::VERNUT, sandbox: false,
);
$prichina = (string) Campaign::find($campaign->id)->status_reason;
expect($prichina)->not->toBe('')
->and(preg_match('/[A-Za-z]/', $prichina))->toBe(0)
->and($prichina)->toContain('деньги');
});
it('в песочнице деньги не трогаем вовсе, а статус двигаем', function () {
[$tenant, $campaign] = rzZastryavshaya();
app(TelegramRazborService::class)->razobrat(
$tenant->id, $campaign->id, TelegramRazborService::VERNUT, sandbox: true,
);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(rzHoldActive($tenant->id, $campaign->id))->toBe(1); // заморозку песочница не трогает
});