feat(реклама показы): суточное списание по ПОКАЗАМ через счётчик показов вместо кликов
Задача 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', 'тест');
|
||||
|
||||
Reference in New Issue
Block a user