From 7d3ed2f6ebd74bbd1ca04bd2d4b8c9adf062968a 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: Sun, 26 Jul 2026 16:07:19 +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=A7=D0=B0=D1=81=D1=82=D1=8C=206=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=D1=83=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=20+=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/app/Models/AdCampaign.php | 2 + .../Advertising/CampaignImpressionCharger.php | 63 +++++++ app/resources/js/api/advertising.ts | 5 +- .../components/advertising/CampaignList.vue | 2 + .../advertising/CampaignReportDialog.vue | 38 +++- .../CampaignImpressionChargerTest.php | 130 +++++++++++++ ...advertising-campaign-report-pokazy.spec.ts | 124 +++++++++++++ ...26-07-26-HANDOFF-reklama-pokazy-stroyka.md | 6 +- ...x-reklama-pokazy-chast6-spisanie-otchet.md | 172 ++++++++++++++++++ 9 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 app/app/Services/Advertising/CampaignImpressionCharger.php create mode 100644 app/tests/Feature/Advertising/CampaignImpressionChargerTest.php create mode 100644 app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts create mode 100644 docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast6-spisanie-otchet.md diff --git a/app/app/Models/AdCampaign.php b/app/app/Models/AdCampaign.php index 268627fb..b7720f45 100644 --- a/app/app/Models/AdCampaign.php +++ b/app/app/Models/AdCampaign.php @@ -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']; diff --git a/app/app/Services/Advertising/CampaignImpressionCharger.php b/app/app/Services/Advertising/CampaignImpressionCharger.php new file mode 100644 index 00000000..cda5d379 --- /dev/null +++ b/app/app/Services/Advertising/CampaignImpressionCharger.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts index 49e56c4c..0e808838 100644 --- a/app/resources/js/api/advertising.ts +++ b/app/resources/js/api/advertising.ts @@ -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 явно diff --git a/app/resources/js/components/advertising/CampaignList.vue b/app/resources/js/components/advertising/CampaignList.vue index 59682a84..a9b3803a 100644 --- a/app/resources/js/components/advertising/CampaignList.vue +++ b/app/resources/js/components/advertising/CampaignList.vue @@ -61,6 +61,8 @@ const STATUS_META: Record = { 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 { diff --git a/app/resources/js/components/advertising/CampaignReportDialog.vue b/app/resources/js/components/advertising/CampaignReportDialog.vue index 1bed2d4f..21d322cb 100644 --- a/app/resources/js/components/advertising/CampaignReportDialog.vue +++ b/app/resources/js/components/advertising/CampaignReportDialog.vue @@ -1,12 +1,13 @@