f83b845658
Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке застрявших оказались три кампании, а не одна — и у двух из них заморозку уже отпустили, денег за ними не осталось. Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽». Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы, что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная галочка над невыполненной работой. Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь список. Когда её нет — так и написано, и кнопка меняет обещание на «просто закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот случай — кабинету уплачено 180 ₽, а заморозки уже нет. Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
8.5 KiB
PHP
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);
|
|
});
|