feat(реклама): CampaignLauncher — запуск кампании (сегмент→Директ→заморозка)

This commit is contained in:
Дмитрий
2026-07-24 23:19:23 +03:00
parent 8b2ad5492b
commit ca42e3b3aa
3 changed files with 269 additions and 0 deletions
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Advertising;
use RuntimeException;
final class AudienceTooSmallException extends RuntimeException
{
public function __construct(public readonly int $size)
{
parent::__construct("Аудитория слишком мала для запуска: {$size} (нужно минимум 100).");
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Exceptions\Advertising\AudienceTooSmallException;
use App\Models\AdCampaign;
use App\Services\Sales\YandexAudienceClient;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Оркестратор запуска рекламной кампании: аудитория сегмент Аудиторий
* Директ (retargeting campaign adgroup target ads) заморозка недельного
* бюджета в кошельке статус «на модерации».
*
* MONEY: суммы для Директа считаются через AdMarkup + bcmath, заморозка
* в клиентских рублях через AdWalletService::freeze (уже атомарна и идемпотентна).
*/
final class CampaignLauncher
{
public function __construct(
private readonly CampaignAudienceBuilder $audience,
private readonly AdWalletService $wallet,
) {}
public function launch(AdCampaign $campaign): void
{
if (! config('services.yandex_direct.enabled')) {
throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).');
}
$phones = $this->audience->build($campaign);
if (count($phones) < 100) {
throw new AudienceTooSmallException(count($phones)); // Яндекс не запустит сегмент <100
}
$markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00'));
// 1) Сегмент Аудиторий.
$audienceClient = new YandexAudienceClient((string) config('services.yandex_audience.token'));
$segmentId = (int) $campaign->yandex_segment_id
?: $audienceClient->createSegment('Лидерра кампания #'.$campaign->id, $phones);
// 2) Директ: retargeting → campaign → adgroup → audience target → ads.
$direct = new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
$retId = $direct->addRetargetingList('Лидерра #'.$campaign->id, $segmentId);
// Недельный бюджет в Директ: клиентский ÷ наценку → микросы.
$weeklyYandexRub = $markup->yandexFromClient((string) $campaign->weekly_budget_rub);
$weeklyMicros = (int) bcmul($weeklyYandexRub, '1000000', 0);
$campaignId = $direct->addCampaign('Лидерра #'.$campaign->id, now()->toDateString(), $weeklyMicros);
$adGroupId = $direct->addAdGroup($campaignId, 'Группа #'.$campaign->id, config('services.yandex_direct.region_ids'));
$bidMicros = (int) bcmul($markup->yandexFromClient((string) ($campaign->click_bid_rub ?? '10.00')), '1000000', 0);
$direct->addAudienceTarget($adGroupId, $retId, $bidMicros);
foreach ($campaign->ads as $ad) {
$textAd = ['Title' => $ad->title, 'Text' => $ad->text, 'Href' => $ad->href];
if ($ad->title2) {
$textAd['Title2'] = $ad->title2;
}
if ($ad->image_normal_hash) {
$textAd['AdImageHash'] = $ad->image_normal_hash;
}
$yandexAdId = $direct->addTextAd($adGroupId, $textAd);
$ad->update(['yandex_ad_id' => $yandexAdId, 'moderation_status' => 'MODERATION']);
}
// 3) Заморозить недельный бюджет (клиентские ₽) в кошельке (Часть A).
$this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, (string) $campaign->weekly_budget_rub);
// 4) Записать id и статус.
$campaign->update([
'yandex_segment_id' => $segmentId,
'yandex_retargeting_list_id' => $retId,
'yandex_campaign_id' => $campaignId,
'yandex_ad_group_id' => $adGroupId,
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'launched_at' => now(),
]);
}
}
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
use App\Exceptions\Advertising\AudienceTooSmallException;
use App\Models\AdCampaign;
use App\Models\AdCampaignAd;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignLauncher;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
function fakeYandexEndpoints(): void
{
Http::fake([
'*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]),
'*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]),
'*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]),
'*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]),
'*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]),
'*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]),
'*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
]);
}
function configureYandex(): void
{
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
config(['services.yandex_audience.token' => 'AUDTOKEN']);
}
/** Наполняет ad_campaign_phones $count уникальными номерами для кампании (обходит фабрику Deal — быстрее). */
function seedAudience(AdCampaign $campaign, int $count): void
{
$rows = [];
for ($i = 0; $i < $count; $i++) {
$rows[] = [
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'phone' => sprintf('799900%05d', $i),
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
}
DB::table('ad_campaign_phones')->insert($rows);
}
it('launches a campaign: segment → Direct → freeze budget → pending moderation', function () {
configureYandex();
fakeYandexEndpoints();
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'C',
'audience_days' => 10,
'use_uploaded_list' => true,
'weekly_budget_rub' => '2600.00',
'click_bid_rub' => '13.00',
]);
$ad = AdCampaignAd::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://liderra.ru',
'moderation_status' => 'draft',
]);
seedAudience($campaign, 100);
app(CampaignLauncher::class)->launch($campaign);
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION)
->and($campaign->yandex_segment_id)->toBe(900001)
->and($campaign->yandex_retargeting_list_id)->toBe(111)
->and($campaign->yandex_campaign_id)->toBe(222)
->and($campaign->yandex_ad_group_id)->toBe(333)
->and($campaign->launched_at)->not->toBeNull();
$ad->refresh();
expect($ad->yandex_ad_id)->toBe(555)
->and($ad->moderation_status)->toBe('MODERATION');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->frozen_rub)->toBe('2600.00');
$hold = AdWalletHold::where('tenant_id', $tenant->id)
->where('channel', 'yandex')->where('source_type', 'campaign')
->where('source_id', $campaign->id)->where('status', AdWalletHold::STATUS_ACTIVE)->first();
expect($hold)->not->toBeNull()
->and($hold->amount_rub)->toBe('2600.00');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/json/v5/campaigns')) {
return false;
}
$limit = $request['params']['Campaigns'][0]['TextCampaign']['BiddingStrategy']['Network']['NetworkHighestPosition']['WeeklySpendLimit'] ?? null;
return $limit === 2000000000;
});
});
it('throws AudienceTooSmallException and does not freeze when audience is under 100', function () {
configureYandex();
fakeYandexEndpoints();
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'C',
'audience_days' => 10,
'use_uploaded_list' => true,
'weekly_budget_rub' => '2600.00',
'click_bid_rub' => '13.00',
]);
seedAudience($campaign, 50);
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
->toThrow(AudienceTooSmallException::class);
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->frozen_rub)->toBe('0.00');
$holdExists = AdWalletHold::where('tenant_id', $tenant->id)
->where('source_type', 'campaign')->where('source_id', $campaign->id)
->where('status', AdWalletHold::STATUS_ACTIVE)->exists();
expect($holdExists)->toBeFalse();
});
it('throws RuntimeException when yandex_direct is disabled and creates nothing', function () {
config(['services.yandex_direct.enabled' => false]);
fakeYandexEndpoints();
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'C',
'audience_days' => 10,
'use_uploaded_list' => false,
'weekly_budget_rub' => '2600.00',
'click_bid_rub' => '13.00',
]);
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
->toThrow(RuntimeException::class);
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT)
->and($campaign->yandex_segment_id)->toBeNull();
Http::assertNothingSent();
});