feat реклама за показы: ручка Исправить и узкое исключение в замке правки

This commit is contained in:
Дмитрий
2026-07-28 11:43:58 +03:00
parent 729a2636df
commit d29f45da11
4 changed files with 149 additions and 1 deletions
@@ -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);
+3
View File
@@ -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');
@@ -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);
});
@@ -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);
});