13ce301ab3
Кампания, брошенная роботом на полпути, попадала в needs_review или draft_ready — статусы, из которых не вело ни одного перехода. Замороженные деньги клиента запирались навсегда, снять их мог только программист правкой боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14. Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено; иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой заморозку и заводили. Заодно: клиенту больше не показывают внутренние адреса и команды запуска — причина отказа переводится на человеческий язык. Портал перестал принимать пустой отчёт робота как успешный. Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.9 KiB
PHP
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); // заморозку песочница не трогает
|
|
});
|