From ca42e3b3aa2e6217fa269be811af7be3de95fd0f 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: Fri, 24 Jul 2026 23:19:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0?= =?UTF-8?q?):=20CampaignLauncher=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20(=D1=81=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=E2=86=92=D0=94?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BA=D1=82=E2=86=92=D0=B7=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B7=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Advertising/AudienceTooSmallException.php | 15 ++ .../Services/Advertising/CampaignLauncher.php | 87 +++++++++ .../Advertising/CampaignLauncherTest.php | 167 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 app/app/Exceptions/Advertising/AudienceTooSmallException.php create mode 100644 app/app/Services/Advertising/CampaignLauncher.php create mode 100644 app/tests/Feature/Advertising/CampaignLauncherTest.php diff --git a/app/app/Exceptions/Advertising/AudienceTooSmallException.php b/app/app/Exceptions/Advertising/AudienceTooSmallException.php new file mode 100644 index 00000000..8c7aa859 --- /dev/null +++ b/app/app/Exceptions/Advertising/AudienceTooSmallException.php @@ -0,0 +1,15 @@ +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(), + ]); + } +} diff --git a/app/tests/Feature/Advertising/CampaignLauncherTest.php b/app/tests/Feature/Advertising/CampaignLauncherTest.php new file mode 100644 index 00000000..43886309 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignLauncherTest.php @@ -0,0 +1,167 @@ + 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(); +});