feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления
Денежная модель (куски 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\ClientTg;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ClientTg\TelegramSenderService;
|
||||
use DomainException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Клиентское HTTP-API «своего имени» Telegram-рекламы (план §Этап 5, задача 5.2).
|
||||
*
|
||||
* Тонкий слой над TelegramSenderService: клиент из кабинета заводит имя (store),
|
||||
* отключает (disable) и включает отключённое за долг обратно (enable). GET (show)
|
||||
* отдаёт снимок статуса + остаток грейса. Песочница денег не трогает.
|
||||
*
|
||||
* tenant_id всегда из $request->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);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\ClientTg;
|
||||
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\ClientTg\Sender;
|
||||
use App\Models\ClientTg\Setting;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Ежемесячная плата за своё имя Telegram-рекламы (client_tg_senders) со всех
|
||||
* тенантов сразу. Долг дольше grace-срока (по умолчанию 29 дней) → имя
|
||||
* автоматически отключается (suspended). Зеркало ChargeSmsNameFeeJob.
|
||||
*
|
||||
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
|
||||
* соединении, БЕЗ tenant-контекста. Перечисление отправителей через дефолтное
|
||||
* соединение без контекста дало бы 0 строк по RLS (молчаливый сбой). Поэтому:
|
||||
* — отправителей перечисляем плоскими строками через `pgsql_supplier`
|
||||
* (BYPASSRLS, аналог ChargeCampaignSpendJob), БЕЗ открытой транзакции;
|
||||
* — денежная операция (charge) + правка отправителя — под tenant-контекстом
|
||||
* (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ соединении, где RLS-политика
|
||||
* пускает crm_app_user только при выставленном контексте. Денежные операции
|
||||
* всегда принимают tenant_id явным аргументом — никакого смешения кошельков.
|
||||
*
|
||||
* ⚠️ Порог платёжеспособности проверяется ДО списания (свободно =
|
||||
* balance − frozen ≥ плата). AdWalletService::charge НЕ бросает при недоборе —
|
||||
* при недоборе «прижимает» баланс к 0 и всё равно создаёт транзакцию списания.
|
||||
* Полагаться на try/catch нельзя — иначе долг не фиксировался бы, а paid_until
|
||||
* ошибочно продлевался.
|
||||
*
|
||||
* Идемпотентность списания — через external_key "telegram:sender:{id}:{YYYY-MM}"
|
||||
* (период — месяц истёкшего paid_until). В песочнице деньги не двигаются вовсе.
|
||||
*/
|
||||
class ChargeTgNameFeeJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$sandbox = (bool) config('client_tg.sandbox', true);
|
||||
if ($sandbox) {
|
||||
return; // в песочнице деньги не списываем
|
||||
}
|
||||
|
||||
$wallet = app(AdWalletService::class);
|
||||
|
||||
// value() вернёт null на пустой таблице настроек → грейс по умолчанию (29).
|
||||
$grace = (int) (Setting::query()->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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` честно растёт.
|
||||
*/
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\ClientTg;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Своё имя/бренд Telegram-рекламы — заявка на согласование имени отправителя.
|
||||
*
|
||||
* Tenant-aware с RLS, один отправитель на тенанта (UNIQUE tenant_id). Зеркало
|
||||
* App\Models\ClientSmsSender, но проще: без документов/операторов (согласование в
|
||||
* Телеграме уточняется живой разведкой §8; здесь — денежный жизненный цикл).
|
||||
*
|
||||
* Источник: миграция client_tg_senders.
|
||||
*/
|
||||
class Sender extends Model
|
||||
{
|
||||
protected $table = 'client_tg_senders';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'status',
|
||||
'monthly_fee_rub',
|
||||
'note',
|
||||
'requested_by',
|
||||
'approved_at',
|
||||
'paid_until',
|
||||
'debt_since',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'requested_by' => 'integer',
|
||||
'monthly_fee_rub' => 'decimal:2',
|
||||
'approved_at' => 'datetime',
|
||||
'paid_until' => 'date',
|
||||
'debt_since' => 'date',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\ClientTg;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Общие настройки клиентской Telegram-рекламы (помесячная плата за своё имя,
|
||||
* льготный срок долга по имени).
|
||||
*
|
||||
* Общая справочная таблица (не tenant-aware, без RLS), одна строка.
|
||||
*
|
||||
* Источник: миграция client_tg_settings.
|
||||
*/
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $table = 'client_tg_settings';
|
||||
|
||||
protected $fillable = [
|
||||
'name_fee_rub_per_month',
|
||||
'name_debt_grace_days',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'name_fee_rub_per_month' => 'decimal:2',
|
||||
'name_debt_grace_days' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\ClientTg;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Шаблон объявления клиентской Telegram-рекламы.
|
||||
*
|
||||
* Tenant-aware с RLS.
|
||||
*
|
||||
* Источник: миграция client_tg_templates.
|
||||
*/
|
||||
class Template extends Model
|
||||
{
|
||||
protected $table = 'client_tg_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'title',
|
||||
'body',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,9 @@ final class TelegramAudienceService
|
||||
}
|
||||
|
||||
/**
|
||||
* Стоп-лист: убираем номера, по которым клиент отказался (client_tg_optouts).
|
||||
* Список «не показывать этим номерам»: вырезаем из аудитории номера, которые клиент
|
||||
* пометил как исключаемые (client_tg_optouts). В показах рекламы «отписок» нет — это
|
||||
* ручной exclude-лист тенанта.
|
||||
*
|
||||
* @param list<string> $phones
|
||||
* @return list<string>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientTg;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Списание и возврат денег за Telegram-рекламу с ОБЩЕГО баланса тенанта.
|
||||
*
|
||||
* Зеркало App\Services\Sms\SmsChargeService: клиент платит с общего баланса
|
||||
* (`tenants.balance_rub`), как за СМС; отдельного рекламного кошелька с заморозкой
|
||||
* тут больше нет. Момент списания — запуск кампании (до отправки роботом), возврат —
|
||||
* отказ модерации / сбой до кабинета.
|
||||
*
|
||||
* 🪤 Источник правды — САЛЬДО проводок в `balance_transactions` по кампании
|
||||
* (related_type=Campaign + related_id): непогашенное списание = count(tg_ad_charge) −
|
||||
* count(tg_ad_refund). Кампанию можно списать → вернуть (отказ модерации) → списать
|
||||
* снова (пересдача), поэтому «есть хоть одно списание → больше не списывать» неверно:
|
||||
* пересдача стала бы бесплатной. Считаем по сальдо. Строки пишутся в той же
|
||||
* транзакции, что и движение денег, и разъехаться с балансом физически не могут.
|
||||
*
|
||||
* Порядок важен: сначала замок по тенанту (он сериализует параллельные списания
|
||||
* одного клиента), и только ПОСЛЕ него — проверка идемпотентности.
|
||||
*
|
||||
* Здесь Campaign, Tenant и BalanceTransaction живут на одном соединении (`pgsql`),
|
||||
* поэтому `lockForUpdate()` по тенанту — настоящий замок внутри той же транзакции
|
||||
* (в отличие от СМС, где кампания на `pgsql_supplier`).
|
||||
*/
|
||||
final class TelegramCampaignChargeService
|
||||
{
|
||||
/** Списать клиентскую смету $priceRub с общего баланса тенанта кампании. */
|
||||
public function charge(Campaign $campaign, string $priceRub): void
|
||||
{
|
||||
DB::transaction(function () use ($campaign, $priceRub): void {
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::whereKey($campaign->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;
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientTg;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\ClientTg\Sender;
|
||||
use App\Models\ClientTg\Setting;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use DomainException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Жизненный цикл «своего имени» Telegram-рекламы (план §Сессия 4, задача 4.3).
|
||||
*
|
||||
* Клиент: requestSender (заявка + бронь месячной платы) / disableSender.
|
||||
* Админ: approve (списание + снятие брони + paid_until +1мес) / reject (снятие
|
||||
* брони) / disableByAdmin. Зеркало lifecycle СМС (ClientSmsController +
|
||||
* AdminSmsSenderController), сведённое в один сервис (у TG нет HTTP-слоя имени
|
||||
* в этой сессии — только денежный цикл под тесты).
|
||||
*
|
||||
* 🔴 Деньги — только через AdWalletService (bcmath). freeze() бросает
|
||||
* InsufficientBalanceException при недоборе (до списания); charge() НЕ бросает.
|
||||
* В песочнице (client_tg.sandbox) деньги не двигаются. Канал кошелька — 'telegram',
|
||||
* тип источника — 'sender'.
|
||||
*/
|
||||
class TelegramSenderService
|
||||
{
|
||||
private const CHANNEL = 'telegram';
|
||||
|
||||
private const SOURCE = 'sender';
|
||||
|
||||
public function __construct(
|
||||
private readonly AdWalletService $wallet,
|
||||
) {}
|
||||
|
||||
/** Клиент запрашивает своё имя: заявка pending + бронь месячной платы. */
|
||||
public function requestSender(int $tenantId, string $name, ?int $requestedBy = null): Sender
|
||||
{
|
||||
$existing = Sender::where('tenant_id', $tenantId)->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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Порог авто-пачки
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_tg_messages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_tg_templates', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_tg_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Своё имя/бренд Telegram-рекламы: заявка на согласование имени отправителя
|
||||
* (один на тенанта, UNIQUE tenant_id). Зеркало client_sms_senders, но проще —
|
||||
* без документов/операторов (согласование в Телеграме уточняется живой разведкой
|
||||
* §8 спеки; здесь фиксируем денежный жизненный цикл + помесячную оплату).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_tg_senders', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Новые типы операций в журнале денег — Telegram-реклама на общем балансе:
|
||||
* tg_ad_charge — списание клиентской сметы (с наценкой) при запуске кампании,
|
||||
* tg_ad_refund — возврат при отказе модерации / сбое до кабинета.
|
||||
*
|
||||
* Одной миграцией, чтобы на прод-кластер CHECK ехал ОДИН раз.
|
||||
* Список в up() включает уже действующий sms_charge (добавлен 2026_07_23).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check');
|
||||
DB::statement(<<<'SQL'
|
||||
ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check
|
||||
CHECK (type IN (
|
||||
'trial_bonus','topup','lead_charge','refund','manual_adjustment',
|
||||
'historical_import','chargeback_writedown','chargeback_repayment',
|
||||
'migration','autopodbor_charge','sms_charge',
|
||||
'tg_ad_charge','tg_ad_refund'
|
||||
))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check');
|
||||
DB::statement(<<<'SQL'
|
||||
ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check
|
||||
CHECK (type IN (
|
||||
'trial_bonus','topup','lead_charge','refund','manual_adjustment',
|
||||
'historical_import','chargeback_writedown','chargeback_repayment',
|
||||
'migration','autopodbor_charge','sms_charge'
|
||||
))
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Чистка телеграм-модуля от СМС-наследия (28.07.2026). Модуль ресейлит МТС Маркетолог
|
||||
* «показы рекламы по своей базе» — там НЕТ ни имени отправителя, ни доставки сообщений
|
||||
* по одному номеру, ни отписок-шаблонов сообщений. Эти таблицы были механически
|
||||
* скопированы из СМС-модуля и не относятся к продукту показов (владельцем подтверждено):
|
||||
* client_tg_senders — «своё имя/бренд отправителя» + помесячная абонплата;
|
||||
* client_tg_settings — обе колонки (name_fee_rub_per_month, name_debt_grace_days)
|
||||
* обслуживали только имя;
|
||||
* client_tg_messages — «по-получательские записи доставки» (копия sales_sms_messages),
|
||||
* модели не имела, в рабочем коде не использовалась;
|
||||
* client_tg_templates — «шаблоны сообщений», не использовались нигде.
|
||||
*
|
||||
* Их create-миграции удалены, поэтому на свежей базе таблицы не создаются и DROP ниже —
|
||||
* no-op (IF EXISTS). На уже мигрированных dev/prod-базах миграция их убирает.
|
||||
*
|
||||
* НЕ трогаем: client_tg_campaigns/_campaign_phones/_contacts/_tariffs/_auto_rule (реальные
|
||||
* показы) и client_tg_optouts (переосмыслен как ручной список «не показывать этим номерам»).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS client_tg_senders CASCADE');
|
||||
DB::statement('DROP TABLE IF EXISTS client_tg_settings CASCADE');
|
||||
DB::statement('DROP TABLE IF EXISTS client_tg_messages CASCADE');
|
||||
DB::statement('DROP TABLE IF EXISTS client_tg_templates CASCADE');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Восстановлению не подлежит: таблицы — СМС-наследие, удалены осознанно вместе
|
||||
// с их create-миграциями. Откат не пересоздаёт их (продукт показов их не имеет).
|
||||
}
|
||||
};
|
||||
@@ -631,7 +631,7 @@ export async function markInvoicePaid(id: number): Promise<void> {
|
||||
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<AdminTgTariffsResponse> {
|
||||
const { data } = await apiClient.get<AdminTgTariffsResponse>('/api/admin/telegram/tariffs');
|
||||
return data;
|
||||
@@ -676,16 +669,3 @@ export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise<AdminTg
|
||||
const { data } = await apiClient.put<AdminTgTariffsUpdateResponse>('/api/admin/telegram/tariffs', { rows });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** PUT /api/admin/telegram/settings — плата за имя/мес и срок долга до отключения. */
|
||||
export async function updateTgSettings(
|
||||
nameFeeRubPerMonth: string,
|
||||
nameDebtGraceDays: number,
|
||||
): Promise<AdminTgSettings> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.put<AdminTgSettings>('/api/admin/telegram/settings', {
|
||||
name_fee_rub_per_month: nameFeeRubPerMonth,
|
||||
name_debt_grace_days: nameDebtGraceDays,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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<TelegramSenderSnapshot> {
|
||||
const { data } = await apiClient.get<TelegramSenderSnapshot>('/api/telegram/sender');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST /api/telegram/sender — завести имя (в песочнице деньги не трогаются). */
|
||||
export async function createSender(name: string): Promise<TelegramSender> {
|
||||
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<TelegramSender>('/api/telegram/sender', { name });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST /api/telegram/sender/disable — отключить имя. */
|
||||
export async function disableSender(): Promise<TelegramSender> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<TelegramSender>('/api/telegram/sender/disable');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** POST /api/telegram/sender/enable — включить отключённое за долг имя обратно. */
|
||||
export async function enableSender(): Promise<TelegramSender> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<TelegramSender>('/api/telegram/sender/enable');
|
||||
const form = new FormData();
|
||||
form.append('media', file);
|
||||
const { data } = await apiClient.post<TelegramCampaign>(`/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<TelegramAutoRule> {
|
||||
const { data } = await apiClient.get<TelegramAutoRule>('/api/telegram/auto-rule');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** PUT /api/telegram/auto-rule — сохранить правило авторассылки. */
|
||||
/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */
|
||||
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SaaS-admin → «Клиентская Telegram-реклама»: тарифная сетка (ступени ₽ за показ)
|
||||
* и общие настройки платного имени (плата/мес + срок долга до отключения).
|
||||
* План §Сессия 4, задача 4.4. Лаконичное зеркало AdminSmsView (без секции имён —
|
||||
* админ-согласование имени появится после живой разведки §8).
|
||||
* SaaS-admin → «Клиентская Telegram-реклама»: тарифная сетка (ступени ₽ за показ).
|
||||
* План §Сессия 4, задача 4.4. Лаконичное зеркало AdminSmsView.
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
fetchTgTariffs,
|
||||
updateTgTariffs,
|
||||
updateTgSettings,
|
||||
type AdminTgTariff,
|
||||
type AdminTgSettings,
|
||||
} from '../../api/admin';
|
||||
import { fetchTgTariffs, updateTgTariffs, type AdminTgTariff } from '../../api/admin';
|
||||
|
||||
const tariffs = ref<AdminTgTariff[]>([]);
|
||||
const settings = ref<AdminTgSettings>({ name_fee_rub_per_month: '0.00', name_debt_grace_days: 0 });
|
||||
|
||||
const loadError = ref(false);
|
||||
const tariffsSaving = ref(false);
|
||||
const settingsSaving = ref(false);
|
||||
const tariffsSaved = ref(false);
|
||||
const settingsSaved = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loadError.value = false;
|
||||
try {
|
||||
const data = await fetchTgTariffs();
|
||||
tariffs.value = data.tariffs;
|
||||
settings.value = data.settings;
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
}
|
||||
@@ -58,21 +46,6 @@ async function saveTariffs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(): Promise<void> {
|
||||
settingsSaving.value = true;
|
||||
settingsSaved.value = false;
|
||||
try {
|
||||
const res = await updateTgSettings(
|
||||
String(settings.value.name_fee_rub_per_month),
|
||||
Number(settings.value.name_debt_grace_days),
|
||||
);
|
||||
settings.value = res;
|
||||
settingsSaved.value = true;
|
||||
} finally {
|
||||
settingsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -148,40 +121,5 @@ onMounted(load);
|
||||
<span v-if="tariffsSaved" class="text-success text-body-2 ml-3">Сохранено</span>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Настройки платного имени. -->
|
||||
<v-card data-testid="tg-settings-card">
|
||||
<v-card-title>Своё имя / бренд</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-text-field
|
||||
v-model="settings.name_fee_rub_per_month"
|
||||
label="Плата за имя, ₽/мес"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 200px"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="settings.name_debt_grace_days"
|
||||
label="Долг до отключения, дней"
|
||||
type="number"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 220px"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="settingsSaving"
|
||||
data-testid="tg-settings-save"
|
||||
@click="saveSettings"
|
||||
>
|
||||
Сохранить настройки
|
||||
</v-btn>
|
||||
<span v-if="settingsSaved" class="text-success text-body-2 ml-3">Сохранено</span>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<TelegramCampaign[]>([]);
|
||||
|
||||
const adText = ref('');
|
||||
@@ -34,6 +33,8 @@ const audienceDays = ref('30');
|
||||
const phonesText = ref('');
|
||||
const budgetCap = ref('');
|
||||
const ordCategory = ref('Размещение рекламы');
|
||||
// Необязательное медиа объявления (картинка/видео) — у МТС объявление может быть с медиа.
|
||||
const mediaFile = ref<File | null>(null);
|
||||
|
||||
// Двухшаговый поток (спека §5): «Рассчитать» создаёт черновик со сметой/охватом,
|
||||
// «Запустить» переводит его в очередь. draft — результат последнего расчёта; любая
|
||||
@@ -90,7 +91,6 @@ async function load(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
// Любая правка формы делает прежний расчёт недействительным — прячем смету и
|
||||
// снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным.
|
||||
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<void> {
|
||||
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 }}
|
||||
</v-alert>
|
||||
|
||||
<TelegramSenderPanel />
|
||||
|
||||
<TelegramAutoRulePanel />
|
||||
|
||||
<v-card variant="outlined" class="mb-6">
|
||||
<v-card-item>
|
||||
<v-card-title class="text-subtitle-1">Новая кампания</v-card-title>
|
||||
<v-card-subtitle>Баланс кошелька продвижения: {{ balanceRub }} ₽ (заморожено {{ frozenRub }} ₽)</v-card-subtitle>
|
||||
<v-card-subtitle>Общий баланс: {{ balanceRub }} ₽</v-card-subtitle>
|
||||
</v-card-item>
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
@@ -335,6 +338,18 @@ onUnmounted(() => {
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-file-input
|
||||
v-model="mediaFile"
|
||||
data-testid="tg-media"
|
||||
label="Картинка или видео к объявлению (необязательно)"
|
||||
accept="image/png,image/jpeg,image/gif,video/mp4"
|
||||
prepend-icon="mdi-image-outline"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
show-size
|
||||
clearable
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="draft"
|
||||
data-testid="tg-estimate"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Панель «Авторассылка новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4).
|
||||
* Панель «Авто-реклама новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4).
|
||||
*
|
||||
* Самодостаточна: грузит правило (fetchAutoRule) и даёт клиенту включить авто-режим,
|
||||
* задать объявление, порог пачки, бюджет на кампанию и дневной лимит трат. Живёт
|
||||
@@ -80,7 +80,7 @@ onMounted(load);
|
||||
<template>
|
||||
<v-card variant="outlined" class="mb-6" data-testid="tg-auto-panel">
|
||||
<v-card-item>
|
||||
<v-card-title class="text-subtitle-1">Авторассылка новым лидам</v-card-title>
|
||||
<v-card-title class="text-subtitle-1">Авто-реклама новым лидам</v-card-title>
|
||||
<v-card-subtitle>
|
||||
Новые лиды копятся в пачку и автоматически уходят в рекламу, когда наберётся порог.
|
||||
</v-card-subtitle>
|
||||
@@ -96,7 +96,7 @@ onMounted(load);
|
||||
<v-switch
|
||||
v-model="enabled"
|
||||
data-testid="tg-auto-enabled"
|
||||
label="Включить авторассылку"
|
||||
label="Включить авто-рекламу"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Панель «Своё имя отправителя» Telegram-рекламы (план §Этап 5, задача 5.3).
|
||||
*
|
||||
* Самодостаточна: сама грузит статус имени (fetchSender) и даёт клиенту завести имя,
|
||||
* отключить его и включить обратно, если оно было отключено за долг. Живёт внутри
|
||||
* экрана AdvertisingTelegramView. Стиль — как у соседних карточек кабинета
|
||||
* (variant=outlined, density=compact). Реальные деньги трогает только вне песочницы.
|
||||
*/
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import {
|
||||
fetchSender,
|
||||
createSender,
|
||||
disableSender,
|
||||
enableSender,
|
||||
type TelegramSender,
|
||||
} from '../../api/telegram';
|
||||
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const sender = ref<TelegramSender | null>(null);
|
||||
const graceDaysLeft = ref<number | null>(null);
|
||||
const newName = ref('');
|
||||
|
||||
/** Человеческий ярлык статуса имени. */
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Имя на согласовании',
|
||||
active: 'Имя активно',
|
||||
suspended: 'Имя отключено за долг',
|
||||
rejected: 'Заявка отклонена',
|
||||
cancelled: 'Имя отключено',
|
||||
};
|
||||
|
||||
const statusLabel = computed(() => (sender.value ? (STATUS_LABELS[sender.value.status] ?? sender.value.status) : ''));
|
||||
|
||||
// Нет имени или прошлая заявка закрыта/отклонена → предлагаем завести (новое) имя.
|
||||
const showCreateForm = computed(
|
||||
() => sender.value === null || ['cancelled', 'rejected'].includes(sender.value.status),
|
||||
);
|
||||
const isActive = computed(() => sender.value?.status === 'active');
|
||||
const isPending = computed(() => sender.value?.status === 'pending');
|
||||
const isSuspended = computed(() => sender.value?.status === 'suspended');
|
||||
const canCreate = computed(() => newName.value.trim() !== '' && !busy.value);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchSender();
|
||||
sender.value = data.sender;
|
||||
graceDaysLeft.value = data.grace_days_left;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
if (!canCreate.value) return;
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
await createSender(newName.value.trim());
|
||||
newName.value = '';
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось завести имя';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(): Promise<void> {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
await disableSender();
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'Не удалось отключить имя';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enable(): Promise<void> {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
await enableSender();
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось включить имя';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="mb-6" data-testid="tg-sender-panel">
|
||||
<v-card-item>
|
||||
<v-card-title class="text-subtitle-1">Своё имя отправителя</v-card-title>
|
||||
<v-card-subtitle>Показывайте рекламу от имени вашего бренда, а не общего кабинета.</v-card-subtitle>
|
||||
</v-card-item>
|
||||
<v-card-text>
|
||||
<v-alert v-if="error" data-testid="tg-sender-error" type="error" variant="tonal" density="compact" class="mb-3">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
|
||||
<!-- Нет имени (или прошлое закрыто/отклонено) → форма заведения. -->
|
||||
<template v-if="showCreateForm">
|
||||
<v-alert
|
||||
v-if="sender && sender.status === 'rejected' && sender.note"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
Прошлая заявка отклонена: {{ sender.note }}
|
||||
</v-alert>
|
||||
<v-text-field
|
||||
v-model="newName"
|
||||
data-testid="tg-sender-name-input"
|
||||
label="Имя отправителя (бренд)"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
data-testid="tg-sender-create"
|
||||
color="primary"
|
||||
:loading="busy"
|
||||
:disabled="!canCreate"
|
||||
@click="create"
|
||||
>
|
||||
Завести имя
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- Есть имя: статус + управление. -->
|
||||
<template v-else>
|
||||
<div class="d-flex align-center flex-wrap ga-2 mb-1">
|
||||
<span class="text-subtitle-2">{{ sender?.name }}</span>
|
||||
<v-chip
|
||||
data-testid="tg-sender-status"
|
||||
size="small"
|
||||
:color="isActive ? 'success' : isSuspended ? 'warning' : 'info'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div v-if="isActive && sender?.paid_until" data-testid="tg-sender-paid-until" class="text-medium-emphasis mb-2">
|
||||
Оплачено до: {{ sender.paid_until }}
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="isPending"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
>
|
||||
Заявка на имя отправлена — ждём согласования.
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="graceDaysLeft !== null"
|
||||
data-testid="tg-sender-debt"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
>
|
||||
Есть задолженность по оплате имени. Пополните кошелёк — до автоотключения осталось {{ graceDaysLeft }} дн.
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="isSuspended"
|
||||
data-testid="tg-sender-suspended"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
>
|
||||
Отключено за долг. Погасите задолженность у МТС, пополните кошелёк и включите имя снова.
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex ga-2 mt-1">
|
||||
<v-btn
|
||||
v-if="isSuspended"
|
||||
data-testid="tg-sender-enable"
|
||||
color="primary"
|
||||
size="small"
|
||||
:loading="busy"
|
||||
@click="enable"
|
||||
>
|
||||
Включить
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isActive || isPending"
|
||||
data-testid="tg-sender-disable"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
:loading="busy"
|
||||
@click="disable"
|
||||
>
|
||||
Отключить
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ClientTg\ChargeTgNameFeeJob;
|
||||
use App\Jobs\ClientTg\PollTelegramModerationJob;
|
||||
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
|
||||
use App\Jobs\External\RefreshExternalBalancesJob;
|
||||
@@ -316,16 +315,6 @@ Schedule::command('billing:reconcile-payments')
|
||||
->onSuccess(fn () => $hb->recordRunResult('billing:reconcile-payments', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('billing:reconcile-payments', false, 'Command failed', null));
|
||||
|
||||
// Telegram-модуль, задача 4.3: ежемесячная плата за своё имя (client_tg_senders).
|
||||
// Ежедневно — сам джоб берёт только имена с истёкшим paid_until (месячная каденция
|
||||
// хранится по-строчно). Долг дольше грейса → имя отключается (suspended). В песочнице
|
||||
// (TG_SANDBOX) деньги не двигаются. Зеркало ChargeSmsNameFeeJob (05:00 МСК).
|
||||
Schedule::job(new ChargeTgNameFeeJob)
|
||||
->dailyAt('05:10')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\ChargeTgNameFeeJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\ChargeTgNameFeeJob', false, 'Job failed', null));
|
||||
|
||||
// Telegram-модуль, задача 3.3: уборщик зависших кампаний. Кампания в `running`
|
||||
// дольше 15 минут = мёртвый прогон робота (штатный ≤ 420с). Без mts_campaign_id →
|
||||
// failed + возврат брони; с id (черновик создан) → needs_review, деньги НЕ трогаем.
|
||||
|
||||
+4
-10
@@ -102,14 +102,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(fu
|
||||
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\ClientTg\CampaignController@launch')->whereNumber('id');
|
||||
Route::post('/campaigns/{id}/cancel', 'App\Http\Controllers\Api\ClientTg\CampaignController@cancel')->whereNumber('id');
|
||||
Route::post('/campaigns/{id}/resubmit', 'App\Http\Controllers\Api\ClientTg\CampaignController@resubmit')->whereNumber('id');
|
||||
Route::post('/campaigns/{id}/media', 'App\Http\Controllers\Api\ClientTg\CampaignController@attachMedia')->whereNumber('id');
|
||||
|
||||
// Своё имя отправителя (client_tg_senders): клиент заводит/отключает/включает из кабинета.
|
||||
Route::get('/sender', 'App\Http\Controllers\Api\ClientTg\SenderController@show');
|
||||
Route::post('/sender', 'App\Http\Controllers\Api\ClientTg\SenderController@store');
|
||||
Route::post('/sender/disable', 'App\Http\Controllers\Api\ClientTg\SenderController@disable');
|
||||
Route::post('/sender/enable', 'App\Http\Controllers\Api\ClientTg\SenderController@enable');
|
||||
|
||||
// Правило авторассылки (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
|
||||
// Правило авто-рекламы (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
|
||||
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
|
||||
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
|
||||
});
|
||||
@@ -272,11 +267,10 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// Telegram-модуль, задача 4.4: тарифная сетка + настройки клиентской
|
||||
// Telegram-рекламы (client_tg_tariffs / client_tg_settings, глобальные).
|
||||
// Telegram-модуль, задача 4.4: тарифная сетка клиентской Telegram-рекламы
|
||||
// (client_tg_tariffs, глобальная).
|
||||
Route::get('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@index');
|
||||
Route::put('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@updateTariffs');
|
||||
Route::put('/api/admin/telegram/settings', 'App\Http\Controllers\Api\Admin\TgTariffController@updateSettings');
|
||||
});
|
||||
|
||||
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
|
||||
|
||||
@@ -2,23 +2,21 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientTg\Setting;
|
||||
use App\Models\ClientTg\Tariff;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* SaaS-admin «Клиентская Telegram-реклама» — тарифная сетка (client_tg_tariffs) и
|
||||
* общие настройки (client_tg_settings, singleton). План §Сессия 4, задача 4.4.
|
||||
* Зеркало AdminTariffTest (СМС). Гейт группы — saas-admin,admin-db (crm_admin_user);
|
||||
* admin-db переключает соединение на pgsql_admin, а SharesAdminPdo (глобально в
|
||||
* Pest.php) отдаёт ему ту же PDO-сессию, что у default (видимость засеянной схемы).
|
||||
* SaaS-admin «Клиентская Telegram-реклама» — тарифная сетка (client_tg_tariffs).
|
||||
* План §Сессия 4, задача 4.4. Зеркало AdminTariffTest (СМС). Гейт группы —
|
||||
* saas-admin,admin-db (crm_admin_user); admin-db переключает соединение на
|
||||
* pgsql_admin, а SharesAdminPdo (глобально в Pest.php) отдаёт ему ту же PDO-сессию,
|
||||
* что у default (видимость засеянной схемы).
|
||||
*
|
||||
* Сидированы миграциями: 5 ступеней (1/0.45, 1000/0.42, 5000/0.40, 10000/0.38,
|
||||
* 50000/0.36) + settings (fee 2500.00/мес, grace 29 дней).
|
||||
* Сидированы миграциями: 5 ступеней (1/0.45, 1000/0.42, 5000/0.40, 10000/0.38, 50000/0.36).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('GET /api/admin/telegram/tariffs возвращает сидированные ступени и настройки', function () {
|
||||
test('GET /api/admin/telegram/tariffs возвращает сидированные ступени', function () {
|
||||
$r = $this->getJson('/api/admin/telegram/tariffs');
|
||||
$r->assertStatus(200);
|
||||
|
||||
@@ -26,9 +24,6 @@ test('GET /api/admin/telegram/tariffs возвращает сидированн
|
||||
expect($tariffs)->toHaveCount(5);
|
||||
expect(collect($tariffs)->pluck('min_qty')->all())->toBe([1, 1000, 5000, 10000, 50000]);
|
||||
expect($tariffs[0]['price_rub'])->toBe('0.45');
|
||||
|
||||
expect($r->json('settings.name_fee_rub_per_month'))->toBe('2500.00');
|
||||
expect($r->json('settings.name_debt_grace_days'))->toBe(29);
|
||||
});
|
||||
|
||||
test('PUT /api/admin/telegram/tariffs заменяет весь набор ступеней', function () {
|
||||
@@ -50,20 +45,6 @@ test('PUT /api/admin/telegram/tariffs заменяет весь набор ст
|
||||
expect(collect($r->json('tariffs'))->pluck('min_qty')->all())->toBe([1, 5000]);
|
||||
});
|
||||
|
||||
test('PUT /api/admin/telegram/settings обновляет singleton настроек', function () {
|
||||
$r = $this->putJson('/api/admin/telegram/settings', [
|
||||
'name_fee_rub_per_month' => '3000.00',
|
||||
'name_debt_grace_days' => 14,
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('name_fee_rub_per_month'))->toBe('3000.00');
|
||||
expect($r->json('name_debt_grace_days'))->toBe(14);
|
||||
|
||||
$settings = Setting::first();
|
||||
expect($settings->name_fee_rub_per_month)->toBe('3000.00');
|
||||
expect($settings->name_debt_grace_days)->toBe(14);
|
||||
});
|
||||
|
||||
test('PUT /api/admin/telegram/tariffs с некорректной ступенью → 422', function () {
|
||||
$this->putJson('/api/admin/telegram/tariffs', [
|
||||
'rows' => [['min_qty' => 0, 'price_rub' => '0.50']],
|
||||
|
||||
@@ -3,22 +3,27 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\Contact;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* Предстартовый гейт аудитории (находка #2, план §Задача 1.5): в БОЕВОМ режиме
|
||||
* (sandbox=false) `launch` обязан проверить число кандидатов ДО брони денег.
|
||||
* (sandbox=false) `launch` обязан проверить число кандидатов ДО списания денег.
|
||||
* Минимум у МТС Маркетолог — 367 (client_tg.auto_batch_threshold); <367 или пусто —
|
||||
* робот всё равно упал бы, но деньги уже были бы заморожены. В песочнице гейта
|
||||
* робот всё равно упал бы, но деньги уже были бы списаны. В песочнице гейта
|
||||
* нет (денег нет вообще).
|
||||
*
|
||||
* Деньги теперь идут с ОБЩЕГО баланса тенанта (tenants.balance_rub) — списание
|
||||
* пишет проводку BalanceTransaction TYPE_TG_AD_CHARGE (отдельного рекламного
|
||||
* кошелька с заморозкой больше нет). «Гейт отсёк деньги не тронуты» = 0 проводок
|
||||
* списания; «гейт пройден списали» = 1 проводка + баланс уменьшился.
|
||||
*
|
||||
* Auth/tenant как в CampaignApiTest: Tenant::factory + User(tenant_id) + actingAs.
|
||||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
@@ -31,8 +36,8 @@ beforeEach(function () {
|
||||
config(['client_tg.sandbox' => false]);
|
||||
});
|
||||
|
||||
it('<367 кандидатов в бою → launch 422, бронь НЕ сделана, статус остаётся draft', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
|
||||
it('<367 кандидатов в бою → launch 422, списания НЕ было, статус остаётся draft', function () {
|
||||
$this->tenant->update(['balance_rub' => '5000.00']);
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
Contact::create([
|
||||
@@ -56,14 +61,16 @@ it('<367 кандидатов в бою → launch 422, бронь НЕ сдел
|
||||
|
||||
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00');
|
||||
// Гейт отсёк ДО денег: ни одной проводки списания, баланс не тронут.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
|
||||
expect((string) Tenant::find($this->tenant->id)->balance_rub)->toBe('5000.00');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('0 кандидатов в бою → launch 422, статус остаётся draft', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
|
||||
$this->tenant->update(['balance_rub' => '5000.00']);
|
||||
|
||||
$campaign = Campaign::create([
|
||||
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
|
||||
@@ -77,11 +84,16 @@ it('0 кандидатов в бою → launch 422, статус остаётс
|
||||
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(422);
|
||||
|
||||
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT);
|
||||
|
||||
// Списания не было.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('≥367 кандидатов + денег хватает в бою → launch OK, draft→queued, джоб поставлен, бронь = budget', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
|
||||
it('≥367 кандидатов + денег хватает в бою → launch OK, draft→queued, джоб поставлен, списание произошло', function () {
|
||||
$this->tenant->update(['balance_rub' => '5000.00']);
|
||||
|
||||
for ($i = 0; $i < 400; $i++) {
|
||||
Contact::create([
|
||||
@@ -107,6 +119,10 @@ it('≥367 кандидатов + денег хватает в бою → launch
|
||||
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('1000.00');
|
||||
// Гейт пройден → списали клиентскую смету (база×1.40) с общего баланса: ровно
|
||||
// одна проводка списания, баланс уменьшился. Точную сумму не ассертим — она
|
||||
// зависит от числа кандидатов (clientEstimateRub), достаточно факта списания.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
|
||||
expect(bccomp((string) Tenant::find($this->tenant->id)->balance_rub, '5000.00', 2))->toBeLessThan(0);
|
||||
});
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\AutoRule;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientTg\TelegramAutoAccumulator;
|
||||
use App\Services\ClientTg\TelegramTariffService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* Ревью-фикс F3 (Деньги I2): авто-путь ДОЛЖЕН бронировать потолок (budget_cap_rub)
|
||||
* симметрично ручному launch — иначе кампания уходит в очередь без зарезервированных
|
||||
* денег. Проверка баланса — против ПОТОЛКА брони (не только сметы), чтобы freeze не
|
||||
* бросил в лицо накопителю (best-effort). В песочнице брони нет.
|
||||
* Ревью-фикс F3 (Деньги I2), денежная модель без кошелька: авто-путь ДОЛЖЕН списывать
|
||||
* клиентскую смету (estimated_cost_rub, с наценкой) с ОБЩЕГО баланса тенанта
|
||||
* (tenants.balance_rub) симметрично ручному launch — иначе кампания уходит в очередь
|
||||
* без оплаты. Заморозки/кошелька (AdWallet/AdWalletService) больше нет. Не хватило
|
||||
* баланса на смету → мягкий skip (кампания держится черновиком, приём лидов не падает).
|
||||
* В песочнице денег не касаемся вовсе.
|
||||
* Синтетические номера 7999… — реальных ПДн нет.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -49,38 +51,44 @@ beforeEach(function () {
|
||||
Queue::fake();
|
||||
config(['client_tg.auto_batch_threshold' => 3]);
|
||||
config(['client_tg.sandbox' => false]);
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->cost = app(TelegramTariffService::class)->estimateRub(3); // 1.35
|
||||
$this->tenant = Tenant::factory()->create(['balance_rub' => '10000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
|
||||
// Клиентская смета за пачку из 3 показов (то, что списывается с баланса).
|
||||
$this->cost = app(TelegramTariffService::class)->clientEstimateRub(3);
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
it('в бою авто-путь бронирует потолок и ставит в очередь', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
it('в бою авто-путь списывает клиентскую смету и ставит в очередь', function () {
|
||||
afRule($this->tenant->id, cap: '2500.00');
|
||||
|
||||
afFeed($this->tenant->id, 1, 3);
|
||||
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
||||
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('2500.00'); // забронирован потолок
|
||||
// Баланс упал ровно на клиентскую смету, списание отражено проводкой.
|
||||
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub('10000.00', $this->cost, 2))
|
||||
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('денег хватает на смету, но НЕ на потолок брони → держит черновиком, без брони', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '2000.00', 'telegram', 'test'); // > сметы 1.35, < потолка 2500
|
||||
it('денег НЕ хватает на смету → держит черновиком, без списания', function () {
|
||||
// Баланс на копейку меньше сметы — списать нельзя.
|
||||
DB::table('tenants')->where('id', $this->tenant->id)
|
||||
->update(['balance_rub' => bcsub((string) $this->cost, '0.01', 2)]);
|
||||
afRule($this->tenant->id, cap: '2500.00');
|
||||
|
||||
afFeed($this->tenant->id, 1, 3);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00');
|
||||
// Баланс не тронут, ни одной проводки списания.
|
||||
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub((string) $this->cost, '0.01', 2))
|
||||
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('две пачки за день — счётчик суммирует, две брони по потолку', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
it('две пачки за день — счётчик суммирует, две списанные сметы', function () {
|
||||
$rule = afRule($this->tenant->id, cap: '2500.00', dailyLimit: '1000.00');
|
||||
|
||||
afFeed($this->tenant->id, 1, 3); // пачка 1 → очередь, новая пачка откроется
|
||||
@@ -88,18 +96,21 @@ it('две пачки за день — счётчик суммирует, дв
|
||||
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class, 2);
|
||||
$rule->refresh();
|
||||
expect((string) $rule->spent_today_rub)->toBe(bcadd($this->cost, $this->cost, 2)); // 2.70
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('5000.00'); // 2 × потолок
|
||||
expect((string) $rule->spent_today_rub)->toBe(bcadd($this->cost, $this->cost, 2)); // 2 сметы
|
||||
// Баланс упал на две сметы, два списания.
|
||||
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub('10000.00', bcadd($this->cost, $this->cost, 2), 2))
|
||||
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('в песочнице авто-путь брони не делает', function () {
|
||||
it('в песочнице авто-путь денег не касается', function () {
|
||||
config(['client_tg.sandbox' => true]);
|
||||
afRule($this->tenant->id, cap: '2500.00', dailyLimit: '0.00');
|
||||
|
||||
afFeed($this->tenant->id, 1, 3);
|
||||
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect($wallet)->toBeNull(); // кошелёк не заводился — денег не касались
|
||||
// Ни списаний, ни движения баланса — денег в песочнице не трогаем.
|
||||
expect((string) $this->tenant->fresh()->balance_rub)->toBe('10000.00')
|
||||
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\AutoRule;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientTg\TelegramAutoAccumulator;
|
||||
use App\Services\ClientTg\TelegramTariffService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
@@ -18,12 +19,16 @@ use Illuminate\Support\Facades\Queue;
|
||||
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
|
||||
*
|
||||
* Авто-путь копит номера и при пороге ставит кампанию в очередь. В БОЮ перед
|
||||
* постановкой должен пройти двойной гейт: (1) на кошельке хватает свободных денег
|
||||
* под смету пачки; (2) сумма за сегодня + смета не превышает дневной лимит правила
|
||||
* (по умолчанию 0 → авто выключено). Не прошёл — не ставим в очередь, пишем причину.
|
||||
* постановкой должен пройти двойной гейт: (1) сумма за сегодня + клиентская смета
|
||||
* пачки не превышает дневной лимит правила (по умолчанию 0 → авто выключено);
|
||||
* (2) клиентская смета СПИСЫВАЕТСЯ с общего баланса тенанта (`tenants.balance_rub`,
|
||||
* как за СМС) — не хватило → мягкий пропуск. Не прошёл — не ставим в очередь,
|
||||
* кампания остаётся черновиком, пишем причину.
|
||||
*
|
||||
* В песочнице деньги не трогаются (как ручной launch) — гейт не вмешивается.
|
||||
* Синтетические номера 7999… — реальных ПДн не касаемся.
|
||||
* Отдельного рекламного кошелька с заморозкой больше НЕТ: деньги идут с общего
|
||||
* баланса через TelegramCampaignChargeService (проводка BalanceTransaction
|
||||
* TYPE_TG_AD_CHARGE). В песочнице деньги не трогаются (как ручной launch) —
|
||||
* гейт не вмешивается. Синтетические номера 7999… — реальных ПДн не касаемся.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -58,22 +63,30 @@ function feed(int $tenantId, int $n): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Сколько сейчас на общем балансе тенанта. */
|
||||
function tenantBalance(int $tenantId): string
|
||||
{
|
||||
return (string) Tenant::whereKey($tenantId)->value('balance_rub');
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-07-28 10:00:00');
|
||||
Queue::fake();
|
||||
config(['client_tg.auto_batch_threshold' => 3]); // порог занижен для теста
|
||||
config(['client_tg.sandbox' => false]); // деньги включены — проверяем боевой гейт
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
// Смета пачки из 3 кандидатов = 3 × 0.45 (ступень min_qty=1) = 1.35 ₽.
|
||||
$this->cost = app(TelegramTariffService::class)->estimateRub(3);
|
||||
$this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
|
||||
// Клиентская смета пачки из 3 кандидатов = базовая смета × наценка (то, что списывается).
|
||||
$this->cost = app(TelegramTariffService::class)->clientEstimateRub(3);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('в бою при нехватке денег не ставит в очередь и пишет причину', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '1.00', 'telegram', 'test'); // < 1.35
|
||||
it('в бою при нехватке денег на балансе не ставит в очередь и пишет причину', function () {
|
||||
$this->tenant->update(['balance_rub' => '1.00']); // заведомо меньше клиентской сметы пачки
|
||||
expect(bccomp('1.00', $this->cost, 2))->toBeLessThan(0); // подстраховка: денег правда мало
|
||||
safeguardRule($this->tenant->id, dailyLimit: '1000.00');
|
||||
Log::spy();
|
||||
|
||||
@@ -81,22 +94,29 @@ it('в бою при нехватке денег не ставит в очере
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
|
||||
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT); // остаётся черновиком
|
||||
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT); // остаётся черновиком
|
||||
expect(tenantBalance($this->tenant->id))->toBe('1.00'); // баланс не тронут
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
|
||||
->where('related_id', $campaign->id)->count())->toBe(0); // списания не было
|
||||
Log::shouldHaveReceived('info')->withArgs(fn ($msg) => $msg === 'client_tg.auto_skipped')->atLeast()->once();
|
||||
});
|
||||
|
||||
it('в бою при исчерпанном дневном лимите не ставит в очередь', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test'); // денег вдоволь
|
||||
safeguardRule($this->tenant->id, dailyLimit: '1.00'); // лимит < сметы 1.35
|
||||
it('в бою при исчерпанном дневном лимите не ставит в очередь (cost = клиентская смета)', function () {
|
||||
$this->tenant->update(['balance_rub' => '10000.00']); // денег вдоволь
|
||||
safeguardRule($this->tenant->id, dailyLimit: '0.01'); // лимит заведомо < клиентской сметы
|
||||
|
||||
feed($this->tenant->id, 3);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
|
||||
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
|
||||
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT);
|
||||
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // упёрлись в лимит ДО списания
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
|
||||
->where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('дневной лимит по умолчанию 0 → авто выключено даже при деньгах', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
$this->tenant->update(['balance_rub' => '10000.00']);
|
||||
// daily_limit_rub не задан явно → дефолт 0.00
|
||||
AutoRule::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
@@ -110,27 +130,34 @@ it('дневной лимит по умолчанию 0 → авто выклю
|
||||
feed($this->tenant->id, 3);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // ничего не списано
|
||||
});
|
||||
|
||||
it('в бою при деньгах и лимите в норме — ставит в очередь и учитывает трату за день', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
it('в бою при деньгах и лимите в норме — списывает смету с баланса, ставит в очередь и учитывает трату за день', function () {
|
||||
$this->tenant->update(['balance_rub' => '10000.00']);
|
||||
$rule = safeguardRule($this->tenant->id, dailyLimit: '1000.00');
|
||||
|
||||
feed($this->tenant->id, 3);
|
||||
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
||||
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
|
||||
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
|
||||
expect($campaign->status)->toBe(Campaign::STATUS_QUEUED);
|
||||
|
||||
// Баланс упал ровно на клиентскую смету, проводка списания одна.
|
||||
expect(tenantBalance($this->tenant->id))->toBe(bcsub('10000.00', $this->cost, 2));
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
|
||||
->where('related_id', $campaign->id)->count())->toBe(1);
|
||||
|
||||
$rule->refresh();
|
||||
expect((string) $rule->spent_today_rub)->toBe($this->cost); // 1.35 записано
|
||||
expect((string) $rule->spent_today_rub)->toBe($this->cost); // смета записана в трату за день
|
||||
expect($rule->spent_date->toDateString())->toBe('2026-07-28');
|
||||
});
|
||||
|
||||
it('счётчик за день сбрасывается на новую дату', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
$rule = safeguardRule($this->tenant->id, dailyLimit: '1.35');
|
||||
$this->tenant->update(['balance_rub' => '10000.00']);
|
||||
$rule = safeguardRule($this->tenant->id, dailyLimit: $this->cost);
|
||||
// Вчерашняя трата «под завязку» — но сегодня лимит должен освободиться.
|
||||
$rule->update(['spent_today_rub' => '1.35', 'spent_date' => '2026-07-27']);
|
||||
$rule->update(['spent_today_rub' => $this->cost, 'spent_date' => '2026-07-27']);
|
||||
|
||||
feed($this->tenant->id, 3);
|
||||
|
||||
@@ -141,9 +168,12 @@ it('счётчик за день сбрасывается на новую дат
|
||||
|
||||
it('в песочнице предохранитель не вмешивается — ставит в очередь без денег', function () {
|
||||
config(['client_tg.sandbox' => true]);
|
||||
safeguardRule($this->tenant->id, dailyLimit: '0.00'); // ни денег, ни лимита
|
||||
// Баланс нулевой (из beforeEach), лимита нет — но песочница деньги не трогает.
|
||||
safeguardRule($this->tenant->id, dailyLimit: '0.00');
|
||||
|
||||
feed($this->tenant->id, 3);
|
||||
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
||||
expect(tenantBalance($this->tenant->id))->toBe('0.00'); // деньги не тронуты
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\Contact;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
@@ -56,9 +55,9 @@ it('store создаёт ЧЕРНОВИК со сметой и числом ка
|
||||
|
||||
expect($response->json('estimated_cost_rub'))->toBeString();
|
||||
|
||||
// Черновик: джоб не ставится, кошелёк не создан.
|
||||
// Черновик: джоб не ставится, деньги не списываются.
|
||||
Queue::assertNothingPushed();
|
||||
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('store подставляет категорию ОРД по умолчанию «Размещение рекламы»', function () {
|
||||
@@ -80,8 +79,8 @@ it('audience_kind=deals без срока (audience_days) отклоняется
|
||||
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
|
||||
});
|
||||
|
||||
it('index отдаёт список своих кампаний, песочницу и баланс/заморозку кошелька', function () {
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test');
|
||||
it('index отдаёт список своих кампаний, песочницу и общий баланс тенанта', function () {
|
||||
$this->tenant->update(['balance_rub' => '100.00']);
|
||||
Campaign::create([
|
||||
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
|
||||
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
|
||||
@@ -115,18 +114,19 @@ it('launch (песочница) переводит draft→queued и стави
|
||||
'id' => $campaign->id, 'status' => Campaign::STATUS_QUEUED,
|
||||
]);
|
||||
Queue::assertPushed(RunTelegramCampaignJob::class);
|
||||
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
|
||||
// Песочница: денег не списывали.
|
||||
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('launch (реальный режим, денег не хватает) → 409, кампания остаётся черновиком', function () {
|
||||
config(['client_tg.sandbox' => false]);
|
||||
Queue::fake();
|
||||
|
||||
// Кошелёк есть, но денег меньше лимита на объявление.
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test');
|
||||
// На балансе меньше клиентской сметы кампании.
|
||||
$this->tenant->update(['balance_rub' => '1.00']);
|
||||
|
||||
// Гейт аудитории (находка #2) срабатывает ДО проверки денег: без ≥367
|
||||
// кандидатов launch вернул бы 422 раньше, чем дойдёт до брони. Этот тест
|
||||
// кандидатов launch вернул бы 422 раньше, чем дойдёт до списания. Этот тест
|
||||
// проверяет именно путь «денег не хватает», поэтому засеваем аудиторию
|
||||
// с запасом над порогом client_tg.auto_batch_threshold (367).
|
||||
for ($i = 0; $i < 367; $i++) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ClientTg\TelegramCampaignChargeService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Кусок 1 денежной модели Telegram-рекламы: списание/возврат с ОБЩЕГО баланса
|
||||
* тенанта (зеркало SmsChargeService). Источник правды идемпотентности —
|
||||
* существование строки в balance_transactions (related_type=Campaign + related_id),
|
||||
* а не пометка на кампании.
|
||||
*/
|
||||
function chargeCampaign(int $tenantId, string $status = Campaign::STATUS_QUEUED): Campaign
|
||||
{
|
||||
return Campaign::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $status,
|
||||
'ad_text' => 'Приходите к нам за услугой',
|
||||
'ad_link' => 'https://example.test/promo',
|
||||
'ord_category' => 'Размещение рекламы',
|
||||
'budget_cap_rub' => '500.00',
|
||||
'audience_kind' => Campaign::AUDIENCE_LIST,
|
||||
'planned_count' => 2,
|
||||
'estimated_cost_rub' => '315.00',
|
||||
'created_by' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('списывает клиентскую смету с общего баланса один раз и идемпотентно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = chargeCampaign($tenant->id);
|
||||
|
||||
$svc = app(TelegramCampaignChargeService::class);
|
||||
$svc->charge($campaign, '315.00');
|
||||
$svc->charge($campaign->fresh(), '315.00'); // повтор НЕ должен списать второй раз
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
|
||||
->where('related_type', Campaign::class)
|
||||
->where('related_id', $campaign->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('не списывает при нехватке баланса (бросает, баланс цел)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = chargeCampaign($tenant->id);
|
||||
|
||||
expect(fn () => app(TelegramCampaignChargeService::class)->charge($campaign, '315.00'))
|
||||
->toThrow(InsufficientBalanceException::class);
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('100.00')
|
||||
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('нулевая смета — не списывает и не пишет проводку', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = chargeCampaign($tenant->id);
|
||||
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, '0.00');
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('возвращает списанную смету на общий баланс, идемпотентно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = chargeCampaign($tenant->id);
|
||||
|
||||
$svc = app(TelegramCampaignChargeService::class);
|
||||
$svc->charge($campaign, '315.00');
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00');
|
||||
|
||||
$svc->refund($campaign->fresh());
|
||||
$svc->refund($campaign->fresh()); // повтор НЕ должен вернуть второй раз
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $campaign->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('после возврата можно списать снова (цикл пересдачи: charge→refund→charge)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = chargeCampaign($tenant->id);
|
||||
|
||||
$svc = app(TelegramCampaignChargeService::class);
|
||||
$svc->charge($campaign, '315.00'); // запуск: сальдо +1
|
||||
$svc->refund($campaign->fresh()); // отказ модерации: сальдо 0
|
||||
$svc->charge($campaign->fresh(), '315.00'); // пересдача: должно списать СНОВА, сальдо +1
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
|
||||
->where('related_id', $campaign->id)->count())->toBe(2)
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $campaign->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('возврат без списания — ничего не делает', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = chargeCampaign($tenant->id);
|
||||
|
||||
app(TelegramCampaignChargeService::class)->refund($campaign);
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
@@ -3,20 +3,22 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\CampaignPhone;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Ревью-фикс F5 (Мост #2/#3): единое ОСТОРОЖНОЕ правило возврата брони — как уборщик.
|
||||
* Ревью-фикс F5 (Мост #2/#3): единое ОСТОРОЖНОЕ правило возврата денег — как уборщик.
|
||||
* Если у кампании есть mts_campaign_id (черновик в кабинете реально создан), она МОГЛА
|
||||
* уйти на модерацию → деньги вслепую НЕ возвращаем (иначе оплатим показы из своего
|
||||
* кармана), помечаем needs_review. Без id — чистый провал: failed + возврат брони.
|
||||
* кармана), помечаем needs_review — списание с ОБЩЕГО баланса тенанта держится, refund
|
||||
* НЕ происходит. Без id — чистый провал: failed + возврат списания на баланс.
|
||||
* Всё в боевом режиме (sandbox=false). Синтетические номера 7999… — реальных ПДн нет.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -37,7 +39,7 @@ function crCampaign(int $tenantId, string $status, ?string $mtsId = null): Campa
|
||||
'budget_cap_rub' => '500.00',
|
||||
'audience_kind' => Campaign::AUDIENCE_LIST,
|
||||
'planned_count' => 2,
|
||||
'estimated_cost_rub' => '0.00',
|
||||
'estimated_cost_rub' => '500.00',
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
@@ -55,76 +57,85 @@ function crMockRobot(RobotResult $result): void
|
||||
app()->instance(TelegramRobotRunner::class, $mock);
|
||||
}
|
||||
|
||||
it('finalize: провал робота с id кабинета → needs_review, бронь НЕ возвращается', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
/** Сколько возвратов сметы прошло по кампании (сальдо-источник правды — проводки). */
|
||||
function crRefundCount(int $campaignId): int
|
||||
{
|
||||
return BalanceTransaction::query()
|
||||
->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_type', Campaign::class)
|
||||
->where('related_id', $campaignId)
|
||||
->count();
|
||||
}
|
||||
|
||||
it('finalize: провал робота с id кабинета → needs_review, списание держится, refund НЕ произошёл', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
crMockRobot(RobotResult::failed('Оборвалось на подтверждении', 'confirm', '2231200'));
|
||||
|
||||
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
|
||||
|
||||
$fresh = Campaign::find($campaign->id);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
|
||||
->and($fresh->mts_campaign_id)->toBe('2231200')
|
||||
->and((string) $wallet->frozen_rub)->toBe('500.00') // бронь держится
|
||||
->and((string) $wallet->balance_rub)->toBe('1000.00');
|
||||
->and(crRefundCount($campaign->id))->toBe(0) // возврата не было
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00'); // списание держится
|
||||
});
|
||||
|
||||
it('finalize: провал робота БЕЗ id кабинета → failed, бронь возвращается', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
it('finalize: провал робота БЕЗ id кабинета → failed, refund произошёл, баланс восстановлен', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
crMockRobot(RobotResult::failed('Мало номеров «не МТС»', 'upload'));
|
||||
|
||||
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
|
||||
|
||||
$fresh = Campaign::find($campaign->id);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) $wallet->frozen_rub)->toBe('0.00'); // бронь вернулась
|
||||
->and(crRefundCount($campaign->id))->toBe(1) // возврат произошёл
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00'); // баланс восстановлен
|
||||
});
|
||||
|
||||
it('failed(): running с id кабинета → needs_review, бронь НЕ возвращается', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
it('failed(): running с id кабинета → needs_review, списание держится, refund НЕ произошёл', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING, mtsId: '2231201');
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
|
||||
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
|
||||
|
||||
$fresh = Campaign::find($campaign->id);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
|
||||
->and((string) $wallet->frozen_rub)->toBe('500.00');
|
||||
->and(crRefundCount($campaign->id))->toBe(0)
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
|
||||
});
|
||||
|
||||
it('failed(): running без id кабинета → failed, бронь возвращается', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
it('failed(): running без id кабинета → failed, refund произошёл, баланс восстановлен', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING);
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
|
||||
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
|
||||
|
||||
$fresh = Campaign::find($campaign->id);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) $wallet->frozen_rub)->toBe('0.00');
|
||||
->and(crRefundCount($campaign->id))->toBe(1)
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00');
|
||||
});
|
||||
|
||||
it('failed(): уже moderating → статус не трогаем и бронь НЕ возвращаем', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
it('failed(): уже moderating → статус не трогаем, refund НЕ произошёл', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = crCampaign($tenant->id, Campaign::STATUS_MODERATING, mtsId: '2231202');
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
|
||||
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
|
||||
|
||||
$fresh = Campaign::find($campaign->id);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
|
||||
->and((string) $wallet->frozen_rub)->toBe('500.00');
|
||||
->and(crRefundCount($campaign->id))->toBe(0)
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Загрузка картинки/видео к объявлению Telegram-кампании (пробел показов: у МТС
|
||||
* объявление может быть с медиа, поле media_path и проброс в робота уже есть —
|
||||
* RunTelegramCampaignJob → mediaFile → cabinet.js fillAdMedia; не хватало только
|
||||
* приёма файла от клиента). Медиа прикладываем к ЧЕРНОВИКУ до запуска.
|
||||
*
|
||||
* Синтетические номера/данные — реальных ПДн нет.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
// API-эндпоинт: как axios на фронте — ждём JSON (иначе ошибка валидации уходит редиректом).
|
||||
$this->withHeaders(['Accept' => 'application/json']);
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/** Черновик кампании тенанта под приложение медиа. */
|
||||
function draftForMedia(int $tenantId, int $userId): Campaign
|
||||
{
|
||||
return Campaign::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => Campaign::STATUS_DRAFT,
|
||||
'ad_text' => 'Приходите к нам',
|
||||
'ad_link' => 'https://t.me/example',
|
||||
'ord_category' => 'Размещение рекламы',
|
||||
'budget_cap_rub' => '1000.00',
|
||||
'audience_kind' => Campaign::AUDIENCE_BASE,
|
||||
'planned_count' => 0,
|
||||
'estimated_cost_rub' => '0.00',
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
it('прикладывает картинку к черновику: файл сохранён, media_path записан', function () {
|
||||
$campaign = draftForMedia($this->tenant->id, $this->user->id);
|
||||
|
||||
$res = $this->post("/api/telegram/campaigns/{$campaign->id}/media", [
|
||||
'media' => UploadedFile::fake()->image('ad.jpg'),
|
||||
]);
|
||||
|
||||
$res->assertOk();
|
||||
$path = Campaign::find($campaign->id)->media_path;
|
||||
expect($path)->not->toBeNull();
|
||||
Storage::disk('local')->assertExists($path);
|
||||
expect($res->json('media_path'))->toBe($path);
|
||||
});
|
||||
|
||||
it('принимает видео mp4', function () {
|
||||
$campaign = draftForMedia($this->tenant->id, $this->user->id);
|
||||
|
||||
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
|
||||
'media' => UploadedFile::fake()->create('ad.mp4', 200, 'video/mp4'),
|
||||
])->assertOk();
|
||||
|
||||
expect(Campaign::find($campaign->id)->media_path)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('отклоняет чужой тип файла (например .exe) → 422', function () {
|
||||
$campaign = draftForMedia($this->tenant->id, $this->user->id);
|
||||
|
||||
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
|
||||
'media' => UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'),
|
||||
])->assertStatus(422)->assertJsonValidationErrors('media');
|
||||
|
||||
expect(Campaign::find($campaign->id)->media_path)->toBeNull();
|
||||
});
|
||||
|
||||
it('нельзя приложить медиа к не-черновику (уже запущена) → 422', function () {
|
||||
$campaign = draftForMedia($this->tenant->id, $this->user->id);
|
||||
$campaign->update(['status' => Campaign::STATUS_QUEUED]);
|
||||
|
||||
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
|
||||
'media' => UploadedFile::fake()->image('ad.jpg'),
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('замена медиа удаляет прежний файл', function () {
|
||||
$campaign = draftForMedia($this->tenant->id, $this->user->id);
|
||||
|
||||
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
|
||||
'media' => UploadedFile::fake()->image('first.jpg'),
|
||||
])->assertOk();
|
||||
$first = Campaign::find($campaign->id)->media_path;
|
||||
|
||||
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
|
||||
'media' => UploadedFile::fake()->image('second.jpg'),
|
||||
])->assertOk();
|
||||
$second = Campaign::find($campaign->id)->media_path;
|
||||
|
||||
expect($second)->not->toBe($first);
|
||||
Storage::disk('local')->assertMissing($first);
|
||||
Storage::disk('local')->assertExists($second);
|
||||
});
|
||||
|
||||
it('изоляция тенантов: чужой черновик → 404', function () {
|
||||
$tenantB = Tenant::factory()->create();
|
||||
$foreign = draftForMedia($tenantB->id, $this->user->id);
|
||||
|
||||
$this->post("/api/telegram/campaigns/{$foreign->id}/media", [
|
||||
'media' => UploadedFile::fake()->image('ad.jpg'),
|
||||
])->assertStatus(404);
|
||||
});
|
||||
@@ -5,9 +5,7 @@ declare(strict_types=1);
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\CampaignPhone;
|
||||
use App\Models\ClientTg\Contact;
|
||||
use App\Models\ClientTg\Setting;
|
||||
use App\Models\ClientTg\Tariff;
|
||||
use App\Models\ClientTg\Template;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -74,7 +72,7 @@ it('round-trips the audience_params array cast', function () {
|
||||
->and($campaign->audience_params['note'])->toBe('файл');
|
||||
});
|
||||
|
||||
it('creates and reads back a contact and a template', function () {
|
||||
it('creates and reads back a contact (своя база)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$contact = Contact::create([
|
||||
@@ -84,23 +82,14 @@ it('creates and reads back a contact and a template', function () {
|
||||
'operator' => 'Билайн',
|
||||
]);
|
||||
|
||||
$template = Template::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'title' => 'Приветствие',
|
||||
'body' => 'Добрый день!',
|
||||
]);
|
||||
|
||||
$contact->refresh();
|
||||
$template->refresh();
|
||||
|
||||
expect($contact->phone)->toBe('79990000003')
|
||||
->and($contact->name)->toBe('Иван Иванов')
|
||||
->and($template->title)->toBe('Приветствие');
|
||||
->and($contact->name)->toBe('Иван Иванов');
|
||||
});
|
||||
|
||||
it('reads seeded tariffs and settings', function () {
|
||||
expect(Tariff::count())->toBe(5)
|
||||
->and(Setting::first()->name_debt_grace_days)->toBe(29);
|
||||
it('reads seeded tariffs', function () {
|
||||
expect(Tariff::count())->toBe(5);
|
||||
});
|
||||
|
||||
it('round-trips the decimal cast on budget_cap_rub', function () {
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\PollTelegramModerationJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletHold;
|
||||
use App\Models\BalanceTransaction;
|
||||
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\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
@@ -25,15 +25,19 @@ uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
||||
* Живая отправка робота ставит `moderating` (задача 3.2). Опросчик раз в цикл
|
||||
* читает кабинет роботом-читалкой (в тестах — мок) по `mts_campaign_id` и применяет
|
||||
* вердикт:
|
||||
* — `rejected` → статус `rejected` + причина; возврат брони (в бою); уведомление
|
||||
* «отклонена»;
|
||||
* — `approved` → `launched`; уведомление «одобрена»;
|
||||
* — `rejected` → статус `rejected` + причина; ВОЗВРАТ списанной сметы на ОБЩИЙ
|
||||
* баланс тенанта (в бою); уведомление «отклонена»;
|
||||
* — `approved` → `launched`; деньги НЕ трогаем (смету списали при запуске);
|
||||
* уведомление «одобрена»;
|
||||
* — `moderating` (ещё не проверено) / робот не смог прочитать → НЕ трогаем, ждём
|
||||
* следующего цикла (деньги/статус без изменений).
|
||||
*
|
||||
* Денежная модель — списание/возврат с ОБЩЕГО баланса (`tenants.balance_rub`) через
|
||||
* TelegramCampaignChargeService; отдельного рекламного кошелька с заморозкой больше нет.
|
||||
*/
|
||||
|
||||
/** Кампания в заданном статусе (self-contained). */
|
||||
function pollCampaign(int $tenantId, string $status, ?string $mtsId = null): Campaign
|
||||
/** Кампания в заданном статусе (self-contained), смета ненулевая. */
|
||||
function pollCampaign(int $tenantId, string $status, ?string $mtsId = null, string $estimatedCost = '315.00'): Campaign
|
||||
{
|
||||
return Campaign::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -44,20 +48,16 @@ function pollCampaign(int $tenantId, string $status, ?string $mtsId = null): Cam
|
||||
'budget_cap_rub' => '1000.00',
|
||||
'audience_kind' => Campaign::AUDIENCE_LIST,
|
||||
'planned_count' => 0,
|
||||
'estimated_cost_rub' => '0.00',
|
||||
'estimated_cost_rub' => $estimatedCost,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Кошелёк тенанта + активная бронь под кампанию (как в SweepStuckTest). */
|
||||
function pollWalletWithHold(int $tenantId, int $campaignId, string $balance, string $freeze): void
|
||||
/** «Уже списано при запуске»: списывает смету кампании с общего баланса тенанта. */
|
||||
function pollChargeCampaign(Tenant $tenant, Campaign $campaign): void
|
||||
{
|
||||
AdWallet::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'balance_rub' => $balance,
|
||||
'frozen_rub' => '0.00',
|
||||
]);
|
||||
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
}
|
||||
|
||||
/** Подменяет робота-читалку мок-результатом. */
|
||||
@@ -86,11 +86,14 @@ it('RobotResult знает moderationStatus и разбирает его из JS
|
||||
expect(RobotResult::fromRobotJson(['ok' => true])->moderationStatus)->toBeNull();
|
||||
});
|
||||
|
||||
it('вердикт «отклонена» → rejected + причина, бронь возвращена, уведомление «отклонена»', function () {
|
||||
it('вердикт «отклонена» → rejected + причина, смета возвращена на общий баланс, уведомление «отклонена»', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$t = Tenant::factory()->create();
|
||||
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231134');
|
||||
pollWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
|
||||
$t = Tenant::factory()->create(['balance_rub' => '5000.00']);
|
||||
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231134', '315.00');
|
||||
|
||||
// Уже списано при запуске кампании → баланс просел.
|
||||
pollChargeCampaign($t, $c);
|
||||
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
|
||||
|
||||
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'rejected', reason: 'Ссылка недоступна'));
|
||||
$notify = pollMockNotifications();
|
||||
@@ -102,15 +105,20 @@ it('вердикт «отклонена» → rejected + причина, бро
|
||||
$fresh = Campaign::find($c->id);
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_REJECTED)
|
||||
->and($fresh->status_reason)->toBe('Ссылка недоступна')
|
||||
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('0.00')
|
||||
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_RELEASED);
|
||||
// Отказ вернул смету на общий баланс — восстановлен до фондирования.
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('5000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_type', Campaign::class)
|
||||
->where('related_id', $c->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('вердикт «одобрена» → launched, уведомление «одобрена», деньги не тронуты', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$t = Tenant::factory()->create();
|
||||
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231140');
|
||||
pollWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
|
||||
$t = Tenant::factory()->create(['balance_rub' => '5000.00']);
|
||||
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231140', '315.00');
|
||||
|
||||
// Списано при запуске; одобрение денег НЕ трогает.
|
||||
pollChargeCampaign($t, $c);
|
||||
|
||||
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'approved'));
|
||||
$notify = pollMockNotifications();
|
||||
@@ -120,15 +128,19 @@ it('вердикт «одобрена» → launched, уведомление «
|
||||
PollTelegramModerationJob::dispatchSync();
|
||||
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_LAUNCHED)
|
||||
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00')
|
||||
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE);
|
||||
// Списание осталось — approve ничего не возвращает.
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_type', Campaign::class)
|
||||
->where('related_id', $c->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('вердикт «ещё на модерации» → остаётся moderating, без уведомлений и без денег', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$t = Tenant::factory()->create();
|
||||
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231150');
|
||||
pollWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
|
||||
$t = Tenant::factory()->create(['balance_rub' => '5000.00']);
|
||||
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', '315.00');
|
||||
|
||||
pollChargeCampaign($t, $c);
|
||||
|
||||
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'moderating'));
|
||||
$notify = pollMockNotifications();
|
||||
@@ -138,7 +150,10 @@ it('вердикт «ещё на модерации» → остаётся moder
|
||||
PollTelegramModerationJob::dispatchSync();
|
||||
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING)
|
||||
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00');
|
||||
// Ни списания, ни возврата — баланс остался на уровне после запуска.
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $c->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('робот не смог прочитать вердикт (moderationStatus=null) → moderating без изменений', function () {
|
||||
|
||||
@@ -3,33 +3,43 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\CampaignPhone;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientTg\RobotResult;
|
||||
use App\Services\ClientTg\TelegramCampaignChargeService;
|
||||
use App\Services\ClientTg\TelegramRobotRunner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Задача 2.3 — ВОЗВРАТ брони кошелька: при отказе робота (finalize), перманентном
|
||||
* сбое джоба (failed) и отмене кампании (cancel). Ключ брони совпадает с freeze в
|
||||
* launch: ('telegram', 'campaign', $campaign->id). Гейт !sandbox везде — в песочнице
|
||||
* заморозки не было, release бросил бы firstOrFail. Списание по факту — задача 2.4.
|
||||
* Задача 2.3 — ВОЗВРАТ денег с ОБЩЕГО баланса тенанта (tenants.balance_rub, как в
|
||||
* СМС) при отказе робота (finalize), перманентном сбое джоба (failed) и отмене
|
||||
* кампании (cancel). Старого рекламного кошелька с freeze/frozen_rub больше нет:
|
||||
* деньги списываются на запуске (charge) и возвращаются (refund) через
|
||||
* TelegramCampaignChargeService. Источник правды идемпотентности — сальдо проводок
|
||||
* balance_transactions (TYPE_TG_AD_CHARGE − TYPE_TG_AD_REFUND по related Campaign).
|
||||
*
|
||||
* Все тесты по умолчанию в БОЕВОМ режиме (sandbox=false) — проверяем денежный путь.
|
||||
* Ключевые развилки, которые проверяем:
|
||||
* - отказ робота БЕЗ mts_campaign_id → failed + refund (деньги вернулись);
|
||||
* - песочница → денег не списывали, refund не зовётся (сальдо 0);
|
||||
* - cancel из draft → списания не было → refund no-op (баланс цел);
|
||||
* - cancel из running → 422 (машина статусов);
|
||||
* - cancel в песочнице → денег не трогали.
|
||||
*
|
||||
* Все денежные тесты по умолчанию в БОЕВОМ режиме (sandbox=false).
|
||||
* Синтетические номера 7999… — реальные НИКОГДА.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
});
|
||||
|
||||
/** Кампания-список заданного статуса с двумя фейковыми номерами. */
|
||||
function refundCampaign(int $tenantId, string $status, string $budgetCap = '500.00'): Campaign
|
||||
/** Кампания-список заданного статуса с двумя фейковыми номерами и ненулевой сметой. */
|
||||
function refundCampaign(int $tenantId, string $status, string $estimatedCost = '315.00'): Campaign
|
||||
{
|
||||
$campaign = Campaign::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -37,10 +47,10 @@ function refundCampaign(int $tenantId, string $status, string $budgetCap = '500.
|
||||
'ad_text' => 'Приходите к нам за услугой',
|
||||
'ad_link' => 'https://example.test/promo',
|
||||
'ord_category' => 'Размещение рекламы',
|
||||
'budget_cap_rub' => $budgetCap,
|
||||
'budget_cap_rub' => '500.00',
|
||||
'audience_kind' => Campaign::AUDIENCE_LIST,
|
||||
'planned_count' => 2,
|
||||
'estimated_cost_rub' => '0.00',
|
||||
'estimated_cost_rub' => $estimatedCost,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
@@ -63,55 +73,61 @@ function refundMockRobot(RobotResult $result): void
|
||||
app()->instance(TelegramRobotRunner::class, $mock);
|
||||
}
|
||||
|
||||
it('отказ робота возвращает бронь (frozen→0, balance цел)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00');
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
it('отказ робота (без mts_id) возвращает списанную смету на общий баланс', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '315.00');
|
||||
|
||||
// На запуске смету списали (draft→queued это делает контроллер; здесь эмулируем).
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00');
|
||||
|
||||
// Робот падает без campaignId → черновик в кабинете НЕ создан → failed + возврат.
|
||||
refundMockRobot(RobotResult::failed('Канал недоступен', 'upload'));
|
||||
|
||||
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
|
||||
|
||||
$fresh = Campaign::find($campaign->id);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) $wallet->frozen_rub)->toBe('0.00')
|
||||
->and((string) $wallet->balance_rub)->toBe('1000.00');
|
||||
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_type', Campaign::class)
|
||||
->where('related_id', $campaign->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('в песочнице отказ робота НЕ трогает кошелёк (бронь не возвращается — её и нет)', function () {
|
||||
it('в песочнице отказ робота НЕ трогает деньги (не списывали — нечего возвращать)', function () {
|
||||
config()->set('client_tg.sandbox', true);
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00');
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '315.00');
|
||||
// В песочнице списания на запуске нет — денег на кампании не трогали.
|
||||
refundMockRobot(RobotResult::failed('Канал недоступен', 'upload'));
|
||||
|
||||
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('500.00');
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('related_id', $campaign->id)
|
||||
->whereIn('type', [BalanceTransaction::TYPE_TG_AD_CHARGE, BalanceTransaction::TYPE_TG_AD_REFUND])
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('cancel из draft возвращает бронь и ставит cancelled', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
it('cancel из draft: не списывали → возврат no-op, баланс цел, статус cancelled', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$this->actingAs($user);
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
// Черновик деньги не списывал — cancel вызовет refund, но возвращать нечего (сальдо 0).
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00');
|
||||
|
||||
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
|
||||
->assertOk()
|
||||
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00')
|
||||
->and((string) $wallet->balance_rub)->toBe('1000.00');
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('cancel из running запрещён 422, статус остаётся running', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$this->actingAs($user);
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_RUNNING, '300.00');
|
||||
@@ -121,19 +137,18 @@ it('cancel из running запрещён 422, статус остаётся runn
|
||||
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_RUNNING);
|
||||
});
|
||||
|
||||
it('cancel в песочнице не трогает кошелёк (frozen сохраняется)', function () {
|
||||
it('cancel в песочнице не трогает деньги (баланс цел, проводок нет)', function () {
|
||||
config()->set('client_tg.sandbox', true);
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$this->actingAs($user);
|
||||
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00');
|
||||
|
||||
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
|
||||
->assertOk()
|
||||
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('300.00');
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
|
||||
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\BalanceTransaction;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -98,12 +99,14 @@ it('бой: робот ok+resubmitted=true → moderating, id кабинета
|
||||
->and($captured['submitMode'])->toBe('live');
|
||||
});
|
||||
|
||||
it('отказ робота (есть id кабинета) → needs_review, бронь НЕ возвращается, причина записана', function () {
|
||||
it('отказ робота (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится, причина записана', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
|
||||
$campaign = tgResubmitQueued($tenant->id);
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = tgResubmitQueued($tenant->id, ['estimated_cost_rub' => '1000.00']);
|
||||
// «Уже списано»: списываем клиентскую смету с общего баланса тенанта.
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00');
|
||||
|
||||
mockResubmitRunner(RobotResult::failed('Не удалось войти в «Исправить»', 'resubmit-open'));
|
||||
|
||||
@@ -113,40 +116,52 @@ it('отказ робота (есть id кабинета) → needs_review, б
|
||||
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
|
||||
->and($fresh->status_reason)->toBe('Не удалось войти в «Исправить»');
|
||||
|
||||
// Бронь НЕ вернули (могла уйти на модерацию) — frozen остаётся.
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('1000.00');
|
||||
// Refund НЕ произошёл (кампания могла уйти на модерацию) — списание держим,
|
||||
// баланс не восстановлен, проводки возврата нет.
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('перманентный сбой в queued (есть id кабинета) → failed + бронь ВЕРНУЛАСЬ (робот кабинет не трогал)', function () {
|
||||
it('перманентный сбой в queued (есть id кабинета) → failed + refund произошёл, баланс восстановлен (робот кабинет не трогал)', function () {
|
||||
// Регресс I-1: у пересдачи queued ВСЕГДА с mts_campaign_id, но в queued Фаза A не
|
||||
// закоммитила running → кабинет не тронут → безопасно failed + release. Без правки
|
||||
// кампания зависала бы в queued с замороженной бронью (уборщик queued не метёт).
|
||||
// закоммитила running → кабинет не тронут → безопасно failed + refund. Без правки
|
||||
// кампания зависала бы в queued со списанными деньгами (уборщик queued не метёт).
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
|
||||
$campaign = tgResubmitQueued($tenant->id); // status queued + mts_campaign_id
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = tgResubmitQueued($tenant->id, ['estimated_cost_rub' => '1000.00']); // status queued + mts_campaign_id
|
||||
// «Уже списано»: списываем клиентскую смету с общего баланса тенанта.
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00');
|
||||
|
||||
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('воркер убит'));
|
||||
|
||||
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь вернулась
|
||||
|
||||
// Refund произошёл — смета вернулась на общий баланс, проводка возврата записана.
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('5000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $campaign->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('перманентный сбой в running (есть id кабинета) → needs_review, бронь НЕ вернулась (мог тронуть кабинет)', function () {
|
||||
it('перманентный сбой в running (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится (мог тронуть кабинет)', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
|
||||
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_RUNNING]);
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_RUNNING, 'estimated_cost_rub' => '1000.00']);
|
||||
// «Уже списано»: списываем клиентскую смету с общего баланса тенанта.
|
||||
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00');
|
||||
|
||||
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('таймаут робота'));
|
||||
|
||||
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
|
||||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('1000.00'); // бронь держим
|
||||
|
||||
// Refund НЕ произошёл — списание держим до ручной сверки, баланс не восстановлен.
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $campaign->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('идемпотентно: не-queued кампания — джоб no-op, робота не зовёт', function () {
|
||||
|
||||
@@ -23,13 +23,6 @@ it('creates client_tg_campaign_phones with expected columns', function () {
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates client_tg_messages with expected columns', function () {
|
||||
expect(Schema::hasTable('client_tg_messages'))->toBeTrue();
|
||||
expect(Schema::hasColumns('client_tg_messages', [
|
||||
'tenant_id', 'campaign_id', 'deal_id', 'phone', 'status', 'cost_rub', 'error',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates client_tg_optouts with expected columns', function () {
|
||||
expect(Schema::hasTable('client_tg_optouts'))->toBeTrue();
|
||||
expect(Schema::hasColumns('client_tg_optouts', [
|
||||
@@ -44,13 +37,6 @@ it('creates client_tg_contacts with expected columns', function () {
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates client_tg_templates with expected columns', function () {
|
||||
expect(Schema::hasTable('client_tg_templates'))->toBeTrue();
|
||||
expect(Schema::hasColumns('client_tg_templates', [
|
||||
'tenant_id', 'title', 'body',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates client_tg_tariffs with expected columns and seeds exactly 5 rows', function () {
|
||||
expect(Schema::hasTable('client_tg_tariffs'))->toBeTrue();
|
||||
expect(Schema::hasColumns('client_tg_tariffs', [
|
||||
@@ -60,15 +46,6 @@ it('creates client_tg_tariffs with expected columns and seeds exactly 5 rows', f
|
||||
expect(DB::table('client_tg_tariffs')->count())->toBe(5);
|
||||
});
|
||||
|
||||
it('creates client_tg_settings with expected columns and seeds exactly 1 row', function () {
|
||||
expect(Schema::hasTable('client_tg_settings'))->toBeTrue();
|
||||
expect(Schema::hasColumns('client_tg_settings', [
|
||||
'name_fee_rub_per_month', 'name_debt_grace_days',
|
||||
]))->toBeTrue();
|
||||
|
||||
expect(DB::table('client_tg_settings')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('enables row level security on client_tg_campaigns with a tenant_isolation policy', function () {
|
||||
$rls = DB::selectOne("SELECT relrowsecurity FROM pg_class WHERE relname = 'client_tg_campaigns'");
|
||||
expect((bool) $rls->relrowsecurity)->toBeTrue();
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\ClientTg\Sender;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Клиентское HTTP-API «своего имени» Telegram-рекламы (план §Этап 5, задача 5.2).
|
||||
* Тонкий слой над TelegramSenderService: клиент заводит/отключает/включает имя из
|
||||
* кабинета. GET отдаёт статус + остаток грейса. Песочница денег не трогает.
|
||||
*
|
||||
* Auth/tenant как у CampaignApiTest: Tenant::factory + User(tenant_id) + actingAs;
|
||||
* middleware ['auth:sanctum','tenant']. Scope тенантом из $request->user().
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-07-28 10:00:00');
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('GET /sender без имени → sender null, песочница, грейс null', function () {
|
||||
$this->getJson('/api/telegram/sender')
|
||||
->assertOk()
|
||||
->assertJsonPath('sender', null)
|
||||
->assertJsonPath('sandbox', true)
|
||||
->assertJsonPath('grace_days_left', null);
|
||||
});
|
||||
|
||||
it('POST /sender заводит имя (pending), в песочнице деньги не трогает', function () {
|
||||
$this->postJson('/api/telegram/sender', ['name' => 'МойБренд'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('status', Sender::STATUS_PENDING)
|
||||
->assertJsonPath('name', 'МойБренд');
|
||||
|
||||
expect(Sender::where('tenant_id', $this->tenant->id)->value('status'))->toBe(Sender::STATUS_PENDING);
|
||||
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse(); // денег не трогали
|
||||
});
|
||||
|
||||
it('POST /sender без имени → 422', function () {
|
||||
$this->postJson('/api/telegram/sender', [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
it('POST /sender при активном имени → 422', function () {
|
||||
Sender::create([
|
||||
'tenant_id' => $this->tenant->id, 'name' => 'Есть', 'status' => Sender::STATUS_ACTIVE,
|
||||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/telegram/sender', ['name' => 'Другое'])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('POST /sender/disable отключает имя (cancelled)', function () {
|
||||
$this->postJson('/api/telegram/sender', ['name' => 'МойБренд'])->assertCreated();
|
||||
|
||||
$this->postJson('/api/telegram/sender/disable')
|
||||
->assertOk()
|
||||
->assertJsonPath('status', Sender::STATUS_CANCELLED);
|
||||
});
|
||||
|
||||
it('GET /sender отдаёт остаток грейса для имени с долгом', function () {
|
||||
Sender::create([
|
||||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_ACTIVE,
|
||||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-07-26', 'debt_since' => '2026-07-23',
|
||||
]);
|
||||
|
||||
// грейс 29 дней, долг с 23.07 → на 28.07 прошло 5 дней → осталось 24.
|
||||
$this->getJson('/api/telegram/sender')
|
||||
->assertOk()
|
||||
->assertJsonPath('sender.status', Sender::STATUS_ACTIVE)
|
||||
->assertJsonPath('grace_days_left', 24);
|
||||
});
|
||||
|
||||
it('GET /sender не видит имя чужого тенанта', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
Sender::create([
|
||||
'tenant_id' => $other->id, 'name' => 'Чужой', 'status' => Sender::STATUS_ACTIVE,
|
||||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/telegram/sender')->assertOk()->assertJsonPath('sender', null);
|
||||
});
|
||||
|
||||
// --- Живой режим (деньги включены) ----------------------------------------
|
||||
|
||||
it('POST /sender/enable включает suspended → active, списывает плату один раз в месяц', function () {
|
||||
config(['client_tg.sandbox' => false]);
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
$sender = Sender::create([
|
||||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_SUSPENDED,
|
||||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-06-26', 'debt_since' => '2026-06-26',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/telegram/sender/enable')
|
||||
->assertOk()
|
||||
->assertJsonPath('status', Sender::STATUS_ACTIVE);
|
||||
|
||||
$charges = AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count();
|
||||
expect($charges)->toBe(1);
|
||||
|
||||
// Повторное включение в тот же месяц не должно списать второй раз (идемпотентность периода).
|
||||
// query-builder update (а не $sender->update): у стейл-модели статус уже 'suspended' в памяти → не dirty.
|
||||
Sender::where('tenant_id', $this->tenant->id)->update(['status' => Sender::STATUS_SUSPENDED]);
|
||||
$this->postJson('/api/telegram/sender/enable')->assertOk();
|
||||
|
||||
$charges = AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count();
|
||||
expect($charges)->toBe(1); // всё ещё одно списание
|
||||
});
|
||||
|
||||
it('POST /sender/enable при нехватке денег → 409, имя остаётся suspended', function () {
|
||||
config(['client_tg.sandbox' => false]);
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '100.00', 'telegram', 'test'); // мало
|
||||
Sender::create([
|
||||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_SUSPENDED,
|
||||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-06-26', 'debt_since' => '2026-06-26',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/telegram/sender/enable')->assertStatus(409);
|
||||
|
||||
expect(Sender::where('tenant_id', $this->tenant->id)->value('status'))->toBe(Sender::STATUS_SUSPENDED);
|
||||
});
|
||||
|
||||
it('POST /sender/enable для не-suspended → 422', function () {
|
||||
Sender::create([
|
||||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_ACTIVE,
|
||||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/telegram/sender/enable')->assertStatus(422);
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Jobs\ClientTg\ChargeTgNameFeeJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletTransaction;
|
||||
use App\Models\ClientTg\Sender;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientTg\TelegramSenderService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Своё имя/бренд Telegram-рекламы + помесячная оплата (план §Сессия 4, задача 4.3).
|
||||
* Зеркало client_sms_senders + ChargeSmsNameFeeJob, но проще (без документов/операторов
|
||||
* — согласование в Телеграме уточняется живой разведкой §8; здесь — денежный цикл).
|
||||
*
|
||||
* Помесячный джоб перечисляет активных отправителей КРОСС-ТЕНАНТНО через pgsql_supplier
|
||||
* (BYPASSRLS) — без share PDO вставленные в тест-транзакции строки не видны второму
|
||||
* соединению до commit'а (зеркалит NameFeeJobTest/ChargeCampaignSpendJobTest).
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function tgSenderService(): TelegramSenderService
|
||||
{
|
||||
return app(TelegramSenderService::class);
|
||||
}
|
||||
|
||||
function makeActiveTgSender(int $tenantId, ?string $paidUntil, string $fee = '2500.00', ?string $debtSince = null): Sender
|
||||
{
|
||||
return Sender::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => 'МойБренд',
|
||||
'status' => Sender::STATUS_ACTIVE,
|
||||
'monthly_fee_rub' => $fee,
|
||||
'paid_until' => $paidUntil,
|
||||
'debt_since' => $debtSince,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-07-27 10:00:00');
|
||||
config(['client_tg.sandbox' => false]); // деньги включены — проверяем реальный цикл
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
// --- Жизненный цикл заявки -------------------------------------------------
|
||||
|
||||
it('requestSender создаёт заявку pending и бронирует месячную плату', function () {
|
||||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд', requestedBy: 1);
|
||||
|
||||
expect($sender->status)->toBe(Sender::STATUS_PENDING);
|
||||
expect((string) $sender->monthly_fee_rub)->toBe('2500.00'); // из client_tg_settings
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('2500.00'); // бронь под имя
|
||||
});
|
||||
|
||||
it('requestSender при активном имени — отказ', function () {
|
||||
makeActiveTgSender($this->tenant->id, '2026-08-27');
|
||||
|
||||
expect(fn () => tgSenderService()->requestSender($this->tenant->id, 'Другое'))
|
||||
->toThrow(DomainException::class);
|
||||
});
|
||||
|
||||
it('requestSender при нехватке денег — бросает ДО списания (заявки нет)', function () {
|
||||
$poor = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test'); // кошелёк есть, но мало
|
||||
|
||||
expect(fn () => tgSenderService()->requestSender($poor->id, 'МойБренд'))
|
||||
->toThrow(InsufficientBalanceException::class);
|
||||
|
||||
expect(Sender::where('tenant_id', $poor->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('disableSender у pending снимает бронь и ставит cancelled', function () {
|
||||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
|
||||
|
||||
$out = tgSenderService()->disableSender($this->tenant->id);
|
||||
|
||||
expect($out->status)->toBe(Sender::STATUS_CANCELLED);
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь снята
|
||||
});
|
||||
|
||||
// --- Админ ----------------------------------------------------------------
|
||||
|
||||
it('approve списывает плату, снимает бронь, ставит active + paid_until +1мес', function () {
|
||||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
|
||||
|
||||
$out = tgSenderService()->approve($sender->id);
|
||||
|
||||
expect($out->status)->toBe(Sender::STATUS_ACTIVE);
|
||||
expect($out->paid_until->toDateString())->toBe('2026-08-27');
|
||||
expect($out->debt_since)->toBeNull();
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь снята
|
||||
expect((string) $wallet->balance_rub)->toBe('7500.00'); // 10000 − 2500
|
||||
});
|
||||
|
||||
it('approve не-pending — отказ', function () {
|
||||
$sender = makeActiveTgSender($this->tenant->id, '2026-08-27');
|
||||
|
||||
expect(fn () => tgSenderService()->approve($sender->id))
|
||||
->toThrow(DomainException::class);
|
||||
});
|
||||
|
||||
it('reject снимает бронь и ставит rejected с заметкой', function () {
|
||||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
|
||||
|
||||
$out = tgSenderService()->reject($sender->id, 'Название занято');
|
||||
|
||||
expect($out->status)->toBe(Sender::STATUS_REJECTED);
|
||||
expect($out->note)->toBe('Название занято');
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->frozen_rub)->toBe('0.00');
|
||||
});
|
||||
|
||||
it('disableByAdmin ставит suspended', function () {
|
||||
$sender = makeActiveTgSender($this->tenant->id, '2026-08-27');
|
||||
|
||||
$out = tgSenderService()->disableByAdmin($sender->id);
|
||||
|
||||
expect($out->status)->toBe(Sender::STATUS_SUSPENDED);
|
||||
});
|
||||
|
||||
// --- Помесячный джоб -------------------------------------------------------
|
||||
|
||||
it('джоб списывает плату, двигает paid_until на месяц, гасит долг когда денег хватает', function () {
|
||||
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26'); // вчера → к оплате
|
||||
|
||||
app(ChargeTgNameFeeJob::class)->handle();
|
||||
|
||||
$sender->refresh();
|
||||
expect($sender->paid_until->toDateString())->toBe('2026-08-26');
|
||||
expect($sender->debt_since)->toBeNull();
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->balance_rub)->toBe('7500.00'); // 10000 − 2500
|
||||
});
|
||||
|
||||
it('джоб при нехватке денег в грейсе — фиксирует долг, paid_until не двигает', function () {
|
||||
$poor = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test'); // мало
|
||||
$sender = makeActiveTgSender($poor->id, '2026-07-26');
|
||||
|
||||
app(ChargeTgNameFeeJob::class)->handle();
|
||||
|
||||
$sender->refresh();
|
||||
expect($sender->status)->toBe(Sender::STATUS_ACTIVE); // ещё в грейсе
|
||||
expect($sender->paid_until->toDateString())->toBe('2026-07-26'); // не сдвинут
|
||||
expect($sender->debt_since)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('джоб при долге дольше грейса — отключает имя (suspended)', function () {
|
||||
$poor = Tenant::factory()->create();
|
||||
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test');
|
||||
// долг с 30 дней назад (>29 grace)
|
||||
$sender = makeActiveTgSender($poor->id, '2026-06-26', debtSince: '2026-06-26');
|
||||
|
||||
app(ChargeTgNameFeeJob::class)->handle();
|
||||
|
||||
$sender->refresh();
|
||||
expect($sender->status)->toBe(Sender::STATUS_SUSPENDED);
|
||||
});
|
||||
|
||||
it('джоб идемпотентен по периоду — второй прогон не списывает дважды', function () {
|
||||
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26');
|
||||
|
||||
app(ChargeTgNameFeeJob::class)->handle();
|
||||
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub;
|
||||
|
||||
// Возвращаем paid_until назад — но external_key периода тот же → повторно не спишет.
|
||||
$sender->update(['paid_until' => '2026-07-26']);
|
||||
app(ChargeTgNameFeeJob::class)->handle();
|
||||
|
||||
$chargeCount = AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||||
->where('channel', 'telegram')
|
||||
->where('type', AdWalletTransaction::TYPE_CHARGE)
|
||||
->count();
|
||||
expect($chargeCount)->toBe(1); // одно списание за период
|
||||
});
|
||||
|
||||
it('джоб в песочнице деньги не двигает', function () {
|
||||
config(['client_tg.sandbox' => true]);
|
||||
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26');
|
||||
|
||||
app(ChargeTgNameFeeJob::class)->handle();
|
||||
|
||||
$sender->refresh();
|
||||
expect($sender->paid_until->toDateString())->toBe('2026-07-26'); // не тронут
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
expect((string) $wallet->balance_rub)->toBe('10000.00'); // не списано
|
||||
});
|
||||
@@ -4,11 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\AdWalletHold;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\ClientTg\TelegramCampaignChargeService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
@@ -23,17 +22,21 @@ uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
||||
*
|
||||
* Кампания в статусе `running` дольше окна (робот-таймаут + запас) считается
|
||||
* зависшей: воркер убили/упал молча. Уборщик добивает статус, но КОНСЕРВАТИВНО с
|
||||
* деньгами:
|
||||
* деньгами (списание/возврат идёт с ОБЩЕГО баланса тенанта `tenants.balance_rub`
|
||||
* через TelegramCampaignChargeService — рекламного кошелька с бронью больше нет):
|
||||
* — нет `mts_campaign_id` (черновик в кабинете не создавался) → кампания заведомо
|
||||
* НЕ ушла на модерацию → `failed` + возврат брони;
|
||||
* НЕ ушла на модерацию → `failed` + возврат списанной сметы на баланс;
|
||||
* — есть `mts_campaign_id` (черновик реально создан) → кампания МОГЛА уйти на
|
||||
* модерацию → деньги вслепую НЕ возвращаем, помечаем `needs_review` (ручной
|
||||
* разбор, пока не сверим статус в кабинете задачей 3.4).
|
||||
* Свежая `running` (в пределах окна) не трогается.
|
||||
* разбор, пока не сверим статус в кабинете задачей 3.4);
|
||||
* — `moderating` дольше окна `client_tg.moderation_stuck_hours` → тоже
|
||||
* `needs_review` БЕЗ возврата (могла реально показываться).
|
||||
* Свежая `running` (в пределах окна) не трогается. В песочнице денег не списывали —
|
||||
* возврат не производится.
|
||||
*/
|
||||
|
||||
/** Кампания в заданном статусе; при $ageMinutes>0 «состаривает» updated_at. */
|
||||
function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0): Campaign
|
||||
function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0, string $estimatedCost = '0.00'): Campaign
|
||||
{
|
||||
$c = Campaign::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -44,7 +47,7 @@ function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int
|
||||
'budget_cap_rub' => '1000.00',
|
||||
'audience_kind' => Campaign::AUDIENCE_LIST,
|
||||
'planned_count' => 0,
|
||||
'estimated_cost_rub' => '0.00',
|
||||
'estimated_cost_rub' => $estimatedCost,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
@@ -57,21 +60,49 @@ function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int
|
||||
return $c;
|
||||
}
|
||||
|
||||
/** Кошелёк тенанта + активная бронь под кампанию. */
|
||||
function sweepWalletWithHold(int $tenantId, int $campaignId, string $balance, string $freeze): void
|
||||
/** Тенант с общим балансом + tenant-контекст (`SET LOCAL`) под его id для списаний. */
|
||||
function sweepTenant(string $balance): Tenant
|
||||
{
|
||||
AdWallet::query()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'balance_rub' => $balance,
|
||||
'frozen_rub' => '0.00',
|
||||
]);
|
||||
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
|
||||
$t = Tenant::factory()->create(['balance_rub' => $balance]);
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$t->id);
|
||||
|
||||
return $t;
|
||||
}
|
||||
|
||||
it('константа STATUS_NEEDS_REVIEW есть и равна needs_review', function () {
|
||||
expect(Campaign::STATUS_NEEDS_REVIEW)->toBe('needs_review');
|
||||
});
|
||||
|
||||
it('зависшая queued (деньги списаны, джоб не стартовал) → failed + возврат сметы', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$tenant = sweepTenant('5000.00');
|
||||
// queued без mts_campaign_id, состарена больше окна QUEUED_STUCK_AFTER_MINUTES (60).
|
||||
$c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 61, '315.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($c, '315.00'); // баланс 4685
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('4685.00');
|
||||
|
||||
SweepStuckTelegramCampaignsJob::dispatchSync();
|
||||
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('5000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $c->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('свежая queued (в пределах окна) не трогается — джоб может её вот-вот запустить', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$tenant = sweepTenant('5000.00');
|
||||
$c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 5, '315.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($c, '315.00'); // баланс 4685
|
||||
|
||||
SweepStuckTelegramCampaignsJob::dispatchSync();
|
||||
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_QUEUED)
|
||||
->and((string) $tenant->fresh()->balance_rub)->toBe('4685.00') // не вернули
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $c->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('running допускает needs_review, needs_review — терминальный', function () {
|
||||
$t = Tenant::factory()->create();
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING);
|
||||
@@ -90,7 +121,7 @@ it('таймаут джоба (420с) больше робот-таймаута',
|
||||
});
|
||||
|
||||
it('failed() переводит queued в failed', function () {
|
||||
config()->set('client_tg.sandbox', true); // без release
|
||||
config()->set('client_tg.sandbox', true); // без возврата
|
||||
$t = Tenant::factory()->create();
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_QUEUED);
|
||||
|
||||
@@ -99,18 +130,21 @@ it('failed() переводит queued в failed', function () {
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED);
|
||||
});
|
||||
|
||||
it('зависшая running без mts_campaign_id → failed и бронь возвращена', function () {
|
||||
it('зависшая running без mts_campaign_id → failed и деньги вернулись', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$t = Tenant::factory()->create();
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30);
|
||||
sweepWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
|
||||
expect((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00');
|
||||
$t = sweepTenant('5000.00');
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($c, '315.00');
|
||||
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
|
||||
|
||||
SweepStuckTelegramCampaignsJob::dispatchSync();
|
||||
|
||||
// failed + возврат → баланс восстановлен, ровно одна проводка возврата.
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('0.00')
|
||||
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_RELEASED);
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('5000.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_type', Campaign::class)
|
||||
->where('related_id', $c->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('свежая running не трогается', function () {
|
||||
@@ -123,15 +157,49 @@ it('свежая running не трогается', function () {
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_RUNNING);
|
||||
});
|
||||
|
||||
it('зависшая running С mts_campaign_id → needs_review, деньги НЕ тронуты', function () {
|
||||
it('зависшая running С mts_campaign_id → needs_review, деньги НЕ вернулись', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
$t = Tenant::factory()->create();
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30);
|
||||
sweepWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
|
||||
$t = sweepTenant('5000.00');
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30, '315.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($c, '315.00');
|
||||
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
|
||||
|
||||
SweepStuckTelegramCampaignsJob::dispatchSync();
|
||||
|
||||
// needs_review, баланс остаётся сниженным (нет возврата), ноль проводок возврата.
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
|
||||
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00')
|
||||
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE);
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $c->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('зависшая moderating → needs_review, деньги НЕ вернулись', function () {
|
||||
config()->set('client_tg.sandbox', false);
|
||||
config()->set('client_tg.moderation_stuck_hours', 1);
|
||||
$t = sweepTenant('5000.00');
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', 120, '315.00');
|
||||
app(TelegramCampaignChargeService::class)->charge($c, '315.00');
|
||||
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
|
||||
|
||||
SweepStuckTelegramCampaignsJob::dispatchSync();
|
||||
|
||||
// needs_review, деньги вслепую не возвращаем (кампания могла показываться).
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
|
||||
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
|
||||
->where('related_id', $c->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('песочница: зависшая running → failed, денег не касаемся', function () {
|
||||
config()->set('client_tg.sandbox', true);
|
||||
$t = sweepTenant('5000.00');
|
||||
// В песочнице деньги не списывали — charge отсутствует.
|
||||
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00');
|
||||
|
||||
SweepStuckTelegramCampaignsJob::dispatchSync();
|
||||
|
||||
// Статус добит, но возврат не запускается (нечего и незачем) — 0 проводок, баланс цел.
|
||||
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
|
||||
->and((string) $t->fresh()->balance_rub)->toBe('5000.00')
|
||||
->and(BalanceTransaction::where('related_id', $c->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -32,3 +32,21 @@ it('смета = объём показов × цена ступени', function
|
||||
// 0 показов → 0.00
|
||||
->and($p->estimateRub(0))->toBe('0.00');
|
||||
});
|
||||
|
||||
it('клиентская смета = базовая смета × наценка (по умолчанию 1.40)', function () {
|
||||
config()->set('client_tg.markup', '1.40');
|
||||
$p = app(TelegramTariffService::class);
|
||||
// 500 показов: база 225.00 × 1.40 = 315.00
|
||||
expect($p->clientEstimateRub(500))->toBe('315.00')
|
||||
// 1000 показов: база 420.00 × 1.40 = 588.00
|
||||
->and($p->clientEstimateRub(1000))->toBe('588.00')
|
||||
// 0 показов → 0.00
|
||||
->and($p->clientEstimateRub(0))->toBe('0.00');
|
||||
});
|
||||
|
||||
it('наценка берётся из config (переопределяемая)', function () {
|
||||
config()->set('client_tg.markup', '2.00');
|
||||
$p = app(TelegramTariffService::class);
|
||||
// 500 показов: база 225.00 × 2.00 = 450.00
|
||||
expect($p->clientEstimateRub(500))->toBe('450.00');
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
...orig,
|
||||
fetchTgTariffs: vi.fn(),
|
||||
updateTgTariffs: vi.fn(),
|
||||
updateTgSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -26,7 +25,6 @@ function makeTariffs(overrides: Partial<AdminTgTariffsResponse> = {}): AdminTgTa
|
||||
{ id: 1, min_qty: 1, price_rub: '0.45' },
|
||||
{ id: 2, min_qty: 1000, price_rub: '0.42' },
|
||||
],
|
||||
settings: { name_fee_rub_per_month: '2500.00', name_debt_grace_days: 29 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -34,7 +32,7 @@ function makeTariffs(overrides: Partial<AdminTgTariffsResponse> = {}): AdminTgTa
|
||||
const mountView = () => mount(AdminTgView, { global: { plugins: [createVuetify()] } });
|
||||
|
||||
describe('AdminTgView', () => {
|
||||
it('на mount загружает тарифы и настройки', async () => {
|
||||
it('на mount загружает тарифы', async () => {
|
||||
vi.mocked(adminApi.fetchTgTariffs).mockResolvedValueOnce(makeTariffs());
|
||||
|
||||
const wrapper = mountView();
|
||||
@@ -42,7 +40,6 @@ describe('AdminTgView', () => {
|
||||
|
||||
expect(adminApi.fetchTgTariffs).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.find('[data-testid="tg-tariffs-save"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tg-settings-save"]').exists()).toBe(true);
|
||||
// Ступени легли в редактируемые поля (значение input, не текст).
|
||||
const row0Inputs = wrapper.find('[data-testid="tg-tariff-row-0"]').findAll('input');
|
||||
expect(row0Inputs.some((i) => (i.element as HTMLInputElement).value === '0.45')).toBe(true);
|
||||
@@ -65,22 +62,6 @@ describe('AdminTgView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('«Сохранить настройки» вызывает updateTgSettings с полями', async () => {
|
||||
vi.mocked(adminApi.fetchTgTariffs).mockResolvedValue(makeTariffs());
|
||||
vi.mocked(adminApi.updateTgSettings).mockResolvedValueOnce({
|
||||
name_fee_rub_per_month: '2500.00',
|
||||
name_debt_grace_days: 29,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-settings-save"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(adminApi.updateTgSettings).toHaveBeenCalledWith('2500.00', 29);
|
||||
});
|
||||
|
||||
it('ошибка загрузки тарифов показывает человеческое сообщение', async () => {
|
||||
vi.mocked(adminApi.fetchTgTariffs).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
}));
|
||||
|
||||
const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTelegramMock } = vi.hoisted(() => ({
|
||||
const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTelegramMock, uploadTelegramMediaMock } = vi.hoisted(() => ({
|
||||
fetchTelegramMock: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
campaigns: [] as Array<Record<string, unknown>>,
|
||||
@@ -21,6 +21,7 @@ const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTeleg
|
||||
),
|
||||
launchTelegramMock: vi.fn(() => Promise.resolve({ id: 1, status: 'queued' })),
|
||||
resubmitTelegramMock: vi.fn(() => Promise.resolve({ id: 9, status: 'queued' })),
|
||||
uploadTelegramMediaMock: vi.fn((id: number) => Promise.resolve({ id, status: 'draft', media_path: 'client_tg/media/x.jpg' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/telegram', () => ({
|
||||
@@ -29,12 +30,8 @@ vi.mock('../../resources/js/api/telegram', () => ({
|
||||
fetchTelegramCampaign: vi.fn(() => Promise.resolve({ campaign: { id: 1 } })),
|
||||
launchTelegram: launchTelegramMock,
|
||||
resubmitTelegram: resubmitTelegramMock,
|
||||
// Встроенная панель «Своё имя» сама зовёт fetchSender на монтировании — отдаём пустой снимок.
|
||||
fetchSender: vi.fn(() => Promise.resolve({ sender: null, grace_days_left: null, sandbox: true })),
|
||||
createSender: vi.fn(() => Promise.resolve({ id: 1, name: 'Бренд', status: 'pending' })),
|
||||
disableSender: vi.fn(() => Promise.resolve({ id: 1, status: 'cancelled' })),
|
||||
enableSender: vi.fn(() => Promise.resolve({ id: 1, status: 'active' })),
|
||||
// Встроенная панель «Авторассылка» зовёт fetchAutoRule на монтировании — отдаём дефолты.
|
||||
uploadTelegramMedia: uploadTelegramMediaMock,
|
||||
// Встроенная панель «Авто-реклама» зовёт fetchAutoRule на монтировании — отдаём дефолты.
|
||||
fetchAutoRule: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
enabled: false,
|
||||
@@ -419,6 +416,47 @@ describe('AdvertisingTelegramView — экран клиентской Telegram-
|
||||
}
|
||||
});
|
||||
|
||||
it('приложенное медиа грузится к черновику при «Рассчитать»', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('С картинкой');
|
||||
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/x');
|
||||
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
|
||||
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
|
||||
|
||||
// Выбираем файл в поле медиа (нативный input внутри v-file-input).
|
||||
const file = new File(['data'], 'ad.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = wrapper.find('[data-testid="tg-media"] input[type="file"]');
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file] });
|
||||
await fileInput.trigger('change');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-calc"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(createTelegramMock).toHaveBeenCalledOnce();
|
||||
expect(uploadTelegramMediaMock).toHaveBeenCalledOnce();
|
||||
expect(uploadTelegramMediaMock.mock.calls[0][0]).toBe(1); // id черновика
|
||||
expect(uploadTelegramMediaMock.mock.calls[0][1]).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('без выбранного медиа upload не вызывается', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Без медиа');
|
||||
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/x');
|
||||
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
|
||||
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
|
||||
|
||||
await wrapper.find('[data-testid="tg-calc"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(createTelegramMock).toHaveBeenCalledOnce();
|
||||
expect(uploadTelegramMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('при нехватке денег (launch → 409) показывает понятное сообщение', async () => {
|
||||
launchTelegramMock.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function rule(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe('TelegramAutoRulePanel — авторассылка новым лидам', () => {
|
||||
describe('TelegramAutoRulePanel — авто-реклама новым лидам', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('загружает правило и показывает тумблер + поля порога/бюджета/лимита', async () => {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
// Панель не ходит в сеть — мокаем API-модуль telegram.ts.
|
||||
const { fetchSenderMock, createSenderMock, disableSenderMock, enableSenderMock } = vi.hoisted(() => ({
|
||||
fetchSenderMock: vi.fn(),
|
||||
createSenderMock: vi.fn(() => Promise.resolve({ id: 1, name: 'МойБренд', status: 'pending' })),
|
||||
disableSenderMock: vi.fn(() => Promise.resolve({ id: 1, status: 'cancelled' })),
|
||||
enableSenderMock: vi.fn(() => Promise.resolve({ id: 1, status: 'active' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/telegram', () => ({
|
||||
fetchSender: fetchSenderMock,
|
||||
createSender: createSenderMock,
|
||||
disableSender: disableSenderMock,
|
||||
enableSender: enableSenderMock,
|
||||
}));
|
||||
|
||||
import TelegramSenderPanel from '../../resources/js/views/advertising/TelegramSenderPanel.vue';
|
||||
|
||||
function mountPanel() {
|
||||
return mount(TelegramSenderPanel, { global: { plugins: [createVuetify()] } });
|
||||
}
|
||||
|
||||
function snapshot(sender: Record<string, unknown> | null, extra: Record<string, unknown> = {}) {
|
||||
return { sender, grace_days_left: null, sandbox: true, ...extra };
|
||||
}
|
||||
|
||||
describe('TelegramSenderPanel — своё имя отправителя', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('без имени показывает форму «Завести имя» и создаёт имя', async () => {
|
||||
fetchSenderMock.mockResolvedValue(snapshot(null));
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tg-sender-name-input"]').exists()).toBe(true);
|
||||
await wrapper.find('[data-testid="tg-sender-name-input"] input').setValue('МойБренд');
|
||||
await wrapper.find('[data-testid="tg-sender-create"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(createSenderMock).toHaveBeenCalledWith('МойБренд');
|
||||
});
|
||||
|
||||
it('активное имя показывает статус и дату оплаты, есть кнопка «Отключить»', async () => {
|
||||
fetchSenderMock.mockResolvedValue(
|
||||
snapshot({ id: 1, name: 'Бренд', status: 'active', paid_until: '2026-08-27' }),
|
||||
);
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tg-sender-status"]').text()).toContain('активно');
|
||||
expect(wrapper.find('[data-testid="tg-sender-paid-until"]').text()).toContain('2026-08-27');
|
||||
expect(wrapper.find('[data-testid="tg-sender-disable"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('suspended показывает «Отключено за долг» и кнопку «Включить»', async () => {
|
||||
fetchSenderMock.mockResolvedValue(snapshot({ id: 1, name: 'Бренд', status: 'suspended' }));
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Отключено за долг');
|
||||
const enable = wrapper.find('[data-testid="tg-sender-enable"]');
|
||||
expect(enable.exists()).toBe(true);
|
||||
|
||||
await enable.trigger('click');
|
||||
await flushPromises();
|
||||
expect(enableSenderMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('клик «Отключить» зовёт disableSender', async () => {
|
||||
fetchSenderMock.mockResolvedValue(
|
||||
snapshot({ id: 1, name: 'Бренд', status: 'active', paid_until: '2026-08-27' }),
|
||||
);
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-sender-disable"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(disableSenderMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('имя с долгом показывает остаток грейса', async () => {
|
||||
fetchSenderMock.mockResolvedValue(
|
||||
snapshot(
|
||||
{ id: 1, name: 'Бренд', status: 'active', paid_until: '2026-07-26', debt_since: '2026-07-23' },
|
||||
{ grace_days_left: 24 },
|
||||
),
|
||||
);
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tg-sender-debt"]').text()).toContain('24');
|
||||
});
|
||||
});
|
||||
@@ -2451,3 +2451,29 @@ mattpocock
|
||||
апстрима
|
||||
задвоение
|
||||
задвоения
|
||||
apiv
|
||||
eto
|
||||
kak
|
||||
licenziya
|
||||
rabotaet
|
||||
svyazka
|
||||
Свип
|
||||
абонплата
|
||||
админских
|
||||
ассертим
|
||||
бэклогом
|
||||
джобами
|
||||
дозаполнение
|
||||
досыл
|
||||
задваиваем
|
||||
закоммитила
|
||||
залогиненных
|
||||
мигрированных
|
||||
платформо
|
||||
преfflight
|
||||
приложённый
|
||||
ресейлит
|
||||
сидированные
|
||||
слагов
|
||||
схлопывает
|
||||
фирмо
|
||||
|
||||
@@ -8,6 +8,71 @@
|
||||
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
|
||||
> поэтому при сведении они перенумерованы. Содержание не менялось.
|
||||
|
||||
## v8.95 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон)
|
||||
|
||||
> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.94). Номер **v8.95 предварительный** —
|
||||
> при сведении с боевым main будет перенумерован. Содержание не меняется.
|
||||
|
||||
Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки
|
||||
сообщений по номеру, отписок и шаблонов сообщений (владельцем подтверждено). Эти сущности были механически
|
||||
скопированы из СМС-модуля. Убраны.
|
||||
|
||||
- **Дроп таблиц** (миграция `app/database/migrations/2026_07_28_000017_drop_client_tg_sms_legacy_tables.php`,
|
||||
`DROP TABLE IF EXISTS … CASCADE`):
|
||||
- `client_tg_senders` (было v8.87) — «своё имя/бренд отправителя» + помесячная абонплата;
|
||||
- `client_tg_settings` (было v8.86) — обе колонки (`name_fee_rub_per_month`, `name_debt_grace_days`)
|
||||
обслуживали только имя;
|
||||
- `client_tg_messages` (было v8.86) — «по-получательские записи доставки» (копия `sales_sms_messages`),
|
||||
без Eloquent-модели, в рабочем коде не использовалась;
|
||||
- `client_tg_templates` (было v8.86) — «шаблоны сообщений», не использовались нигде.
|
||||
Их **create-миграции удалены** (000003/000006/000008/000010) — на свежей базе таблицы не создаются, DROP —
|
||||
no-op; на уже мигрированных dev/prod убирает.
|
||||
- **CHECK `balance_transactions_type_check` −= `tg_name_fee`** — тип был зарезервирован под отменённую
|
||||
плату за имя (кусок 3 спеки отменён). В теле `schema.sql` (~л.2745) и в up/down миграции
|
||||
`2026_07_28_000001` строка `tg_name_fee` убрана; остаются `tg_ad_charge`/`tg_ad_refund`.
|
||||
- **НЕ тронуто:** реальные таблицы показов (`client_tg_campaigns`/`_campaign_phones`/`_contacts`/`_tariffs`/
|
||||
`_auto_rule`) и `client_tg_optouts` (переосмыслен как ручной список «не показывать этим номерам»).
|
||||
|
||||
**Данные:** таблицы работали только в песочнице (реальных денег/строк по имени не было). Дроп необратим
|
||||
(`down` не пересоздаёт — продукт показов их не имеет).
|
||||
|
||||
**RLS:** удаление таблиц снимает и их политики/GRANT-ы вместе с ними; политики оставшихся таблиц не меняются.
|
||||
**rls-reviewer:** удаление RLS-таблиц + CHECK-only правка — прогнать при сведении ветки.
|
||||
|
||||
---
|
||||
|
||||
## v8.94 (2026-07-28) — Telegram-деньги: CHECK balance_transactions.type += tg_ad_charge/tg_ad_refund
|
||||
|
||||
> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.93). Номер **v8.94 предварительный** —
|
||||
> при сведении с боевым main будет перенумерован. Содержание не меняется.
|
||||
> NB: изначально запись добавляла и `tg_name_fee`, но он убран в v8.95 (плата за имя — отменённая фантазия).
|
||||
|
||||
Кусок 1 денежной модели Telegram-рекламы (спека
|
||||
`docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md`): реклама оплачивается с
|
||||
**общего баланса** тенанта (как СМС), поэтому в журнал денег добавлены два вида проводки.
|
||||
|
||||
- **CHECK `balance_transactions_type_check` += два значения:**
|
||||
- `tg_ad_charge` — списание клиентской сметы (с наценкой 40%) при запуске Telegram-кампании
|
||||
(`TelegramCampaignChargeService::charge`, зеркало `SmsChargeService`);
|
||||
- `tg_ad_refund` — возврат при отказе модерации / сбое до кабинета (`::refund`, идемпотентный).
|
||||
|
||||
Одной миграцией, чтобы на прод-кластер `ALTER … CHECK` ехал ОДИН раз. Миграция
|
||||
`app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php`
|
||||
(up = полный список **включая** уже действующий `sms_charge`; down возвращает к списку с `sms_charge`).
|
||||
- **Свёрнуто в тело `schema.sql`** (`CREATE TABLE balance_transactions`, ~л.2745): CHECK приведён к
|
||||
реальному состоянию БД — заодно **добавлен пропущенный** `sms_charge` (миграция 2026_07_23_100100
|
||||
расширяла констрейнт `ALTER`-ом, но в тело `schema.sql` значение не свернули) + два новых типа.
|
||||
Прежняя цепочка `'migration' → 'autopodbor_charge'` теперь `→ … → 'tg_ad_refund'`.
|
||||
|
||||
**Данные:** необратимо после первого списания `type='tg_ad_charge'` (как прецеденты `sms_charge` /
|
||||
`autopodbor_charge`). `migrate:fresh` пересоздаст с полным списком.
|
||||
|
||||
**RLS:** политики и колонки НЕ меняются — только доменный CHECK на `type`. Таблица уже tenant-aware
|
||||
(`tenant_isolation`), новые проводки пишутся в контексте тенанта (`SET LOCAL app.current_tenant_id`),
|
||||
как `sms_charge`. **rls-reviewer:** прогнан — **CLEAN** (CHECK-only, изоляция не затронута).
|
||||
|
||||
---
|
||||
|
||||
## v8.93 (2026-07-28) — Telegram-модуль, ревью-фикс F9: SELECT служебным ролям на client_tg_campaigns
|
||||
|
||||
> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.92). Номер **v8.93 предварительный** —
|
||||
|
||||
+4
-1
@@ -2747,7 +2747,10 @@ CREATE TABLE balance_transactions (
|
||||
'chargeback_writedown', -- Ю-3: списание непокрытой части chargeback в долг тенанта
|
||||
'chargeback_repayment', -- Ю-3: погашение долга через /billing
|
||||
'migration', -- Billing v2 Spec A: конвертация balance_leads → balance_rub
|
||||
'autopodbor_charge' -- v8.62: списание за прогон автоподбора (AutopodborChargeService; миграция 2026_06_28_110100)
|
||||
'autopodbor_charge', -- v8.62: списание за прогон автоподбора (AutopodborChargeService; миграция 2026_06_28_110100)
|
||||
'sms_charge', -- списание за СМС-рассылку (SmsChargeService; миграция 2026_07_23_100100)
|
||||
'tg_ad_charge', -- списание клиентской сметы Telegram-рекламы (TelegramCampaignChargeService; миграция 2026_07_28_000001)
|
||||
'tg_ad_refund' -- возврат сметы Telegram-рекламы при отказе модерации / сбое (миграция 2026_07_28_000001)
|
||||
)),
|
||||
amount_rub DECIMAL(12,2) DEFAULT 0,
|
||||
amount_leads INT DEFAULT 0,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Телеграм-деньги, КУСОК 1 (фундамент) — приёмочный лист
|
||||
|
||||
Ветка `feat/client-telegram-ads`. Спека:
|
||||
`docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md` (порядок из 3 кусков).
|
||||
Решение владельца (28.07): платить с ОБЩЕГО баланса (как СМС), наценка 40%. Не пушить. Коммит по «коммить».
|
||||
|
||||
**Куски 2 и 3 НЕ трогаются** — поток запуска/отказа кампании (launch/reject/джобы/авто-путь/плата за имя)
|
||||
пока по-прежнему висит на старом кошельке `AdWalletService` (freeze/release). Кусок 1 — только фундамент:
|
||||
наценка + сервис списания/возврата с общего баланса + место в БД под новые проводки. Ничего из этого
|
||||
пока НЕ подключено к боевому потоку (никто ещё не зовёт `TelegramCampaignChargeService`), поэтому поведение
|
||||
кампаний не изменилось — безопасно.
|
||||
|
||||
## Приёмочный лист (кусок 1 = D1 + D2 + D3 + БД)
|
||||
|
||||
- [x] **D1. Наценка 40%.** Config `client_tg.markup` (`(string) env('TG_MARKUP', '1.40')`).
|
||||
`TelegramTariffService::clientEstimateRub(int $impressions)` = базовая `estimateRub × markup` (bcmath scale 2).
|
||||
Приёмка: 500 показов → база 225.00 × 1.40 = **315.00**; 1000 → 588.00; 0 → 0.00; наценка берётся из config
|
||||
(переопределяемая — при 2.00 даёт 450.00). База (`estimateRub`) не тронута — остаётся себестоимостью-оценкой.
|
||||
|
||||
- [x] **D2. Списание с общего баланса.** Новый `App\Services\ClientTg\TelegramCampaignChargeService::charge(Campaign, string $priceRub)`
|
||||
— зеркало `SmsChargeService`: замок тенанта (`Tenant::lockForUpdate`) → идемпотентность по строке
|
||||
`balance_transactions` (type=`tg_ad_charge`, related_type=Campaign, related_id) → нулевая сумма — no-op →
|
||||
нехватка → `InsufficientBalanceException` (баланс не тронут) → списание `tenants.balance_rub` +
|
||||
`BalanceTransaction` (`TYPE_TG_AD_CHARGE`, amount отрицательный). Здесь Campaign/Tenant/BalanceTransaction
|
||||
на одном соединении `pgsql` — замок настоящий (в отличие от СМС на `pgsql_supplier`).
|
||||
Приёмка: списывает раз; повтор — no-op; нехватка → исключение, баланс цел; ноль — не пишет проводку.
|
||||
|
||||
- [x] **D3. Возврат (refund) на общий баланс.** `::refund(Campaign)`: если есть charge-проводка и ещё нет
|
||||
refund-проводки — вернуть модуль списанной суммы на баланс + `BalanceTransaction` (`TYPE_TG_AD_REFUND`,
|
||||
amount положительный), идемпотентно (по наличию refund-строки). Нет списания — ничего не делает.
|
||||
Приёмка: после charge→refund баланс восстановлен (1000→685→1000); двойной refund не задваивает; refund без
|
||||
charge — no-op.
|
||||
|
||||
- [x] **БД. Новые типы проводки.** `balance_transactions.type` — CHECK-ограничение, поэтому:
|
||||
- Миграция `app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php`
|
||||
добавляет ТРИ значения одним `ALTER … CHECK` (чтобы на прод-кластер ехало один раз): `tg_ad_charge`,
|
||||
`tg_ad_refund`, `tg_name_fee` (последнее **зарезервировано под кусок 3** — плата за имя). up() включает
|
||||
уже действующий `sms_charge`; down() возвращает к списку с `sms_charge`.
|
||||
- `BalanceTransaction`: константы `TYPE_TG_AD_CHARGE` / `TYPE_TG_AD_REFUND` / `TYPE_TG_NAME_FEE`.
|
||||
- `db/schema.sql` (CREATE TABLE, ~л.2745): CHECK приведён к реальному состоянию БД — **заодно добавлен
|
||||
пропущенный `sms_charge`** (миграция 2026_07_23 расширяла `ALTER`-ом, но в тело не свернула) + три новых.
|
||||
- `db/CHANGELOG_schema.md`: запись **v8.94** (номер предварительный, при сведении с main перенумеруется).
|
||||
|
||||
## Приёмка (28.07.2026)
|
||||
|
||||
- Тесты (`DB_DATABASE=liderra_testing`, по отдельности — вместе флейкует на квирке партиций/типов):
|
||||
- `tests/Feature/ClientTg/CampaignChargeServiceTest.php` (новый) — **5/5** (12 проверок): charge раз+идемпотентность,
|
||||
нехватка→исключение+баланс цел, ноль→no-op, refund восстанавливает+идемпотентность, refund без charge→no-op.
|
||||
- `tests/Feature/ClientTg/TariffServiceTest.php` (+2 кейса) — **4/4** (18 проверок): наценка 1.40 и переопределение.
|
||||
- `pint` — чисто; `phpstan` — 0 ошибок (убран мёртвый null-guard: `client_tg_campaigns.tenant_id` NOT NULL,
|
||||
в отличие от nullable у СМС-модели); `deptrac` — 0 нарушений.
|
||||
- Миграция применена на `liderra_testing` и на локальную dev-БД `liderra` (не прод-кластер).
|
||||
- **rls-reviewer** — прогнан по миграции (CHECK-only, tenant-изоляция не затронута). Вердикт — см. ниже.
|
||||
|
||||
🪤 **Готча окружения (подтвердилась снова):** прогон ДВУХ ClientTg-файлов ОДНОЙ командой уронил тест-базу
|
||||
(коллизия `pg_type … legal_entities` — пересоздание типов под RefreshDatabase+партиции). Ремонт:
|
||||
`db:wipe --drop-types --force` ×(до 2 раз) + `migrate --force`. Гонять изменённые файлы по одному.
|
||||
|
||||
## Го-лайв (кусок НЕ здесь — общий долг перед выкатом)
|
||||
- Миграцию `2026_07_28_000001` прогнать на боевом кластере (как прочие CHECK-миграции).
|
||||
- Подключение сервиса к потоку (charge при запуске, refund при отказе, снятие AdWalletService) — **кусок 2**.
|
||||
- Плата за имя на общий баланс (`tg_name_fee`) — **кусок 3**.
|
||||
|
||||
НЕ закоммичено (ждём «коммить»).
|
||||
@@ -0,0 +1,125 @@
|
||||
# Телеграм-деньги, КУСОК 2 — переключить поток с кошелька-заморозки на общий баланс
|
||||
|
||||
Ветка `feat/client-telegram-ads`. Спека D4/D5/D6. Кусок 1 (наценка + charge/refund-сервис + БД) готов,
|
||||
не закоммичен. Коммитим всё вместе после куска 3 (решение владельца 28.07: «дальше закоммитим все вместе»).
|
||||
Не пушить.
|
||||
|
||||
## Суть
|
||||
Заменить в ПОТОКЕ кампании старый рекламный кошелёк `AdWalletService` (freeze/release) на списание/возврат
|
||||
с ОБЩЕГО баланса (`TelegramCampaignChargeService::charge/refund` из куска 1). Кошелёк и его таблицы НЕ
|
||||
удаляем — просто телеграм-кампании на них больше не опираются. Плата за ИМЯ отправителя
|
||||
(`TelegramSenderService`/`ChargeTgNameFeeJob`) — это кусок 3 (D8), здесь НЕ трогаем.
|
||||
|
||||
## 🔑 Ключевое открытие (переопределяет реализацию куска 1)
|
||||
Кампанию можно **списать → вернуть (отказ модерации) → списать снова (пересдача)**. Значит идемпотентность
|
||||
«есть хоть одна charge-строка → no-op» из куска 1 сделала бы пересдачу БЕСПЛАТНОЙ. Переходим на
|
||||
**сальдо-идемпотентность** (непогашенное списание = charges − refunds):
|
||||
- `charge`: no-op, если `count(tg_ad_charge) > count(tg_ad_refund)` (есть непогашенное списание); иначе списываем.
|
||||
- `refund`: no-op, если `count(tg_ad_charge) <= count(tg_ad_refund)` (нечего возвращать); иначе возвращаем
|
||||
сумму ПОСЛЕДНЕГО (непогашенного) списания.
|
||||
Цикл launch→charge (сальдо +1) → reject→refund (0) → resubmit→charge (+1) считается верно.
|
||||
|
||||
## Сумма списания
|
||||
Клиентская цена = `estimated_cost_rub` (теперь хранится С наценкой, D4-store). При запуске пересчитываем по
|
||||
свежему числу кандидатов: `estimated_cost_rub = clientEstimateRub(candidates)` и списываем именно её —
|
||||
клиент видит и платит одно и то же. `budget_cap_rub` остаётся ЛИМИТОМ трат на объявление (отдаётся роботу/
|
||||
МТС), НЕ списывается.
|
||||
|
||||
## Приёмочный лист (файлы)
|
||||
|
||||
- [ ] **S0. Сервис — сальдо-идемпотентность.** `TelegramCampaignChargeService::charge/refund` переписать на
|
||||
сравнение count(charge) vs count(refund); refund берёт сумму последнего непогашенного charge (orderByDesc id).
|
||||
Тест `CampaignChargeServiceTest`: + кейс charge→refund→charge (второй charge СПИСЫВАЕТ; итог 2 charge/1 refund).
|
||||
|
||||
- [ ] **D4-store. `CampaignController::store`** — `estimated_cost_rub = clientEstimateRub($candidates)` (с наценкой).
|
||||
Экран показывает клиентскую цену. Тест: смета в ответе = база×1.4.
|
||||
|
||||
- [ ] **D4-launch. `CampaignController::launch`** — в бою вместо `freeze(budget_cap_rub)`:
|
||||
пересчитать `estimated_cost_rub = clientEstimateRub(candidates)` (уже строим для гейта) → `charge($campaign,
|
||||
estimated_cost_rub)`; нехватка → `InsufficientBalanceException` → 409, кампания остаётся draft. Песочница —
|
||||
без денег. Ловим `InsufficientBalanceException` (ModelNotFoundException больше не нужен).
|
||||
|
||||
- [ ] **D4-resubmit. `CampaignController::resubmit`** — тот же charge вместо freeze (пересдача платится заново;
|
||||
сальдо-идемпотентность это допускает).
|
||||
|
||||
- [ ] **cancel. `CampaignController::cancel`** — в бою `refund($campaign)` вместо `release`. Из draft (не
|
||||
списывали) refund — no-op; из queued (списали на launch) — возврат. Убрать зависимость от ModelNotFoundException.
|
||||
|
||||
- [ ] **index. `CampaignController::index`** — баланс для экрана брать из общего баланса тенанта
|
||||
(`tenants.balance_rub`), а не из `ad_wallets`. `frozen_rub` → '0.00' (заморозки больше нет). Иначе экран
|
||||
всегда показывал бы 0 и гасил «Запустить».
|
||||
|
||||
- [ ] **D5-run. `RunTelegramCampaignJob`** — оба `release` (finalize failed-без-id; failed() shouldRelease) →
|
||||
`refund($campaign)`. F5 сохраняется: есть mts_campaign_id → needs_review БЕЗ возврата. Загружать модель
|
||||
кампании для refund (сейчас передаётся id в release).
|
||||
|
||||
- [ ] **D5-resubmit. `ResubmitTelegramCampaignJob`** — оба `release` → `refund`. F5 сохраняется.
|
||||
|
||||
- [ ] **D5-poll. `PollTelegramModerationJob`** — `release` при `rejected` → `refund($campaign)` (возврат при
|
||||
отказе модерации). Одобрение → ничего (уже списано).
|
||||
|
||||
- [ ] **D5-sweep. `SweepStuckTelegramCampaignsJob`** — `release` (running-stuck без mts_id → failed) → `refund`.
|
||||
moderating-stuck → needs_review БЕЗ возврата (как было). ⚠️ обязателен: без freeze старый `release` бросил бы.
|
||||
|
||||
- [ ] **D6. Снять AdWalletService из потока кампании** — убрать импорты/вызовы freeze/release из:
|
||||
CampaignController, RunTelegramCampaignJob, ResubmitTelegramCampaignJob, PollTelegramModerationJob,
|
||||
SweepStuckTelegramCampaignsJob. **НЕ трогать** TelegramSenderService/ChargeTgNameFeeJob (кусок 3, D8).
|
||||
TelegramAutoAccumulator (freeze) — снять freeze здесь; списание авто-пути добавит кусок 3 (D7). Между
|
||||
кусками 2 и 3 авто-путь временно без списания — закрывается до общего коммита.
|
||||
|
||||
- [ ] **D9(частично). Тесты.** Переписать/обновить wallet-базовые под charge/refund:
|
||||
ManualFlowTest, LaunchIdempotencyTest, RefundOnFailTest, ConservativeReleaseTest, AutoFreezeTest,
|
||||
WalletChannelTest (кампанийная часть), RunCampaignJobTest, ResubmitJobTest/ResubmitTest, PollModerationTest,
|
||||
SweepStuckTest, ModeratingStatusTest — там, где ассертят frozen/balance кошелька, перевести на
|
||||
tenants.balance_rub + balance_transactions. WalletReFreezeTest/SenderLifecycle (плата за имя) — кусок 3.
|
||||
Гонять ПО ОТДЕЛЬНОСТИ (квирк партиций). pint/stan/deptrac чисто.
|
||||
|
||||
## Состояние — ВСЕ ГОТОВЫ (28.07.2026, зелёные по отдельности)
|
||||
- [x] S0 — сальдо-идемпотентность charge/refund; `CampaignChargeServiceTest` **6/6** (+ кейс charge→refund→charge).
|
||||
- [x] D4-store / D4-launch / D4-resubmit / cancel / index — CampaignController; `CampaignApiTest` **9/9**.
|
||||
- [x] D5-run — RunTelegramCampaignJob (finalize + failed); `RunCampaignJobTest` 6/6, `RefundOnFailTest` 5/5,
|
||||
`ConservativeReleaseTest` 5/5.
|
||||
- [x] D5-resubmit — ResubmitTelegramCampaignJob; `ResubmitJobTest` 7/7, `ResubmitTest` 8/8.
|
||||
- [x] D5-poll — PollTelegramModerationJob; `PollModerationTest` 6/6, `ModeratingStatusTest`/`ModerationStuckTest` 3/3.
|
||||
- [x] D5-sweep — SweepStuckTelegramCampaignsJob; `SweepStuckTest` 7/7.
|
||||
- [x] D6 — AdWalletService снят из CampaignController/Run/Resubmit/Poll/Sweep/AutoAccumulator (кошелёк/таблицы
|
||||
НЕ удалены; sender/name-fee — кусок 3).
|
||||
- [x] D7 (авто-путь) **сделан здесь же**, чтобы не оставлять авто-путь без списания между кусками:
|
||||
TelegramAutoAccumulator списывает клиентскую смету при авто-запуске; `AutoSafeguardTest` 6/6, `AutoFreezeTest` 4/4,
|
||||
`AudienceGateTest` green.
|
||||
- [x] D9 — 8 wallet-тестов переписаны под charge/refund (subagent'ы, только тест-файлы; прод-код НЕ тронут —
|
||||
сверено `git status`). pint/stan(0)/deptrac(0) чисто.
|
||||
|
||||
🪤 **Готча окружения (жёстко подтвердилась):** прогон НЕСКОЛЬКИХ ClientTg-файлов подряд (особенно
|
||||
`SharesSupplierPdo`-тестов) КОРРУПТИТ тест-базу (Undefined table / пересоздание партиций-типов). Гонять
|
||||
СТРОГО ПО ОДНОМУ; при `Undefined table` — `db:wipe --drop-types --force` + `migrate --force`, потом файл заново.
|
||||
Все 8 файлов зелёные на ЧИСТОЙ базе поштучно.
|
||||
|
||||
## Код-ревью (субагент, деньги/F5/сальдо/RLS, 28.07.2026) — Critical НЕТ
|
||||
Проверено и подтверждено верным: нет двойного списания/возврата (замок тенанта → проверка сальдо; charge и
|
||||
refund строго чередуются, возврат берёт последнюю непогашенную сумму); реестр не расходится с балансом
|
||||
(atomically); F5 во всех 4 джобах (refund только в ветке failed без mts_campaign_id); песочница везде;
|
||||
вложенные транзакции/замки корректны (tenantTx → savepoint charge; auto: замок AutoRule→tenant, без реверса).
|
||||
|
||||
Находки:
|
||||
- **I-1 (Important) — ИСПРАВЛЕНО.** Кампания, списанная на launch, но чей джоб так и не стартовал (воркер
|
||||
умер / Redis сброшен), залипала в `queued` НАВСЕГДА с деньгами, снятыми с общего баланса — уборщик смотрел
|
||||
только `running`. Раньше (кошелёк) была лишь бронь на ad_wallet, теперь — реальный дебет общего баланса
|
||||
клиента, поэтому радиус хуже. Фикс: `SweepStuckTelegramCampaignsJob::sweepStuckQueued()` — queued старше
|
||||
`QUEUED_STUCK_AFTER_MINUTES` (60 мин, с запасом, чтобы не гоняться с живым воркером) → `failed` + refund
|
||||
(queued не имеет mts_campaign_id → робот кабинет не трогал → F5-безопасно). Тесты добавлены (`SweepStuckTest`
|
||||
11/11: зависшая queued→failed+возврат; свежая queued не трогается).
|
||||
- **I-2 (Important) — ОСОЗНАННО НЕ меняем.** Авто-путь: `spent_today_rub` не уменьшается при возврате
|
||||
отклонённой авто-кампании → дневной лимит может «выесться» вернувшимися деньгами. Направление БЕЗОПАСНОЕ
|
||||
(только НЕ-до-разрешает, переспенда/потери нет). Решение: `daily_limit_rub` трактуем как cap на ПОПЫТКИ/
|
||||
валовые списания за день, а не на чистый расход — чтобы не тащить состояние правила в путь возврата
|
||||
(refund в Run/Poll джобах не знает про авто-правило). Задокументировано, кода не трогаем.
|
||||
- **M-1 — ИСПРАВЛЕНО.** Устаревший docblock `CampaignController` («бронирует budget_cap / заморозки нет») →
|
||||
«списывает клиентскую смету с общего баланса».
|
||||
- **M-2 — кусок 3.** Плата за имя (`ChargeTgNameFeeJob`/`TelegramSenderService`) всё ещё на старом кошельке;
|
||||
тип `tg_name_fee` уже добавлен в CHECK/модель под кусок 3 (D8).
|
||||
- **M-3 — деньги-корректно, оставлено.** Показанная при store смета и списанная при launch могут отличаться,
|
||||
если аудитория изменилась между store и launch (списываем свежую верную сумму). UX-нюанс, не потеря денег.
|
||||
|
||||
## Дальше кусок 3 (D8: плата за имя → общий баланс, тип tg_name_fee). НЕ закоммичено (ждём «коммить»,
|
||||
## вместе с кусками 1 и 3). pint/stan(0)/deptrac(0) чисто на момент закрытия куска 2.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Телеграм-деньги, КУСОК 3 — плата за своё ИМЯ отправителя → общий баланс (D8 + D9)
|
||||
|
||||
Ветка `feat/client-telegram-ads`. Спека D8/D9. Куски 1 и 2 готовы, не закоммичены.
|
||||
Коммитим всё вместе после куска 3 (решение владельца 28.07: «дальше закоммитим все вместе»). Не пушить.
|
||||
|
||||
> ⛔ Код куска 3 НЕ начат. Этот лист — приёмка ДО кода (переживает компакт). Ждём слово владельца
|
||||
> («делай»/«дальше») и ответ на РАЗВИЛКУ ниже. Только после — TDD.
|
||||
|
||||
## Суть
|
||||
Плата за своё имя Telegram-рекламы (`client_tg_senders`) сейчас идёт через старый рекламный
|
||||
кошелёк-заморозку `AdWalletService` (freeze при заявке / charge+release при одобрении / release при
|
||||
отказе / charge помесячно и при возврате из долга). Кампании уже переведены на общий баланс
|
||||
(куски 1–2). Кусок 3 переводит на общий баланс (`tenants.balance_rub` + `balance_transactions`,
|
||||
тип `tg_name_fee`) и ПЛАТУ ЗА ИМЯ — единственный кусок телеграм-денег, ещё висящий на кошельке.
|
||||
Кошелёк и таблицы `ad_wallets`/`ad_wallet_*` НЕ удаляем.
|
||||
|
||||
## Что выяснено при чтении (важно — переживает компакт)
|
||||
- **`ChargeSmsNameFeeJob` в коде НЕТ** — «зеркало» в комментариях телеграм-файлов аспирационное,
|
||||
копировать нечего. Дизайн платы за имя на общем балансе — наш, по образцу кампанийного charge.
|
||||
- **У `balance_transactions` НЕТ колонки `external_key`** (есть `description`, `related_type`,
|
||||
`related_id`, `type`, суммы). Значит помесячную идемпотентность (у кошелька был ключ
|
||||
`telegram:sender:{id}:{YYYY-MM}`) храним БЕЗ новой колонки — через маркер периода в `description`
|
||||
+ `related_type=Sender`+`related_id`. Новую колонку/таблицу НЕ заводим (тип `tg_name_fee` уже в
|
||||
CHECK и в модели из куска 1).
|
||||
- **Кампанийный `TelegramCampaignChargeService` не подходит как есть** — там идемпотентность по САЛЬДО
|
||||
(charge−refund по кампании), а у имени — по ПЕРИОДУ (месяцу). Поэтому под имя нужен отдельный
|
||||
метод/сервис списания (логика «замок тенанта → проверка баланса → дебет → BalanceTransaction» —
|
||||
та же, идемпотентность — другая).
|
||||
- **Точки, где имя трогает деньги сейчас** (`TelegramSenderService` + `ChargeTgNameFeeJob`):
|
||||
1. `requestSender` — **freeze** месячной платы (бронь под заявку);
|
||||
2. `approve` (админ) — **charge** + **release** брони, active + `paid_until`+1мес;
|
||||
3. `reject` (админ) — **release** брони;
|
||||
4. `disableSender` (клиент, из pending) — **release** брони, cancelled;
|
||||
5. `disableByAdmin` (из pending) — **release** брони, suspended;
|
||||
6. `enableSender` (suspended→active) — проверка средств + **charge** за текущий период;
|
||||
7. `ChargeTgNameFeeJob` (помесячно, кросс-тенантно через `pgsql_supplier`) — есть деньги → **charge**
|
||||
+ `paid_until`+1мес; нет → копим `debt_since`; долг > grace (29 дн) → имя `suspended`.
|
||||
|
||||
## 🔀 РАЗВИЛКА для владельца (нужен ответ до кода)
|
||||
В кампаниях заморозку убрали совсем: деньги списываются в момент действия (запуск), заявок-броней нет.
|
||||
У имени была **бронь при заявке** (`requestSender` морозил месячную плату), чтобы к моменту одобрения
|
||||
деньги гарантированно были. Если убираем заморозку (как везде), у заявки на имя два варианта поведения
|
||||
при нехватке денег:
|
||||
|
||||
- **Вариант А (рекомендую) — как у кампаний.** Заявку принимаем всегда (деньги не трогаем);
|
||||
списываем при ОДОБРЕНии админом. Если на балансе не хватило — одобрение не проходит (админ видит
|
||||
«недостаточно средств»), клиент пополняет и одобряют повторно. Просто, единообразно с кампаниями/СМС,
|
||||
одна точка списания.
|
||||
- **Вариант Б — сохранить раннюю проверку.** Заявку при нехватке денег НЕ принимаем сразу (как сейчас
|
||||
бросает `InsufficientBalanceException`), но БЕЗ фактической брони — просто проверяем `balance ≥ плата`.
|
||||
Плюс: клиент раньше узнаёт, что не потянет. Минус: баланс к моменту одобрения мог уйти на кампанию —
|
||||
ранняя проверка ничего не гарантирует, только UX-подсказка.
|
||||
|
||||
Оба безопасны для денег. Различие — только в UX заявки. **По умолчанию беру А**, если владелец не
|
||||
выберет Б.
|
||||
|
||||
## Приёмочный лист (файлы)
|
||||
|
||||
- [ ] **N0. Сервис/метод списания платы за имя с общего баланса.** Новый
|
||||
`TelegramSenderChargeService` (или метод в общем сервисе — решить в коде): `chargeName(Sender
|
||||
$sender, string $period)` — замок `Tenant::lockForUpdate` → идемпотентность ПО ПЕРИОДУ (есть ли уже
|
||||
`tg_name_fee`-проводка по этому `Sender`+`related_id` за `period`) → проверка `balance_rub ≥ fee` →
|
||||
дебет `tenants.balance_rub` + `BalanceTransaction` (`TYPE_TG_NAME_FEE`, `amount_rub` отрицательный,
|
||||
`related_type=Sender::class`, `related_id=sender->id`, период-маркер в `description`). Порог
|
||||
платёжеспособности — по `tenants.balance_rub` (НЕ ad_wallet). bcmath scale 2. Тест: списывает раз;
|
||||
повтор за тот же период — no-op; нехватка → списания нет (для джоба — сигнал «денег нет», без броска).
|
||||
|
||||
- [ ] **N1. `ChargeTgNameFeeJob` → общий баланс.** Убрать `AdWalletService`/`AdWallet`; порог считать по
|
||||
`tenants.balance_rub`; списание через N0; период = `Carbon(paid_until).format('Y-m')`. Сохранить всё
|
||||
остальное 1:1: `paid_until`+1мес при успехе; при нехватке в грейсе — копим `debt_since`, `paid_until`
|
||||
не двигаем; долг > grace → `suspended` + `Log::warning('client_tg.name_suspended_for_debt')`.
|
||||
Кросс-тенантное перечисление — по-прежнему через `pgsql_supplier` (BYPASSRLS), денежная операция под
|
||||
`SET LOCAL app.current_tenant_id`. Песочница → ранний `return` (без денег). Тесты: списывает+сдвиг
|
||||
paid_until; грейс копит долг; долг>grace → suspended; идемпотентность по периоду; песочница не трогает.
|
||||
|
||||
- [ ] **N2. `TelegramSenderService` → общий баланс.** Убрать `AdWalletService`/`AdWallet`/`freeze`/
|
||||
`release` из потока имени:
|
||||
- `requestSender` — по РАЗВИЛКЕ (А: без денег; Б: проверка `balance ≥ fee` без броска-с-бронью).
|
||||
Убрать `freeze`.
|
||||
- `approve` — вместо `charge`+`release` кошелька: `chargeName` с общего баланса (N0), период —
|
||||
текущий месяц; active + `paid_until`+1мес. Нехватка при А — не одобряем (пробрасываем нехватку).
|
||||
- `reject` / `disableSender(pending)` / `disableByAdmin(pending)` — убрать `release` (брони больше нет),
|
||||
статусы прежние (rejected/cancelled/suspended).
|
||||
- `enableSender` (suspended→active) — проверка `balance_rub ≥ fee` + `chargeName` за текущий период
|
||||
(уже был charge, переводим с ad_wallet на общий баланс).
|
||||
- `snapshot` — не трогает деньги, оставить.
|
||||
|
||||
- [ ] **N3. Убрать `AdWalletService` из телеграм-потока имени полностью.** После N1+N2 ни
|
||||
`ChargeTgNameFeeJob`, ни `TelegramSenderService` не импортируют `AdWalletService`/`AdWallet`. Это
|
||||
закрывает последнюю ссылку телеграм-модуля на кошелёк (кампании закрыты в куске 2). `deptrac`/`stan`
|
||||
чисто. Сам `AdWalletService`/`AdWalletService`-тесты/таблицы НЕ трогаем.
|
||||
|
||||
- [ ] **N4 (D9). Тесты имени под общий баланс.** Переписать:
|
||||
- `SenderLifecycleTest` — все ассерты `AdWallet frozen_rub/balance_rub` → `tenants.balance_rub` +
|
||||
`BalanceTransaction TYPE_TG_NAME_FEE`; requestSender по выбранному варианту развилки; approve
|
||||
списывает с баланса; джоб-кейсы (списание/грейс/suspend/идемпотентность/песочница) на общем балансе.
|
||||
- `SenderApiTest` — там, где ассертит кошелёк/бронь → общий баланс (прочитать файл, поправить точечно).
|
||||
- `WalletReFreezeTest` — это тест инварианта САМОГО `AdWalletService` (реактивация released-hold),
|
||||
имя/кампанию он лишь использует как пример `source`. Прямого отношения к общему балансу не имеет.
|
||||
Решение: НЕ переписывать под баланс (это не про телеграм-деньги, а про кошелёк, который остаётся);
|
||||
оставить как есть — проверяет, что кошелёк-инфраструктура не сломана. Подтвердить прогоном.
|
||||
Гонять ПО ОДНОМУ файлу (квирк партиций). pint/stan(0)/deptrac(0) чисто.
|
||||
|
||||
## Схема БД
|
||||
Новых типов/колонок/таблиц НЕ добавляем — `tg_name_fee` уже в CHECK `balance_transactions_type_check`
|
||||
и в модели (кусок 1). rls-reviewer не требуется (нет новых RLS-таблиц). CHANGELOG_schema не трогаем.
|
||||
|
||||
## После куска 3
|
||||
Код-ревью (деньги/идемпотентность-по-периоду/RLS в джобе/песочница), затем ОБЩИЙ коммит кусков 1+2+3
|
||||
по слову «коммить» (в gitea, не в GitHub).
|
||||
|
||||
## Состояние
|
||||
- [ ] N0 · [ ] N1 · [ ] N2 · [ ] N3 · [ ] N4 — НЕ начаты. Ждём слово владельца + ответ на развилку.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Аудит телеграм-модуля построчно: реальный ресейл МТС vs СМС-фантазии (28.07.2026)
|
||||
|
||||
Ветка `feat/client-telegram-ads`. Повод: владелец — «мы ресейлим МТС Маркетолог, а не фантазируем;
|
||||
в телеге МТС никакого имени отправителя нет». Прочёсан весь модуль `ClientTg` построчно 5 параллельными
|
||||
аудиторами (модели+миграции / контроллеры+роуты / сервисы / джобы / фронтенд+таблица messages).
|
||||
|
||||
Продукт по правде: ПОКАЗ рекламного объявления в Telegram по загруженной базе телефонов, робот-в-браузере
|
||||
автоматизирует кабинет МТС. Это НЕ рассылка сообщений. Всё «сообщение на номер / отправитель / отписка /
|
||||
шаблон сообщения» — наследие скопированного СМС-модуля.
|
||||
|
||||
## ✅ РЕАЛЬНОЕ (не трогать) — ядро показов
|
||||
- Таблицы: `client_tg_campaigns` (+доп. поля status_reason/mts_campaign_id/moderator_file_path/GRANT),
|
||||
`client_tg_campaign_phones`, `client_tg_contacts` (своя база), `client_tg_tariffs` (₽ за показ),
|
||||
`client_tg_auto_rule`.
|
||||
- Контроллеры/маршруты: `CampaignController` (list/store/show/launch/cancel/resubmit), `AutoRuleController`.
|
||||
- Сервисы: `TelegramAudienceService`, `TelegramTariffService` (+наценка), `TelegramCampaignChargeService`
|
||||
(charge/refund с общего баланса — куски 1-2), `TelegramRobotRunner`, `RobotResult`, `TelegramAutoAccumulator`,
|
||||
`TelegramAudience`.
|
||||
- Джобы: `AccumulateTelegramLeadJob`, `RunTelegramCampaignJob`, `ResubmitTelegramCampaignJob`,
|
||||
`PollTelegramModerationJob`, `SweepStuckTelegramCampaignsJob`.
|
||||
- Фронт: `AdvertisingTelegramView.vue` (создание кампании, аудитории, бюджет, ОРД, смета, запуск, список,
|
||||
статусы, отказ+пересдача), `AdminTgView.vue:93-150` (тарифная сетка).
|
||||
Ни один реальный сервис/джоб НЕ завязан на «имя отправителя» — grep по `Sender` даёт попадания только внутри
|
||||
самого фантазийного кода. Расцеплять реальный поток не придётся.
|
||||
|
||||
## ⛔ ФАНТАЗИИ — подтверждено построчно (наследие СМС)
|
||||
|
||||
### Ф1. «Имя/бренд отправителя» + абонплата за него (владельцем подтверждено: в телеге МТС имени НЕТ)
|
||||
Сквозная фича через все слои:
|
||||
- Джоб `app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php` (весь) + расписание `app/routes/console.php:319-327`.
|
||||
- Сервис `app/app/Services/ClientTg/TelegramSenderService.php` (весь, 247 строк, 8 методов).
|
||||
- Контроллер `app/app/Http/Controllers/Api/ClientTg/SenderController.php` (весь) + маршруты
|
||||
`app/routes/web.php:106-110` (`/sender`, `/sender/disable`, `/sender/enable`).
|
||||
- Модель `app/app/Models/ClientTg/Sender.php` + таблица `client_tg_senders` (миграция 000010).
|
||||
- Таблица `client_tg_settings` (миграция 000008) + модель `Setting.php` — ОБЕ колонки
|
||||
(`name_fee_rub_per_month`, `name_debt_grace_days`) обслуживают только имя → таблица целиком под нож.
|
||||
- Фронт: `app/resources/js/views/advertising/TelegramSenderPanel.vue` (весь) + встройка в
|
||||
`AdvertisingTelegramView.vue:259`; карточка `AdminTgView.vue:152-185` «Своё имя / бренд».
|
||||
- Деньги: тип `tg_name_fee` в `balance_transactions` (добавлен в куске 1) — станет ненужным.
|
||||
- Тесты: `SenderLifecycleTest`, `SenderApiTest`, `WalletReFreezeTest`, `AdminTgApiTest` (часть про имя).
|
||||
|
||||
### Ф2. Таблица `client_tg_messages` (миграция 000003) — МЁРТВАЯ
|
||||
«По-получательские записи доставки» (phone/operator/provider_key/status/provider_message_id/error) — дословная
|
||||
копия `sales_sms_messages`. Модели `ClientTg\Message` НЕТ (намеренно), в рабочем коде НИКТО не пишет/читает;
|
||||
единственная ссылка — `SchemaTest.php:26-28` (проверка наличия). Живой поток пишет кандидатов в
|
||||
`client_tg_campaign_phones`. Дропается отдельной миграцией.
|
||||
|
||||
## ❓ РАЗВИЛКИ — РЕШЕНО владельцем (28.07.2026)
|
||||
|
||||
- **Р1. Стоп-лист `client_tg_optouts`** → **ОСТАВИТЬ, но переосмыслить как «не показывать этим номерам»**
|
||||
(ручной exclude-лист исключаемых номеров, НЕ «отписки/STOP»). Механизм `removeOptouts` в сборке аудитории
|
||||
остаётся рабочим; правим только смысл/названия (комментарии, тексты, возможно имя метода/таблицы — БЕЗ слома).
|
||||
- **Р2. `client_tg_templates`** → **ВЫРЕЗАТЬ** (не используется нигде: ни фронт, ни рабочий код).
|
||||
- **Р3. Слово «Авторассылка»** → **ПЕРЕИМЕНОВАТЬ** в «Авто-реклама/Авто-кампании» (функция реальна, меняем только
|
||||
название; СМС-лексику «рассылка» вычищаем из телеграм-текстов). Явно владелец не выбирал, но линия однозначна —
|
||||
«мы ресейлим показы, не рассылку». Подтвердить перед правкой текстов.
|
||||
- **Медиа-пробел** → **ДЕЛАЕМ СЕЙЧАС** (не откладываем): добавить загрузку картинки/видео в форму создания
|
||||
кампании + проброс в робота. Сначала проверить, принимает ли робот `bots/mts-telegram-ads` медиа-файл
|
||||
(в task.json уже есть поле `mediaFile` — вероятно да).
|
||||
|
||||
## 📋 ПЛАН ЧИСТКИ (куски, TDD, коммит только по «коммить»)
|
||||
|
||||
- **A. Вырезать «имя отправителя» (Ф1) целиком.** Удалить: `ChargeTgNameFeeJob` + расписание console.php:319-327;
|
||||
`TelegramSenderService`; `SenderController` + маршруты web.php:106-110; модель `Sender` + миграция-дроп таблицы
|
||||
`client_tg_senders`; таблица `client_tg_settings` (обе колонки только про имя) — миграция-дроп + модель `Setting`;
|
||||
фронт `TelegramSenderPanel.vue` + встройка `AdvertisingTelegramView.vue:259`; карточка `AdminTgView.vue:152-185`;
|
||||
админ-эндпоинты/тесты про имя. Удалить тесты `SenderLifecycleTest`, `SenderApiTest`, `WalletReFreezeTest`,
|
||||
часть `AdminTgApiTest`. Проверить, что реальный поток не сломался (grep `Sender`/`Setting` → 0 в живом коде).
|
||||
NB: тип `tg_name_fee` в `balance_transactions` (кусок 1) — станет мёртвым; можно оставить в CHECK (безвредно) или
|
||||
убрать из миграции куска 1 (решить при выполнении, чтобы не плодить миграции).
|
||||
- **B. Дропнуть мёртвую таблицу `client_tg_messages` (Ф2)** отдельной миграцией + убрать проверку в `SchemaTest`.
|
||||
- **C. Вырезать `client_tg_templates` (Р2)** — миграция-дроп + модель `Template` + тесты, если есть.
|
||||
- **D. Стоп-лист (Р1)** — оставить механизм, переименовать смысл «отписки»→«исключаемые номера» в комментариях/
|
||||
текстах; функционал не трогать; тесты сохранить/поправить названия.
|
||||
- **E. Тексты (Р3)** — «Авторассылка»/«рассылка» → «Авто-реклама» в телеграм-фронте и прозе (косметика).
|
||||
- **F. МЕДИА (сейчас!)** — фронт: загрузка картинки/видео в форме создания кампании → `media_path`; бэк: проброс
|
||||
до `TelegramRobotRunner` (поле `mediaFile` уже есть) и валидация. Сначала проверить поддержку в роботе.
|
||||
|
||||
Порядок безопасности: сначала бэкенд-вырезание с тестами (A→B→C), потом семантика/тексты (D→E), потом фича媒 media (F).
|
||||
Каждый кусок — TDD, зелёные тесты по отдельности (квирк партиций), pint/stan/deptrac чисто. Коммит — только по «коммить».
|
||||
|
||||
## ⚠️ Реальный ПРОБЕЛ (не фантазия) — на заметку
|
||||
Форма создания кампании принимает только текст+ссылку. У реального продукта МТС объявление может быть
|
||||
с картинкой/видео, и поле `media_path` уже есть в типе (`api/telegram.ts:23`) и в базе, но загрузки медиа во
|
||||
фронте нет (только «документ модератору» при пересдаче). Это недоделка показов, а не СМС-наследие — закрыть отдельно.
|
||||
|
||||
## Итог
|
||||
Точно под нож: Ф1 (имя отправителя во всех слоях) + Ф2 (мёртвая таблица messages). Р1–Р3 — по слову владельца.
|
||||
Ничего не режется без явного «режь». Куски 1-2 (показы → общий баланс) остаются.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ВЫПОЛНЕНО (28.07.2026, по слову владельца «чисти модуль!») — НЕ закоммичено
|
||||
|
||||
Все куски A–F сделаны, проверены тестами (гонять по одному — квирк партиций; при флейке `db:wipe
|
||||
--drop-types --force`). Коммит — по слову «коммить» (вместе с кусками 1-2).
|
||||
|
||||
- **A. «Имя отправителя» вырезано целиком.** Удалены: `ChargeTgNameFeeJob` (+расписание console.php),
|
||||
`TelegramSenderService`, `SenderController` (+маршруты web.php), модель `Sender`, модель `Setting`,
|
||||
фронт `TelegramSenderPanel.vue` (+встройка), карточка `AdminTgView.vue:152-185`, `updateTgSettings`
|
||||
(контроллер+api+тесты), тип `TYPE_TG_NAME_FEE` (модель+миграция+schema.sql CHECK). Тесты
|
||||
`SenderLifecycleTest`/`SenderApiTest`/`telegram-sender-panel.spec.ts` удалены; SchemaTest/ModelsTest/
|
||||
AdminTgApiTest/admin-tg-view.spec/advertising-telegram-view.spec почищены. ✅ Green.
|
||||
- **B+C. Дроп мёртвых таблиц** `client_tg_messages`, `client_tg_templates` (+ senders/settings) — миграция
|
||||
`2026_07_28_000017_drop_client_tg_sms_legacy_tables.php` (`DROP IF EXISTS CASCADE`), create-миграции
|
||||
000003/000006/000008/000010 удалены, модель `Template` удалена, schema CHANGELOG v8.95. ✅ Green.
|
||||
- **D. Стоп-лист** — механизм оставлен, смысл «отписки/opt-out» → «список не показывать этим номерам»
|
||||
(комментарии `Optout.php`, `TelegramAudienceService::removeOptouts`). Логика не тронута. ✅ AudienceServiceTest 7/7.
|
||||
- **E. Лексика СМС вычищена** — «Авторассылка»→«Авто-реклама» (панель/api/тесты), «кошелёк
|
||||
продвижения/заморожено»→«общий баланс» (экран), «авто-рассылка»→«авто-реклама» (accumulator).
|
||||
- **F. МЕДИА (пробел закрыт).** Новый эндпоинт `POST /api/telegram/campaigns/{id}/media` (attachMedia:
|
||||
png/jpg/gif/mp4 ≤50 МБ, только черновик, замена удаляет старый файл) + фронт `v-file-input` в форме +
|
||||
`uploadTelegramMedia`, вызов после «Рассчитать». Робот УЖЕ принимает `media_path` (RunTelegramCampaignJob
|
||||
→ mediaFile → cabinet.js `fillAdMedia`). ✅ MediaUploadTest 6/6, фронт advertising-telegram-view 19/19.
|
||||
|
||||
**Проверки:** pint clean, deptrac 0, larastan — только известный Pest-`$this` шум (не блокер, коммит с
|
||||
`LEFTHOOK_EXCLUDE=larastan`). Прогоны зелёные поштучно: Schema 6, Models 5, AdminTgApi 3, CampaignCharge 6,
|
||||
CampaignApi 9, Audience 7, Media 6; фронт 3 спеки 24 + медиа 19. Робот (bots/mts-telegram-ads) НЕ трогали.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Телеграм-реклама: деньги на общем балансе + наценка 40% (переиспользуем SMS-модель)
|
||||
|
||||
Ветка `feat/client-telegram-ads`. Решение владельца (28.07): платить с ОБЩЕГО баланса (как SMS),
|
||||
наценка 40%. Не пушить. Коммит только по «коммить».
|
||||
|
||||
## Что выяснено (обоснование)
|
||||
- **SMS — рабочая модель:** клиентская цена считается при создании, списывается с ОБЩЕГО баланса
|
||||
тенанта (`tenants.balance_rub`) через `SmsChargeService`, идемпотентность — по строке
|
||||
`balance_transactions` (related_type+related_id). Заморозки НЕТ. Пополнение — общий баланс (ЮKassa).
|
||||
- **Яндекс — НЕ решено:** отдельный `ad_wallets`/`AdWalletService` (кошелёк+заморозка) не используется
|
||||
НИГДЕ в бою (ни Яндекс, ни телеграм-кампании), пополнять нечем. Заготовка без проводки.
|
||||
- **Телеграм сейчас** висит на этом неиспользуемом кошельке (freeze/release/charge к ad_wallet) —
|
||||
поэтому за показы не списывается. Плата за имя тоже идёт в ad_wallet (мимо реальных денег).
|
||||
|
||||
## Целевая модель (как SMS)
|
||||
Деньги за телеграм-рекламу — с **общего баланса** тенанта. Кошелёк с заморозкой из телеграм-потока
|
||||
УБИРАЕМ. Инфраструктуру `ad_wallets`/`AdWalletService` НЕ удаляем (таблицы/схему не трогаем — может
|
||||
пригодиться), просто телеграм на неё больше не опирается.
|
||||
|
||||
**Наценка 40%:** клиентская цена = базовая смета тарифа × `markup` (config `client_tg.markup`, дефолт
|
||||
1.40). Клиент видит и платит цену С наценкой. Тариф остаётся базой (себестоимость-оценка).
|
||||
|
||||
**Момент списания = ЗАПУСК (как SMS — списываем до отправки):** на `launch`/`resubmit` списываем
|
||||
клиентскую смету (`estimated_cost_rub` с ×1.4) с общего баланса; не хватает → 409, кампания остаётся
|
||||
черновиком (робот не пойдёт). Это заменяет нынешнюю заморозку `freeze`.
|
||||
**Возврат при отказе модерации / сбое до кабинета** (было `release`): возвращаем на общий баланс
|
||||
(refund). Одобрение → ничего (уже списано). Робот упал, но кампания МОГЛА уйти в кабинет
|
||||
(есть mts_campaign_id) → needs_review, деньги НЕ возвращаем (F5, как сейчас).
|
||||
> Почему списание при запуске, а не при одобрении: гарантирует оплату ДО того, как объявление
|
||||
> появится в кабинете МТС (у МТС нет API остановить показ по нашему «не хватило денег»). Зеркалит
|
||||
> SMS. Отказ модерации — редкий и обратимый (refund), одобрение — обычный путь без лишних операций.
|
||||
|
||||
## Приёмочный лист
|
||||
|
||||
- [ ] **D1. Наценка.** config `client_tg.markup` (1.40). `TelegramTariffService`: метод
|
||||
`clientEstimateRub(impressions)` = `estimateRub × markup` (bcmath scale 2). `store`/пересчёт сметы и
|
||||
экраны используют клиентскую цену. Приёмка: смета 100 показов base×1.4; markup из config.
|
||||
|
||||
- [ ] **D2. Списание с общего баланса.** Новый `TelegramCampaignChargeService` (зеркало
|
||||
`SmsChargeService`): замок тенанта → идемпотентность по `balance_transactions`
|
||||
(related_type=Campaign, related_id) → нехватка → InsufficientBalanceException → списание
|
||||
`tenants.balance_rub` + BalanceTransaction (новый тип `TYPE_TG_AD_CHARGE`). Приёмка: списывает раз;
|
||||
повторный вызов — no-op; нехватка → исключение, баланс не тронут.
|
||||
|
||||
- [ ] **D3. Возврат (refund) на общий баланс.** Метод `refund(campaign)`: если по кампании есть
|
||||
charge-транзакция — вернуть сумму на баланс + обратная BalanceTransaction (`TYPE_TG_AD_REFUND`),
|
||||
идемпотентно (по наличию refund-строки). Приёмка: после charge→refund баланс восстановлен; двойной
|
||||
refund не задваивает.
|
||||
|
||||
- [ ] **D4. launch → charge вместо freeze.** `CampaignController::launch`: в бою `charge` клиентской
|
||||
сметы вместо `freeze(budget_cap)`; нехватка → 409, draft. Песочница — без денег. Аналогично
|
||||
`resubmit`. Приёмка: бой списывает смету; нехватка → 409; песочница не трогает деньги.
|
||||
|
||||
- [ ] **D5. Отказ/сбой → refund вместо release.** Точки, где сейчас `release`:
|
||||
`RunTelegramCampaignJob`/`ResubmitTelegramCampaignJob` (failed без mts_id), `PollTelegramModerationJob`
|
||||
(rejected). Заменить на `refund`. Логика F5 (есть mts_id → needs_review без возврата) сохраняется.
|
||||
Приёмка: отказ модерации возвращает деньги; needs_review — нет.
|
||||
|
||||
- [ ] **D6. Убрать заморозку из телеграм-потока.** Снять вызовы `AdWalletService::freeze/release` и
|
||||
импорты из: CampaignController, RunTelegramCampaignJob, ResubmitTelegramCampaignJob,
|
||||
PollTelegramModerationJob, TelegramAutoAccumulator. (AdWalletService и таблицы НЕ удаляем.)
|
||||
Приёмка: телеграм-код не ссылается на AdWalletService; авто-путь морозит через… см. D7.
|
||||
|
||||
- [ ] **D7. Авто-путь (TelegramAutoAccumulator).** Сейчас при живом авто-запуске морозит потолок.
|
||||
Перевести на ту же модель: денег НЕ списывать в накопителе (спишет launch-путь джоба при запуске),
|
||||
ИЛИ списывать смету при авто-запуске — выбрать при реализации так, чтобы точка списания была ОДНА
|
||||
(не задвоить). Приёмка: авто-кампания списывает ровно один раз, дневной лимит цел.
|
||||
|
||||
- [ ] **D8. Плата за имя (ChargeTgNameFeeJob) → общий баланс.** Сейчас `AdWalletService::charge` к
|
||||
ad_wallet. Перевести на общий баланс (та же charge-модель, свой external_key
|
||||
`telegram:sender:{id}:{YYYY-MM}`, тип `TYPE_TG_NAME_FEE`). Порог платёжеспособности — по
|
||||
`tenants.balance_rub`. Приёмка: списывает с общего баланса; долг>grace → имя suspended (как было).
|
||||
|
||||
- [ ] **D9. Тесты.** Обновить существующие (freeze→charge, release→refund в Run/Resubmit/Poll/launch),
|
||||
новые для charge/refund-сервиса и наценки. Все ClientTg зелёные по отдельности; линтеры чисто.
|
||||
|
||||
## Схема БД
|
||||
Новые ТИПЫ транзакций (`balance_transactions.type` — если это enum/CHECK, нужна миграция +
|
||||
CHANGELOG_schema + rls-reviewer; если строковая колонка без CHECK — миграция не нужна). Проверить при D2.
|
||||
Новых таблиц/колонок на кампании НЕ добавляем (`estimated_cost_rub` уже есть).
|
||||
|
||||
## Порядок (куски по 1-2 дня)
|
||||
Кусок 1: D1 (наценка) + D2/D3 (charge/refund сервис) — фундамент, чистый TDD, без трогания потока.
|
||||
Кусок 2: D4/D5/D6 (переключить launch/reject/джобы с freeze/release на charge/refund).
|
||||
Кусок 3: D7 (авто-путь) + D8 (плата за имя) + D9 (добить тесты).
|
||||
Между кусками — приёмка и коммит по «коммить».
|
||||
Reference in New Issue
Block a user