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); });