feat(реклама): Часть 6 — списание по факту показов + клиентский отчёт по показам
CampaignImpressionCharger: списание с рекламного кошелька за фактически
показанные показы (показано×120/1000, дельта от уже списанного, идемпотентно
по external_key yandex-imp:{id}:{billable}), режет по оплаченному, статус
completed при достижении оплаченного; bcmath, 6 boundary-тестов. Клиентский
отчёт CampaignReportDialog переведён с недельного бюджета на показы
(оплачено/показано/частота/потрачено); статусы queued/completed в списке и
отчёте. Маржа/yandex_cost клиенту не видны. Бэкенд 105/105, фронт 104/104.
Отложено до Части 4 (нужен Директ): джоб, тянущий фактические показы из отчёта
Директа и зовущий CampaignImpressionCharger; campaigns.suspend при стопе;
переделка админ-маржи с наценки-% на реальный yandex_cost_rub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,8 @@ class AdCampaign extends Model
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
/** yandex_cost_rub — наш расход у Яндекса (основа маржи), клиенту НИКОГДА не показываем.
|
||||
* Только атрибут-доступ (админка строит явные массивы), сериализация — скрыта (Ч.5b, спека §3). */
|
||||
protected $hidden = ['yandex_cost_rub'];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Advertising;
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Списание с рекламного кошелька клиента ЗА ФАКТИЧЕСКИ показанные показы.
|
||||
*
|
||||
* Direct-НЕзависим: принимает накопительное число показов числом (Часть 4
|
||||
* подаёт его из отчёта Директа). Списывает ДЕЛЬТУ между суммарной клиентской
|
||||
* стоимостью (по плоской цене `ad_settings.client_cpm_rub`) и уже списанным
|
||||
* (`charged_client_rub`) — идемпотентно за счёт external_key в AdWalletService.
|
||||
*
|
||||
* MONEY: только bcmath (scale 2), без float.
|
||||
*/
|
||||
final class CampaignImpressionCharger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdImpressionPricing $pricing,
|
||||
private readonly AdWalletService $wallet,
|
||||
) {}
|
||||
|
||||
public function charge(AdCampaign $campaign, int $deliveredImpressions): void
|
||||
{
|
||||
$cpm = (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00');
|
||||
|
||||
$paid = (int) ($campaign->paid_impressions ?? 0);
|
||||
$billable = $paid > 0
|
||||
? min($deliveredImpressions, $paid)
|
||||
: max(0, $deliveredImpressions);
|
||||
|
||||
$totalCost = $this->pricing->clientCostRub($billable, $cpm);
|
||||
|
||||
$already = (string) ($campaign->charged_client_rub ?? '0.00');
|
||||
$delta = bcsub($totalCost, $already, 2);
|
||||
|
||||
if (bccomp($delta, '0.00', 2) === 1) {
|
||||
$this->wallet->charge(
|
||||
(int) $campaign->tenant_id,
|
||||
'yandex',
|
||||
'campaign',
|
||||
(int) $campaign->id,
|
||||
$delta,
|
||||
"yandex-imp:{$campaign->id}:{$billable}",
|
||||
);
|
||||
}
|
||||
|
||||
$update = [
|
||||
'delivered_impressions' => $deliveredImpressions,
|
||||
'charged_client_rub' => $totalCost,
|
||||
];
|
||||
|
||||
if ($paid > 0 && $deliveredImpressions >= $paid) {
|
||||
$update['status'] = AdCampaign::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
$campaign->update($update);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ export type CampaignStatus =
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'rejected'
|
||||
| 'stopped_no_funds';
|
||||
| 'stopped_no_funds'
|
||||
| 'completed';
|
||||
|
||||
/** Ответ GET /api/advertising/wallet — статус рекламного кошелька тенанта. */
|
||||
export interface AdWalletStatus {
|
||||
@@ -44,6 +45,8 @@ export interface Campaign {
|
||||
estimated_impressions?: number;
|
||||
/** Итоговый бюджет показов клиента — та же смета, что и estimated_impressions. */
|
||||
budget_rub?: string;
|
||||
/** Сколько показов фактически откручено на данный момент (Ч.6, CampaignImpressionCharger). */
|
||||
delivered_impressions?: number;
|
||||
/**
|
||||
* Приходят и в POST/PATCH-ответе, и в GET .../{id} (detail, controller::show
|
||||
* отдаёт модель целиком) — но НЕ в списке GET /campaigns (index явно
|
||||
|
||||
@@ -61,6 +61,8 @@ const STATUS_META: Record<CampaignStatus, StatusMeta> = {
|
||||
stopped_no_funds: { label: 'Остановлено (нет денег)', color: 'error', icon: 'mdi-alert-circle' },
|
||||
paused: { label: 'На паузе', color: 'grey', icon: 'mdi-pause-circle' },
|
||||
draft: { label: 'Черновик', color: 'grey', icon: 'mdi-file-outline' },
|
||||
// Ч.6 — показы полностью откручены/оплачены (CampaignImpressionCharger).
|
||||
completed: { label: 'Показы откручены', color: 'success', icon: 'mdi-check-all' },
|
||||
};
|
||||
|
||||
function statusMeta(status: CampaignStatus): StatusMeta {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* CampaignReportDialog — диалог отчёта по кампании (Часть B2, задача T2).
|
||||
* CampaignReportDialog — диалог отчёта по кампании (Часть B2 T2 → Ч.6 Task 3, модель «за показы»).
|
||||
*
|
||||
* При открытии грузит GET /api/advertising/campaigns/{id} (api/advertising.ts::
|
||||
* fetchCampaign) и показывает то, что реально отдаёт backend: имя/статус
|
||||
* кампании, недельный бюджет, ПОТРАЧЕНО (spent_rub) и список объявлений с их
|
||||
* статусом модерации. Показы/клики/охват НЕ показываем — Reports по В8
|
||||
* недоступен, backend их не отдаёт (Р35 — фронт не выдумывает данные).
|
||||
* кампании, показы (оплачено/показано/частота) и ПОТРАЧЕНО (spent_rub) —
|
||||
* деньги клиента, списанные по факту откручённых показов
|
||||
* (CampaignImpressionCharger). yandex_cost_rub/маржу НЕ показываем — их и нет
|
||||
* в клиентском JSON ($hidden на модели, Р35 — фронт не выдумывает данные).
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchCampaign, type CampaignDetail, type CampaignStatus } from '../../api/advertising';
|
||||
@@ -26,11 +27,15 @@ interface StatusMeta {
|
||||
|
||||
const STATUS_META: Record<CampaignStatus, StatusMeta> = {
|
||||
running: { label: 'Крутится', color: 'success', icon: 'mdi-play-circle' },
|
||||
// Ч.5b — заявка отправлена (submitCampaign), ждёт запуска оператором (Директа пока нет).
|
||||
queued: { label: 'Готова к запуску', color: 'info', icon: 'mdi-clock-check-outline' },
|
||||
pending_moderation: { label: 'Проверяется Яндексом', color: 'warning', icon: 'mdi-clock-outline' },
|
||||
rejected: { label: 'Отклонено', color: 'error', icon: 'mdi-close-circle' },
|
||||
stopped_no_funds: { label: 'Остановлено (нет денег)', color: 'error', icon: 'mdi-alert-circle' },
|
||||
paused: { label: 'На паузе', color: 'grey', icon: 'mdi-pause-circle' },
|
||||
draft: { label: 'Черновик', color: 'grey', icon: 'mdi-file-outline' },
|
||||
// Ч.6 — показы полностью откручены/оплачены (CampaignImpressionCharger).
|
||||
completed: { label: 'Показы откручены', color: 'success', icon: 'mdi-check-all' },
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -62,6 +67,11 @@ watch(
|
||||
function close(): void {
|
||||
model.value = false;
|
||||
}
|
||||
|
||||
/** Число показов/частоты — «—» вместо пустоты, не роняем на undefined/null. */
|
||||
function formatCount(value: number | null | undefined): string {
|
||||
return value === null || value === undefined ? '—' : String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,9 +101,23 @@ function close(): void {
|
||||
</div>
|
||||
|
||||
<div class="report-stat mb-1">
|
||||
<span class="text-caption text-medium-emphasis">Бюджет в неделю</span>
|
||||
<span class="num text-body-2 font-weight-medium">
|
||||
{{ formatPlain(Number(detail.campaign.weekly_budget_rub)) }}
|
||||
<span class="text-caption text-medium-emphasis">Показов оплачено</span>
|
||||
<span class="num text-body-2 font-weight-medium" data-testid="report-impressions-paid">
|
||||
{{ formatCount(detail.campaign.estimated_impressions) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="report-stat mb-1">
|
||||
<span class="text-caption text-medium-emphasis">Показано</span>
|
||||
<span class="num text-body-2 font-weight-medium" data-testid="report-impressions-delivered">
|
||||
{{ formatCount(detail.campaign.delivered_impressions) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="report-stat mb-1">
|
||||
<span class="text-caption text-medium-emphasis">Частота</span>
|
||||
<span class="num text-body-2 font-weight-medium" data-testid="report-frequency">
|
||||
{{ formatCount(detail.campaign.frequency) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\Advertising\CampaignImpressionCharger;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makePokazyCampaign(int $tenantId, ?int $paidImpressions): AdCampaign
|
||||
{
|
||||
return AdCampaign::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => 'Кампания за показы',
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'audience_days' => 30,
|
||||
'use_uploaded_list' => true,
|
||||
'frequency' => 15,
|
||||
'estimated_impressions' => $paidImpressions,
|
||||
'paid_impressions' => $paidImpressions,
|
||||
]);
|
||||
}
|
||||
|
||||
it('charges the client for actually delivered impressions', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
|
||||
$campaign = makePokazyCampaign($tenant->id, 10000);
|
||||
|
||||
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->charged_client_rub)->toBe('300.00')
|
||||
->and($campaign->delivered_impressions)->toBe(2500)
|
||||
->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($wallet->balance_rub)->toBe('9700.00');
|
||||
|
||||
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('does not double-charge when called again with the same delivered count', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
|
||||
$campaign = makePokazyCampaign($tenant->id, 10000);
|
||||
|
||||
$charger = app(CampaignImpressionCharger::class);
|
||||
$charger->charge($campaign, 2500);
|
||||
$charger->charge($campaign->refresh(), 2500);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->charged_client_rub)->toBe('300.00');
|
||||
|
||||
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('charges only the delta on a later top-up call', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
|
||||
$campaign = makePokazyCampaign($tenant->id, 10000);
|
||||
|
||||
$charger = app(CampaignImpressionCharger::class);
|
||||
$charger->charge($campaign, 2500);
|
||||
$charger->charge($campaign->refresh(), 6000);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->charged_client_rub)->toBe('720.00')
|
||||
->and($campaign->delivered_impressions)->toBe(6000);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($wallet->balance_rub)->toBe('9280.00'); // 10000 − 720
|
||||
|
||||
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('marks the campaign completed once delivered reaches the paid amount', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
|
||||
$campaign = makePokazyCampaign($tenant->id, 5000);
|
||||
|
||||
app(CampaignImpressionCharger::class)->charge($campaign, 5000);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->charged_client_rub)->toBe('600.00')
|
||||
->and($campaign->delivered_impressions)->toBe(5000)
|
||||
->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED);
|
||||
});
|
||||
|
||||
it('caps billable impressions at the paid amount when delivered overshoots', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
|
||||
$campaign = makePokazyCampaign($tenant->id, 5000);
|
||||
|
||||
app(CampaignImpressionCharger::class)->charge($campaign, 8000);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->charged_client_rub)->toBe('600.00')
|
||||
->and($campaign->delivered_impressions)->toBe(8000)
|
||||
->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($wallet->balance_rub)->toBe('9400.00'); // 10000 − 600
|
||||
});
|
||||
|
||||
it('charges nothing for zero delivered impressions', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
|
||||
$campaign = makePokazyCampaign($tenant->id, 5000);
|
||||
|
||||
app(CampaignImpressionCharger::class)->charge($campaign, 0);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->charged_client_rub)->toBe('0.00')
|
||||
->and($campaign->delivered_impressions)->toBe(0)
|
||||
->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($wallet->balance_rub)->toBe('10000.00');
|
||||
|
||||
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
/**
|
||||
* CampaignReportDialog под модель «за показы» (Ч.6, Task 3).
|
||||
*
|
||||
* Диалог должен показывать показы (оплачено/показано/частота) + потрачено
|
||||
* (списанное по факту CampaignImpressionCharger), метку статуса queued/completed,
|
||||
* и НЕ показывать старый недельный клик-бюджет / нашу маржу (yandex_cost) / цену
|
||||
* за клик — клиент их не видит (Р35, $hidden на модели).
|
||||
*/
|
||||
const { fetchCampaignMock } = vi.hoisted(() => ({
|
||||
fetchCampaignMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/advertising', () => ({
|
||||
fetchCampaign: fetchCampaignMock,
|
||||
}));
|
||||
|
||||
import CampaignReportDialog from '../../resources/js/components/advertising/CampaignReportDialog.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const DIALOG_STUBS = {
|
||||
VDialog: {
|
||||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
};
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe('CampaignReportDialog.vue (Ч.6 Task 3 — отчёт по показам)', () => {
|
||||
beforeEach(() => {
|
||||
fetchCampaignMock.mockReset();
|
||||
});
|
||||
|
||||
it('completed-кампания показов: показывает показано/частоту/потрачено + метку «Показы откручены», без weekly/yandex_cost/клика', async () => {
|
||||
fetchCampaignMock.mockResolvedValue({
|
||||
campaign: {
|
||||
id: 77,
|
||||
name: 'Директора Казани',
|
||||
status: 'completed',
|
||||
audience_days: 30,
|
||||
launched_at: '2026-07-01T10:00:00Z',
|
||||
estimated_impressions: 5000,
|
||||
delivered_impressions: 5000,
|
||||
frequency: 5,
|
||||
},
|
||||
ads: [],
|
||||
spent_rub: '600.00',
|
||||
});
|
||||
|
||||
const w = mount(CampaignReportDialog, {
|
||||
props: { modelValue: false, campaignId: 77 },
|
||||
global: { plugins: [vuetify], stubs: DIALOG_STUBS },
|
||||
});
|
||||
|
||||
await w.setProps({ modelValue: true });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(fetchCampaignMock).toHaveBeenCalledWith(77);
|
||||
|
||||
// Метка статуса.
|
||||
expect(w.text()).toContain('Показы откручены');
|
||||
|
||||
// Показано.
|
||||
const delivered = w.find('[data-testid="report-impressions-delivered"]');
|
||||
expect(delivered.exists()).toBe(true);
|
||||
expect(delivered.text()).toBe('5000');
|
||||
|
||||
// Показов оплачено (смета).
|
||||
const paid = w.find('[data-testid="report-impressions-paid"]');
|
||||
expect(paid.exists()).toBe(true);
|
||||
expect(paid.text()).toBe('5000');
|
||||
|
||||
// Частота.
|
||||
const frequency = w.find('[data-testid="report-frequency"]');
|
||||
expect(frequency.exists()).toBe(true);
|
||||
expect(frequency.text()).toBe('5');
|
||||
|
||||
// Потрачено.
|
||||
const spent = w.find('[data-testid="report-spent"]');
|
||||
expect(spent.exists()).toBe(true);
|
||||
expect(spent.text()).toBe(`${new Intl.NumberFormat('ru-RU').format(600)} ₽`);
|
||||
|
||||
// 🔴 Клиент не видит маржу/цену за клик/старый недельный бюджет.
|
||||
expect(w.text()).not.toMatch(/Бюджет в неделю/);
|
||||
expect(w.text()).not.toMatch(/weekly/i);
|
||||
expect(w.text()).not.toMatch(/yandex_cost/i);
|
||||
expect(w.text()).not.toMatch(/клик/i);
|
||||
});
|
||||
|
||||
it('queued-кампания: метка «Готова к запуску», пустые показы/частота — «—», не роняет', async () => {
|
||||
fetchCampaignMock.mockResolvedValue({
|
||||
campaign: {
|
||||
id: 78,
|
||||
name: 'Черновик показов',
|
||||
status: 'queued',
|
||||
audience_days: 14,
|
||||
launched_at: null,
|
||||
},
|
||||
ads: [],
|
||||
spent_rub: '0.00',
|
||||
});
|
||||
|
||||
const w = mount(CampaignReportDialog, {
|
||||
props: { modelValue: true, campaignId: 78 },
|
||||
global: { plugins: [vuetify], stubs: DIALOG_STUBS },
|
||||
});
|
||||
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.text()).toContain('Готова к запуску');
|
||||
expect(w.find('[data-testid="report-impressions-delivered"]').text()).toBe('—');
|
||||
expect(w.find('[data-testid="report-impressions-paid"]').text()).toBe('—');
|
||||
expect(w.find('[data-testid="report-frequency"]').text()).toBe('—');
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,10 @@
|
||||
- ✅ **Подана заявка на ПОЛНЫЙ (боевой) доступ 26.07** через `apiCertificationUpgradeRequest` (НЕ AddRequest — тот даёт «Заявка уже создана Вами»; Upgrade повышает существующую). Кабинет sasha261185 → API → Мои заявки: приложение «Лидерра — реклама на кандидатов», **доступ: полный, статус: новая** (на рассмотрении). «Программный доступ: открыт», IP без ограничений. Заполнено: контакт info@liderra.ru, компания «Лидерра (ИП Кондратьев П. Д.)» / liderra.ru, тип «Прямой заказчик, автоматизирую собственные кампании», PHP+JSON, логин sasha261185, цели automation+sync, функции статистика+управление кампаниями+ставки, описание схемы (CpmBannerCampaign flow), скриншот интерфейса портала, демо-доступ (info@lkomega.ru). Ждём вердикт Яндекса.
|
||||
- 🧱 **Вторая стена ПОДТВЕРЖДЕНА официальной докой (26.07):** объявление медийной CPM-кампании `CpmBannerAdBuilderAd` требует `Creative.CreativeId`; картинку через `AdImageHash` (как у обычных объявлений) передать **нельзя**. `creatives.add` умеет **только видео** (VideoExtensionCreative по VideoId) — графический баннер-креатив через API создать невозможно. НО: в **веб-кабинете** готовый баннер-креатив создать **можно** (загрузить готовый JPG/PNG/GIF / по шаблону / «Идеи креативов»). ⇒ Часть 4 = **ГИБРИД, выполнимый**: портал генерит баннеры (Часть 3) + автоматизирует сегмент+кампанию+группу+стратегию+частоту+бюджет+объявление через API; **баннер-креатив человек один раз заливает в кабинет Яндекса и вставляет CreativeId в портал**.
|
||||
- Что делать, когда откроют доступ: переделать `YandexDirectClient` (`TextCampaign`→`CpmBannerCampaign`, `CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, `AverageCpm`/`SpendLimit`) + `CampaignLauncher` (деньги по показам), объявления через CreativeId. Обкатка в песочнице. TDD через `Http::fake` (структуру campaigns.add CpmBannerCampaign сверял — yandex.ru/dev/direct/doc/ru/campaigns/add-cpm-banner-campaign).
|
||||
- **Часть 5 (мастер, Vue):** 4 шага (окно дней → частота → 1 картинка+превью-утверждение → смета+запуск); `store/update` контроллера клик→частота; убрать «цену за клик».
|
||||
- **Часть 6 (отчёты/списание):** отчёт по показам (не кликам), списание с кошелька по факту, стоп при достижении оплаченного, админ-маржа. **Скрыть `yandex_cost_rub` от клиента** (см. ниже).
|
||||
- **Часть 5b (мастер, Vue) — ✅ ГОТОВО, коммит `de9f0d7d`:** мастер `CampaignWizard` переделан на 4 шага показов (окно дней → частота+живая смета → 1 картинка+превью+утверждение → проверка+«Отправить заявку»); `store/update` клик→частота/бюджет; новый `submit` → статус `queued` (Директа нет, реальный запуск — Ч.4); `yandex_cost_rub` скрыт от клиента (`$hidden` + тест); `CampaignList` — метка queued + показы. Бэкенд 99/99, фронт 102/102.
|
||||
- **Часть 6 (отчёты/списание) — ⏳ ЧАСТИЧНО (Direct-независимый слой готов, ждёт коммита):**
|
||||
- ✅ **Сделано сейчас (без Директа):** `CampaignImpressionCharger` — списание по факту показов (показано×120/1000, дельта, идемпотентно по external_key `yandex-imp:{id}:{billable}`, стоп/`STATUS_COMPLETED` при оплаченном, перекрут режется, bcmath) — 6 boundary-тестов; клиентский отчёт `CampaignReportDialog` под показы (оплачено/показано/частота/потрачено, убран `weekly_budget_rub`) + статусы queued/completed. План: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast6-spisanie-otchet.md`.
|
||||
- ⏳ **ОТЛОЖЕНО до Ч.4 (нужен Директ):** джоб, тянущий фактические показы из отчёта Директа и зовущий `CampaignImpressionCharger` (переделка `ChargeCampaignSpendJob` клики→показы); `campaigns.suspend` при стопе; **админ-маржа** `AdminAdvertisingController` — сейчас считает расход Яндекса через наценку-% (`client_spend/1.3`, клик-логика); в модели показов маржа = списано − **реальный** `yandex_cost_rub` из отчёта Директа. Пока Директа нет — рабочую админку НЕ трогаем (даст неверную маржу). ⚠️ Взаимодействие charge↔freeze/solvency: `CampaignImpressionCharger` списывает дельту, а заморозка сметы + gate-solvency + stopAll — обёртка джоба (Ч.4).
|
||||
|
||||
## 🔴 Обязательные требования/мины (не потерять)
|
||||
- **Маржа НЕ видна клиенту** (владелец резко подчёркивал). RLS не режет столбцы: клиентская выдача НИКОГДА не отдаёт `yandex_cost_rub` (и margin-производные) — allowlist столбцов + тест «yandex_cost_rub не в клиентском JSON». Часть 5/6. (CHANGELOG v8.96.)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# План — Реклама «за показы», Часть 6 (сейчас): списание по факту + клиентский отчёт
|
||||
|
||||
> **Для агентов:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`. Шаги — чекбоксами.
|
||||
|
||||
**Goal:** Построить Direct-НЕзависимую часть «сопровождения»: (6a) денежное ядро списания по факту
|
||||
показов — сервис `CampaignImpressionCharger` (списание = показано × 120 ₽/1000, стоп по оплаченному,
|
||||
идемпотентно) + статус `completed`; (6b) клиентский отчёт по показам — переделка `CampaignReportDialog`
|
||||
(сейчас читает старый `weekly_budget_rub`) + статусы `queued`/`completed`.
|
||||
|
||||
**Что ОТЛОЖЕНО до Части 4 (нужен Директ):** джоб, тянущий фактические показы из отчёта Директа и
|
||||
зовущий `CampaignImpressionCharger`; `campaigns.suspend` в Директе при стопе; переделка админ-маржи
|
||||
(`AdminAdvertisingController`) с наценки-% на реальный `yandex_cost_rub` из отчёта Директа. Пока Директа
|
||||
нет — реального расхода Яндекса нет, трогать рабочую админку не будем (даст неверную маржу).
|
||||
|
||||
**Tech Stack:** Laravel 13 / Pest 4 / bcmath (backend); Vue 3 + Vuetify 3 + Vitest (frontend).
|
||||
**Ветка:** `feat/reklama-yandex-pokazy` (HEAD после Ч.5b `de9f0d7d`). Тестовая БД `liderra_testing` ОБЩАЯ:
|
||||
перед бэкенд-тестами `DB_DATABASE=liderra_testing php artisan migrate --force`. Фронт: `npx vitest run`.
|
||||
Коммит — контроллер по «го». Субагент НЕ коммитит (§15.1).
|
||||
|
||||
---
|
||||
|
||||
## Что уже есть (не переделывать)
|
||||
|
||||
- `AdImpressionPricing`: `clientCostRub(int $impressions, string $cpmRub): string` (сумма 120₽/1000 вверх
|
||||
до копейки), `marginRub(...)`. `ad_settings.client_cpm_rub` = 120.
|
||||
- `AdWalletService::charge(int $tenantId, string $channel, string $relatedType, int $relatedId,
|
||||
string $amountRub, string $externalKey): void` — идемпотентно по `externalKey`, проверяет средства.
|
||||
- Модель `AdCampaign`: поля `delivered_impressions`(default 0), `paid_impressions`, `charged_client_rub`
|
||||
(default 0), `estimated_impressions`, `budget_rub`, `frequency`; статусы + `STATUS_QUEUED` (Ч.5b).
|
||||
- `AdvertisingCampaignController::show()` уже отдаёт `campaign` (модель, без `yandex_cost_rub` — $hidden) +
|
||||
`ads` + `spent_rub` (сумма charge-транзакций).
|
||||
|
||||
---
|
||||
|
||||
## Файловая карта
|
||||
|
||||
- Изменить: `app/app/Models/AdCampaign.php` — `STATUS_COMPLETED`.
|
||||
- Создать: `app/app/Services/Advertising/CampaignImpressionCharger.php`.
|
||||
- Изменить: `app/resources/js/components/advertising/CampaignReportDialog.vue` — показы вместо недельного
|
||||
бюджета + статусы queued/completed.
|
||||
- Изменить: `app/resources/js/components/advertising/CampaignList.vue` — метка `completed` (у `queued` уже есть).
|
||||
- Тесты: `app/tests/Feature/Advertising/CampaignImpressionChargerTest.php`,
|
||||
`app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts` (или дополнить существующий report-спек).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: статус completed (backend, TDD-совместно с Task 2)
|
||||
|
||||
**Files:** Изменить `app/app/Models/AdCampaign.php`.
|
||||
|
||||
- [ ] Добавить константу рядом с прочими STATUS_*: `public const STATUS_COMPLETED = 'completed';`
|
||||
(смысл — «показы полностью откручены/оплачены»). Отдельного теста не нужно — покрывается Task 2.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: CampaignImpressionCharger — списание по факту показов (backend, TDD)
|
||||
|
||||
**Files:** Создать `app/app/Services/Advertising/CampaignImpressionCharger.php`;
|
||||
Тест `app/tests/Feature/Advertising/CampaignImpressionChargerTest.php`.
|
||||
|
||||
**Контракт сервиса:**
|
||||
```php
|
||||
final class CampaignImpressionCharger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdImpressionPricing $pricing,
|
||||
private readonly AdWalletService $wallet,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Списать с рекламного кошелька клиента за ФАКТИЧЕСКИ показанные показы.
|
||||
* $deliveredImpressions — накопительное число показов из отчёта Директа
|
||||
* (Часть 4 подаёт его; сервис Direct-НЕзависим — принимает числом).
|
||||
*
|
||||
* Логика: реальные показы режем по оплаченному (`paid_impressions`); считаем
|
||||
* суммарную клиентскую стоимость по 120₽/1000 (AdImpressionPricing); списываем
|
||||
* ДЕЛЬТУ (суммарно − уже списано `charged_client_rub`); обновляем
|
||||
* delivered_impressions + charged_client_rub; при показано ≥ оплачено — статус
|
||||
* completed. Идемпотентно: повторный вызов с тем же delivered ничего не спишет.
|
||||
*/
|
||||
public function charge(AdCampaign $campaign, int $deliveredImpressions): void
|
||||
}
|
||||
```
|
||||
|
||||
**Реализация (money — bcmath, scale 2):**
|
||||
1. `$cpm = (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00');`
|
||||
2. `$paid = (int) ($campaign->paid_impressions ?? 0);` — если `paid` > 0, `$billable = min($delivered, $paid)`,
|
||||
иначе `$billable = max(0, $delivered)` (нет лимита — считаем по факту).
|
||||
3. `$totalCost = $this->pricing->clientCostRub($billable, $cpm);` — суммарная клиентская стоимость.
|
||||
4. `$already = (string) ($campaign->charged_client_rub ?? '0.00');`
|
||||
`$delta = bcsub($totalCost, $already, 2);`
|
||||
5. Если `bccomp($delta, '0.00', 2) === 1` (дельта > 0): списать
|
||||
`$this->wallet->charge((int)$campaign->tenant_id, 'yandex', 'campaign', (int)$campaign->id, $delta,
|
||||
"yandex-imp:{$campaign->id}:{$billable}");` (external_key по накопленным оплачиваемым показам —
|
||||
идемпотентность по «докуда уже списали»).
|
||||
6. Обновить кампанию: `delivered_impressions = $delivered`; `charged_client_rub = $totalCost`;
|
||||
если `$paid > 0 && $delivered >= $paid` → `status = AdCampaign::STATUS_COMPLETED`. Один `->update([...])`.
|
||||
|
||||
**Тесты (`CampaignImpressionChargerTest.php`, `uses(DatabaseTransactions::class)`):**
|
||||
- [ ] **Списание по факту:** paid=10000, charged=0, delivered=2500 → charge вызван на `clientCostRub(2500,120)`
|
||||
= «300.00» (2500×120/1000); `delivered_impressions=2500`, `charged_client_rub='300.00'`, статус НЕ completed.
|
||||
- [ ] **Идемпотентность:** второй вызов с delivered=2500 → дельта 0 → charge НЕ вызывается повторно
|
||||
(проверить кол-во charge-транзакций = 1 в `ad_wallet_transactions`, либо мок AdWalletService и assert once).
|
||||
- [ ] **Докрутка:** после 2500 приходит delivered=6000 → списывается только ДЕЛЬТА
|
||||
(`clientCostRub(6000)−clientCostRub(2500)`), `charged_client_rub` = clientCostRub(6000).
|
||||
- [ ] **Стоп по оплаченному:** paid=5000, delivered=5000 → `charged_client_rub=clientCostRub(5000)`,
|
||||
статус `completed`.
|
||||
- [ ] **Перекрут режем:** paid=5000, delivered=8000 → billable=5000, списание как за 5000 (не за 8000),
|
||||
статус `completed`.
|
||||
- [ ] **Ноль:** delivered=0 → ничего не списано, charged=0, статус не меняется.
|
||||
|
||||
Используй реальный `AdWalletService` (транзакции в тесте) + пополни кошелёк тенанта заранее
|
||||
(`AdWalletService::topup` или фабрика `ad_wallets` с балансом), чтобы средств хватало на списание.
|
||||
Точную фабрику кошелька/тенанта подсмотри в существующих тестах рекламы (`AdvertisingWallet*Test`,
|
||||
`ChargeCampaignSpend*`-тесты если есть). Проверить внешний ключ идемпотентности реально работает
|
||||
(`ad_wallet_transactions.external_key` unique — повтор не задваивает).
|
||||
|
||||
- [ ] Прогон: `DB_DATABASE=liderra_testing php artisan migrate --force && DB_DATABASE=liderra_testing php artisan test --filter=CampaignImpressionCharger` → зелёно. Затем весь `tests/Feature/Advertising` — не сломать.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CampaignReportDialog под показы (frontend, TDD)
|
||||
|
||||
> Навык дизайна: строго Vuetify 3 + Forest, повторять паттерны текущего файла (`.report-stat`, `.num`).
|
||||
|
||||
**Files:** Изменить `app/resources/js/components/advertising/CampaignReportDialog.vue`;
|
||||
Тест `app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts` (новый или дополнить report-спек).
|
||||
|
||||
Изменения:
|
||||
- `STATUS_META` — добавить `queued: { label:'Готова к запуску', color:'info', icon:'mdi-clock-check-outline' }`
|
||||
и `completed: { label:'Показы откручены', color:'success', icon:'mdi-check-all' }` (иначе рантайм-ошибка
|
||||
доступа по несуществующему ключу для этих статусов).
|
||||
- Заменить строку «Бюджет в неделю» (`weekly_budget_rub`) на блок показов:
|
||||
- «Показов оплачено» = `campaign.estimated_impressions` (или `paid_impressions` если есть в типе; сейчас
|
||||
в типе `Campaign` есть `estimated_impressions` — используем его как «оплачено/смета»);
|
||||
- «Показано» = `campaign.delivered_impressions` (добавить поле в тип `Campaign` в `advertising.ts`,
|
||||
опциональное `delivered_impressions?: number`);
|
||||
- «Частота» = `campaign.frequency`.
|
||||
- «Потрачено» (`spent_rub`) — оставить (это списанное по факту, деньги клиента, показывать можно).
|
||||
- 🔴 НЕ показывать `yandex_cost_rub`/маржу (их и нет в клиентском JSON — $hidden).
|
||||
- Список `ads` для показов пуст — оставить как есть (текст «статистика появится после подключения
|
||||
Директа» уместен), либо заменить на «Баннеров: N» если легко (не обязательно).
|
||||
|
||||
Тест (`advertising-campaign-report-pokazy.spec.ts`, мок `fetchCampaign`): при открытии диалога с кампанией
|
||||
показов (status `completed`, estimated_impressions=5000, delivered_impressions=5000, frequency=5,
|
||||
spent_rub='600.00') — в разметке видно «Показано» 5000, «Частота» 5, «Потрачено» 600, метку «Показы
|
||||
откручены»; НЕТ «Бюджет в неделю»/«weekly»/«yandex_cost»/«клик».
|
||||
|
||||
- [ ] Прогон: `npx vitest run tests/Frontend/advertising-campaign-report-pokazy.spec.ts` + существующий
|
||||
`advertising-campaign-report*.spec.ts` (если есть — обновить под показы, не оставлять красным).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: CampaignList.vue — метка completed (frontend, мелко)
|
||||
|
||||
**Files:** Изменить `app/resources/js/components/advertising/CampaignList.vue`.
|
||||
- [ ] В `STATUS_META` добавить `completed: { label:'Показы откручены', color:'success', icon:'mdi-check-all' }`.
|
||||
Обновить/добавить тест списка при необходимости.
|
||||
|
||||
---
|
||||
|
||||
## Готовность (self-review контроллера)
|
||||
|
||||
- [ ] Деньги: списание = показано×120/1000, дельта, идемпотентно (external_key), стоп/`completed` при
|
||||
оплаченном, перекрут режется, ноль-кейс. Все boundary-тесты зелёные. bcmath, без float.
|
||||
- [ ] Клиент нигде не видит `yandex_cost_rub`/маржу; отчёт по показам, не по клик-бюджету.
|
||||
- [ ] Статусы `queued`/`completed` есть во всех местах, где рендерится STATUS_META (список, отчёт) — нет
|
||||
рантайм-обращений к несуществующему ключу.
|
||||
- [ ] Миграций нет (статусы — `string(24)` без CHECK) → CHANGELOG_schema не трогаем; если реализатор
|
||||
добавит миграцию — запись + `rls-reviewer`.
|
||||
- [ ] Отложенное (джоб из Директа, суспенд, админ-маржа по реальному расходу) — явно в HANDOFF, ждёт Ч.4.
|
||||
- [ ] Ничего на бой/gitea без «го».
|
||||
Reference in New Issue
Block a user