diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php index 05931b32..58ca93bf 100644 --- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php @@ -18,6 +18,7 @@ use App\Services\Advertising\BannerUploadPolicy; use App\Services\Advertising\CampaignAudienceBuilder; use App\Services\Advertising\CampaignEstimateService; use App\Services\Advertising\CampaignLauncher; +use App\Services\Advertising\CampaignReviveService; use App\Services\Advertising\CreativeJobService; use App\Services\Advertising\CreativeValidator; use App\Services\Advertising\YandexDirectClient; @@ -170,8 +171,14 @@ class AdvertisingCampaignController extends Controller // запуске) расходится с тем, что видит клиент. Отдельно landing_url — он уходит в // Яндекс адресом перехода по клику у КАЖДОГО объявления: смена посреди возобновляемого // запуска развела бы объявления по разным адресам (часть со старым, часть с новым). + // + // ОДНО узкое исключение: отклонённую кампанию клиент обязан иметь возможность + // починить. У неё показов нет и денег на ней нет — расходиться с Яндексом нечему. + // 🪤 Исключение написано ПО СТАТУСУ, а не по «есть ли номер кампании»: иначе оно + // открыло бы правку и работающей рекламе, которая крутится за деньги клиента. $editable = array_diff(array_keys($data), ['name']); - $inYandex = $campaign->yandex_campaign_id !== null || $campaign->yandex_segment_id !== null; + $inYandex = ($campaign->yandex_campaign_id !== null || $campaign->yandex_segment_id !== null) + && $campaign->status !== AdCampaign::STATUS_REJECTED; if ($inYandex && $editable !== []) { return response()->json([ 'message' => 'Кампания уже заведена в Яндексе — менять параметры показа нельзя. Название поменять можно.', @@ -330,6 +337,29 @@ class AdvertisingCampaignController extends Controller return response()->json(['status' => $campaign->fresh()->status]); } + /** + * «Исправить» у отклонённой кампании — вернуть её в черновик, чтобы клиент переделал + * картинки привычными экранами и нажал обычную кнопку «Запустить». Второго пути + * запуска не появляется: дальше работает существующий CampaignLauncher. + * + * Второе нажатие видит кампанию уже черновиком и получает отказ 409 — оно стёрло бы + * номера объявлений, которые к тому моменту мог создать новый запуск. + */ + public function revive(Request $request, int $id, CampaignReviveService $service): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + try { + $service->revive($campaign); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 409); + } + + return response()->json(['status' => $campaign->refresh()->status]); + } + /** * Ч.5b — «отправить заявку на запуск». Пока Директ закрыт (заявка на доступ на рассмотрении), * мастер не запускает кампанию в Директе (это Часть 4), а переводит готовую кампанию в статус @@ -803,6 +833,17 @@ class AdvertisingCampaignController extends Controller return null; } + // То же узкое исключение, что и у замка на параметрах (см. update()): отклонённой + // кампании нужна новая картинка, иначе кнопка «Исправить» ведёт в тупик. Показов + // у неё нет, деньги вернулись клиенту при отказе. + // 🪤 По СТАТУСУ, а не по наличию номера кампании — иначе исключение протечёт + // на работающую рекламу. Отдельная защита баннера с собственным `yandex_ad_id` + // (см. deleteBanner) остаётся на месте и после оживления снимается сама: + // сервис оживления обнуляет номера только у отклонённых баннеров. + if ($campaign->status === AdCampaign::STATUS_REJECTED) { + return null; + } + return response()->json([ 'message' => 'Кампания уже заведена в Яндексе — менять набор баннеров нельзя.', ], 409); diff --git a/app/routes/web.php b/app/routes/web.php index fcd43676..fdb2a87d 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -419,6 +419,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group Route::post('/campaigns/{id}/banners/approve', 'App\Http\Controllers\Api\AdvertisingCampaignController@approveBanners')->whereNumber('id'); Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id'); Route::post('/campaigns/{id}/submit', 'App\Http\Controllers\Api\AdvertisingCampaignController@submit')->whereNumber('id'); + // «Исправить» — вернуть отклонённую кампанию в черновик. Отдельного пути запуска + // за этим не появляется: дальше клиент жмёт обычное «Запустить». + Route::post('/campaigns/{id}/revive', 'App\Http\Controllers\Api\AdvertisingCampaignController@revive')->whereNumber('id'); Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id'); Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id'); Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id'); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php index c8b74adb..729cc81b 100644 --- a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php +++ b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php @@ -717,3 +717,87 @@ it('в списке кампаний у отклонённой видна при expect($res->getContent())->not->toContain('yandex_cost_rub') ->and($res->getContent())->not->toContain('ad_margin_percent'); }); + +/** Кампания заданного статуса, уже заведённая в Яндексе, со своим пользователем. */ +function campaignInYandex(string $status): array +{ + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + 'status' => $status, + 'yandex_campaign_id' => 555, 'yandex_ad_group_id' => 666, 'yandex_segment_id' => 777, + ]); + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, + 'path' => 'ad-banners/a/300x250.jpg', + 'yandex_creative_id' => 100, 'yandex_ad_id' => 200, + 'moderation_status' => AdCampaignBanner::MOD_REJECTED, + ]); + + return [$tenant, $user, $campaign]; +} + +it('клиент оживляет свою отклонённую кампанию', function () { + config(['services.yandex_direct.enabled' => false]); + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive"); + + $res->assertOk(); + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +it('оживить работающую кампанию нельзя', function () { + config(['services.yandex_direct.enabled' => false]); + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING); + + $this->actingAs($user) + ->postJson("/api/advertising/campaigns/{$campaign->id}/revive") + ->assertStatus(409); + + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +/** + * Двойной щелчок по «Исправить». Второй заход видит кампанию уже черновиком — и обязан + * получить отказ, а не пройти второй раз по чистке номеров: он стёр бы номера объявлений, + * которые к тому моменту мог создать новый запуск. + */ +it('второе нажатие Исправить получает отказ', function () { + config(['services.yandex_direct.enabled' => false]); + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertOk(); + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertStatus(409); +}); + +it('чужую кампанию оживить нельзя', function () { + config(['services.yandex_direct.enabled' => false]); + [, , $campaignA] = campaignInYandex(AdCampaign::STATUS_REJECTED); + [, $userB] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $this->actingAs($userB) + ->postJson("/api/advertising/campaigns/{$campaignA->id}/revive") + ->assertStatus(404); + + expect($campaignA->refresh()->status)->toBe(AdCampaign::STATUS_REJECTED); +}); + +it('у отклонённой кампании правка настроек разрешена', function () { + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $this->actingAs($user) + ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) + ->assertOk(); +}); + +/** 🔑 Исключение не должно протечь на работающую рекламу — она крутится за деньги клиента. */ +it('у работающей кампании правка настроек по-прежнему заперта', function () { + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING); + + $this->actingAs($user) + ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) + ->assertStatus(409); +}); diff --git a/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php b/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php index 12532987..408c278d 100644 --- a/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php +++ b/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php @@ -413,3 +413,23 @@ it('баннер с номером объявления в Яндексе не expect(AdCampaignBanner::find($banner->id))->not->toBeNull(); }); + +/** + * Узкое исключение из замка: у отклонённой кампании показов нет и денег на ней нет, + * поэтому картинку клиент обязан иметь возможность переделать. Соседний тест + * «перезалить баннер после заведения кампании в Яндексе нельзя» держит вторую половину + * правила: у НЕотклонённой кампании замок остаётся на месте. + */ +it('у отклонённой кампании картинку перезалить можно', function () { + Storage::fake('local'); + [$user, $campaign, $banner] = launchedBannerCampaign(); + $campaign->update(['status' => AdCampaign::STATUS_REJECTED]); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('new.jpg', 728, 90), + ]); + + $res->assertStatus(201); + expect($banner->fresh()->bytes)->not->toBe(1000); +});