feat реклама за показы: ручка Исправить и узкое исключение в замке правки
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user