From 2e5db6737b8e30f7c09398b24d1e462d02fc2f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 28 Jul 2026 07:28:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=82=D0=B5=D0=BB=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC-=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0):=20=D0=AD?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=202=20(=D1=87=D0=B0=D1=81=D1=82=D1=8C)=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=C2=AB=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B0=C2=BB=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=20=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B5/=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрыты денежно-независимые куски Этапа 2 (находки #1, часть). Списание по факту (2.2/2.4) ждёт подтверждения билинг-модели МТС. - 2.1 Статус кампании «cancelled»: константа STATUS_CANCELLED, переходы draft→cancelled и queued→cancelled (отмена только ДО запуска); cancelled — терминальный. Из running/терминальных отмена запрещена. Миграция не нужна — status это string(16) без CHECK-констрейнта. - 2.3 Возврат брони (release), иначе резерв кошелька залипал (находка #1): • при отказе робота (finalize) — release после записи FAILED, отдельным tenantTx, чтобы сбой возврата не откатил статус; • при перманентном сбое джоба (failed) — тоже release; • новый endpoint POST /api/telegram/campaigns/{id}/cancel: draft/queued → cancelled с возвратом брони; из running/терминальных — 422. Возврат гейтится !sandbox (в песочнице заморозки не было). Ключи release совпадают с freeze в launch ('telegram','campaign',id). RLS: денежная операция в джобе идёт под SET LOCAL app.current_tenant_id (образец ChargeTgNameFeeJob). Возврат при rejected здесь НЕ трогаем — его выставляет опросчик модерации (Этап 3, задача 3.4). TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера). Приёмка: Pest ClientTg RefundOnFail 5/5 + регрессия соседей (32/32 суммарно), deptrac 0, pint чисто, phpstan по боевым файлам (джоб/контроллер) 0. Co-Authored-By: Claude Opus 4.8 --- .../Api/ClientTg/CampaignController.php | 32 ++++ .../Jobs/ClientTg/RunTelegramCampaignJob.php | 35 ++++- app/app/Models/ClientTg/Campaign.php | 14 +- app/routes/web.php | 1 + .../Feature/ClientTg/RefundOnFailTest.php | 139 ++++++++++++++++++ .../Feature/ClientTg/StatusMachineTest.php | 46 ++++++ 6 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 app/tests/Feature/ClientTg/RefundOnFailTest.php diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php index c4dc16cc..f2f96031 100644 --- a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -162,6 +162,38 @@ class CampaignController extends Controller return response()->json($campaign->fresh()); } + /** + * Отмена кампании ДО запуска (draft/queued → cancelled). В бою возвращает бронь + * кошелька (release снимает hold, уменьшает frozen) — иначе резерв залипает + * (находка #1, задача 2.3). Песочница брони не имела — release пропускаем. + * Из running/терминальных отмена запрещена (422) по машине статусов модели. + */ + public function cancel(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if (! $campaign->canTransitionTo(Campaign::STATUS_CANCELLED)) { + return response()->json(['message' => 'Эту кампанию нельзя отменить'], 422); + } + + $sandbox = (bool) config('client_tg.sandbox', true); + + try { + DB::transaction(function () use ($tenantId, $campaign, $sandbox): void { + if (! $sandbox) { + app(AdWalletService::class)->release($tenantId, 'telegram', 'campaign', $campaign->id); + } + $campaign->transitionTo(Campaign::STATUS_CANCELLED); + }); + } catch (ModelNotFoundException $e) { + return response()->json(['message' => 'Не удалось вернуть бронь'], 409); + } + + return response()->json($campaign->fresh()); + } + /** * Валидация полей кампании. Для «по сделкам» срок обязателен — иначе выборка * ушла бы по ВСЕЙ истории (и сканировала бы все партиции deals). diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php index 5e4772ab..4e7db7d4 100644 --- a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -6,6 +6,7 @@ namespace App\Jobs\ClientTg; use App\Models\ClientTg\Campaign; use App\Models\Tenant; +use App\Services\Advertising\AdWalletService; use App\Services\ClientTg\RobotResult; use App\Services\ClientTg\TelegramAudienceService; use App\Services\ClientTg\TelegramRobotRunner; @@ -108,7 +109,7 @@ class RunTelegramCampaignJob implements ShouldQueue // Фаза B — итог под tenant-контекстом. Деньги в песочнице/черновике не трогаем // (живое списание — Сессия 6, когда известен источник фактической цены). - $this->finalize($campaign, $result); + $this->finalize($campaign, $result, $sandbox); } finally { if (is_string($phonesFile) && file_exists($phonesFile)) { @unlink($phonesFile); @@ -116,7 +117,7 @@ class RunTelegramCampaignJob implements ShouldQueue } } - private function finalize(Campaign $campaign, RobotResult $result): void + private function finalize(Campaign $campaign, RobotResult $result, bool $sandbox): void { // Сессия 5: при провале робота причина сохраняется в status_reason и клиент // уведомляется. Тенант/кампанию грузим ВНУТРИ tenant-контекста (иначе RLS на @@ -148,6 +149,22 @@ class RunTelegramCampaignJob implements ShouldQueue $fresh->transitionTo($result->launched ? Campaign::STATUS_LAUNCHED : Campaign::STATUS_DRAFT_READY); }); + // Задача 2.3: при отказе робота возвращаем бронь кошелька (frozen), иначе она + // залипает (находка #1). Отдельный tenantTx ПОСЛЕ записи статуса — чтобы сбой + // возврата не откатил FAILED. В песочнице заморозки не было — release пропускаем + // (иначе firstOrFail на несуществующем кошельке). Ключ совпадает с freeze в launch. + if ($failedCampaign !== null && ! $sandbox) { + try { + $this->tenantTx(fn () => app(AdWalletService::class) + ->release($this->tenantId, 'telegram', 'campaign', $failedCampaign->id)); + } catch (Throwable $e) { + Log::warning('client_tg.release_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + if ($failedCampaign !== null && $failedTenant !== null) { try { app(NotificationService::class)->notifyTelegramCampaignRejected($failedTenant, $failedCampaign); @@ -182,6 +199,20 @@ class RunTelegramCampaignJob implements ShouldQueue 'error' => $inner->getMessage(), ]); } + + // Задача 2.3: перманентный сбой джоба тоже возвращает бронь (в бою). Отдельный + // tenantTx в своём try/catch — сбой возврата не должен ронять обработчик failed. + if (! (bool) config('client_tg.sandbox', true)) { + try { + $this->tenantTx(fn () => app(AdWalletService::class) + ->release($this->tenantId, 'telegram', 'campaign', $this->campaignId)); + } catch (Throwable $inner) { + Log::warning('client_tg.release_failed', [ + 'campaign_id' => $this->campaignId, + 'error' => $inner->getMessage(), + ]); + } + } } /** diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php index b281823e..926901e2 100644 --- a/app/app/Models/ClientTg/Campaign.php +++ b/app/app/Models/ClientTg/Campaign.php @@ -39,6 +39,8 @@ class Campaign extends Model public const STATUS_REJECTED = 'rejected'; + public const STATUS_CANCELLED = 'cancelled'; + public const AUDIENCE_DEALS = 'deals'; public const AUDIENCE_BASE = 'base'; @@ -48,14 +50,17 @@ class Campaign extends Model /** * Разрешённые переходы статуса (план §Сессия 2, задача 2.1): * draft → queued → running → (draft_ready | launched | failed | rejected). - * Терминальные статусы (draft_ready/launched/failed/rejected) — без исходящих - * переходов на этом этапе; повторная отправка (resubmit) появится в Сессии 5. + * Отмена (cancelled) разрешена только ДО запуска — из draft и из queued; + * из running отмену не пускаем (запуск уже пошёл, отмена там — по другой + * дороге в задаче 2.3). Терминальные статусы (draft_ready/launched/failed/ + * rejected/cancelled) — без исходящих переходов на этом этапе; повторная + * отправка (resubmit) появится в Сессии 5. * * @var array> */ public const TRANSITIONS = [ - self::STATUS_DRAFT => [self::STATUS_QUEUED], - self::STATUS_QUEUED => [self::STATUS_RUNNING], + self::STATUS_DRAFT => [self::STATUS_QUEUED, self::STATUS_CANCELLED], + self::STATUS_QUEUED => [self::STATUS_RUNNING, self::STATUS_CANCELLED], self::STATUS_RUNNING => [ self::STATUS_DRAFT_READY, self::STATUS_LAUNCHED, @@ -66,6 +71,7 @@ class Campaign extends Model self::STATUS_LAUNCHED => [], self::STATUS_FAILED => [], self::STATUS_REJECTED => [], + self::STATUS_CANCELLED => [], ]; protected $fillable = [ diff --git a/app/routes/web.php b/app/routes/web.php index 4da70a50..a499977d 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -100,6 +100,7 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(fu Route::post('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@store'); Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\ClientTg\CampaignController@show')->whereNumber('id'); Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\ClientTg\CampaignController@launch')->whereNumber('id'); + Route::post('/campaigns/{id}/cancel', 'App\Http\Controllers\Api\ClientTg\CampaignController@cancel')->whereNumber('id'); }); // Reports backend. Schema §13.5 report_jobs. Auth обязательный. diff --git a/app/tests/Feature/ClientTg/RefundOnFailTest.php b/app/tests/Feature/ClientTg/RefundOnFailTest.php new file mode 100644 index 00000000..86c7081d --- /dev/null +++ b/app/tests/Feature/ClientTg/RefundOnFailTest.php @@ -0,0 +1,139 @@ +id). Гейт !sandbox везде — в песочнице + * заморозки не было, release бросил бы firstOrFail. Списание по факту — задача 2.4. + * + * Все тесты по умолчанию в БОЕВОМ режиме (sandbox=false) — проверяем денежный путь. + * Синтетические номера 7999… — реальные НИКОГДА. + */ +beforeEach(function () { + config()->set('client_tg.sandbox', false); +}); + +/** Кампания-список заданного статуса с двумя фейковыми номерами. */ +function refundCampaign(int $tenantId, string $status, string $budgetCap = '500.00'): Campaign +{ + $campaign = Campaign::query()->create([ + 'tenant_id' => $tenantId, + 'status' => $status, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => $budgetCap, + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '0.00', + 'created_by' => 1, + ]); + + foreach (['79990000001', '79990000002'] as $phone) { + CampaignPhone::query()->create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'phone' => $phone, + ]); + } + + return $campaign; +} + +/** Подменяет раннер моком, возвращающим $result. */ +function refundMockRobot(RobotResult $result): void +{ + $mock = Mockery::mock(TelegramRobotRunner::class); + $mock->shouldReceive('run')->andReturn($result); + app()->instance(TelegramRobotRunner::class, $mock); +} + +it('отказ робота возвращает бронь (frozen→0, balance цел)', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); + $campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00'); + app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00'); + refundMockRobot(RobotResult::failed('Канал недоступен', 'upload')); + + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + + $fresh = Campaign::find($campaign->id); + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($fresh->status)->toBe(Campaign::STATUS_FAILED) + ->and((string) $wallet->frozen_rub)->toBe('0.00') + ->and((string) $wallet->balance_rub)->toBe('1000.00'); +}); + +it('в песочнице отказ робота НЕ трогает кошелёк (бронь не возвращается — её и нет)', function () { + config()->set('client_tg.sandbox', true); + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); + $campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00'); + app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00'); + refundMockRobot(RobotResult::failed('Канал недоступен', 'upload')); + + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect((string) $wallet->frozen_rub)->toBe('500.00'); +}); + +it('cancel из draft возвращает бронь и ставит cancelled', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $this->actingAs($user); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); + $campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00'); + app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00'); + + $this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel") + ->assertOk() + ->assertJsonPath('status', Campaign::STATUS_CANCELLED); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect((string) $wallet->frozen_rub)->toBe('0.00') + ->and((string) $wallet->balance_rub)->toBe('1000.00'); +}); + +it('cancel из running запрещён 422, статус остаётся running', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $this->actingAs($user); + $campaign = refundCampaign($tenant->id, Campaign::STATUS_RUNNING, '300.00'); + + $this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")->assertStatus(422); + + expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_RUNNING); +}); + +it('cancel в песочнице не трогает кошелёк (frozen сохраняется)', function () { + config()->set('client_tg.sandbox', true); + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $this->actingAs($user); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); + $campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00'); + app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00'); + + $this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel") + ->assertOk() + ->assertJsonPath('status', Campaign::STATUS_CANCELLED); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect((string) $wallet->frozen_rub)->toBe('300.00'); +}); diff --git a/app/tests/Feature/ClientTg/StatusMachineTest.php b/app/tests/Feature/ClientTg/StatusMachineTest.php index a1946c0c..064b1450 100644 --- a/app/tests/Feature/ClientTg/StatusMachineTest.php +++ b/app/tests/Feature/ClientTg/StatusMachineTest.php @@ -81,3 +81,49 @@ it('canTransitionTo сообщает допустимость без броск expect($c->canTransitionTo(Campaign::STATUS_RUNNING))->toBeTrue() ->and($c->canTransitionTo(Campaign::STATUS_DRAFT_READY))->toBeFalse(); }); + +it('константа STATUS_CANCELLED существует и равна cancelled', function () { + expect(Campaign::STATUS_CANCELLED)->toBe('cancelled'); +}); + +it('разрешает отмену из draft, переход персистится в БД', function () { + $c = tgCampaign(Campaign::STATUS_DRAFT); + + expect($c->canTransitionTo(Campaign::STATUS_CANCELLED))->toBeTrue(); + + $c->transitionTo(Campaign::STATUS_CANCELLED); + expect($c->status)->toBe(Campaign::STATUS_CANCELLED); + + expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_CANCELLED); +}); + +it('разрешает отмену из queued', function () { + $c = tgCampaign(Campaign::STATUS_QUEUED); + + expect($c->canTransitionTo(Campaign::STATUS_CANCELLED))->toBeTrue(); + + $c->transitionTo(Campaign::STATUS_CANCELLED); + expect($c->status)->toBe(Campaign::STATUS_CANCELLED); +}); + +it('запрещает отмену из running', function () { + $c = tgCampaign(Campaign::STATUS_RUNNING); + + expect($c->canTransitionTo(Campaign::STATUS_CANCELLED))->toBeFalse(); + + expect(fn () => $c->transitionTo(Campaign::STATUS_CANCELLED)) + ->toThrow(DomainException::class); + + expect($c->status)->toBe(Campaign::STATUS_RUNNING); +}); + +it('cancelled — терминальный статус', function () { + $c = tgCampaign(Campaign::STATUS_CANCELLED); + + expect($c->canTransitionTo(Campaign::STATUS_QUEUED))->toBeFalse(); + + expect(fn () => $c->transitionTo(Campaign::STATUS_QUEUED)) + ->toThrow(DomainException::class); + + expect($c->status)->toBe(Campaign::STATUS_CANCELLED); +});