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:
Дмитрий
2026-07-26 16:07:19 +03:00
parent de9f0d7d8e
commit 7d3ed2f6eb
9 changed files with 532 additions and 10 deletions
+2
View File
@@ -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);
}
}
+4 -1
View File
@@ -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 без «го».