From 6f469fb13d370ac0b2a5c7a0e1efb29929facc6b 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: Wed, 29 Jul 2026 06:26:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=82=D0=B5=D0=BB=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC-=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0):=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=20=D0=BD=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BC=20=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D0=B5?= =?UTF-8?q?=20+=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=A1=D0=9C?= =?UTF-8?q?=D0=A1-=D1=84=D0=B0=D0=BD=D1=82=D0=B0=D0=B7=D0=B8=D0=B9=20+=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере) оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске, возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund). Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены). Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён (сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом. Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы, а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС: - «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет): ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting, фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee. Таблицы client_tg_senders/_settings дропнуты. - мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены). - client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»). - лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс». Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media (картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile → cabinet.js fillAdMedia) — не хватало только приёма файла от клиента. Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному): Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view 19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот bots/mts-telegram-ads НЕ трогали. Co-Authored-By: Claude Opus 4.8 --- .../Api/Admin/TgTariffController.php | 28 +- .../Api/ClientTg/CampaignController.php | 128 +++++---- .../Api/ClientTg/SenderController.php | 85 ------ app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php | 126 --------- .../ClientTg/PollTelegramModerationJob.php | 20 +- .../ClientTg/ResubmitTelegramCampaignJob.php | 40 ++- .../Jobs/ClientTg/RunTelegramCampaignJob.php | 36 ++- .../SweepStuckTelegramCampaignsJob.php | 113 ++++++-- app/app/Models/BalanceTransaction.php | 4 + app/app/Models/ClientTg/Optout.php | 9 +- app/app/Models/ClientTg/Sender.php | 55 ---- app/app/Models/ClientTg/Setting.php | 33 --- app/app/Models/ClientTg/Template.php | 32 --- .../ClientTg/TelegramAudienceService.php | 4 +- .../ClientTg/TelegramAutoAccumulator.php | 60 ++--- .../TelegramCampaignChargeService.php | 147 +++++++++++ .../ClientTg/TelegramSenderService.php | 247 ------------------ .../ClientTg/TelegramTariffService.php | 11 + app/config/client_tg.php | 10 + ...07_27_000003_create_client_tg_messages.php | 43 --- ...7_27_000006_create_client_tg_templates.php | 34 --- ...07_27_000008_create_client_tg_settings.php | 43 --- ..._07_27_000010_create_client_tg_senders.php | 60 ----- ...balance_transactions_type_for_telegram.php | 44 ++++ ...00017_drop_client_tg_sms_legacy_tables.php | 41 +++ app/resources/js/api/admin.ts | 24 +- app/resources/js/api/telegram.ts | 54 +--- app/resources/js/views/admin/AdminTgView.vue | 68 +---- .../advertising/AdvertisingTelegramView.vue | 35 ++- .../advertising/TelegramAutoRulePanel.vue | 6 +- .../views/advertising/TelegramSenderPanel.vue | 221 ---------------- app/routes/console.php | 11 - app/routes/web.php | 14 +- app/tests/Feature/ClientTg/AdminTgApiTest.php | 33 +-- .../Feature/ClientTg/AudienceGateTest.php | 42 ++- app/tests/Feature/ClientTg/AutoFreezeTest.php | 59 +++-- .../Feature/ClientTg/AutoSafeguardTest.php | 80 ++++-- .../Feature/ClientTg/CampaignApiTest.php | 20 +- .../ClientTg/CampaignChargeServiceTest.php | 118 +++++++++ .../ClientTg/ConservativeReleaseTest.php | 83 +++--- .../Feature/ClientTg/MediaUploadTest.php | 116 ++++++++ app/tests/Feature/ClientTg/ModelsTest.php | 19 +- .../Feature/ClientTg/PollModerationTest.php | 79 +++--- .../Feature/ClientTg/RefundOnFailTest.php | 99 ++++--- .../Feature/ClientTg/ResubmitJobTest.php | 67 +++-- app/tests/Feature/ClientTg/SchemaTest.php | 23 -- app/tests/Feature/ClientTg/SenderApiTest.php | 147 ----------- .../Feature/ClientTg/SenderLifecycleTest.php | 204 --------------- app/tests/Feature/ClientTg/SweepStuckTest.php | 130 ++++++--- .../Feature/ClientTg/TariffServiceTest.php | 18 ++ app/tests/Frontend/admin-tg-view.spec.ts | 21 +- .../advertising-telegram-view.spec.ts | 52 +++- .../Frontend/telegram-auto-rule-panel.spec.ts | 2 +- .../Frontend/telegram-sender-panel.spec.ts | 96 ------- cspell-words.txt | 26 ++ db/CHANGELOG_schema.md | 65 +++++ db/schema.sql | 5 +- ...2026-07-28-telegram-dengi-kusok1-ACCEPT.md | 64 +++++ ...2026-07-28-telegram-dengi-kusok2-ACCEPT.md | 125 +++++++++ ...2026-07-28-telegram-dengi-kusok3-ACCEPT.md | 113 ++++++++ ...026-07-28-telegram-module-fantasy-audit.md | 118 +++++++++ ...-28-telegram-dengi-obshchiy-balans-spec.md | 86 ++++++ 62 files changed, 1893 insertions(+), 2103 deletions(-) delete mode 100644 app/app/Http/Controllers/Api/ClientTg/SenderController.php delete mode 100644 app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php delete mode 100644 app/app/Models/ClientTg/Sender.php delete mode 100644 app/app/Models/ClientTg/Setting.php delete mode 100644 app/app/Models/ClientTg/Template.php create mode 100644 app/app/Services/ClientTg/TelegramCampaignChargeService.php delete mode 100644 app/app/Services/ClientTg/TelegramSenderService.php delete mode 100644 app/database/migrations/2026_07_27_000003_create_client_tg_messages.php delete mode 100644 app/database/migrations/2026_07_27_000006_create_client_tg_templates.php delete mode 100644 app/database/migrations/2026_07_27_000008_create_client_tg_settings.php delete mode 100644 app/database/migrations/2026_07_27_000010_create_client_tg_senders.php create mode 100644 app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php create mode 100644 app/database/migrations/2026_07_28_000017_drop_client_tg_sms_legacy_tables.php delete mode 100644 app/resources/js/views/advertising/TelegramSenderPanel.vue create mode 100644 app/tests/Feature/ClientTg/CampaignChargeServiceTest.php create mode 100644 app/tests/Feature/ClientTg/MediaUploadTest.php delete mode 100644 app/tests/Feature/ClientTg/SenderApiTest.php delete mode 100644 app/tests/Feature/ClientTg/SenderLifecycleTest.php delete mode 100644 app/tests/Frontend/telegram-sender-panel.spec.ts create mode 100644 docs/superpowers/2026-07-28-telegram-dengi-kusok1-ACCEPT.md create mode 100644 docs/superpowers/2026-07-28-telegram-dengi-kusok2-ACCEPT.md create mode 100644 docs/superpowers/2026-07-28-telegram-dengi-kusok3-ACCEPT.md create mode 100644 docs/superpowers/2026-07-28-telegram-module-fantasy-audit.md create mode 100644 docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md diff --git a/app/app/Http/Controllers/Api/Admin/TgTariffController.php b/app/app/Http/Controllers/Api/Admin/TgTariffController.php index ae8b00f5..6f8d9ea7 100644 --- a/app/app/Http/Controllers/Api/Admin/TgTariffController.php +++ b/app/app/Http/Controllers/Api/Admin/TgTariffController.php @@ -5,20 +5,18 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; -use App\Models\ClientTg\Setting; use App\Models\ClientTg\Tariff; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; /** - * SaaS-admin → Клиентская Telegram-реклама: тарифная сетка (client_tg_tariffs) + - * общие настройки (client_tg_settings, singleton). План §Сессия 4, задача 4.4. + * SaaS-admin → Клиентская Telegram-реклама: тарифная сетка (client_tg_tariffs). + * План §Сессия 4, задача 4.4. * - * Зона saas-admin/admin-db (crm_admin_user); GRANT — миграции client_tg_tariffs / - * client_tg_settings. Обе таблицы глобальные (без RLS, не tenant-scoped) — единый - * прайс на всех тенантов. updateTariffs заменяет весь набор ступеней целиком - * (owner правит сетку списком). Зеркало AdminSmsTariffController. + * Зона saas-admin/admin-db (crm_admin_user); GRANT — миграция client_tg_tariffs. + * Таблица глобальная (без RLS, не tenant-scoped) — единый прайс на всех тенантов. + * updateTariffs заменяет весь набор ступеней целиком (owner правит сетку списком). */ class TgTariffController extends Controller { @@ -27,7 +25,6 @@ class TgTariffController extends Controller { return response()->json([ 'tariffs' => Tariff::orderBy('min_qty')->get(), - 'settings' => Setting::first(), ]); } @@ -61,19 +58,4 @@ class TgTariffController extends Controller 'tariffs' => Tariff::orderBy('min_qty')->get(), ]); } - - /** PUT /api/admin/telegram/settings — singleton настроек. */ - public function updateSettings(Request $request): JsonResponse - { - $validated = $request->validate([ - 'name_fee_rub_per_month' => ['required', 'numeric', 'min:0'], - 'name_debt_grace_days' => ['required', 'integer', 'min:0'], - ]); - - $settings = Setting::first() ?? new Setting; - $settings->fill($validated); - $settings->save(); - - return response()->json($settings); - } } diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php index 1a9f54b2..3aa08386 100644 --- a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -8,17 +8,17 @@ use App\Exceptions\Billing\InsufficientBalanceException; use App\Http\Controllers\Controller; use App\Jobs\ClientTg\ResubmitTelegramCampaignJob; use App\Jobs\ClientTg\RunTelegramCampaignJob; -use App\Models\AdWallet; use App\Models\ClientTg\Campaign; use App\Models\ClientTg\CampaignPhone; -use App\Services\Advertising\AdWalletService; +use App\Models\Tenant; use App\Services\ClientTg\TelegramAudienceService; +use App\Services\ClientTg\TelegramCampaignChargeService; use App\Services\ClientTg\TelegramTariffService; use App\Support\PhoneNormalizer; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; /** * HTTP-API клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.1). @@ -26,9 +26,9 @@ use Illuminate\Support\Facades\DB; * (смета-потолок), RunTelegramCampaignJob (робот кабинета МТС). * * 🔴 Отличие от СМС-близнеца: создание и запуск РАЗДЕЛЕНЫ. `store` создаёт ЧЕРНОВИК - * (деньги/робот не трогаются) — клиент видит смету и число кандидатов; `launch` - * переводит draft→queued, в бою бронирует лимит на объявление (budget_cap_rub) и - * ставит джоб. Песочница (TG_SANDBOX): заморозки нет, робот пойдёт черновиком. + * (деньги/робот не трогаются) — клиент видит клиентскую смету (с наценкой) и число + * кандидатов; `launch` переводит draft→queued, в бою СПИСЫВАЕТ клиентскую смету с + * общего баланса и ставит джоб. Песочница (TG_SANDBOX): денег не трогаем, робот — черновиком. * * tenant_id всегда из $request->user()->tenant_id; каждый запрос — с явным * ->where('tenant_id', ...) поверх RLS (defense-in-depth, паттерн ClientSmsController). @@ -44,9 +44,9 @@ class CampaignController extends Controller { $tenantId = (int) $request->user()->tenant_id; - // Баланс/заморозка — чтобы экран мог погасить «Запустить» при нехватке - // свободных средств ДО обращения к серверу. - $wallet = AdWallet::where('tenant_id', $tenantId)->first(); + // Баланс — общий баланс тенанта (как СМС): экран гасит «Запустить» при нехватке + // средств ДО обращения к серверу. Заморозки больше нет (frozen всегда 0). + $tenant = Tenant::find($tenantId); return response()->json([ 'campaigns' => Campaign::where('tenant_id', $tenantId) @@ -54,8 +54,8 @@ class CampaignController extends Controller ->limit(50) ->get(), 'sandbox' => (bool) config('client_tg.sandbox', true), - 'balance_rub' => $wallet !== null ? (string) $wallet->balance_rub : '0.00', - 'frozen_rub' => $wallet !== null ? (string) $wallet->frozen_rub : '0.00', + 'balance_rub' => $tenant !== null ? (string) $tenant->balance_rub : '0.00', + 'frozen_rub' => '0.00', ]); } @@ -95,7 +95,8 @@ class CampaignController extends Controller $candidates = $this->audience->build($campaign)->candidatesCount; $campaign->update([ 'planned_count' => $candidates, - 'estimated_cost_rub' => $this->tariff->estimateRub($candidates), + // Клиентская цена (с наценкой) — её клиент видит и платит при запуске. + 'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates), ]); $payload = $campaign->fresh()->toArray(); @@ -114,9 +115,9 @@ class CampaignController extends Controller } /** - * Запуск кампании: draft → queued + джоб робота. В бою бронирует лимит на - * объявление (budget_cap_rub); не хватает денег → 409, кампания остаётся - * черновиком. Песочница — без заморозки. Не-черновик → 422 (повторно не жмём). + * Запуск кампании: draft → queued + джоб робота. В бою СПИСЫВАЕТ клиентскую смету + * (с наценкой) с общего баланса; не хватает денег → 409, кампания остаётся + * черновиком. Песочница — без денег. Не-черновик → 422 (повторно не жмём). */ public function launch(Request $request, int $id): JsonResponse { @@ -130,10 +131,11 @@ class CampaignController extends Controller $sandbox = (bool) config('client_tg.sandbox', true); - // Предстартовый гейт аудитории (находка #2): в бою НЕ бронируем деньги под + // Предстартовый гейт аудитории (находка #2): в бою НЕ списываем деньги под // заведомо непроходную кампанию. Минимум у МТС Маркетолог — 367 кандидатов; - // <367 или пусто — робот всё равно упал бы, но бронь уже залипла. В песочнице + // <367 или пусто — робот всё равно упал бы, но деньги уже ушли бы. В песочнице // гейта нет — денег нет, а сам робот отсеет по MIN_NON_MTS. + $candidates = 0; if (! $sandbox) { $candidates = $this->audience->build($campaign)->candidatesCount; $min = (int) config('client_tg.auto_batch_threshold', 367); @@ -146,16 +148,19 @@ class CampaignController extends Controller } try { - DB::transaction(function () use ($tenantId, $campaign, $sandbox): void { + DB::transaction(function () use ($campaign, $sandbox, $candidates): void { if (! $sandbox) { - app(AdWalletService::class)->freeze( - $tenantId, 'telegram', 'campaign', $campaign->id, (string) $campaign->budget_cap_rub, - ); + // Пересчёт клиентской сметы по свежему числу кандидатов — клиент видит + // и платит одно и то же. Списание с общего баланса; нехватка бросает + // InsufficientBalanceException → откат транзакции → кампания в draft. + $clientCost = $this->tariff->clientEstimateRub($candidates); + $campaign->update(['estimated_cost_rub' => $clientCost]); + app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost); } $campaign->transitionTo(Campaign::STATUS_QUEUED); }); - } catch (InsufficientBalanceException|ModelNotFoundException $e) { - return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409); + } catch (InsufficientBalanceException $e) { + return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409); } RunTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit(); @@ -164,10 +169,10 @@ class CampaignController extends Controller } /** - * Отмена кампании ДО запуска (draft/queued → cancelled). В бою возвращает бронь - * кошелька (release снимает hold, уменьшает frozen) — иначе резерв залипает - * (находка #1, задача 2.3). Песочница брони не имела — release пропускаем. - * Из running/терминальных отмена запрещена (422) по машине статусов модели. + * Отмена кампании ДО запуска (draft/queued → cancelled). В бою возвращает списанную + * смету на общий баланс (refund) — из queued (уже списали на launch) вернёт деньги, + * из draft (не списывали) refund — no-op. Песочница денег не трогала. Из running/ + * терминальных отмена запрещена (422) по машине статусов модели. */ public function cancel(Request $request, int $id): JsonResponse { @@ -181,16 +186,12 @@ class CampaignController extends Controller $sandbox = (bool) config('client_tg.sandbox', true); - try { - DB::transaction(function () use ($tenantId, $campaign, $sandbox): void { - if (! $sandbox) { - app(AdWalletService::class)->release($tenantId, 'telegram', 'campaign', $campaign->id); - } - $campaign->transitionTo(Campaign::STATUS_CANCELLED); - }); - } catch (ModelNotFoundException $e) { - return response()->json(['message' => 'Не удалось вернуть бронь'], 409); - } + DB::transaction(function () use ($campaign, $sandbox): void { + if (! $sandbox) { + app(TelegramCampaignChargeService::class)->refund($campaign); + } + $campaign->transitionTo(Campaign::STATUS_CANCELLED); + }); return response()->json($campaign->fresh()); } @@ -235,8 +236,9 @@ class CampaignController extends Controller $sandbox = (bool) config('client_tg.sandbox', true); - // Предстартовый гейт аудитории (как launch): в бою не бронируем деньги под + // Предстартовый гейт аудитории (как launch): в бою не списываем деньги под // заведомо непроходную кампанию. Песочница — без гейта (денег нет, робот отсеет). + $candidates = 0; if (! $sandbox) { $candidates = $this->audience->build($campaign)->candidatesCount; $min = (int) config('client_tg.auto_batch_threshold', 367); @@ -249,11 +251,14 @@ class CampaignController extends Controller } try { - DB::transaction(function () use ($tenantId, $campaign, $data, $moderatorPath, $sandbox): void { + DB::transaction(function () use ($campaign, $data, $moderatorPath, $sandbox, $candidates): void { + // Пересдача платится ЗАНОВО: на отказе модерации смету вернул опросчик + // (refund), сальдо выровнялось — новое списание проходит. Не хватает → + // InsufficientBalanceException → откат → кампания остаётся rejected. if (! $sandbox) { - app(AdWalletService::class)->freeze( - $tenantId, 'telegram', 'campaign', $campaign->id, (string) $campaign->budget_cap_rub, - ); + $clientCost = $this->tariff->clientEstimateRub($candidates); + $campaign->update(['estimated_cost_rub' => $clientCost]); + app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost); } $campaign->fill([ @@ -268,8 +273,8 @@ class CampaignController extends Controller $campaign->transitionTo(Campaign::STATUS_QUEUED); }); - } catch (InsufficientBalanceException|ModelNotFoundException $e) { - return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409); + } catch (InsufficientBalanceException $e) { + return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409); } ResubmitTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit(); @@ -277,6 +282,41 @@ class CampaignController extends Controller return response()->json($campaign->fresh()); } + /** + * Прикладывает картинку/видео к объявлению ЧЕРНОВИКА (у МТС объявление может быть + * с медиа). Файл кладём на локальный диск; путь в `media_path` робот отдаёт кабинету + * МТС при запуске (RunTelegramCampaignJob → mediaFile → cabinet.js `fillAdMedia`). + * Только для черновика — после запуска объявление уже ушло в кабинет. Замена медиа + * удаляет прежний файл, чтобы не копить мусор на диске. + */ + public function attachMedia(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if ($campaign->status !== Campaign::STATUS_DRAFT) { + return response()->json(['message' => 'Медиа можно приложить только к черновику — до запуска'], 422); + } + + $request->validate([ + // Картинка (png/jpg/gif) или видео (mp4); лимит 50 МБ (кабинет МТС жмёт крупные сам). + 'media' => 'required|file|mimes:png,jpg,jpeg,gif,mp4|max:51200', + ]); + + $path = $request->file('media')->store('client_tg/media', 'local'); + + $old = $campaign->media_path; + $campaign->update(['media_path' => $path]); + + // Прежний файл больше не нужен — убираем с диска (после успешной записи пути). + if ($old !== null && $old !== '' && $old !== $path) { + Storage::disk('local')->delete($old); + } + + return response()->json($campaign->fresh()); + } + /** * Валидация полей кампании. Для «по сделкам» срок обязателен — иначе выборка * ушла бы по ВСЕЙ истории (и сканировала бы все партиции deals). diff --git a/app/app/Http/Controllers/Api/ClientTg/SenderController.php b/app/app/Http/Controllers/Api/ClientTg/SenderController.php deleted file mode 100644 index 6a16f712..00000000 --- a/app/app/Http/Controllers/Api/ClientTg/SenderController.php +++ /dev/null @@ -1,85 +0,0 @@ -user()->tenant_id — сервис скоупит по тенанту - * поверх RLS (defense-in-depth, паттерн CampaignController). - */ -class SenderController extends Controller -{ - public function __construct( - private readonly TelegramSenderService $senders, - ) {} - - public function show(Request $request): JsonResponse - { - $tenantId = (int) $request->user()->tenant_id; - - return response()->json($this->senders->snapshot($tenantId)); - } - - public function store(Request $request): JsonResponse - { - $tenantId = (int) $request->user()->tenant_id; - $data = $request->validate([ - 'name' => 'required|string|max:64', // = колонка client_tg_senders.name varchar(64) - ]); - - try { - $sender = $this->senders->requestSender($tenantId, $data['name'], (int) $request->user()->id); - } catch (InsufficientBalanceException $e) { - return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409); - } catch (DomainException $e) { - return response()->json(['message' => $e->getMessage()], 422); - } - - return response()->json($sender, 201); - } - - public function disable(Request $request): JsonResponse - { - $tenantId = (int) $request->user()->tenant_id; - - try { - $sender = $this->senders->disableSender($tenantId); - } catch (ModelNotFoundException $e) { - return response()->json(['message' => 'Имя отправителя не найдено'], 404); - } - - return response()->json($sender); - } - - public function enable(Request $request): JsonResponse - { - $tenantId = (int) $request->user()->tenant_id; - - try { - $sender = $this->senders->enableSender($tenantId); - } catch (InsufficientBalanceException $e) { - return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409); - } catch (DomainException $e) { - return response()->json(['message' => $e->getMessage()], 422); - } catch (ModelNotFoundException $e) { - return response()->json(['message' => 'Имя отправителя не найдено'], 404); - } - - return response()->json($sender); - } -} diff --git a/app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php b/app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php deleted file mode 100644 index 82a6c670..00000000 --- a/app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php +++ /dev/null @@ -1,126 +0,0 @@ -value('name_debt_grace_days') ?? 29); - $today = Carbon::now()->toDateString(); - - $rows = DB::connection('pgsql_supplier')->table('client_tg_senders') - ->where('status', Sender::STATUS_ACTIVE) - ->whereNotNull('paid_until') - ->whereDate('paid_until', '<', $today) - ->get(['id', 'tenant_id', 'monthly_fee_rub', 'paid_until', 'debt_since']); - - foreach ($rows as $row) { - $tenantId = (int) $row->tenant_id; - - try { - DB::transaction(function () use ($row, $tenantId, $grace, $today, $wallet): void { - DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - - $fee = (string) $row->monthly_fee_rub; - $period = Carbon::parse($row->paid_until)->format('Y-m'); - $externalKey = "telegram:sender:{$row->id}:{$period}"; - - $adWallet = AdWallet::where('tenant_id', $tenantId)->first(); - $free = $adWallet !== null - ? bcsub((string) $adWallet->balance_rub, (string) $adWallet->frozen_rub, 2) - : '0.00'; - - if (bccomp($free, $fee, 2) >= 0) { - $wallet->charge($tenantId, 'telegram', 'sender', (int) $row->id, $fee, $externalKey); - - Sender::where('tenant_id', $tenantId)->whereKey($row->id)->update([ - 'paid_until' => Carbon::parse($row->paid_until)->addMonthNoOverflow()->toDateString(), - 'debt_since' => null, - ]); - - return; - } - - // Денег не хватает — фиксируем/копим долг, paid_until НЕ двигаем. - $debtSince = $row->debt_since ?? $today; - - if (Carbon::parse($debtSince)->diffInDays(Carbon::parse($today)) > $grace) { - Sender::where('tenant_id', $tenantId)->whereKey($row->id)->update([ - 'status' => Sender::STATUS_SUSPENDED, - 'note' => 'Долг более '.$grace.' дней — имя отключено, погасить у МТС', - 'debt_since' => $debtSince, - ]); - - // Сигнал владельцу: имя выключено за долг — у МТС гасим вручную (API нет). - Log::warning('client_tg.name_suspended_for_debt', [ - 'sender_id' => $row->id, - 'tenant_id' => $tenantId, - 'debt_since' => $debtSince, - ]); - } else { - Sender::where('tenant_id', $tenantId)->whereKey($row->id)->update([ - 'debt_since' => $debtSince, - ]); - } - }); - } catch (Throwable $e) { - Log::warning('ChargeTgNameFeeJob: сбой списания платы за имя', [ - 'sender_id' => $row->id, - 'tenant_id' => $row->tenant_id, - 'error' => $e->getMessage(), - ]); - } - } - } -} diff --git a/app/app/Jobs/ClientTg/PollTelegramModerationJob.php b/app/app/Jobs/ClientTg/PollTelegramModerationJob.php index 8f199df5..71f6b293 100644 --- a/app/app/Jobs/ClientTg/PollTelegramModerationJob.php +++ b/app/app/Jobs/ClientTg/PollTelegramModerationJob.php @@ -6,8 +6,8 @@ namespace App\Jobs\ClientTg; use App\Models\ClientTg\Campaign; use App\Models\Tenant; -use App\Services\Advertising\AdWalletService; use App\Services\ClientTg\RobotResult; +use App\Services\ClientTg\TelegramCampaignChargeService; use App\Services\ClientTg\TelegramRobotRunner; use App\Services\NotificationService; use Illuminate\Bus\Queueable; @@ -28,12 +28,11 @@ use Throwable; * bots/mts-telegram-ads/FLOW-FINDINGS.md, «Разведка Сессии 6»). Опросчик применяет * вердикт КОНСЕРВАТИВНО: * - * — `rejected` («Отклонена») → статус `rejected` + причина из кабинета; возврат - * брони кошелька (в бою — зеркало release при отказе робота, задача 2.3); + * — `rejected` («Отклонена») → статус `rejected` + причина из кабинета; ВОЗВРАТ + * списанной сметы на общий баланс (в бою — refund, зеркало отказа робота); * уведомление клиенту «реклама отклонена». * — `approved` («Одобрена») → `launched` (показы пошли); уведомление «одобрена». - * Деньги НЕ трогаем — бронь под потолок держится, фактическую стоимость спишет - * билинг по факту показов (задача 2.4 → позже). + * Деньги НЕ трогаем — смету списали при запуске, одобрение ничего не меняет. * — `moderating` / робот не смог прочитать вердикт (null) → НЕ трогаем, оставляем * на модерации до следующего цикла (тихо пропускаем). * @@ -87,15 +86,14 @@ class PollTelegramModerationJob implements ShouldQueue [$outcome, $campaign, $tenant] = $applied; - // Возврат брони — только при отказе и только в бою (в песочнице заморозки - // не было; ключ совпадает с freeze в launch). Отдельный tenantTx: сбой - // возврата не должен откатить уже проставленный статус. + // Возврат сметы — только при отказе и только в бою (в песочнице денег не + // списывали, refund найдёт сальдо 0). Отдельный tenantTx: сбой возврата + // не должен откатить уже проставленный статус. if ($outcome === Campaign::STATUS_REJECTED && ! $sandbox) { try { - $this->tenantTx($tenantId, fn () => app(AdWalletService::class) - ->release($tenantId, 'telegram', 'campaign', $campaignId)); + $this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($campaign)); } catch (Throwable $e) { - Log::warning('client_tg.poll_release_failed', [ + Log::warning('client_tg.poll_refund_failed', [ 'campaign_id' => $campaignId, 'error' => $e->getMessage(), ]); diff --git a/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php b/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php index 037e0737..71be4f22 100644 --- a/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php +++ b/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php @@ -6,8 +6,8 @@ namespace App\Jobs\ClientTg; use App\Models\ClientTg\Campaign; use App\Models\Tenant; -use App\Services\Advertising\AdWalletService; use App\Services\ClientTg\RobotResult; +use App\Services\ClientTg\TelegramCampaignChargeService; use App\Services\ClientTg\TelegramRobotRunner; use App\Services\NotificationService; use Illuminate\Bus\Queueable; @@ -119,8 +119,8 @@ class ResubmitTelegramCampaignJob implements ShouldQueue $fresh->status_reason = $result->reason; // Осторожно с деньгами (F5): есть mts_campaign_id → кампания в кабинете - // реальна и могла уйти на пере-модерацию → needs_review БЕЗ возврата брони. - // Без id (край — контроллер это отсекает) → failed + возврат брони. + // реальна и могла уйти на пере-модерацию → needs_review БЕЗ возврата сметы. + // Без id (край — контроллер это отсекает) → failed + возврат сметы. if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') { $fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW); @@ -144,21 +144,20 @@ class ResubmitTelegramCampaignJob implements ShouldQueue $fresh->transitionTo(Campaign::STATUS_DRAFT_READY); } else { // Аномалия контракта: бой (submitMode:'live') ДОЛЖЕН давать resubmitted=true. - // Если робот вернул ok, но не отправил — деньги заморожены, а кампания на - // модерацию НЕ ушла → needs_review (бронь держим, ручная сверка), не - // терминальный draft_ready (иначе бронь зависнет навсегда). + // Если робот вернул ok, но не отправил — смета списана, а кампания на + // модерацию НЕ ушла → needs_review (списание держим, ручная сверка), не + // терминальный draft_ready (иначе деньги зависнут навсегда). $fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW); } }); - // Возврат брони — ТОЛЬКО для заведомо не ушедших (failed без id), как в + // Возврат сметы — ТОЛЬКО для заведомо не ушедших (failed без id), как в // RunTelegramCampaignJob. Отдельный tenantTx, чтобы сбой возврата не откатил статус. if ($failedCampaign !== null && ! $sandbox) { try { - $this->tenantTx(fn () => app(AdWalletService::class) - ->release($this->tenantId, 'telegram', 'campaign', $failedCampaign->id)); + $this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign)); } catch (Throwable $e) { - Log::warning('client_tg.release_failed', [ + Log::warning('client_tg.refund_failed', [ 'campaign_id' => $failedCampaign->id, 'error' => $e->getMessage(), ]); @@ -190,13 +189,13 @@ class ResubmitTelegramCampaignJob implements ShouldQueue // 🔑 Отличие пересдачи от RunTelegramCampaignJob: у неё queued ВСЕГДА с // mts_campaign_id. Но в статусе `queued` Фаза A ещё НЕ закоммитила `running` → // робот кабинет НЕ трогал → кампания в кабинете осталась прежней (rejected), - // бронь безопасно вернуть. Поэтому queued → failed + release (переход + // смету безопасно вернуть. Поэтому queued → failed + refund (переход // queued→needs_review в TRANSITIONS отсутствует; без этой ветки кампания зависла - // бы в queued с замороженными деньгами — уборщик метёт только running/moderating). + // бы в queued со списанными деньгами — уборщик метёт только running/moderating). // А вот `running` мог тронуть кабинет → прежняя осторожная логика по mts_campaign_id. - $shouldRelease = false; + $campaignToRefund = null; try { - $this->tenantTx(function () use (&$shouldRelease): void { + $this->tenantTx(function () use (&$campaignToRefund): void { $campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId); if ($campaign === null) { return; @@ -205,7 +204,7 @@ class ResubmitTelegramCampaignJob implements ShouldQueue if ($campaign->status === Campaign::STATUS_QUEUED) { if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) { $campaign->transitionTo(Campaign::STATUS_FAILED); - $shouldRelease = true; + $campaignToRefund = $campaign; } return; @@ -216,7 +215,7 @@ class ResubmitTelegramCampaignJob implements ShouldQueue } // running: робот мог войти в кабинет. Есть mts_campaign_id → needs_review - // без release (могла уйти на пере-модерацию); нет id → failed + release. + // без возврата (могла уйти на пере-модерацию); нет id → failed + refund. $hasMtsId = $campaign->mts_campaign_id !== null && $campaign->mts_campaign_id !== ''; if ($hasMtsId) { if ($campaign->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW)) { @@ -228,7 +227,7 @@ class ResubmitTelegramCampaignJob implements ShouldQueue if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) { $campaign->transitionTo(Campaign::STATUS_FAILED); - $shouldRelease = true; + $campaignToRefund = $campaign; } }); } catch (Throwable $inner) { @@ -238,12 +237,11 @@ class ResubmitTelegramCampaignJob implements ShouldQueue ]); } - if ($shouldRelease && ! (bool) config('client_tg.sandbox', true)) { + if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) { try { - $this->tenantTx(fn () => app(AdWalletService::class) - ->release($this->tenantId, 'telegram', 'campaign', $this->campaignId)); + $this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund)); } catch (Throwable $inner) { - Log::warning('client_tg.release_failed', [ + Log::warning('client_tg.refund_failed', [ 'campaign_id' => $this->campaignId, 'error' => $inner->getMessage(), ]); diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php index 06717026..9cfed471 100644 --- a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -6,9 +6,9 @@ namespace App\Jobs\ClientTg; use App\Models\ClientTg\Campaign; use App\Models\Tenant; -use App\Services\Advertising\AdWalletService; use App\Services\ClientTg\RobotResult; use App\Services\ClientTg\TelegramAudienceService; +use App\Services\ClientTg\TelegramCampaignChargeService; use App\Services\ClientTg\TelegramRobotRunner; use App\Services\NotificationService; use Illuminate\Bus\Queueable; @@ -178,16 +178,15 @@ class RunTelegramCampaignJob implements ShouldQueue $fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY); }); - // Задача 2.3: при отказе робота возвращаем бронь кошелька (frozen), иначе она - // залипает (находка #1). Отдельный tenantTx ПОСЛЕ записи статуса — чтобы сбой - // возврата не откатил FAILED. В песочнице заморозки не было — release пропускаем - // (иначе firstOrFail на несуществующем кошельке). Ключ совпадает с freeze в launch. + // При отказе робота (заведомо не ушла в кабинет — id нет) возвращаем списанную + // смету на общий баланс, иначе клиент заплатил за несделанную работу. Отдельный + // tenantTx ПОСЛЕ записи статуса — чтобы сбой возврата не откатил FAILED. В + // песочнице денег не списывали — refund просто ничего не найдёт (сальдо 0). if ($failedCampaign !== null && ! $sandbox) { try { - $this->tenantTx(fn () => app(AdWalletService::class) - ->release($this->tenantId, 'telegram', 'campaign', $failedCampaign->id)); + $this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign)); } catch (Throwable $e) { - Log::warning('client_tg.release_failed', [ + Log::warning('client_tg.refund_failed', [ 'campaign_id' => $failedCampaign->id, 'error' => $e->getMessage(), ]); @@ -217,12 +216,12 @@ class RunTelegramCampaignJob implements ShouldQueue // Осторожно с деньгами (ревью-фикс F5, симметрия с уборщиком/finalize): трогаем // ТОЛЬКО кампанию, застрявшую до старта (running/queued) — если finalize уже - // увёл её дальше (moderating/…), статус не меняем и бронь не возвращаем. Есть - // mts_campaign_id → могла уйти на модерацию → needs_review без release; нет id → - // failed + возврат брони. release делаем ТОЛЬКО когда реально ушли в failed. - $shouldRelease = false; + // увёл её дальше (moderating/…), статус не меняем и деньги не возвращаем. Есть + // mts_campaign_id → могла уйти на модерацию → needs_review без возврата; нет id → + // failed + возврат сметы. refund делаем ТОЛЬКО когда реально ушли в failed. + $campaignToRefund = null; try { - $this->tenantTx(function () use (&$shouldRelease): void { + $this->tenantTx(function () use (&$campaignToRefund): void { $campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId); if ($campaign === null) { return; @@ -242,7 +241,7 @@ class RunTelegramCampaignJob implements ShouldQueue if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) { $campaign->transitionTo(Campaign::STATUS_FAILED); - $shouldRelease = true; + $campaignToRefund = $campaign; } }); } catch (Throwable $inner) { @@ -252,15 +251,14 @@ class RunTelegramCampaignJob implements ShouldQueue ]); } - // Задача 2.3: перманентный сбой джоба возвращает бронь (в бою) — но ТОЛЬКО для + // Перманентный сбой джоба возвращает списанную смету (в бою) — но ТОЛЬКО для // заведомо не ушедших (failed). Отдельный tenantTx в своём try/catch — сбой // возврата не должен ронять обработчик failed. - if ($shouldRelease && ! (bool) config('client_tg.sandbox', true)) { + if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) { try { - $this->tenantTx(fn () => app(AdWalletService::class) - ->release($this->tenantId, 'telegram', 'campaign', $this->campaignId)); + $this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund)); } catch (Throwable $inner) { - Log::warning('client_tg.release_failed', [ + Log::warning('client_tg.refund_failed', [ 'campaign_id' => $this->campaignId, 'error' => $inner->getMessage(), ]); diff --git a/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php b/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php index 8b20d245..c6f75424 100644 --- a/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php +++ b/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Jobs\ClientTg; use App\Models\ClientTg\Campaign; -use App\Services\Advertising\AdWalletService; +use App\Services\ClientTg\TelegramCampaignChargeService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -25,17 +25,25 @@ use Throwable; * не идущий прогон. Уборщик добивает статус, но 🔴 КОНСЕРВАТИВНО с деньгами: * * — НЕТ `mts_campaign_id` (черновик в кабинете МТС не создавался) → кампания - * заведомо НЕ ушла на модерацию → `failed` + возврат брони кошелька; + * заведомо НЕ ушла на модерацию → `failed` + возврат списанной сметы на баланс; * — ЕСТЬ `mts_campaign_id` (черновик реально создан) → кампания МОГЛА уйти на * модерацию МТС → деньги вслепую НЕ возвращаем (иначе оплатим показанную * рекламу из своего кармана), помечаем `needs_review` до ручной сверки / * опросчика вердикта (задача 3.4). * + * Плюс зависшие `queued` (ревью-фикс: деньги списаны на launch, но джоб так и не + * стартовал — воркер умер / Redis сброшен / задача потеряна). Такая кампания без + * этой уборки залипла бы в `queued` НАВСЕГДА со списанными с общего баланса деньгами + * (finalize/failed срабатывают только если джоб реально бежал). queued НЕ имеет + * `mts_campaign_id` (его ставит робот уже в running) → робот кабинет не трогал → + * безопасно `failed` + ВОЗВРАТ сметы. Порог для queued — с большим запасом + * (QUEUED_STUCK_AFTER_MINUTES), чтобы не гоняться с живым, но занятым воркером. + * * 🔴 RLS/роли: на проде очередь бежит под `crm_app_user` (НЕ BYPASSRLS) БЕЗ * tenant-контекста — перечисление кампаний через дефолтное соединение вернуло бы * 0 строк (молчаливый сбой). Поэтому кросс-тенантное перечисление идёт плоскими - * строками через `pgsql_supplier` (BYPASSRLS, как ChargeTgNameFeeJob), а правка - * статуса и возврат брони — под `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ + * строками через `pgsql_supplier` (BYPASSRLS, как PollTelegramModerationJob), а правка + * статуса и возврат сметы — под `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ * соединении; денежные операции всегда получают tenant_id явным аргументом. */ class SweepStuckTelegramCampaignsJob implements ShouldQueue @@ -45,13 +53,20 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue /** Окно «зависания»: running дольше стольких минут = мёртвый прогон (робот ≤ 420с). */ public const STUCK_AFTER_MINUTES = 15; + /** + * Окно для зависшей `queued`: джоб в норме подхватывается за секунды. Большой запас + * (1 час), чтобы НЕ добить кампанию, которую живой, но занятый бэклогом воркер вот-вот + * запустит. Деньги при этом не теряются — они уже списаны и вернутся возвратом. + */ + public const QUEUED_STUCK_AFTER_MINUTES = 60; + public function handle(): void { $sandbox = (bool) config('client_tg.sandbox', true); $cutoff = Carbon::now()->subMinutes(self::STUCK_AFTER_MINUTES); // Кросс-тенантное перечисление зависших running через BYPASSRLS-роль, - // БЕЗ открытой транзакции (как ChargeTgNameFeeJob). + // БЕЗ открытой транзакции (как PollTelegramModerationJob). $rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns') ->where('status', Campaign::STATUS_RUNNING) ->where('updated_at', '<', $cutoff) @@ -63,8 +78,8 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue $hasMtsId = $row->mts_campaign_id !== null && $row->mts_campaign_id !== ''; try { - // Возвращаем true, только если реально перевели в failed (нужен release). - $needsRelease = $this->tenantTx($tenantId, function () use ($campaignId, $hasMtsId): bool { + // Возвращаем true, только если реально перевели в failed (нужен возврат). + $needsRefund = $this->tenantTx($tenantId, function () use ($campaignId, $hasMtsId): bool { // Блокировка + повторная проверка статуса: между перечислением и // этим моментом finalize мог увести кампанию из running (гонка) — // тогда не трогаем (идемпотентность). @@ -86,7 +101,7 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue return false; } - // Черновика нет → заведомо не ушла → добиваем в failed, бронь вернём. + // Черновика нет → заведомо не ушла → добиваем в failed, смету вернём. $campaign->status_reason = 'Прогон оборвался до создания черновика — кампания не запущена'; $campaign->transitionTo(Campaign::STATUS_FAILED); @@ -97,15 +112,19 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue return true; }); - // Возврат брони — только для заведомо не ушедших (failed) и только в бою - // (в песочнице заморозки не было). Отдельный tenantTx: сбой возврата не - // должен откатить уже проставленный статус. Ключ совпадает с freeze. - if ($needsRelease && ! $sandbox) { + // Возврат сметы — только для заведомо не ушедших (failed) и только в бою + // (в песочнице денег не списывали, refund найдёт сальдо 0). Отдельный + // tenantTx: сбой возврата не должен откатить уже проставленный статус. + if ($needsRefund && ! $sandbox) { try { - $this->tenantTx($tenantId, fn () => app(AdWalletService::class) - ->release($tenantId, 'telegram', 'campaign', $campaignId)); + $this->tenantTx($tenantId, function () use ($campaignId): void { + $campaign = Campaign::where('id', $campaignId)->first(); + if ($campaign !== null) { + app(TelegramCampaignChargeService::class)->refund($campaign); + } + }); } catch (Throwable $e) { - Log::warning('client_tg.sweep_release_failed', [ + Log::warning('client_tg.sweep_refund_failed', [ 'campaign_id' => $campaignId, 'error' => $e->getMessage(), ]); @@ -120,13 +139,75 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue } } + $this->sweepStuckQueued(); $this->sweepStuckModeration(); } + /** + * Зависшие `queued` (деньги списаны на launch, но джоб не стартовал). queued не имеет + * `mts_campaign_id` (его ставит робот в running) → робот кабинет не трогал → `failed` + * + возврат сметы. lockForUpdate + повторная проверка статуса: если джоб УЖЕ подхватил + * (стал running) — не трогаем (гонка решается блокировкой; кто первый, того и статус). + */ + private function sweepStuckQueued(): void + { + $sandbox = (bool) config('client_tg.sandbox', true); + $cutoff = Carbon::now()->subMinutes(self::QUEUED_STUCK_AFTER_MINUTES); + + $rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns') + ->where('status', Campaign::STATUS_QUEUED) + ->where('updated_at', '<', $cutoff) + ->get(['id', 'tenant_id']); + + foreach ($rows as $row) { + $tenantId = (int) $row->tenant_id; + $campaignId = (int) $row->id; + + try { + $needsRefund = $this->tenantTx($tenantId, function () use ($campaignId): bool { + $campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first(); + if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) { + return false; // джоб уже подхватил (running) / ушёл дальше — не трогаем + } + + $campaign->status_reason = 'Кампания зависла в очереди — воркер не подхватил задачу; запуск отменён, деньги возвращены'; + $campaign->transitionTo(Campaign::STATUS_FAILED); + + Log::warning('client_tg.sweep_queued_failed', ['campaign_id' => $campaignId]); + + return true; + }); + + // Возврат сметы (в бою). Отдельный tenantTx: сбой возврата не откатит статус. + if ($needsRefund && ! $sandbox) { + try { + $this->tenantTx($tenantId, function () use ($campaignId): void { + $campaign = Campaign::where('id', $campaignId)->first(); + if ($campaign !== null) { + app(TelegramCampaignChargeService::class)->refund($campaign); + } + }); + } catch (Throwable $e) { + Log::warning('client_tg.sweep_queued_refund_failed', [ + 'campaign_id' => $campaignId, + 'error' => $e->getMessage(), + ]); + } + } + } catch (Throwable $e) { + Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей queued-кампании', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + } + } + } + /** * Ревью-фикс F4: кампания на модерации дольше окна `client_tg.moderation_stuck_hours` * — робот не смог прочитать вердикт (сломались селекторы кабинета / id протух). - * Уводим в `needs_review` для ручного разбора, БЕЗ возврата брони: кампания могла + * Уводим в `needs_review` для ручного разбора, БЕЗ возврата сметы: кампания могла * реально показываться, деньги вслепую не возвращаем (как ветка «есть mts_id» выше). * Опросчик не трогает строку, пока вердикта нет, поэтому `updated_at` честно растёт. */ diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index fb3f23d7..e144d472 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -46,6 +46,10 @@ class BalanceTransaction extends Model public const TYPE_SMS_CHARGE = 'sms_charge'; + public const TYPE_TG_AD_CHARGE = 'tg_ad_charge'; + + public const TYPE_TG_AD_REFUND = 'tg_ad_refund'; + public $timestamps = false; protected $fillable = [ diff --git a/app/app/Models/ClientTg/Optout.php b/app/app/Models/ClientTg/Optout.php index a68f3b73..16b63e19 100644 --- a/app/app/Models/ClientTg/Optout.php +++ b/app/app/Models/ClientTg/Optout.php @@ -7,11 +7,12 @@ namespace App\Models\ClientTg; use Illuminate\Database\Eloquent\Model; /** - * Отказ клиента от Telegram-рекламы (по номеру телефона, на уровне тенанта). + * Список «не показывать этим номерам» — ручной exclude-лист тенанта (по номеру + * телефона). В показах рекламы «отписки» нет (реклама показывается пачке в кабинете + * МТС, а не шлётся адресно); это просто номера, которые клиент не хочет включать + * в аудиторию — вырезаются при сборке (TelegramAudienceService). * - * Tenant-aware с RLS. Используется стоп-листом при сборке аудитории. - * - * Источник: миграция client_tg_optouts. + * Tenant-aware с RLS. Источник: миграция client_tg_optouts. */ class Optout extends Model { diff --git a/app/app/Models/ClientTg/Sender.php b/app/app/Models/ClientTg/Sender.php deleted file mode 100644 index 0b2e736d..00000000 --- a/app/app/Models/ClientTg/Sender.php +++ /dev/null @@ -1,55 +0,0 @@ - 'integer', - 'requested_by' => 'integer', - 'monthly_fee_rub' => 'decimal:2', - 'approved_at' => 'datetime', - 'paid_until' => 'date', - 'debt_since' => 'date', - ]; - } -} diff --git a/app/app/Models/ClientTg/Setting.php b/app/app/Models/ClientTg/Setting.php deleted file mode 100644 index 153e8b80..00000000 --- a/app/app/Models/ClientTg/Setting.php +++ /dev/null @@ -1,33 +0,0 @@ - 'decimal:2', - 'name_debt_grace_days' => 'integer', - ]; - } -} diff --git a/app/app/Models/ClientTg/Template.php b/app/app/Models/ClientTg/Template.php deleted file mode 100644 index 6a384ae3..00000000 --- a/app/app/Models/ClientTg/Template.php +++ /dev/null @@ -1,32 +0,0 @@ - 'integer', - ]; - } -} diff --git a/app/app/Services/ClientTg/TelegramAudienceService.php b/app/app/Services/ClientTg/TelegramAudienceService.php index a9a37e5a..bea7ef8b 100644 --- a/app/app/Services/ClientTg/TelegramAudienceService.php +++ b/app/app/Services/ClientTg/TelegramAudienceService.php @@ -110,7 +110,9 @@ final class TelegramAudienceService } /** - * Стоп-лист: убираем номера, по которым клиент отказался (client_tg_optouts). + * Список «не показывать этим номерам»: вырезаем из аудитории номера, которые клиент + * пометил как исключаемые (client_tg_optouts). В показах рекламы «отписок» нет — это + * ручной exclude-лист тенанта. * * @param list $phones * @return list diff --git a/app/app/Services/ClientTg/TelegramAutoAccumulator.php b/app/app/Services/ClientTg/TelegramAutoAccumulator.php index 1d4d3f19..99e654d0 100644 --- a/app/app/Services/ClientTg/TelegramAutoAccumulator.php +++ b/app/app/Services/ClientTg/TelegramAutoAccumulator.php @@ -6,11 +6,9 @@ namespace App\Services\ClientTg; use App\Exceptions\Billing\InsufficientBalanceException; use App\Jobs\ClientTg\RunTelegramCampaignJob; -use App\Models\AdWallet; use App\Models\ClientTg\AutoRule; use App\Models\ClientTg\Campaign; use App\Models\ClientTg\CampaignPhone; -use App\Services\Advertising\AdWalletService; use App\Support\PhoneNormalizer; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -20,8 +18,9 @@ use Illuminate\Support\Facades\Log; * Накопитель авто-режима Telegram-рекламы (план §Сессия 4, задача 4.1). * * Копит номера новых лидов в один «открытый» черновик авто-кампании; когда наберётся - * пачка (≥ порога кандидатов — по умолчанию 367), доводит смету и ставит кампанию в - * очередь В РАМКАХ `budget_cap_rub` из авто-правила. Ниже порога — только копим. + * пачка (≥ порога кандидатов — по умолчанию 367), доводит смету, СПИСЫВАЕТ клиентскую + * смету с общего баланса (как ручной launch) и ставит кампанию в очередь. Ниже порога — + * только копим. `budget_cap_rub` из правила — лимит трат в кабинете МТС, не резерв. * * 🔴 Отличие от СМС-близнеца: СМС шлёт по одной на каждый лид, Telegram копит ПАЧКУ — * МТС Маркетолог показывает рекламу загруженной базе целиком, а не по одному номеру. @@ -34,7 +33,7 @@ class TelegramAutoAccumulator public function __construct( private readonly TelegramAudienceService $audience, private readonly TelegramTariffService $tariff, - private readonly AdWalletService $wallets, + private readonly TelegramCampaignChargeService $charger, ) {} /** @@ -69,11 +68,11 @@ class TelegramAutoAccumulator ]); } - // Кандидаты (нормализованы, без дублей/стоп-листа) + смета-потолок для UI. + // Кандидаты (нормализованы, без дублей/стоп-листа) + клиентская смета (с наценкой). $candidates = $this->audience->build($campaign)->candidatesCount; $campaign->update([ 'planned_count' => $candidates, - 'estimated_cost_rub' => $this->tariff->estimateRub($candidates), + 'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates), ]); // Порог пачки — из правила (клиент мог поднять), иначе дефолт из конфига. @@ -91,16 +90,15 @@ class TelegramAutoAccumulator } /** - * Предохранитель авто-рассылки (план §Этап 5, задача 5.1 + ревью-фикс F3). В + * Предохранитель авто-рекламы (план §Этап 5, задача 5.1 + ревью-фикс F3). В * песочнице деньги не трогаются — гейта нет (как ручной launch). В бою: - * - смета пачки + трата за сегодня не должна превышать дневной лимит правила - * (дефолт 0 → авто выключено); - * - свободного остатка (balance − frozen) должно хватать на ПОТОЛОК брони - * (budget_cap_rub) — его и бронируем симметрично ручному launch, иначе кампания - * уходила в очередь без зарезервированных денег (находка I2). + * - смета пачки (клиентская, с наценкой) + трата за сегодня не должна превышать + * дневной лимит правила (дефолт 0 → авто выключено); + * - клиентская смета СПИСЫВАЕТСЯ с общего баланса (charge, как ручной launch) — + * не хватило → мягко пропускаем (приём лидов не должен падать). * Всё под `lockForUpdate` строки правила — счётчик `spent_today_rub` не гоняется * двумя параллельными джобами одного тенанта. Не прошла — держим черновиком и - * пишем причину; прошла — бронируем потолок и учитываем трату за сегодня. + * пишем причину; прошла — списываем смету и учитываем трату за сегодня. */ private function passesSafeguard(int $tenantId, AutoRule $rule, Campaign $campaign): bool { @@ -109,10 +107,9 @@ class TelegramAutoAccumulator } $cost = (string) $campaign->estimated_cost_rub; - $cap = (string) $campaign->budget_cap_rub; $today = Carbon::now()->toDateString(); - return DB::transaction(function () use ($tenantId, $rule, $campaign, $cost, $cap, $today): bool { + return DB::transaction(function () use ($tenantId, $rule, $campaign, $cost, $today): bool { $locked = AutoRule::where('id', $rule->id)->lockForUpdate()->first(); if ($locked === null) { return false; @@ -135,34 +132,17 @@ class TelegramAutoAccumulator return false; } - $wallet = AdWallet::where('tenant_id', $tenantId)->first(); - $free = $wallet !== null - ? bcsub((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2) - : '0.00'; - - if (bccomp($free, $cap, 2) < 0) { + // Списываем клиентскую смету с общего баланса симметрично ручному launch. + // Не хватило — мягко пропускаем (кампания остаётся черновиком, приём лидов + // не падает). Идемпотентность charge по сальдо: повтор не задвоит. + try { + $this->charger->charge($campaign, $cost); + } catch (InsufficientBalanceException $e) { Log::info('client_tg.auto_skipped', [ 'reason' => 'insufficient_balance', 'tenant_id' => $tenantId, 'campaign_id' => $campaign->id, - 'cap_rub' => $cap, - 'free_rub' => $free, - ]); - - return false; - } - - // Бронируем потолок симметрично ручному launch. InsufficientBalance тут не - // ожидается (free≥cap выше), но при гонке с другим каналом ловим и мягко - // пропускаем — приём лидов не должен падать. - try { - $this->wallets->freeze($tenantId, 'telegram', 'campaign', $campaign->id, $cap); - } catch (InsufficientBalanceException $e) { - Log::info('client_tg.auto_skipped', [ - 'reason' => 'insufficient_balance_race', - 'tenant_id' => $tenantId, - 'campaign_id' => $campaign->id, - 'cap_rub' => $cap, + 'cost_rub' => $cost, ]); return false; diff --git a/app/app/Services/ClientTg/TelegramCampaignChargeService.php b/app/app/Services/ClientTg/TelegramCampaignChargeService.php new file mode 100644 index 00000000..f9c94ddf --- /dev/null +++ b/app/app/Services/ClientTg/TelegramCampaignChargeService.php @@ -0,0 +1,147 @@ +tenant_id)->lockForUpdate()->firstOrFail(); + + // Идемпотентность по сальдо — строго ПОСЛЕ взятия замка. Есть непогашенное + // списание (charges > refunds) → повтор не списывает. После возврата сальдо + // выравнивается и новое списание (пересдача) проходит. + if ($this->hasOutstandingCharge($campaign)) { + return; + } + + if (bccomp($priceRub, '0', 2) === 0) { + return; + } + + if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($priceRub, '100', 0), + balanceRub: (string) $tenant->balance_rub, + ); + } + + $newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TG_AD_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => Campaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + }); + } + + /** + * Вернуть ранее списанную смету на общий баланс тенанта. Идемпотентно: + * если списания не было или возврат уже сделан — ничего не делает. + */ + public function refund(Campaign $campaign): void + { + DB::transaction(function () use ($campaign): void { + /** @var Tenant $tenant */ + $tenant = Tenant::whereKey($campaign->tenant_id)->lockForUpdate()->firstOrFail(); + + // Нечего возвращать — непогашенного списания нет (списаний не было или все + // уже возвращены). + if (! $this->hasOutstandingCharge($campaign)) { + return; + } + + /** @var BalanceTransaction $chargeTx */ + $chargeTx = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->orderByDesc('id') + ->firstOrFail(); + + // Сумма возврата = модуль ПОСЛЕДНЕГО (непогашенного) списания + // (amount_rub у charge отрицательный). + $amount = ltrim((string) $chargeTx->amount_rub, '-'); + + if (bccomp($amount, '0', 2) === 0) { + return; + } + + $newBalance = bcadd((string) $tenant->balance_rub, $amount, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TG_AD_REFUND, + 'amount_rub' => $amount, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => Campaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + }); + } + + /** Есть ли по кампании непогашенное списание: count(tg_ad_charge) > count(tg_ad_refund). */ + private function hasOutstandingCharge(Campaign $campaign): bool + { + $charges = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->count(); + + $refunds = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_REFUND) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->count(); + + return $charges > $refunds; + } +} diff --git a/app/app/Services/ClientTg/TelegramSenderService.php b/app/app/Services/ClientTg/TelegramSenderService.php deleted file mode 100644 index fec424f9..00000000 --- a/app/app/Services/ClientTg/TelegramSenderService.php +++ /dev/null @@ -1,247 +0,0 @@ -first(); - if ($existing !== null) { - if ($existing->status === Sender::STATUS_ACTIVE) { - throw new DomainException('У вас уже есть активное имя отправителя'); - } - if ($existing->status === Sender::STATUS_PENDING) { - throw new DomainException('Заявка на имя уже на рассмотрении'); - } - } - - $monthlyFee = (string) (Setting::query()->value('name_fee_rub_per_month') ?? '2500.00'); - $sandbox = (bool) config('client_tg.sandbox', true); - - return DB::transaction(function () use ($tenantId, $name, $requestedBy, $monthlyFee, $sandbox): Sender { - $sender = Sender::updateOrCreate( - ['tenant_id' => $tenantId], - [ - 'name' => $name, - 'status' => Sender::STATUS_PENDING, - 'monthly_fee_rub' => $monthlyFee, - 'requested_by' => $requestedBy, - 'note' => null, - 'approved_at' => null, - 'paid_until' => null, - 'debt_since' => null, - ], - ); - - // Бронь ПОСЛЕ создания строки: freeze бросит InsufficientBalanceException - // при недоборе → транзакция откатится, заявка не сохранится. - if (! $sandbox) { - $this->wallet->freeze($tenantId, self::CHANNEL, self::SOURCE, $sender->id, $monthlyFee); - } - - return $sender; - }); - } - - /** Клиент отключает своё имя. У pending снимаем бронь; ставим cancelled. */ - public function disableSender(int $tenantId): Sender - { - $sender = Sender::where('tenant_id', $tenantId)->first(); - if ($sender === null) { - throw new ModelNotFoundException('Имя отправителя не найдено'); - } - - $sandbox = (bool) config('client_tg.sandbox', true); - - DB::transaction(function () use ($tenantId, $sender, $sandbox): void { - if ($sender->status === Sender::STATUS_PENDING && ! $sandbox) { - $this->wallet->release($tenantId, self::CHANNEL, self::SOURCE, $sender->id); - } - $sender->update(['status' => Sender::STATUS_CANCELLED]); - }); - - return $sender->fresh(); - } - - /** - * Клиент включает отключённое за долг имя обратно (suspended → active). В бою: - * проверяем средства ДО списания (charge не бросает) и списываем плату за текущий - * период; идемпотентность по external_key {Y-m} не даёт списать дважды за один - * календарный месяц. В песочнице деньги не трогаем. - */ - public function enableSender(int $tenantId): Sender - { - $sender = Sender::where('tenant_id', $tenantId)->first(); - if ($sender === null) { - throw new ModelNotFoundException('Имя отправителя не найдено'); - } - if ($sender->status !== Sender::STATUS_SUSPENDED) { - throw new DomainException('Имя не отключено'); - } - - $sandbox = (bool) config('client_tg.sandbox', true); - - return DB::transaction(function () use ($tenantId, $sender, $sandbox): Sender { - if (! $sandbox) { - $fee = (string) $sender->monthly_fee_rub; - $wallet = AdWallet::where('tenant_id', $tenantId)->first(); - $free = $wallet !== null - ? bcsub((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2) - : '0.00'; - if (bccomp($free, $fee, 2) < 0) { - throw new InsufficientBalanceException( - priceKopecks: (int) bcmul($fee, '100', 0), - balanceRub: $free, - ); - } - - $externalKey = 'telegram:sender:'.$sender->id.':'.Carbon::now()->format('Y-m'); - $this->wallet->charge($tenantId, self::CHANNEL, self::SOURCE, $sender->id, $fee, $externalKey); - } - - $sender->update([ - 'status' => Sender::STATUS_ACTIVE, - 'paid_until' => Carbon::now()->addMonthNoOverflow()->toDateString(), - 'debt_since' => null, - 'note' => null, - ]); - - return $sender->fresh(); - }); - } - - /** - * Снимок имени для кабинета: сам отправитель (или null), режим песочницы и остаток - * грейса (дней до автоотключения) для имени с долгом. Грейс — из настроек - * (name_debt_grace_days, по умолчанию 29), как в помесячном джобе. - * - * @return array{sender: Sender|null, grace_days_left: int|null, sandbox: bool} - */ - public function snapshot(int $tenantId): array - { - $sender = Sender::where('tenant_id', $tenantId)->first(); - - $graceDaysLeft = null; - if ($sender !== null && $sender->debt_since !== null) { - $grace = (int) (Setting::query()->value('name_debt_grace_days') ?? 29); - $used = (int) Carbon::parse($sender->debt_since)->diffInDays(Carbon::now()); - $graceDaysLeft = max(0, $grace - $used); - } - - return [ - 'sender' => $sender, - 'grace_days_left' => $graceDaysLeft, - 'sandbox' => (bool) config('client_tg.sandbox', true), - ]; - } - - /** Админ одобряет: списывает плату, снимает бронь, active + paid_until +1мес. */ - public function approve(int $senderId): Sender - { - $sender = Sender::findOrFail($senderId); - if ($sender->status !== Sender::STATUS_PENDING) { - throw new DomainException('Имя не в статусе на рассмотрении'); - } - - $sandbox = (bool) config('client_tg.sandbox', true); - - DB::transaction(function () use ($sender, $sandbox): void { - if (! $sandbox) { - $externalKey = 'telegram:sender:'.$sender->id.':'.Carbon::now()->format('Y-m'); - $this->wallet->charge( - (int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id, - (string) $sender->monthly_fee_rub, $externalKey, - ); - $this->wallet->release((int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id); - } - - $sender->update([ - 'status' => Sender::STATUS_ACTIVE, - 'approved_at' => Carbon::now(), - 'paid_until' => Carbon::now()->addMonthNoOverflow()->toDateString(), - 'debt_since' => null, - ]); - }); - - return $sender->fresh(); - } - - /** Админ отклоняет: снимает бронь, ставит rejected с заметкой. */ - public function reject(int $senderId, ?string $note = null): Sender - { - $sender = Sender::findOrFail($senderId); - if ($sender->status !== Sender::STATUS_PENDING) { - throw new DomainException('Имя не в статусе на рассмотрении'); - } - - $sandbox = (bool) config('client_tg.sandbox', true); - - DB::transaction(function () use ($sender, $sandbox, $note): void { - if (! $sandbox) { - $this->wallet->release((int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id); - } - $sender->update([ - 'status' => Sender::STATUS_REJECTED, - 'note' => $note, - ]); - }); - - return $sender->fresh(); - } - - /** Админ служебно отключает имя (suspended). */ - public function disableByAdmin(int $senderId): Sender - { - $sender = Sender::findOrFail($senderId); - - $sandbox = (bool) config('client_tg.sandbox', true); - - DB::transaction(function () use ($sender, $sandbox): void { - if ($sender->status === Sender::STATUS_PENDING && ! $sandbox) { - $this->wallet->release((int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id); - } - $sender->update([ - 'status' => Sender::STATUS_SUSPENDED, - 'note' => 'Отключено владельцем — погасить у МТС', - ]); - }); - - return $sender->fresh(); - } -} diff --git a/app/app/Services/ClientTg/TelegramTariffService.php b/app/app/Services/ClientTg/TelegramTariffService.php index 696b124b..fb9066cb 100644 --- a/app/app/Services/ClientTg/TelegramTariffService.php +++ b/app/app/Services/ClientTg/TelegramTariffService.php @@ -30,4 +30,15 @@ final class TelegramTariffService { return bcmul((string) max($impressions, 0), $this->pricePerImpressionRub($impressions), 2); } + + /** + * Клиентская смета = базовая смета × наценка (config client_tg.markup). + * Это цена, которую клиент ВИДИТ и ПЛАТИТ (базовый тариф — себестоимость-оценка). + */ + public function clientEstimateRub(int $impressions): string + { + $markup = (string) config('client_tg.markup', '1.40'); + + return bcmul($this->estimateRub($impressions), $markup, 2); + } } diff --git a/app/config/client_tg.php b/app/config/client_tg.php index dc547ebe..6da04fc6 100644 --- a/app/config/client_tg.php +++ b/app/config/client_tg.php @@ -14,6 +14,16 @@ return [ */ 'sandbox' => filter_var(env('TG_SANDBOX', true), FILTER_VALIDATE_BOOL), + /* + |-------------------------------------------------------------------------- + | Наценка на клиентскую цену Telegram-рекламы + |-------------------------------------------------------------------------- + | Клиент платит цену С наценкой: клиентская смета = базовая смета тарифа + | (себестоимость-оценка) × markup. 1.40 = +40%. Строка для bcmath (scale 2). + | Меняется без правки кода через env TG_MARKUP. + */ + 'markup' => (string) env('TG_MARKUP', '1.40'), + /* |-------------------------------------------------------------------------- | Порог авто-пачки diff --git a/app/database/migrations/2026_07_27_000003_create_client_tg_messages.php b/app/database/migrations/2026_07_27_000003_create_client_tg_messages.php deleted file mode 100644 index 182c4a51..00000000 --- a/app/database/migrations/2026_07_27_000003_create_client_tg_messages.php +++ /dev/null @@ -1,43 +0,0 @@ -id(); - $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); - $table->unsignedBigInteger('campaign_id')->nullable(); - $table->unsignedBigInteger('deal_id')->nullable(); - $table->string('phone', 11); - $table->text('operator')->nullable(); - $table->string('provider_key', 32)->nullable(); - $table->string('status', 24); - $table->decimal('cost_rub', 14, 2)->default('0.00'); - $table->string('provider_message_id', 64)->nullable(); - $table->string('error', 255)->nullable(); - $table->timestamps(); - - $table->index(['tenant_id', 'campaign_id']); - $table->index(['tenant_id', 'deal_id']); - $table->unique(['tenant_id', 'campaign_id', 'phone'], 'client_tg_messages_tenant_campaign_phone_unique'); - }); - - DB::statement('ALTER TABLE client_tg_messages ENABLE ROW LEVEL SECURITY'); - DB::statement('ALTER TABLE client_tg_messages FORCE ROW LEVEL SECURITY'); - DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_messages'); - DB::statement("CREATE POLICY tenant_isolation ON client_tg_messages USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); - - DB::statement('GRANT SELECT, INSERT, UPDATE ON client_tg_messages TO crm_app_user'); - } - - public function down(): void - { - Schema::dropIfExists('client_tg_messages'); - } -}; diff --git a/app/database/migrations/2026_07_27_000006_create_client_tg_templates.php b/app/database/migrations/2026_07_27_000006_create_client_tg_templates.php deleted file mode 100644 index f9798dff..00000000 --- a/app/database/migrations/2026_07_27_000006_create_client_tg_templates.php +++ /dev/null @@ -1,34 +0,0 @@ -id(); - $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); - $table->string('title', 200); - $table->string('body', 1000); - $table->timestamps(); - - $table->index(['tenant_id']); - }); - - DB::statement('ALTER TABLE client_tg_templates ENABLE ROW LEVEL SECURITY'); - DB::statement('ALTER TABLE client_tg_templates FORCE ROW LEVEL SECURITY'); - DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_templates'); - DB::statement("CREATE POLICY tenant_isolation ON client_tg_templates USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); - - DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON client_tg_templates TO crm_app_user'); - } - - public function down(): void - { - Schema::dropIfExists('client_tg_templates'); - } -}; diff --git a/app/database/migrations/2026_07_27_000008_create_client_tg_settings.php b/app/database/migrations/2026_07_27_000008_create_client_tg_settings.php deleted file mode 100644 index f39562e4..00000000 --- a/app/database/migrations/2026_07_27_000008_create_client_tg_settings.php +++ /dev/null @@ -1,43 +0,0 @@ -id(); - $table->decimal('name_fee_rub_per_month', 10, 2)->default('2500.00'); - $table->integer('name_debt_grace_days')->default(29); - $table->timestamps(); - }); - - DB::table('client_tg_settings')->insert([ - 'name_fee_rub_per_month' => '2500.00', - 'name_debt_grace_days' => 29, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::statement(<<<'SQL' -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN - GRANT SELECT ON client_tg_settings TO crm_app_user; - END IF; - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN - GRANT SELECT, INSERT, UPDATE, DELETE ON client_tg_settings TO crm_admin_user; - END IF; -END $$; -SQL); - } - - public function down(): void - { - Schema::dropIfExists('client_tg_settings'); - } -}; diff --git a/app/database/migrations/2026_07_27_000010_create_client_tg_senders.php b/app/database/migrations/2026_07_27_000010_create_client_tg_senders.php deleted file mode 100644 index ae8306f4..00000000 --- a/app/database/migrations/2026_07_27_000010_create_client_tg_senders.php +++ /dev/null @@ -1,60 +0,0 @@ -id(); - $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); - $table->string('name', 64); - $table->string('status', 16)->default('pending'); - $table->decimal('monthly_fee_rub', 10, 2)->default('0.00'); - $table->string('note', 255)->nullable(); - $table->unsignedBigInteger('requested_by')->nullable(); - $table->timestampTz('approved_at')->nullable(); - $table->date('paid_until')->nullable(); - $table->date('debt_since')->nullable(); - $table->timestamps(); - - $table->unique('tenant_id'); - }); - - DB::statement('ALTER TABLE client_tg_senders ENABLE ROW LEVEL SECURITY'); - DB::statement('ALTER TABLE client_tg_senders FORCE ROW LEVEL SECURITY'); - DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_senders'); - DB::statement("CREATE POLICY tenant_isolation ON client_tg_senders USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); - - DB::statement('GRANT SELECT, INSERT, UPDATE ON client_tg_senders TO crm_app_user'); - - // Помесячный джоб перечисляет активные имена кросс-тенантно через - // crm_supplier_worker (BYPASSRLS); админ (crm_admin_user) согласует. - DB::statement(<<<'SQL' -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN - GRANT SELECT ON client_tg_senders TO crm_supplier_worker; - END IF; - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN - GRANT SELECT, UPDATE ON client_tg_senders TO crm_admin_user; - END IF; -END $$; -SQL); - } - - public function down(): void - { - Schema::dropIfExists('client_tg_senders'); - } -}; diff --git a/app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php b/app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php new file mode 100644 index 00000000..7e93c0d9 --- /dev/null +++ b/app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php @@ -0,0 +1,44 @@ + { await apiClient.post(`/api/admin/invoices/${id}/mark-paid`); } -// --- Клиентская Telegram-реклама: тарифы + настройки (задача 4.4) ----------- +// --- Клиентская Telegram-реклама: тарифная сетка (задача 4.4) --------------- // Зеркало SMS-функций выше. Ступень = ₽ за показ по объёму (у нас — потолок/оценка). /** Ступень тарифа Telegram-рекламы. */ @@ -647,16 +647,9 @@ export interface AdminTgTariffRow { price_rub: string; } -/** Настройки платного имени (часть GET /api/admin/telegram/tariffs, а также PUT /api/admin/telegram/settings). */ -export interface AdminTgSettings { - name_fee_rub_per_month: string; - name_debt_grace_days: number; -} - /** Ответ GET /api/admin/telegram/tariffs. */ export interface AdminTgTariffsResponse { tariffs: AdminTgTariff[]; - settings: AdminTgSettings; } /** Ответ PUT /api/admin/telegram/tariffs — заменённый набор ступеней. */ @@ -664,7 +657,7 @@ export interface AdminTgTariffsUpdateResponse { tariffs: AdminTgTariff[]; } -/** GET /api/admin/telegram/tariffs — ступени тарифа + настройки платного имени. */ +/** GET /api/admin/telegram/tariffs — ступени тарифа. */ export async function fetchTgTariffs(): Promise { const { data } = await apiClient.get('/api/admin/telegram/tariffs'); return data; @@ -676,16 +669,3 @@ export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise('/api/admin/telegram/tariffs', { rows }); return data; } - -/** PUT /api/admin/telegram/settings — плата за имя/мес и срок долга до отключения. */ -export async function updateTgSettings( - nameFeeRubPerMonth: string, - nameDebtGraceDays: number, -): Promise { - await ensureCsrfCookie(); - const { data } = await apiClient.put('/api/admin/telegram/settings', { - name_fee_rub_per_month: nameFeeRubPerMonth, - name_debt_grace_days: nameDebtGraceDays, - }); - return data; -} diff --git a/app/resources/js/api/telegram.ts b/app/resources/js/api/telegram.ts index db8df08f..1eda0c4e 100644 --- a/app/resources/js/api/telegram.ts +++ b/app/resources/js/api/telegram.ts @@ -111,55 +111,19 @@ export async function resubmitTelegram(id: number, payload: TelegramResubmitPayl } /** - * Своё имя отправителя Telegram-рекламы (план §Этап 5, задачи 5.2/5.3). - * Один отправитель на тенанта; клиент заводит/отключает/включает его из кабинета. + * POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ + * объявления (png/jpg/gif или mp4, ≤50 МБ). Робот отдаёт файл кабинету МТС при запуске. */ -export interface TelegramSender { - id: number; - name: string; - status: string; - monthly_fee_rub?: string; - paid_until?: string | null; - debt_since?: string | null; - note?: string | null; -} - -/** Ответ GET /api/telegram/sender — сам отправитель (или null), остаток грейса, режим. */ -export interface TelegramSenderSnapshot { - sender: TelegramSender | null; - grace_days_left: number | null; - sandbox: boolean; -} - -/** GET /api/telegram/sender — статус имени тенанта. */ -export async function fetchSender(): Promise { - const { data } = await apiClient.get('/api/telegram/sender'); - return data; -} - -/** POST /api/telegram/sender — завести имя (в песочнице деньги не трогаются). */ -export async function createSender(name: string): Promise { +export async function uploadTelegramMedia(id: number, file: File): Promise { await ensureCsrfCookie(); - const { data } = await apiClient.post('/api/telegram/sender', { name }); - return data; -} - -/** POST /api/telegram/sender/disable — отключить имя. */ -export async function disableSender(): Promise { - await ensureCsrfCookie(); - const { data } = await apiClient.post('/api/telegram/sender/disable'); - return data; -} - -/** POST /api/telegram/sender/enable — включить отключённое за долг имя обратно. */ -export async function enableSender(): Promise { - await ensureCsrfCookie(); - const { data } = await apiClient.post('/api/telegram/sender/enable'); + const form = new FormData(); + form.append('media', file); + const { data } = await apiClient.post(`/api/telegram/campaigns/${id}/media`, form); return data; } /** - * Правило авторассылки Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент + * Правило авто-рекламы Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент * из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит. */ export interface TelegramAutoRule { @@ -173,13 +137,13 @@ export interface TelegramAutoRule { batch_threshold: number | null; } -/** GET /api/telegram/auto-rule — правило авторассылки тенанта (или дефолты). */ +/** GET /api/telegram/auto-rule — правило авто-рекламы тенанта (или дефолты). */ export async function fetchAutoRule(): Promise { const { data } = await apiClient.get('/api/telegram/auto-rule'); return data; } -/** PUT /api/telegram/auto-rule — сохранить правило авторассылки. */ +/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */ export async function saveAutoRule(payload: TelegramAutoRule): Promise { await ensureCsrfCookie(); const { data } = await apiClient.put('/api/telegram/auto-rule', payload); diff --git a/app/resources/js/views/admin/AdminTgView.vue b/app/resources/js/views/admin/AdminTgView.vue index 073d7185..1712c6c2 100644 --- a/app/resources/js/views/admin/AdminTgView.vue +++ b/app/resources/js/views/admin/AdminTgView.vue @@ -1,34 +1,22 @@ @@ -148,40 +121,5 @@ onMounted(load); Сохранено - - - - Своё имя / бренд - -
- - -
-
- - - Сохранить настройки - - Сохранено - -
diff --git a/app/resources/js/views/advertising/AdvertisingTelegramView.vue b/app/resources/js/views/advertising/AdvertisingTelegramView.vue index b80039ea..4eedb473 100644 --- a/app/resources/js/views/advertising/AdvertisingTelegramView.vue +++ b/app/resources/js/views/advertising/AdvertisingTelegramView.vue @@ -14,17 +14,16 @@ import { createTelegram, launchTelegram, resubmitTelegram, + uploadTelegramMedia, type TelegramCampaign, type TelegramAudienceKind, type TelegramCreatePayload, } from '../../api/telegram'; -import TelegramSenderPanel from './TelegramSenderPanel.vue'; import TelegramAutoRulePanel from './TelegramAutoRulePanel.vue'; const loading = ref(false); const sandbox = ref(false); const balanceRub = ref('0.00'); -const frozenRub = ref('0.00'); const campaigns = ref([]); const adText = ref(''); @@ -34,6 +33,8 @@ const audienceDays = ref('30'); const phonesText = ref(''); const budgetCap = ref(''); const ordCategory = ref('Размещение рекламы'); +// Необязательное медиа объявления (картинка/видео) — у МТС объявление может быть с медиа. +const mediaFile = ref(null); // Двухшаговый поток (спека §5): «Рассчитать» создаёт черновик со сметой/охватом, // «Запустить» переводит его в очередь. draft — результат последнего расчёта; любая @@ -90,7 +91,6 @@ async function load(): Promise { const data = await fetchTelegram(); sandbox.value = data.sandbox; balanceRub.value = data.balance_rub; - frozenRub.value = data.frozen_rub; campaigns.value = data.campaigns; } finally { loading.value = false; @@ -127,7 +127,11 @@ async function calc(): Promise { error.value = ''; calculating.value = true; try { - draft.value = await createTelegram(buildPayload()); + const created = await createTelegram(buildPayload()); + // Приложили картинку/видео — грузим к свежему черновику (робот отдаст его кабинету МТС). + draft.value = mediaFile.value + ? await uploadTelegramMedia(created.id, mediaFile.value) + : created; } catch { error.value = 'Не удалось рассчитать кампанию'; } finally { @@ -143,10 +147,11 @@ async function launch(): Promise { try { await launchTelegram(draft.value.id); draft.value = null; + mediaFile.value = null; await load(); } catch (e: unknown) { const status = (e as { response?: { status?: number } })?.response?.status; - error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось запустить кампанию'; + error.value = status === 409 ? 'Не хватает денег на балансе' : 'Не удалось запустить кампанию'; } finally { submitting.value = false; } @@ -154,7 +159,7 @@ async function launch(): Promise { // Любая правка формы делает прежний расчёт недействительным — прячем смету и // снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным. -watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory], () => { +watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => { draft.value = null; }); @@ -206,7 +211,7 @@ async function submitResubmit(): Promise { await load(); } catch (e: unknown) { const status = (e as { response?: { status?: number } })?.response?.status; - error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось пересдать кампанию'; + error.value = status === 409 ? 'Не хватает денег на балансе' : 'Не удалось пересдать кампанию'; } finally { resubmitting.value = false; } @@ -256,14 +261,12 @@ onUnmounted(() => { {{ error }} - - Новая кампания - Баланс кошелька продвижения: {{ balanceRub }} ₽ (заморожено {{ frozenRub }} ₽) + Общий баланс: {{ balanceRub }} ₽ { variant="outlined" /> + + /** - * Панель «Авторассылка новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4). + * Панель «Авто-реклама новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4). * * Самодостаточна: грузит правило (fetchAutoRule) и даёт клиенту включить авто-режим, * задать объявление, порог пачки, бюджет на кампанию и дневной лимит трат. Живёт @@ -80,7 +80,7 @@ onMounted(load);