From 86c560e0c4affa649552c2ecd9bedb41b4274ca1 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 09:24:09 +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=203.2=20=E2=80=94=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=C2=AB=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=C2=BB=20+=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=85=D0=BE=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 3 «Жизненный цикл и модерация», задача 3.2. Живая отправка робота в МТС означает «ушло на модерацию», а не «запущено»: кампания получает статус `moderating`, а `launched` придёт позже, когда опросчик вердикта (задача 3.4) увидит одобрение. - Campaign.php: константа STATUS_MODERATING + переходы running→moderating, moderating→launched|rejected, rejected→queued (пересдача, задача 3.6). running→launched оставлен ради обратной совместимости. Миграция НЕ нужна: на колонке status нет CHECK-ограничения (машина статусов — в модели), `moderating` влезает в varchar(16). - RunTelegramCampaignJob.finalize(): живой успех (launched=true) → moderating вместо launched; песочница (черновик) по-прежнему → draft_ready. Тест-инфра (побочно, но необходимо для проверки): tests/TestCase.php получил `protected $dropTypes = true`. RefreshDatabase's migrate:fresh дропал таблицы, но НЕ типы Postgres; при заблокированном дропе таблицы её composite row-type переживал db:wipe, и перезагрузка db/schema.sql падала на дубле типа («legal_entities … уже существует»), оставляя ЧАСТИЧНУЮ схему — давний интермиттентный флак «migrate:fresh иногда прерывается» (site_events/ client_tg_tariffs случайно отсутствовали). Дроп типов на каждом refresh резко снизил флак (было 15–46/130 → стало 93–126/130). Только для APP_ENV=testing, на прод не влияет. Остаточный редкий обрыв — отдельная пред-существующая проблема, не этой задачи. TDD, робот замокан, песочница. Приёмка (моя область, чистый прогон): ModeratingStatusTest 8/8 + StatusMachineTest/RunCampaignJobTest/ExternalIdTest/ RobotRunnerTest 35/35 суммарно; phpstan (Campaign+Job) 0, deptrac 0, pint чисто. Co-Authored-By: Claude Opus 4.8 --- .../Jobs/ClientTg/RunTelegramCampaignJob.php | 6 +- app/app/Models/ClientTg/Campaign.php | 21 +-- .../Feature/ClientTg/ModeratingStatusTest.php | 135 ++++++++++++++++++ app/tests/TestCase.php | 14 ++ 4 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 app/tests/Feature/ClientTg/ModeratingStatusTest.php diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php index 0406d540..3798dda6 100644 --- a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -151,8 +151,10 @@ class RunTelegramCampaignJob implements ShouldQueue } $fresh->matched_count = $result->matched; - // launched=true бывает только в живом режиме (Сессия 6); черновик → draft_ready. - $fresh->transitionTo($result->launched ? Campaign::STATUS_LAUNCHED : Campaign::STATUS_DRAFT_READY); + // launched=true (живой режим) = кампания УШЛА НА МОДЕРАЦИЮ МТС, а не + // «запущена»: ставим `moderating`, одобрение переведёт в `launched` + // опросчик вердикта (задача 3.4). Песочница (черновик) → `draft_ready`. + $fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY); }); // Задача 2.3: при отказе робота возвращаем бронь кошелька (frozen), иначе она diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php index 8933d9b1..ecc8a193 100644 --- a/app/app/Models/ClientTg/Campaign.php +++ b/app/app/Models/ClientTg/Campaign.php @@ -33,6 +33,8 @@ class Campaign extends Model public const STATUS_DRAFT_READY = 'draft_ready'; + public const STATUS_MODERATING = 'moderating'; + public const STATUS_LAUNCHED = 'launched'; public const STATUS_FAILED = 'failed'; @@ -48,13 +50,14 @@ class Campaign extends Model public const AUDIENCE_LIST = 'list'; /** - * Разрешённые переходы статуса (план §Сессия 2, задача 2.1): - * draft → queued → running → (draft_ready | launched | failed | rejected). - * Отмена (cancelled) разрешена только ДО запуска — из draft и из queued; - * из running отмену не пускаем (запуск уже пошёл, отмена там — по другой - * дороге в задаче 2.3). Терминальные статусы (draft_ready/launched/failed/ - * rejected/cancelled) — без исходящих переходов на этом этапе; повторная - * отправка (resubmit) появится в Сессии 5. + * Разрешённые переходы статуса (план §Сессия 2 задача 2.1 + Этап 3 задача 3.2): + * draft → queued → running → (draft_ready | moderating | launched | failed | rejected). + * Живая отправка робота ставит `moderating` («на модерации МТС»), а не сразу + * `launched`: одобрение придёт от опросчика вердикта (задача 3.4), тогда + * moderating → launched; отказ модерации — moderating → rejected. Отклонённую + * кампанию можно пересдать: rejected → queued (задача 3.6). Отмена (cancelled) + * разрешена только ДО запуска — из draft и из queued; из running отмену не + * пускаем. Терминальные — draft_ready/launched/failed/cancelled. * * @var array> */ @@ -63,14 +66,16 @@ class Campaign extends Model self::STATUS_QUEUED => [self::STATUS_RUNNING, self::STATUS_CANCELLED], self::STATUS_RUNNING => [ self::STATUS_DRAFT_READY, + self::STATUS_MODERATING, self::STATUS_LAUNCHED, self::STATUS_FAILED, self::STATUS_REJECTED, ], + self::STATUS_MODERATING => [self::STATUS_LAUNCHED, self::STATUS_REJECTED], self::STATUS_DRAFT_READY => [], self::STATUS_LAUNCHED => [], self::STATUS_FAILED => [], - self::STATUS_REJECTED => [], + self::STATUS_REJECTED => [self::STATUS_QUEUED], self::STATUS_CANCELLED => [], ]; diff --git a/app/tests/Feature/ClientTg/ModeratingStatusTest.php b/app/tests/Feature/ClientTg/ModeratingStatusTest.php new file mode 100644 index 00000000..b8a19eba --- /dev/null +++ b/app/tests/Feature/ClientTg/ModeratingStatusTest.php @@ -0,0 +1,135 @@ +create(); + + return Campaign::query()->create([ + 'tenant_id' => $tenant->id, + 'status' => $status, + 'ad_text' => 'Тест модерации', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '1000.00', + 'audience_kind' => Campaign::AUDIENCE_DEALS, + 'planned_count' => 0, + 'estimated_cost_rub' => '0.00', + 'created_by' => 1, + ]); +} + +/** Queued-кампания-список с фейковыми номерами для прогона джоба. */ +function moderatingQueuedCampaign(int $tenantId): Campaign +{ + $campaign = Campaign::query()->create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_QUEUED, + 'ad_text' => 'Приходите к нам', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '1500.00', + '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; +} + +function moderatingMockRobot(RobotResult $result): void +{ + $mock = Mockery::mock(TelegramRobotRunner::class); + $mock->shouldReceive('run')->andReturn($result); + app()->instance(TelegramRobotRunner::class, $mock); +} + +it('константа STATUS_MODERATING существует и равна moderating', function () { + expect(Campaign::STATUS_MODERATING)->toBe('moderating'); +}); + +it('из running допускает moderating', function () { + $c = moderatingCampaign(Campaign::STATUS_RUNNING); + expect($c->canTransitionTo(Campaign::STATUS_MODERATING))->toBeTrue(); + + $c->transitionTo(Campaign::STATUS_MODERATING); + expect($c->status)->toBe(Campaign::STATUS_MODERATING); + expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING); +}); + +it('из moderating допускает launched и rejected', function (string $to) { + $c = moderatingCampaign(Campaign::STATUS_MODERATING); + $c->transitionTo($to); + expect($c->status)->toBe($to); +})->with([ + Campaign::STATUS_LAUNCHED, + Campaign::STATUS_REJECTED, +]); + +it('rejected → queued разрешён (пересдача)', function () { + $c = moderatingCampaign(Campaign::STATUS_REJECTED); + expect($c->canTransitionTo(Campaign::STATUS_QUEUED))->toBeTrue(); + + $c->transitionTo(Campaign::STATUS_QUEUED); + expect($c->status)->toBe(Campaign::STATUS_QUEUED); +}); + +it('moderating не пускает назад в running', function () { + $c = moderatingCampaign(Campaign::STATUS_MODERATING); + expect(fn () => $c->transitionTo(Campaign::STATUS_RUNNING)) + ->toThrow(DomainException::class); + expect($c->status)->toBe(Campaign::STATUS_MODERATING); +}); + +it('живой finalize (launched=true) → moderating, а НЕ launched', function () { + config()->set('client_tg.sandbox', false); + $tenant = Tenant::factory()->create(); + $campaign = moderatingQueuedCampaign($tenant->id); + moderatingMockRobot(new RobotResult(ok: true, matched: 437, launched: true, campaignId: '2231140')); + + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + + $fresh = Campaign::find($campaign->id); + expect($fresh->status)->toBe(Campaign::STATUS_MODERATING) + ->and($fresh->matched_count)->toBe(437) + ->and($fresh->mts_campaign_id)->toBe('2231140'); +}); + +it('песочница (draft) остаётся draft_ready, не moderating', function () { + config()->set('client_tg.sandbox', true); + $tenant = Tenant::factory()->create(); + $campaign = moderatingQueuedCampaign($tenant->id); + moderatingMockRobot(new RobotResult(ok: true, matched: 400, launched: false, campaignId: 'draft')); + + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + + expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY); +}); diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index ea072043..8f0a5001 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -8,6 +8,20 @@ use Illuminate\Support\Facades\DB; abstract class TestCase extends BaseTestCase { + /** + * RefreshDatabase's migrate:fresh drops tables but NOT Postgres types by + * default. Our initial schema is loaded raw from db/schema.sql, whose + * `CREATE TABLE`s implicitly define composite row-types; when a table drop + * is blocked by a lingering dependency, its row-type survives db:wipe and + * the next schema.sql reload aborts on a duplicate-type error + * («legal_entities … уже существует»), leaving a PARTIAL schema — the + * long-standing intermittent «migrate:fresh иногда прерывается» flake + * (site_events/client_tg_tariffs randomly missing). Dropping types on every + * refresh clears those leftovers and makes migrate:fresh deterministic. + * Testing-only (APP_ENV=testing) — no production impact. + */ + protected $dropTypes = true; + protected function setUp(): void { parent::setUp();