From 3b55c96a85c0cb87da770e7fb8c1902ce2d5e50a 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: Mon, 27 Jul 2026 00:38:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B):=20=D1=81=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=9F=D0=9E=D0=9A=D0=90?= =?UTF-8?q?=D0=97=D0=90=D0=9C=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача 8a. Джоб ChargeCampaignSpendJob переключён с модели за клики и наценки 30% делением на модель за показы и наценку 40% вычитанием через готовый CampaignImpressionCharger. YandexDirectClient.getCampaignSpend заменён на getCampaignImpressions за всё время кампании ALL_TIME, счётчик сам считает дельту от уже списанного и идемпотентен по external_key yandex-imp. Всё за рубильником yandex_direct.enabled ВЫКЛ, на боевых деньгах ничего не крутится. Реклама-модуль 190/190 зелёный. Отчёт показов помечен «проверить при go-live» ReportName без даты, ALL_TIME, формат TSV. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/ChargeCampaignSpendJob.php | 53 +++++++------------ .../Advertising/YandexDirectClient.php | 25 ++++----- .../ChargeCampaignSpendJobTest.php | 34 ++++++------ 3 files changed, 48 insertions(+), 64 deletions(-) diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php index 3def97a3..5ac2d4ed 100644 --- a/app/app/Jobs/ChargeCampaignSpendJob.php +++ b/app/app/Jobs/ChargeCampaignSpendJob.php @@ -5,44 +5,45 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\AdCampaign; -use App\Services\Advertising\AdMarkup; use App\Services\Advertising\AdStopAllService; use App\Services\Advertising\AdWalletGate; -use App\Services\Advertising\AdWalletService; +use App\Services\Advertising\CampaignImpressionCharger; use App\Services\Advertising\YandexDirectClient; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Throwable; /** - * Суточное списание расхода Директа по кликам (Cost × наценка) со всех - * тенантов сразу. + * Суточное списание расхода Директа по ПОКАЗАМ (плоская цена ₽/1000, наценка + * вычитанием) со всех тенантов сразу. * * 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном * соединении, БЕЗ tenant-контекста. Перечисление кампаний через дефолтное * соединение без контекста дало бы 0 строк по RLS (молчаливый сбой), а чтение - * кошелька внутри AdWalletService/AdWalletGate/AdStopAllService вообще упало бы - * ModelNotFound. Поэтому: + * кошелька внутри CampaignImpressionCharger/AdWalletGate/AdStopAllService вообще + * упало бы ModelNotFound. Поэтому: * — кампании перечисляем плоскими строками через `pgsql_supplier` (BYPASSRLS, * аналог SyncSupplierProjectsJob/SendNewLeadsDigestJob), БЕЗ открытой * транзакции — не держим транзакцию во время сетевого запроса к Директу; - * — денежная операция (charge/isSolvent/stopAll) — под tenant-контекстом - * (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ соединении, где живут - * AdWallet/AdWalletTransaction; тогда crm_app_user с контекстом проходит - * RLS корректно. Денежные операции всегда принимают tenant_id явным - * аргументом — никакого смешения кошельков между тенантами. + * — денежная операция (charge через CampaignImpressionCharger/isSolvent/stopAll) + * — под tenant-контекстом (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ + * соединении, где живут AdCampaign/AdWallet/AdWalletTransaction; тогда + * crm_app_user с контекстом проходит RLS корректно. Денежные операции + * всегда принимают tenant_id явным аргументом — никакого смешения + * кошельков между тенантами. * * Рубильник: пока services.yandex_direct.enabled=false — джоб не делает ни * одного обращения к Яндексу и ничего не списывает. * - * Идемпотентность — через AdWalletService::charge по external_key - * "yandex:{campaign->id}:{date}" (дата — вчерашние сутки, которые списываем). + * Идемпотентность — через CampaignImpressionCharger → AdWalletService::charge + * по external_key "yandex-imp:{campaign->id}:{billable}" (billable — число + * оплачиваемых показов на момент списания, счётчик сам считает дельту от + * уже списанного charged_client_rub). */ class ChargeCampaignSpendJob implements ShouldQueue { @@ -54,12 +55,10 @@ class ChargeCampaignSpendJob implements ShouldQueue return; } - $wallet = app(AdWalletService::class); + $charger = app(CampaignImpressionCharger::class); $gate = app(AdWalletGate::class); $stopAll = app(AdStopAllService::class); - $date = Carbon::now()->subDay()->toDateString(); - $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00')); $direct = new YandexDirectClient( $this->configString('services.yandex_direct.base_url'), $this->configString('services.yandex_direct.token'), @@ -72,26 +71,14 @@ class ChargeCampaignSpendJob implements ShouldQueue foreach ($rows as $row) { try { - $spend = $direct->getCampaignSpend((int) $row->yandex_campaign_id, $date); - $clientCost = $markup->clientFromYandex($spend['cost']); - - if (bccomp($clientCost, '0', 2) === 0) { - continue; - } - + $delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id); $tenantId = (int) $row->tenant_id; - DB::transaction(function () use ($row, $clientCost, $date, $tenantId, $wallet, $gate, $stopAll): void { + DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void { DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - $wallet->charge( - $tenantId, - 'yandex', - 'campaign', - (int) $row->id, - $clientCost, - "yandex:{$row->id}:{$date}", - ); + $campaign = AdCampaign::where('id', $row->id)->firstOrFail(); + $charger->charge($campaign, $delivered); if (! $gate->isSolvent($tenantId)) { $stopAll->stopAll($tenantId); diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php index fabb2cc0..dd1c3a67 100644 --- a/app/app/Services/Advertising/YandexDirectClient.php +++ b/app/app/Services/Advertising/YandexDirectClient.php @@ -270,20 +270,19 @@ final class YandexDirectClient } /** - * Reports CAMPAIGN_PERFORMANCE_REPORT за дату. Возвращает ['clicks'=>int,'cost'=>string ₽]. + * Reports CAMPAIGN_PERFORMANCE_REPORT за ВСЁ ВРЕМЯ кампании (накопительно). Возвращает + * суммарное число показов — источник для CampaignImpressionCharger, который сам считает + * дельту от уже списанного и идемпотентен по external_key. * * ⚠️ TODO(verify): Reports часто отдаёт TSV (не JSON) и может строиться асинхронно - * (HTTP 201/202 + заголовок retryIn) — перед боем сверить формат/async/НДС по + * (HTTP 201/202 + заголовок retryIn) — перед боем сверить формат/async по * yandex.ru/dev/direct/doc/ru/reports/* и при необходимости добавить ретрай-поллинг. * Здесь — минимальный синхронный путь (для песочницы/тестов). - * - * @return array{clicks: int, cost: string} */ - public function getCampaignSpend(int $campaignId, string $date): array + public function getCampaignImpressions(int $campaignId): int { $resp = Http::withToken($this->token) ->withHeaders([ - 'returnMoneyInMicros' => 'false', 'skipReportHeader' => 'true', 'skipReportSummaryRow' => 'true', 'skipColumnHeader' => 'true', @@ -291,29 +290,25 @@ final class YandexDirectClient ->acceptJson() ->post(rtrim($this->baseUrl, '/').'/json/v5/reports', ['params' => [ 'SelectionCriteria' => [ - 'DateFrom' => $date, - 'DateTo' => $date, 'Filter' => [[ 'Field' => 'CampaignId', 'Operator' => 'EQUALS', 'Values' => [(string) $campaignId], ]], ], - 'FieldNames' => ['Clicks', 'Cost'], - 'ReportName' => 'spend-'.$campaignId.'-'.$date, + 'FieldNames' => ['Impressions'], + 'ReportName' => 'impressions-'.$campaignId, 'ReportType' => 'CAMPAIGN_PERFORMANCE_REPORT', - 'DateRangeType' => 'CUSTOM_DATE', + 'DateRangeType' => 'ALL_TIME', 'Format' => 'TSV', - 'IncludeVAT' => 'YES', // ⚠️ verify: влияет на Cost ]]); $body = trim((string) $resp->body()); if ($body === '') { - return ['clicks' => 0, 'cost' => '0.00']; + return 0; } - [$clicks, $cost] = array_pad(explode("\t", $body), 2, '0'); - return ['clicks' => (int) trim($clicks), 'cost' => bcadd(trim($cost) !== '' ? trim($cost) : '0', '0', 2)]; + return (int) $body; } /** diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php index 36fa825f..589a7161 100644 --- a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php +++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php @@ -10,7 +10,6 @@ use App\Models\AdWalletTransaction; use App\Models\Tenant; use App\Services\Advertising\AdWalletService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Tests\Concerns\SharesSupplierPdo; @@ -29,8 +28,8 @@ function configureYandexDirectForSpend(): void config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); } -/** TSV: Clicks=7, Cost=200.00 ₽ (яндексовы рубли, до наценки). */ -function fakeYandexReportsSpend(string $tsv = "7\t200.00"): void +/** TSV: накопительное число показов за всё время кампании (ALL_TIME). */ +function fakeYandexImpressionsReport(string $tsv = '2500'): void { Http::fake(['*/json/v5/reports' => Http::response($tsv, 200)]); } @@ -43,13 +42,16 @@ function makeRunningCampaign(int $tenantId): AdCampaign 'status' => AdCampaign::STATUS_RUNNING, 'yandex_campaign_id' => 222, 'audience_days' => 10, - 'weekly_budget_rub' => '500.00', + 'use_uploaded_list' => true, + 'frequency' => 15, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, ]); } -it('charges the client the marked-up cost of yesterday\'s Direct spend', function () { +it('charges the client for delivered impressions at the flat CPM (client_cpm_rub not set → default 120.00 ₽/1000)', function () { configureYandexDirectForSpend(); - fakeYandexReportsSpend(); + fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); @@ -58,21 +60,21 @@ it('charges the client the marked-up cost of yesterday\'s Direct spend', functio app(ChargeCampaignSpendJob::class)->handle(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); - expect($wallet->balance_rub)->toBe('740.00'); // 1000.00 − 260.00 + // 2500 показов × 120.00 ₽/1000 = 300.00 ₽ клиенту. + expect($wallet->balance_rub)->toBe('700.00'); // 1000.00 − 300.00 - $date = Carbon::now()->subDay()->toDateString(); $tx = AdWalletTransaction::where('tenant_id', $tenant->id) - ->where('external_key', "yandex:{$campaign->id}:{$date}") + ->where('external_key', "yandex-imp:{$campaign->id}:2500") ->first(); expect($tx)->not->toBeNull() ->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE) - ->and($tx->amount_rub)->toBe('-260.00'); + ->and($tx->amount_rub)->toBe('-300.00'); }); it('does not double-charge on a second run (idempotent by external_key)', function () { configureYandexDirectForSpend(); - fakeYandexReportsSpend(); + fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); @@ -85,17 +87,17 @@ it('does not double-charge on a second run (idempotent by external_key)', functi $balanceAfterSecond = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub; expect($balanceAfterSecond)->toBe($balanceAfterFirst) - ->and($balanceAfterSecond)->toBe('740.00'); + ->and($balanceAfterSecond)->toBe('700.00'); }); it('stops all advertising when the charge leaves the tenant insolvent', function () { Event::fake([AdvertisingStopped::class]); configureYandexDirectForSpend(); - fakeYandexReportsSpend(); + fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); - // Малый баланс, крупная заморозка → после списания 260.00 баланс уйдёт ниже frozen. - app(AdWalletService::class)->topup($tenant->id, '300.00', 'yandex', 'тест'); + // Малый баланс, крупная заморозка → после списания 300.00 баланс (100.00) уйдёт ниже frozen (200.00). + app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест'); $campaign = makeRunningCampaign($tenant->id); app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '200.00'); @@ -106,7 +108,7 @@ it('stops all advertising when the charge leaves the tenant insolvent', function it('does nothing and sends no HTTP when the yandex_direct switch is off', function () { config(['services.yandex_direct.enabled' => false]); - fakeYandexReportsSpend(); + fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест');