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 @@