Merge remote-tracking branch 'gitea/main' into fix/project-sync-status-pivot
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled

This commit is contained in:
Дмитрий
2026-07-29 17:31:23 +03:00
169 changed files with 16386 additions and 40 deletions
+12 -1
View File
@@ -153,6 +153,11 @@ paths = [
# Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны
# в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits.
'''docs/superpowers/specs/.*\.md''',
# Файл-образец, который клиент скачивает перед загрузкой своего списка номеров.
# Номера синтетические — 79001234567, 79161112233, 79995551234 и т.п. На main он
# уже разрешён по коммиту 5a4c0e02, но при сведении ветки тот же текст приходит
# НОВЫМ коммитом, и разрешение по хешу его не покрывает — нужен путь.
'''app/public/examples/.*\.csv''',
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
'''app/resources/js/composables/mockDeals\.ts''',
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
@@ -203,7 +208,13 @@ paths = [
# (напр. +7 495 000-00-00) для проверки извлечения номера из кода сайта, и
# публичный ИНН для проверки резолва. Не реальные ПДн; та же категория, что
# app/tests/*.php и app/tests/fixtures/*. Токены живут в secrets/ (.gitignore).
'''моя/sales-finder/tests/.*'''
'''моя/sales-finder/tests/.*''',
# TDD-тесты бота Telegram Ads (bots/mts-telegram-ads/test/) — синтетические
# телефоны-фикстуры для проверки нормализации номеров: 345-67-89 (сплошная
# возрастающая последовательность) и 111-22-33 (шаблон). Таких номеров не
# существует в реальности; не клиентские ПДн. Только каталог test/ (src/
# сканируется штатно). Та же категория, что app/tests/*.php.
'''bots/mts-telegram-ads/test/.*'''
]
regexTarget = "match"
regexes = [
+7 -5
View File
@@ -19,7 +19,7 @@
# CLAUDE.md — техконтекст Лидерры
**Версия:** 2.48 от 01.07.2026 — в §ГЛАВНОЕ добавлен горящий баннер «БОЕВОЙ ПРОД» (доступ только с разрешения владельца, БД по умолчанию только чтение, ЛК поставщика на проде = crm.lead.store, снос базы только по PROD-DESTROY-OK); прод очищен «с нуля» и взведён для боевой работы 01.07.2026 (см. `ПИЛОТ.md` + план `docs/superpowers/plans/2026-07-01-prod-cleanup-supplier-lk-swap.md`). Прежняя запись: 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
**Версия:** 2.49 от 28.07.2026 — зарегистрирован инструмент **#90 `grilling`** (вендоренный скил `mattpocock/skills`, MIT, user-level `~/.claude/skills/grilling/` — вне репозитория): безжалостный допрос по **уже имеющемуся** решению, обход дерева развилок по одному вопросу с рекомендуемым ответом на каждый, протокол `docs/grilling/` (Решили / Отрезали и почему / Осталось открытым — переживает компакт контекста). Вторая позиция подкатегории discovery-tooling рядом с #55 `discovery-interview`; граница ADR-021 GR1 — **по наличию решения**: grilling куёт готовое, discovery-interview вскрывает проблему. §0 версии квинтета синхронизированы (Pravila v1.45 / PSR_v1 v3.25 / Прил. Н v2.26), §3.4 +#90. Счётчики не дублируются — канон в Прил. Н §0. Прежняя запись: 2.48 от 01.07.2026 — в §ГЛАВНОЕ добавлен горящий баннер «БОЕВОЙ ПРОД» (доступ только с разрешения владельца, БД по умолчанию только чтение, ЛК поставщика на проде = crm.lead.store, снос базы только по PROD-DESTROY-OK); прод очищен «с нуля» и взведён для боевой работы 01.07.2026 (см. `ПИЛОТ.md` + план `docs/superpowers/plans/2026-07-01-prod-cleanup-supplier-lk-swap.md`). Прежняя запись: 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в квинтете 14.06.2026.)
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
@@ -32,9 +32,9 @@
| Тема | Документ (текущая версия) |
|---|---|
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.44 от 14.06.2026) |
| Правила совместного использования плагинов Claude | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (v3.24 от 14.06.2026) |
| Полный реестр позиций тулчейна (счётчики — канон в Прил. Н §0) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v2.25 от 14.06.2026) |
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.45 от 28.07.2026) |
| Правила совместного использования плагинов Claude | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (v3.25 от 28.07.2026) |
| Полный реестр позиций тулчейна (счётчики — канон в Прил. Н §0) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v2.26 от 28.07.2026) |
| Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026) |
| Схема БД | [db/schema.sql](db/schema.sql) — метрики и версия схемы **канон в header файла** + [db/CHANGELOG_schema.md](db/CHANGELOG_schema.md); CLAUDE.md числа не дублирует |
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.83 от 13.05.2026) |
@@ -145,7 +145,7 @@
| 24 | Каталог компонентов | Histoire (НЕ Storybook) | `npm run story` |
| 30 | Доменная база UI (компоненты, паттерны, состояния, a11y-принципы) | **Frontend Design plugin** (Anthropic, paired со Superpowers) | автоматически через `~/.claude/settings.json`; **обязательный стек-фильтр** Vue+Vuetify (см. [Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) Правило 6) |
**Off-phase инструменты (#31–#89, 20 подкатегорий)** — полный реестр, команды, конфликты и счётчики — канон в [Tooling Прил. Н §0](docs/Tooling_v8_3.md). Routing-аид «триггер задачи → off-phase узел» + канонические связки — [docs/routing-off-phase.md](docs/routing-off-phase.md). Ключевые: #33 `claude-md-management` (обязательный канал правок CLAUDE.md, §5 п.10), #34 Sentry MCP / #35 Redis MCP (READ-ONLY отладка прод-runtime), #60 context7 (актуальная документация библиотек), #86 graphifyy (граф проекта, §5 п.14), #8789 perplexity/exa/firecrawl (веб-разведка, READ-ONLY).
**Off-phase инструменты (#31–#90, 20 подкатегорий)** — полный реестр, команды, конфликты и счётчики — канон в [Tooling Прил. Н §0](docs/Tooling_v8_3.md). Routing-аид «триггер задачи → off-phase узел» + канонические связки — [docs/routing-off-phase.md](docs/routing-off-phase.md). Ключевые: #33 `claude-md-management` (обязательный канал правок CLAUDE.md, §5 п.10), #34 Sentry MCP / #35 Redis MCP (READ-ONLY отладка прод-runtime), #60 context7 (актуальная документация библиотек), #86 graphifyy (граф проекта, §5 п.14), #8789 perplexity/exa/firecrawl (веб-разведка, READ-ONLY), #55 `discovery-interview` / #90 `grilling` (интервью: вскрыть проблему, когда решения нет — vs обстрелять решение, которое уже есть; граница ADR-021 GR1).
### 3.4. Фаза 3 — pre-production (+5, итого 29)
@@ -291,4 +291,6 @@ trivy image liderra:latest
Полная история — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) (туда же 15.06.2026 дописан полный снимок прежнего CLAUDE.md перед компактизацией — без потерь). Здесь — последняя запись:
- **v2.49 от 28.07.2026 — регистрация #90 `grilling`** — вендоренный скил из `mattpocock/skills` (MIT, `skills/productivity/grilling`), установлен user-level `~/.claude/skills/grilling/SKILL.md` — вне репозитория, единственная копия (проектная удалена во избежание задвоения). Роль: допрос по **уже имеющемуся** плану/решению — обход дерева развилок по одному вопросу за раз, к каждому вопросу свой рекомендуемый ответ, факты ищутся самостоятельно, работа не начинается до подтверждения заказчика. Надстройка проекта поверх немодифицированного апстрима: протокол `docs/grilling/ГГГГ-ММ-ДД-<тема>.md` (Решили / Отрезали и почему / Осталось открытым — пишется по ходу, перечитывается после компакта, закрывает класс «договорённость сгорела при компакте»), порядок «сначала необратимое», остановка по пустому фронту развилок с объявлением вслух. Новой подкатегории не заводит — вторая позиция 12-й (discovery-tooling) рядом с #55 `discovery-interview`; разрез ADR-021 GR1 по **наличию решения**. Врезан в связку L1 между `brainstorming` и `writing-plans`, классификация `planning` вес 0.8. Синхронизировано: Прил. Н v2.26 (§4.63 #90, §0 счётчик 87→88), Pravila v1.45 (§13.2), PSR_v1 v3.25 (R10.1 Блок 1 note), реестр `docs/registry/nodes.yaml` + контракт, `docs/routing-off-phase.md`, `tools/observer-chain-map.json`, ADR-021. Через `claude-md-management`. NB: запись о v2.48 в этом разделе отсутствует (пропущена 01.07.2026) — содержание v2.48 сохранено в строке версии в шапке.
- **v2.47 от 15.06.2026 — структурная компактизация** — история версий (v1.80…v2.46), цепочки «наследие» (строка версии + ячейки §0) и журнал фаз (§6) вынесены в CHANGELOG; вырезаны разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила (§1, §5), нормативка (§0 квинтет, версии не тронуты) и состав продукта (§2, §7) **не изменены** — только структура. Файл сокращён с 347 КБ (точный новый размер — командой `wc -c` после применения). Cross-ref версии CLAUDE.md в Pravila/PSR/Tooling указывают 2.46 — синхронизация квинтета на 2.47 — отдельный follow-up. Через `claude-md-management`.
+9
View File
@@ -122,5 +122,14 @@ SMS_MTS_ENABLED=false
SMS_MTS_TOKEN=
SMS_MTS_PRICE_KOP=0
# Реклама в Телеграме по своей базе (клиентский модуль, робот-в-браузере — у МТС нет API).
# Песочница по умолчанию ВКЛ — робот доводит только до черновика, деньги не списываются.
# TG_SANDBOX=false открывается осознанно (Сессия 6) после проверки в песочнице.
TG_SANDBOX=true
# Node-робот кабинета МТС (bots/mts-telegram-ads). Воркер живёт на Windows-машине с сессией МТС.
TG_ROBOT_NODE=node
TG_ROBOT_SCRIPT=
TG_ROBOT_CWD=
TG_ROBOT_TIMEOUT=300
# Канал робота-грузчика креативов в веб-кабинет Яндекса. Пусто → канал закрыт.
CREATIVE_ROBOT_TOKEN=
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\ClientTg\Tariff;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin Клиентская Telegram-реклама: тарифная сетка (client_tg_tariffs).
* План §Сессия 4, задача 4.4.
*
* Зона saas-admin/admin-db (crm_admin_user); GRANT миграция client_tg_tariffs.
* Таблица глобальная (без RLS, не tenant-scoped) единый прайс на всех тенантов.
* updateTariffs заменяет весь набор ступеней целиком (owner правит сетку списком).
*/
class TgTariffController extends Controller
{
/** GET /api/admin/telegram/tariffs */
public function index(Request $request): JsonResponse
{
return response()->json([
'tariffs' => Tariff::orderBy('min_qty')->get(),
]);
}
/** PUT /api/admin/telegram/tariffs — заменяет весь набор ступеней. */
public function updateTariffs(Request $request): JsonResponse
{
$validated = $request->validate([
'rows' => ['required', 'array', 'min:1'],
'rows.*.min_qty' => ['required', 'integer', 'min:1'],
'rows.*.price_rub' => ['required', 'numeric', 'gt:0'],
]);
DB::transaction(function () use ($validated): void {
Tariff::query()->delete();
$now = now();
$rows = array_map(
fn (array $row): array => [
'min_qty' => $row['min_qty'],
'price_rub' => $row['price_rub'],
'created_at' => $now,
'updated_at' => $now,
],
$validated['rows'],
);
Tariff::query()->insert($rows);
});
return response()->json([
'tariffs' => Tariff::orderBy('min_qty')->get(),
]);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\ClientTg;
use App\Http\Controllers\Controller;
use App\Models\ClientTg\AutoRule;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Клиентское HTTP-API правила авторассылки Telegram (план §Этап 5, задача 5.4).
*
* Одно правило на тенанта. Клиент из кабинета включает/выключает авто, задаёт
* объявление, порог пачки (batch_threshold, ≥367 минимум МТС), бюджет на кампанию
* и дневной лимит трат (daily_limit_rub, дефолт 0 = авто выключено). Предохранители
* трат в TelegramAutoAccumulator (задача 5.1). Scope тенантом из $request->user().
*/
class AutoRuleController extends Controller
{
public function show(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
return response()->json($this->present(
AutoRule::where('tenant_id', $tenantId)->first(),
));
}
public function update(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$data = $request->validate([
'enabled' => 'required|boolean',
'ad_text' => 'required_if:enabled,true|nullable|string|max:1000',
'ad_link' => 'nullable|string|max:500',
'ord_category' => 'nullable|string|max:200',
'budget_cap_rub' => 'required|numeric|min:1',
'daily_limit_rub' => 'required|numeric|min:0',
'batch_threshold' => 'nullable|integer|min:367',
]);
$rule = AutoRule::updateOrCreate(
['tenant_id' => $tenantId],
[
'enabled' => $data['enabled'],
'ad_text' => $data['ad_text'] ?? '',
'ad_link' => $data['ad_link'] ?? null,
'ord_category' => $data['ord_category'] ?? 'Размещение рекламы',
'budget_cap_rub' => $data['budget_cap_rub'],
'daily_limit_rub' => $data['daily_limit_rub'],
'batch_threshold' => $data['batch_threshold'] ?? null,
'updated_by' => (int) $request->user()->id,
],
);
return response()->json($this->present($rule->fresh()));
}
/**
* Плоский снимок правила для кабинета. Без правила безопасные значения по
* умолчанию (авто выключено, дневной лимит 0). Десятичные строками, как модель.
*
* @return array<string, mixed>
*/
private function present(?AutoRule $rule): array
{
if ($rule === null) {
return [
'enabled' => false,
'ad_text' => '',
'ad_link' => '',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '0.00',
'daily_limit_rub' => '0.00',
'batch_threshold' => null,
];
}
return [
'enabled' => (bool) $rule->enabled,
'ad_text' => $rule->ad_text,
'ad_link' => $rule->ad_link ?? '',
'ord_category' => $rule->ord_category,
'budget_cap_rub' => (string) $rule->budget_cap_rub,
'daily_limit_rub' => (string) $rule->daily_limit_rub,
'batch_threshold' => $rule->batch_threshold,
];
}
}
@@ -0,0 +1,374 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
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\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* HTTP-API клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.1).
* Тонкий слой над ядром: TelegramAudienceService (кандидаты), TelegramTariffService
* (смета-потолок), RunTelegramCampaignJob (робот кабинета МТС).
*
* 🔴 Отличие от СМС-близнеца: создание и запуск РАЗДЕЛЕНЫ. `store` создаёт ЧЕРНОВИК
* (деньги/робот не трогаются) клиент видит клиентскую смету (с наценкой) и число
* кандидатов; `launch` переводит draft→queued, в бою СПИСЫВАЕТ клиентскую смету с
* общего баланса и ставит джоб. Песочница (TG_SANDBOX): денег не трогаем, робот черновиком.
*
* tenant_id всегда из $request->user()->tenant_id; каждый запрос с явным
* ->where('tenant_id', ...) поверх RLS (defense-in-depth, паттерн ClientSmsController).
*/
class CampaignController extends Controller
{
public function __construct(
private readonly TelegramAudienceService $audience,
private readonly TelegramTariffService $tariff,
) {}
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
// Баланс — общий баланс тенанта (как СМС): экран гасит «Запустить» при нехватке
// средств ДО обращения к серверу. Заморозки больше нет (frozen всегда 0).
$tenant = Tenant::find($tenantId);
return response()->json([
'campaigns' => Campaign::where('tenant_id', $tenantId)
->orderByDesc('id')
->limit(50)
->get(),
'sandbox' => (bool) config('client_tg.sandbox', true),
'balance_rub' => $tenant !== null ? (string) $tenant->balance_rub : '0.00',
'frozen_rub' => '0.00',
]);
}
/**
* Создаёт ЧЕРНОВИК кампании: сохраняет объявление/аудиторию, считает кандидатов
* и смету-потолок. Ни робот, ни деньги не трогаются это делает `launch`.
*/
public function store(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$data = $this->validatePayload($request);
$campaign = Campaign::create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => $data['ad_text'],
'ad_link' => $data['ad_link'],
'ord_category' => $data['ord_category'] ?? 'Размещение рекламы',
'budget_cap_rub' => $data['budget_cap_rub'],
'audience_kind' => $data['audience_kind'],
'audience_params' => $data['audience_kind'] === Campaign::AUDIENCE_DEALS
? ['days' => (int) $data['audience_days']]
: null,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => $request->user()->id,
]);
// Список номеров (audience_kind=list) — сохраняем как строки кампании; их
// потом читает TelegramAudienceService::fromList по campaign_id.
$dropped = 0;
if ($data['audience_kind'] === Campaign::AUDIENCE_LIST) {
$dropped = $this->storePhones($tenantId, $campaign->id, $data['phones'] ?? []);
}
// Кандидаты (нормализованы, без дублей/стоп-листа) + смета-потолок для UI.
$candidates = $this->audience->build($campaign)->candidatesCount;
$campaign->update([
'planned_count' => $candidates,
// Клиентская цена (с наценкой) — её клиент видит и платит при запуске.
'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates),
]);
$payload = $campaign->fresh()->toArray();
$payload['dropped_count'] = $dropped;
return response()->json($payload, 201);
}
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id);
return response()->json(['campaign' => $campaign]);
}
/**
* Запуск кампании: draft queued + джоб робота. В бою СПИСЫВАЕТ клиентскую смету
* (с наценкой) с общего баланса; не хватает денег 409, кампания остаётся
* черновиком. Песочница без денег. Не-черновик 422 (повторно не жмём).
*/
public function launch(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id);
if (! $campaign->canTransitionTo(Campaign::STATUS_QUEUED)) {
return response()->json(['message' => 'Кампанию уже запускали'], 422);
}
$sandbox = (bool) config('client_tg.sandbox', true);
// Предстартовый гейт аудитории (находка #2): в бою НЕ списываем деньги под
// заведомо непроходную кампанию. Минимум у МТС Маркетолог — 367 кандидатов;
// <367 или пусто — робот всё равно упал бы, но деньги уже ушли бы. В песочнице
// гейта нет — денег нет, а сам робот отсеет по MIN_NON_MTS.
$candidates = 0;
if (! $sandbox) {
$candidates = $this->audience->build($campaign)->candidatesCount;
$min = (int) config('client_tg.auto_batch_threshold', 367);
if ($candidates === 0) {
return response()->json(['message' => 'В аудитории нет номеров — добавьте контакты или расширьте условия'], 422);
}
if ($candidates < $min) {
return response()->json(['message' => "Недостаточно номеров: нужно не меньше {$min}, а набралось {$candidates}"], 422);
}
}
try {
DB::transaction(function () use ($campaign, $sandbox, $candidates): void {
if (! $sandbox) {
// Пересчёт клиентской сметы по свежему числу кандидатов — клиент видит
// и платит одно и то же. Списание с общего баланса; нехватка бросает
// 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 $e) {
return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409);
}
RunTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit();
return response()->json($campaign->fresh());
}
/**
* Отмена кампании ДО запуска (draft/queued cancelled). В бою возвращает списанную
* смету на общий баланс (refund) из queued (уже списали на launch) вернёт деньги,
* из draft (не списывали) refund no-op. Песочница денег не трогала. Из running/
* терминальных отмена запрещена (422) по машине статусов модели.
*/
public function cancel(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id);
if (! $campaign->canTransitionTo(Campaign::STATUS_CANCELLED)) {
return response()->json(['message' => 'Эту кампанию нельзя отменить'], 422);
}
$sandbox = (bool) config('client_tg.sandbox', true);
DB::transaction(function () use ($campaign, $sandbox): void {
if (! $sandbox) {
app(TelegramCampaignChargeService::class)->refund($campaign);
}
$campaign->transitionTo(Campaign::STATUS_CANCELLED);
});
return response()->json($campaign->fresh());
}
/**
* Пересдача отклонённой модерацией кампании (задача 3.6 + связка mode:'resubmit'):
* rejected queued с правками объявления и (опц.) документом модератору. Робот
* ЧИНИТ ТУ ЖЕ кампанию в кабинете через «Исправить» (по mts_campaign_id)
* поэтому id кабинета СОХРАНЯЕМ (не создаём новую), чистим только причину отказа.
* Аудитория не меняется. В бою бронь ставится ЗАНОВО (при отказе её вернул
* опросчик 3.4; freeze идемпотентен по ACTIVE-холду). Не-rejected 422; нет
* mts_campaign_id (нечего «Исправить») 422.
*/
public function resubmit(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id);
if ($campaign->status !== Campaign::STATUS_REJECTED) {
return response()->json(['message' => 'Пересдать можно только отклонённую кампанию'], 422);
}
// Пересдача чинит СУЩЕСТВУЮЩУЮ кампанию кабинета через «Исправить» — без id
// кабинета чинить нечего (в норме у отклонённой он всегда есть; защита от края).
if ($campaign->mts_campaign_id === null || $campaign->mts_campaign_id === '') {
return response()->json(['message' => 'Эту кампанию нельзя пересдать: она не заведена в кабинете'], 422);
}
$data = $request->validate([
'ad_text' => 'required|string|max:1000',
'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500)
'ord_category' => 'nullable|string|max:200', // = колонка client_tg_campaigns.ord_category varchar(200)
'moderator_file' => 'nullable|file|mimes:png,jpg,jpeg,pdf|max:10240',
]);
// Документ модератору (лицензия/договор) — сохраняем ДО смены статуса; путь
// отдадим роботу как moderatorFile. Файл не приложили — старый путь очищаем.
$moderatorPath = $request->hasFile('moderator_file')
? $request->file('moderator_file')->store('client_tg/moderator', 'local')
: null;
$sandbox = (bool) config('client_tg.sandbox', true);
// Предстартовый гейт аудитории (как launch): в бою не списываем деньги под
// заведомо непроходную кампанию. Песочница — без гейта (денег нет, робот отсеет).
$candidates = 0;
if (! $sandbox) {
$candidates = $this->audience->build($campaign)->candidatesCount;
$min = (int) config('client_tg.auto_batch_threshold', 367);
if ($candidates === 0) {
return response()->json(['message' => 'В аудитории нет номеров — добавьте контакты или расширьте условия'], 422);
}
if ($candidates < $min) {
return response()->json(['message' => "Недостаточно номеров: нужно не меньше {$min}, а набралось {$candidates}"], 422);
}
}
try {
DB::transaction(function () use ($campaign, $data, $moderatorPath, $sandbox, $candidates): void {
// Пересдача платится ЗАНОВО: на отказе модерации смету вернул опросчик
// (refund), сальдо выровнялось — новое списание проходит. Не хватает →
// InsufficientBalanceException → откат → кампания остаётся rejected.
if (! $sandbox) {
$clientCost = $this->tariff->clientEstimateRub($candidates);
$campaign->update(['estimated_cost_rub' => $clientCost]);
app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost);
}
$campaign->fill([
'ad_text' => $data['ad_text'],
'ad_link' => $data['ad_link'],
'ord_category' => $data['ord_category'] ?? $campaign->ord_category,
'moderator_file_path' => $moderatorPath,
// Чиним ту же кампанию кабинета — id СОХРАНЯЕМ, чистим только причину отказа.
'status_reason' => null,
]);
$campaign->save();
$campaign->transitionTo(Campaign::STATUS_QUEUED);
});
} catch (InsufficientBalanceException $e) {
return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409);
}
ResubmitTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit();
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).
*
* @return array<string, mixed>
*/
private function validatePayload(Request $request): array
{
return $request->validate([
'ad_text' => 'required|string|max:1000',
'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500)
'audience_kind' => 'required|in:deals,base,list',
'audience_days' => 'required_if:audience_kind,deals|nullable|integer|min:1|max:365',
'budget_cap_rub' => 'required|numeric|min:1',
'ord_category' => 'nullable|string|max:200', // = колонка client_tg_campaigns.ord_category varchar(200)
'phones' => 'nullable|array|max:200000',
'phones.*' => 'string|max:32',
]);
}
/**
* Нормализует и складывает номера списка в строки кампании. Нераспознанные
* молча пропускаем (клиент видит итоговое число кандидатов), но СЧИТАЕМ их
* `store` возвращает `dropped_count`, чтобы клиент видел, что часть номеров
* не распозналась. ПДн: голый 7XXXX.
*
* @param array<int, string> $rawPhones
* @return int число нераспознанных (отброшенных) номеров
*/
private function storePhones(int $tenantId, int $campaignId, array $rawPhones): int
{
$rows = [];
$dropped = 0;
foreach ($rawPhones as $raw) {
$normalized = PhoneNormalizer::normalize((string) $raw);
if ($normalized === null) {
$dropped++;
continue;
}
$rows[] = [
'tenant_id' => $tenantId,
'campaign_id' => $campaignId,
'phone' => substr($normalized, 1), // "+7..." → "7..."
'created_at' => now(),
'updated_at' => now(),
];
}
if ($rows !== []) {
CampaignPhone::insert($rows);
}
return $dropped;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\Deal;
use App\Services\ClientTg\TelegramAutoAccumulator;
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\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Кормит накопитель авто-режима Telegram одним новым лидом (план §Сессия 4, задача 4.2).
*
* Ставится DealTelegramObserver'ом на КАЖДЫЙ свежий лид (freshness-guard в observer'е
* ограничивает поток настоящими новыми лидами). Аналог SendAutoSmsForDealJob.
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS). tenant_id приходит
* явным аргументом; джоб ставит `SET LOCAL app.current_tenant_id` перед работой, поэтому
* все записи накопителя (черновик авто-кампании, номера) проходят RLS. Best-effort:
* ЛЮБОЙ сбой логируем и выходим, НИКОГДА не роняем (под sync-очередью джоб может
* исполниться прямо в запросе приёма лида исключение сломало бы приём).
*
* ПДн: телефон в payload джоба НЕ кладём перечитываем по deal_id под tenant-контекстом.
*/
class AccumulateTelegramLeadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
private readonly int $dealId,
private readonly int $tenantId,
) {}
public function handle(TelegramAutoAccumulator $accumulator): void
{
try {
DB::transaction(function () use ($accumulator): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
$deal = Deal::where('tenant_id', $this->tenantId)->find($this->dealId);
if ($deal === null) {
return;
}
// Накопитель работает под уже установленным tenant-контекстом транзакции.
// Невалидный/пустой номер отсекает сам накопитель (PhoneNormalizer → null).
$accumulator->accumulate($this->tenantId, (string) $deal->phone);
});
} catch (Throwable $e) {
Log::warning('client_tg.auto_job_failed', [
'deal_id' => $this->dealId,
'tenant_id' => $this->tenantId,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
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\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Опросчик вердикта модерации МТС (план Этап 3, задача 3.4).
*
* Живая отправка кампании ставит статус `moderating` («на модерации МТС», задача
* 3.2). У МТС нет API, поэтому раз в цикл робот-читалка открывает кабинет по
* `mts_campaign_id` и читает вердикт (селекторы экрана отказа сняты живьём
* bots/mts-telegram-ads/FLOW-FINDINGS.md, «Разведка Сессии 6»). Опросчик применяет
* вердикт КОНСЕРВАТИВНО:
*
* `rejected` («Отклонена») статус `rejected` + причина из кабинета; ВОЗВРАТ
* списанной сметы на общий баланс (в бою refund, зеркало отказа робота);
* уведомление клиенту «реклама отклонена».
* `approved` («Одобрена») `launched` (показы пошли); уведомление «одобрена».
* Деньги НЕ трогаем смету списали при запуске, одобрение ничего не меняет.
* `moderating` / робот не смог прочитать вердикт (null) НЕ трогаем, оставляем
* на модерации до следующего цикла (тихо пропускаем).
*
* 🔴 RLS/роли: на проде очередь бежит под `crm_app_user` (НЕ BYPASSRLS) БЕЗ
* tenant-контекста перечисление кампаний через дефолтное соединение вернуло бы 0
* строк (молчаливый сбой). Поэтому кросс-тенантное перечисление идёт плоскими
* строками через `pgsql_supplier` (BYPASSRLS, как SweepStuckTelegramCampaignsJob), а
* правка статуса, возврат брони и загрузка моделей для уведомления под
* `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ соединении; денежные операции
* всегда получают tenant_id явным аргументом.
*/
class PollTelegramModerationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(TelegramRobotRunner $runner): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
// Кросс-тенантное перечисление кампаний на модерации через BYPASSRLS-роль,
// БЕЗ открытой транзакции. Только те, у кого есть id кабинета — иначе роботу
// читать нечего (кампания без черновика на модерацию уйти не могла).
$rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns')
->where('status', Campaign::STATUS_MODERATING)
->whereNotNull('mts_campaign_id')
->get(['id', 'tenant_id', 'mts_campaign_id']);
foreach ($rows as $row) {
$tenantId = (int) $row->tenant_id;
$campaignId = (int) $row->id;
$mtsId = (string) $row->mts_campaign_id;
try {
// Чтение вердикта роботом — сетевая/браузерная работа ВНЕ транзакции.
// Зовём РОВНО ОДИН РАЗ на кампанию (каждый вызов = заход в кабинет).
$result = $runner->readModeration($mtsId);
$verdict = $result->moderationStatus;
// Ещё на модерации / робот не смог прочитать → ждём следующего цикла.
if ($verdict !== 'approved' && $verdict !== 'rejected') {
continue;
}
// Применяем вердикт под tenant-контекстом; получаем модели для пост-
// транзакционных действий (возврат брони, уведомление) либо null (гонка).
$applied = $this->applyVerdict($tenantId, $campaignId, $result);
if ($applied === null) {
continue;
}
[$outcome, $campaign, $tenant] = $applied;
// Возврат сметы — только при отказе и только в бою (в песочнице денег не
// списывали, refund найдёт сальдо 0). Отдельный tenantTx: сбой возврата
// не должен откатить уже проставленный статус.
if ($outcome === Campaign::STATUS_REJECTED && ! $sandbox) {
try {
$this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($campaign));
} catch (Throwable $e) {
Log::warning('client_tg.poll_refund_failed', [
'campaign_id' => $campaignId,
'error' => $e->getMessage(),
]);
}
}
// Уведомление клиенту — ПОСЛЕ транзакции (notifyInApp ставит свой SET
// LOCAL в отдельной транзакции; вложенности избегаем). Сбой канала не
// должен ронять джоб.
if ($tenant !== null) {
try {
if ($outcome === Campaign::STATUS_REJECTED) {
app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign);
} else {
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
}
} catch (Throwable $e) {
Log::warning('client_tg.poll_notify_failed', [
'campaign_id' => $campaignId,
'error' => $e->getMessage(),
]);
}
}
} catch (Throwable $e) {
Log::warning('PollTelegramModerationJob: сбой обработки кампании на модерации', [
'campaign_id' => $campaignId,
'tenant_id' => $tenantId,
'error' => $e->getMessage(),
]);
}
}
}
/**
* Применяет вердикт `$result` к кампании под tenant-контекстом и возвращает
* [терминальный статус, campaign, tenant] для пост-транзакционных действий, либо
* null, если кампания уже ушла из moderating (гонка с параллельным опросом).
*
* @return array{0: string, 1: Campaign, 2: Tenant|null}|null
*/
private function applyVerdict(int $tenantId, int $campaignId, RobotResult $result): ?array
{
return $this->tenantTx($tenantId, function () use ($campaignId, $tenantId, $result): ?array {
// Блокировка + повторная проверка статуса: между перечислением и этим
// моментом статус мог измениться (гонка) — тогда не трогаем.
$campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first();
if ($campaign === null || $campaign->status !== Campaign::STATUS_MODERATING) {
return null;
}
if ($result->moderationStatus === 'rejected') {
$campaign->status_reason = $result->reason;
$campaign->transitionTo(Campaign::STATUS_REJECTED);
Log::warning('client_tg.poll_rejected', ['campaign_id' => $campaignId]);
} else {
$campaign->transitionTo(Campaign::STATUS_LAUNCHED);
Log::info('client_tg.poll_approved', ['campaign_id' => $campaignId]);
}
return [$campaign->status, $campaign, Tenant::find($tenantId)];
});
}
/**
* Выполняет $fn в транзакции с установленным tenant-контекстом $tenantId.
*
* @template T
*
* @param callable(): T $fn
* @return T
*/
private function tenantTx(int $tenantId, callable $fn)
{
return DB::transaction(function () use ($tenantId, $fn) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return $fn();
});
}
}
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
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\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* ПЕРЕСДАЧА отклонённой Telegram-кампании через Node-робота (связка Laravel
* robot mode:'resubmit'). В отличие от RunTelegramCampaignJob (создаёт НОВУЮ
* кампанию), пересдача ЧИНИТ ту же: робот входит в существующую кампанию кабинета
* через «Исправить» (по `mts_campaign_id`), вносит правки текста/ссылки/ОРД + (опц.)
* документ модератору и переотправляет её на модерацию. Аудиторию/файл номеров НЕ
* трогаем они у кампании уже есть.
*
* 🔴 RLS: очередь на проде бежит под `crm_app_user` (НЕ BYPASSRLS) без tenant-
* контекста tenant_id приходит аргументом, джоб сам ставит `SET LOCAL
* app.current_tenant_id` перед каждой операцией (tenantTx). Иначе RLS вернёт 0 строк.
*
* 🔴 Песочница (TG_SANDBOX): робот идёт submitMode:'draft' доходит до подтверждения,
* НЕ отправляет; деньги не трогаются. Бой: submitMode:'live' робот жмёт «Отправить
* на модерацию без оплаты» (0 ). Идемпотентность: работаем только со статусом
* `queued` (его ставит контроллер); иной статус no-op.
*
* Осторожно с деньгами (ревью-фикс F5, симметрия с RunTelegramCampaignJob): при отказе
* робота, когда есть `mts_campaign_id` (кампания в кабинете реальна и могла уйти на
* пере-модерацию), уводим в needs_review БЕЗ возврата брони ждём ручной сверки.
*/
class ResubmitTelegramCampaignJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
/** Робот входит через «Исправить», правит поля и доводит до /payment — с запасом до 420с. */
public int $timeout = 420;
public function __construct(
private readonly int $campaignId,
private readonly int $tenantId,
) {}
public function handle(TelegramRobotRunner $runner): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
// Фаза A — под tenant-контекстом: берём queued-кампанию, переводим в running.
// Не-queued статус → no-op (идемпотентность). Аудиторию НЕ собираем.
$campaign = $this->tenantTx(function (): ?Campaign {
$campaign = Campaign::where('tenant_id', $this->tenantId)
->where('id', $this->campaignId)
->lockForUpdate()
->first();
if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) {
return null;
}
$campaign->transitionTo(Campaign::STATUS_RUNNING);
return $campaign;
});
if ($campaign === null) {
Log::info('client_tg.resubmit_noop', [
'campaign_id' => $this->campaignId,
'tenant_id' => $this->tenantId,
]);
return;
}
// Сетевой/браузерный запуск робота — ВНЕ транзакции. Пересдача правит
// существующую кампанию кабинета (по mts_campaign_id) через «Исправить».
// В песочнице submitMode:'draft' (не отправляем), в бою — 'live' (без оплаты).
$result = $runner->run([
'mode' => 'resubmit',
'campaignId' => $campaign->mts_campaign_id,
'submitMode' => $sandbox ? 'draft' : 'live',
'adText' => $campaign->ad_text,
'buttonUrl' => $campaign->ad_link,
'ordCategory' => $campaign->ord_category,
'moderatorFile' => $campaign->moderator_file_path,
]);
$this->finalize($campaign, $result, $sandbox);
}
private function finalize(Campaign $campaign, RobotResult $result, bool $sandbox): void
{
$failedTenant = null;
$failedCampaign = null;
$this->tenantTx(function () use ($campaign, $result, $sandbox, &$failedTenant, &$failedCampaign): void {
/** @var Campaign $fresh */
$fresh = Campaign::where('tenant_id', $this->tenantId)->findOrFail($campaign->id);
// mts_campaign_id НЕ трогаем — чиним ту же кампанию, id прежний.
if (! $result->ok) {
Log::warning('client_tg.resubmit_failed', [
'campaign_id' => $fresh->id,
'step' => $result->step,
'reason' => $result->reason,
]);
$fresh->status_reason = $result->reason;
// Осторожно с деньгами (F5): есть mts_campaign_id → кампания в кабинете
// реальна и могла уйти на пере-модерацию → needs_review БЕЗ возврата сметы.
// Без id (край — контроллер это отсекает) → failed + возврат сметы.
if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') {
$fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
return;
}
$fresh->transitionTo(Campaign::STATUS_FAILED);
$failedCampaign = $fresh;
$failedTenant = Tenant::find($this->tenantId);
return;
}
// Успех: resubmitted=true (live) = кампания УШЛА НА ПЕРЕ-МОДЕРАЦИЮ → moderating
// (одобрение переведёт в launched опросчик 3.4). Песочница (draft — не
// отправляли) → draft_ready. Причину прошлого отказа чистим.
$fresh->status_reason = null;
if ($result->resubmitted) {
$fresh->transitionTo(Campaign::STATUS_MODERATING);
} elseif ($sandbox) {
$fresh->transitionTo(Campaign::STATUS_DRAFT_READY);
} else {
// Аномалия контракта: бой (submitMode:'live') ДОЛЖЕН давать resubmitted=true.
// Если робот вернул ok, но не отправил — смета списана, а кампания на
// модерацию НЕ ушла → needs_review (списание держим, ручная сверка), не
// терминальный draft_ready (иначе деньги зависнут навсегда).
$fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
}
});
// Возврат сметы — ТОЛЬКО для заведомо не ушедших (failed без id), как в
// RunTelegramCampaignJob. Отдельный tenantTx, чтобы сбой возврата не откатил статус.
if ($failedCampaign !== null && ! $sandbox) {
try {
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign));
} catch (Throwable $e) {
Log::warning('client_tg.refund_failed', [
'campaign_id' => $failedCampaign->id,
'error' => $e->getMessage(),
]);
}
}
if ($failedCampaign !== null && $failedTenant !== null) {
try {
app(NotificationService::class)->notifyTelegramCampaignRejected($failedTenant, $failedCampaign);
} catch (Throwable $e) {
Log::warning('client_tg.reject_notify_failed', [
'campaign_id' => $failedCampaign->id,
'error' => $e->getMessage(),
]);
}
}
}
public function failed(Throwable $e): void
{
Log::error('client_tg.resubmit_failed_permanently', [
'campaign_id' => $this->campaignId,
'tenant_id' => $this->tenantId,
'error' => $e->getMessage(),
]);
// Осторожно с деньгами (F5): трогаем ТОЛЬКО застрявшую до итога (running/queued).
//
// 🔑 Отличие пересдачи от RunTelegramCampaignJob: у неё queued ВСЕГДА с
// mts_campaign_id. Но в статусе `queued` Фаза A ещё НЕ закоммитила `running` →
// робот кабинет НЕ трогал → кампания в кабинете осталась прежней (rejected),
// смету безопасно вернуть. Поэтому queued → failed + refund (переход
// queued→needs_review в TRANSITIONS отсутствует; без этой ветки кампания зависла
// бы в queued со списанными деньгами — уборщик метёт только running/moderating).
// А вот `running` мог тронуть кабинет → прежняя осторожная логика по mts_campaign_id.
$campaignToRefund = null;
try {
$this->tenantTx(function () use (&$campaignToRefund): void {
$campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId);
if ($campaign === null) {
return;
}
if ($campaign->status === Campaign::STATUS_QUEUED) {
if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) {
$campaign->transitionTo(Campaign::STATUS_FAILED);
$campaignToRefund = $campaign;
}
return;
}
if ($campaign->status !== Campaign::STATUS_RUNNING) {
return; // finalize уже терминализовал — не трогаем
}
// running: робот мог войти в кабинет. Есть mts_campaign_id → needs_review
// без возврата (могла уйти на пере-модерацию); нет id → failed + refund.
$hasMtsId = $campaign->mts_campaign_id !== null && $campaign->mts_campaign_id !== '';
if ($hasMtsId) {
if ($campaign->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW)) {
$campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
}
return;
}
if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) {
$campaign->transitionTo(Campaign::STATUS_FAILED);
$campaignToRefund = $campaign;
}
});
} catch (Throwable $inner) {
Log::error('client_tg.resubmit_finalize_failed', [
'campaign_id' => $this->campaignId,
'error' => $inner->getMessage(),
]);
}
if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) {
try {
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund));
} catch (Throwable $inner) {
Log::warning('client_tg.refund_failed', [
'campaign_id' => $this->campaignId,
'error' => $inner->getMessage(),
]);
}
}
}
/**
* Выполняет $fn в транзакции с установленным tenant-контекстом.
*
* @template T
*
* @param callable(): T $fn
* @return T
*/
private function tenantTx(callable $fn)
{
return DB::transaction(function () use ($fn) {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
return $fn();
});
}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
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;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Запуск клиентской Telegram-кампании через Node-робота кабинета МТС
* (план §Сессия 2, задача 2.3).
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
* соединении, БЕЗ tenant-контекста. Поэтому tenant_id приходит явным аргументом от
* контроллера при dispatch, а джоб сам ставит `SET LOCAL app.current_tenant_id`
* перед каждой читающей/пишущей операцией (helper tenantTx). Без контекста RLS
* вернула бы 0 строк (молчаливый сбой).
*
* Порядок (как ChargeCampaignSpendJob/SendClientSmsCampaignJob): подготовка под
* tenant-транзакцией сетевой запуск робота ВНЕ транзакции запись итога под
* tenant-транзакцией. Файл номеров ПДн (152-ФЗ): пишем во временный файл, отдаём
* роботу, удаляем в finally независимо от исхода.
*
* 🔴 Песочница (TG_SANDBOX): робот всегда mode:'draft', деньги НЕ списываются.
* Живой запуск и списание Сессия 6. Идемпотентность: джоб работает только со
* статусом `queued`; иной статус no-op (повторный заход ничего не ломает).
*/
class RunTelegramCampaignJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
/**
* Робот в браузере (Playwright) идёт ~300с (аудитория черновик объявление)
* плюс навигации кабинета МТС; ставим запас до 420с, чтобы штатный прогон НЕ
* убивался по таймауту раньше времени. Зависшую дольше окна кампанию добивает
* уборщик SweepStuckTelegramCampaignsJob (задача 3.3).
*/
public int $timeout = 420;
public function __construct(
private readonly int $campaignId,
private readonly int $tenantId,
) {}
public function handle(TelegramAudienceService $audience, TelegramRobotRunner $runner): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
// Фаза A — под tenant-контекстом: берём queued-кампанию, переводим в running,
// собираем кандидатов. Не-queued статус → no-op (идемпотентность).
/** @var array{0: Campaign, 1: list<string>}|null $prepared */
$prepared = $this->tenantTx(function () use ($audience): ?array {
// Блокировка строки (lockForUpdate) сериализует конкурирующих воркеров:
// второй воркер ждёт снятия блокировки, видит уже running и делает no-op
// (находка #14 — защита от гонки двух воркеров на одной queued-кампании).
$campaign = Campaign::where('tenant_id', $this->tenantId)
->where('id', $this->campaignId)
->lockForUpdate()
->first();
if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) {
return null;
}
$campaign->transitionTo(Campaign::STATUS_RUNNING);
// build() открывает свою SET-LOCAL транзакцию (вложенный savepoint).
$candidates = $audience->build($campaign)->phones;
return [$campaign, $candidates];
});
if ($prepared === null) {
Log::info('client_tg.job_noop', [
'campaign_id' => $this->campaignId,
'tenant_id' => $this->tenantId,
]);
return;
}
[$campaign, $candidates] = $prepared;
// Файл номеров для робота (ПДн) — временный, чистим в finally.
$phonesFile = tempnam(sys_get_temp_dir(), 'tg-phones-');
try {
file_put_contents($phonesFile, implode("\n", $candidates));
// Сетевой/браузерный запуск робота — ВНЕ транзакции. В песочнице всегда draft.
$result = $runner->run([
'mode' => $sandbox ? 'draft' : 'live',
'phonesFile' => $phonesFile,
'adText' => $campaign->ad_text,
'buttonUrl' => $campaign->ad_link,
'budgetRub' => (string) $campaign->budget_cap_rub,
'ordCategory' => $campaign->ord_category,
'mediaFile' => $campaign->media_path,
'moderatorFile' => $campaign->moderator_file_path,
'clientTag' => 'tg:'.$campaign->id,
]);
// Фаза B — итог под tenant-контекстом. Деньги в песочнице/черновике не трогаем
// (живое списание — Сессия 6, когда известен источник фактической цены).
$this->finalize($campaign, $result, $sandbox);
} finally {
if (is_string($phonesFile) && file_exists($phonesFile)) {
@unlink($phonesFile);
}
}
}
private function finalize(Campaign $campaign, RobotResult $result, bool $sandbox): void
{
// Сессия 5: при провале робота причина сохраняется в status_reason и клиент
// уведомляется. Тенант/кампанию грузим ВНУТРИ tenant-контекста (иначе RLS на
// проде вернёт 0), уведомление шлём ПОСЛЕ транзакции (notifyInApp ставит свой
// SET LOCAL в отдельной транзакции — вложенности избегаем).
$failedTenant = null;
$failedCampaign = null;
$this->tenantTx(function () use ($campaign, $result, &$failedTenant, &$failedCampaign): void {
/** @var Campaign $fresh */
$fresh = Campaign::where('tenant_id', $this->tenantId)->findOrFail($campaign->id);
// Ранний/надёжный захват id кампании МТС: сохраняем при любом исходе (успех/отказ),
// не затирая уже сохранённый ранее id, если робот на этот раз id не вернул.
if ($result->campaignId !== null && $result->campaignId !== '' && $result->campaignId !== 'unknown') {
$fresh->mts_campaign_id = $result->campaignId;
}
if (! $result->ok) {
Log::warning('client_tg.robot_failed', [
'campaign_id' => $fresh->id,
'step' => $result->step,
'reason' => $result->reason,
]);
$fresh->status_reason = $result->reason;
// Осторожно с деньгами (ревью-фикс F5, как уборщик, находка #2): есть
// mts_campaign_id (черновик в кабинете реально создан) → кампания МОГЛА
// уйти на модерацию → needs_review БЕЗ возврата брони, ждём ручной сверки
// / опросчика. Без id — заведомо не ушла → failed + возврат брони.
if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') {
$fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
Log::warning('client_tg.robot_failed_needs_review', ['campaign_id' => $fresh->id]);
return; // деньги не трогаем, уведомление об отказе не шлём (не отклонена)
}
$fresh->transitionTo(Campaign::STATUS_FAILED);
$failedCampaign = $fresh;
$failedTenant = Tenant::find($this->tenantId);
return;
}
$fresh->matched_count = $result->matched;
// launched=true (живой режим) = кампания УШЛА НА МОДЕРАЦИЮ МТС, а не
// «запущена»: ставим `moderating`, одобрение переведёт в `launched`
// опросчик вердикта (задача 3.4). Песочница (черновик) → `draft_ready`.
$fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY);
});
// При отказе робота (заведомо не ушла в кабинет — id нет) возвращаем списанную
// смету на общий баланс, иначе клиент заплатил за несделанную работу. Отдельный
// tenantTx ПОСЛЕ записи статуса — чтобы сбой возврата не откатил FAILED. В
// песочнице денег не списывали — refund просто ничего не найдёт (сальдо 0).
if ($failedCampaign !== null && ! $sandbox) {
try {
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign));
} catch (Throwable $e) {
Log::warning('client_tg.refund_failed', [
'campaign_id' => $failedCampaign->id,
'error' => $e->getMessage(),
]);
}
}
if ($failedCampaign !== null && $failedTenant !== null) {
try {
app(NotificationService::class)->notifyTelegramCampaignRejected($failedTenant, $failedCampaign);
} catch (Throwable $e) {
// Сбой канала уведомления не должен ронять джоб/итог кампании.
Log::warning('client_tg.reject_notify_failed', [
'campaign_id' => $failedCampaign->id,
'error' => $e->getMessage(),
]);
}
}
}
public function failed(Throwable $e): void
{
Log::error('client_tg.campaign_failed_permanently', [
'campaign_id' => $this->campaignId,
'tenant_id' => $this->tenantId,
'error' => $e->getMessage(),
]);
// Осторожно с деньгами (ревью-фикс F5, симметрия с уборщиком/finalize): трогаем
// ТОЛЬКО кампанию, застрявшую до старта (running/queued) — если finalize уже
// увёл её дальше (moderating/…), статус не меняем и деньги не возвращаем. Есть
// mts_campaign_id → могла уйти на модерацию → needs_review без возврата; нет id →
// failed + возврат сметы. refund делаем ТОЛЬКО когда реально ушли в failed.
$campaignToRefund = null;
try {
$this->tenantTx(function () use (&$campaignToRefund): void {
$campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId);
if ($campaign === null) {
return;
}
if (! in_array($campaign->status, [Campaign::STATUS_RUNNING, Campaign::STATUS_QUEUED], true)) {
return; // finalize уже терминализовал — не трогаем
}
$hasMtsId = $campaign->mts_campaign_id !== null && $campaign->mts_campaign_id !== '';
if ($hasMtsId) {
if ($campaign->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW)) {
$campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
}
return; // деньги не трогаем — черновик мог уйти на модерацию
}
if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) {
$campaign->transitionTo(Campaign::STATUS_FAILED);
$campaignToRefund = $campaign;
}
});
} catch (Throwable $inner) {
Log::error('client_tg.finalize_failed', [
'campaign_id' => $this->campaignId,
'error' => $inner->getMessage(),
]);
}
// Перманентный сбой джоба возвращает списанную смету (в бою) — но ТОЛЬКО для
// заведомо не ушедших (failed). Отдельный tenantTx в своём try/catch — сбой
// возврата не должен ронять обработчик failed.
if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) {
try {
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund));
} catch (Throwable $inner) {
Log::warning('client_tg.refund_failed', [
'campaign_id' => $this->campaignId,
'error' => $inner->getMessage(),
]);
}
}
}
/**
* Выполняет $fn в транзакции с установленным tenant-контекстом.
*
* @template T
*
* @param callable(): T $fn
* @return T
*/
private function tenantTx(callable $fn)
{
return DB::transaction(function () use ($fn) {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
return $fn();
});
}
}
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Services\ClientTg\TelegramCampaignChargeService;
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-кампаний (план Этап 3, задача 3.3).
*
* Кампания в статусе `running` дольше окна STUCK_AFTER_MINUTES считается зависшей:
* воркер RunTelegramCampaignJob убили/он упал молча (штатный прогон робота 420с,
* см. RunTelegramCampaignJob::$timeout), поэтому running дольше 15 минут заведомо
* не идущий прогон. Уборщик добивает статус, но 🔴 КОНСЕРВАТИВНО с деньгами:
*
* НЕТ `mts_campaign_id` (черновик в кабинете МТС не создавался) кампания
* заведомо НЕ ушла на модерацию `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, как PollTelegramModerationJob), а правка
* статуса и возврат сметы под `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ
* соединении; денежные операции всегда получают tenant_id явным аргументом.
*/
class SweepStuckTelegramCampaignsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Окно «зависания»: 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-роль,
// БЕЗ открытой транзакции (как PollTelegramModerationJob).
$rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns')
->where('status', Campaign::STATUS_RUNNING)
->where('updated_at', '<', $cutoff)
->get(['id', 'tenant_id', 'mts_campaign_id']);
foreach ($rows as $row) {
$tenantId = (int) $row->tenant_id;
$campaignId = (int) $row->id;
$hasMtsId = $row->mts_campaign_id !== null && $row->mts_campaign_id !== '';
try {
// Возвращаем true, только если реально перевели в failed (нужен возврат).
$needsRefund = $this->tenantTx($tenantId, function () use ($campaignId, $hasMtsId): bool {
// Блокировка + повторная проверка статуса: между перечислением и
// этим моментом finalize мог увести кампанию из running (гонка) —
// тогда не трогаем (идемпотентность).
$campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first();
if ($campaign === null || $campaign->status !== Campaign::STATUS_RUNNING) {
return false;
}
if ($hasMtsId) {
// Черновик в кабинете есть → мог уйти на модерацию → ручной разбор,
// деньги НЕ трогаем.
$campaign->status_reason = 'Прогон оборвался; черновик в кабинете МТС создан — нужна ручная сверка статуса';
$campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
Log::warning('client_tg.sweep_needs_review', [
'campaign_id' => $campaignId,
]);
return false;
}
// Черновика нет → заведомо не ушла → добиваем в failed, смету вернём.
$campaign->status_reason = 'Прогон оборвался до создания черновика — кампания не запущена';
$campaign->transitionTo(Campaign::STATUS_FAILED);
Log::warning('client_tg.sweep_failed', [
'campaign_id' => $campaignId,
]);
return true;
});
// Возврат сметы — только для заведомо не ушедших (failed) и только в бою
// (в песочнице денег не списывали, refund найдёт сальдо 0). Отдельный
// 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_refund_failed', [
'campaign_id' => $campaignId,
'error' => $e->getMessage(),
]);
}
}
} catch (Throwable $e) {
Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей кампании', [
'campaign_id' => $campaignId,
'tenant_id' => $tenantId,
'error' => $e->getMessage(),
]);
}
}
$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` для ручного разбора, БЕЗ возврата сметы: кампания могла
* реально показываться, деньги вслепую не возвращаем (как ветка «есть mts_id» выше).
* Опросчик не трогает строку, пока вердикта нет, поэтому `updated_at` честно растёт.
*/
private function sweepStuckModeration(): void
{
$hours = (int) config('client_tg.moderation_stuck_hours', 48);
$cutoff = Carbon::now()->subHours($hours);
$rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns')
->where('status', Campaign::STATUS_MODERATING)
->where('updated_at', '<', $cutoff)
->get(['id', 'tenant_id']);
foreach ($rows as $row) {
$tenantId = (int) $row->tenant_id;
$campaignId = (int) $row->id;
try {
$this->tenantTx($tenantId, function () use ($campaignId): void {
// Блокировка + повторная проверка: между перечислением и этим моментом
// опросчик мог применить вердикт (гонка) — тогда не трогаем.
$campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first();
if ($campaign === null || $campaign->status !== Campaign::STATUS_MODERATING) {
return;
}
$campaign->status_reason = 'Вердикт модерации не удалось получить в срок — нужна ручная сверка статуса в кабинете МТС';
$campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
Log::warning('client_tg.sweep_moderation_stuck', ['campaign_id' => $campaignId]);
});
} catch (Throwable $e) {
Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей модерации', [
'campaign_id' => $campaignId,
'tenant_id' => $tenantId,
'error' => $e->getMessage(),
]);
}
}
}
/**
* Выполняет $fn в транзакции с установленным tenant-контекстом $tenantId.
*
* @template T
*
* @param callable(): T $fn
* @return T
*/
private function tenantTx(int $tenantId, callable $fn)
{
return DB::transaction(function () use ($tenantId, $fn) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return $fn();
});
}
}
+4
View File
@@ -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 = [
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Правило авто-режима Telegram-рекламы (одно на тенанта UNIQUE tenant_id).
*
* Хранит объявление и лимит на пачку. По нему TelegramAutoAccumulator формирует
* авто-кампанию, когда наберётся пачка (≥367 кандидатов). Tenant-aware с RLS.
*
* Источник: миграция client_tg_auto_rule. Зеркало App\Models\ClientSmsAutoRule.
*/
class AutoRule extends Model
{
protected $table = 'client_tg_auto_rule';
protected $fillable = [
'tenant_id',
'enabled',
'ad_text',
'ad_link',
'ord_category',
'budget_cap_rub',
'daily_limit_rub',
'batch_threshold',
'spent_today_rub',
'spent_date',
'updated_by',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'enabled' => 'boolean',
'budget_cap_rub' => 'decimal:2',
'daily_limit_rub' => 'decimal:2',
'batch_threshold' => 'integer',
'spent_today_rub' => 'decimal:2',
'spent_date' => 'date',
'updated_by' => 'integer',
];
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use DomainException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Кампания клиентской Telegram-рекламы «по своей базе» (сделки / база / список).
*
* Tenant-aware с RLS. В отличие от СМС-кампании, «мотор» робот в кабинете МТС
* (у МТС нет API), поэтому здесь объявление (`ad_text`/`ad_link`/`media_path`),
* категория ОРД, лимит трат на объявление (`budget_cap_rub`) и способ выбора
* аудитории (`audience_kind` + `audience_params`). `matched_count` заполняет робот
* после загрузки базы в кабинет МТС.
*
* Источник: миграция client_tg_campaigns.
*
* @property array<array-key, mixed>|null $audience_params
*/
class Campaign extends Model
{
protected $table = 'client_tg_campaigns';
public const STATUS_DRAFT = 'draft';
public const STATUS_QUEUED = 'queued';
public const STATUS_RUNNING = 'running';
public const STATUS_DRAFT_READY = 'draft_ready';
public const STATUS_MODERATING = 'moderating';
public const STATUS_LAUNCHED = 'launched';
public const STATUS_FAILED = 'failed';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_NEEDS_REVIEW = 'needs_review';
public const AUDIENCE_DEALS = 'deals';
public const AUDIENCE_BASE = 'base';
public const AUDIENCE_LIST = 'list';
/**
* Разрешённые переходы статуса (план §Сессия 2 задача 2.1 + Этап 3 задачи 3.2/3.3):
* draft queued running (draft_ready | moderating | launched | failed
* | rejected | needs_review).
* Живая отправка робота ставит `moderating` («на модерации МТС»), а не сразу
* `launched`: одобрение придёт от опросчика вердикта (задача 3.4), тогда
* moderating launched; отказ модерации moderating rejected. Отклонённую
* кампанию можно пересдать: rejected queued (задача 3.6). Отмена (cancelled)
* разрешена только ДО запуска из draft и из queued; из running отмену не
* пускаем.
*
* queued failed: обработчик `failed()` джоба при перманентном сбое до старта
* робота (задача 3.3) добивает статус зависшей queued-кампании.
*
* running needs_review: уборщик зависших (задача 3.3) помечает так кампанию,
* которая застряла в `running`, НО уже имеет `mts_campaign_id` (черновик в
* кабинете реально создан) она могла уйти на модерацию, поэтому деньги вслепую
* не возвращаем, ждём ручного разбора / сверки статуса в кабинете (3.4).
*
* moderating needs_review (ревью-фикс F4): кампания вечно висит на модерации,
* если робот не может прочитать вердикт (сломались селекторы / id протух). Уборщик
* по возрасту (client_tg.moderation_stuck_hours) уводит её в needs_review для
* ручного разбора БЕЗ возврата брони (могла реально показываться).
*
* Терминальные draft_ready/launched/failed/cancelled/needs_review.
*
* @var array<string, list<string>>
*/
public const TRANSITIONS = [
self::STATUS_DRAFT => [self::STATUS_QUEUED, self::STATUS_CANCELLED],
self::STATUS_QUEUED => [self::STATUS_RUNNING, self::STATUS_CANCELLED, self::STATUS_FAILED],
self::STATUS_RUNNING => [
self::STATUS_DRAFT_READY,
self::STATUS_MODERATING,
self::STATUS_LAUNCHED,
self::STATUS_FAILED,
self::STATUS_REJECTED,
self::STATUS_NEEDS_REVIEW,
],
self::STATUS_MODERATING => [self::STATUS_LAUNCHED, self::STATUS_REJECTED, self::STATUS_NEEDS_REVIEW],
self::STATUS_DRAFT_READY => [],
self::STATUS_LAUNCHED => [],
self::STATUS_FAILED => [],
self::STATUS_REJECTED => [self::STATUS_QUEUED],
self::STATUS_CANCELLED => [],
self::STATUS_NEEDS_REVIEW => [],
];
protected $fillable = [
'tenant_id',
'status',
'status_reason',
'mts_campaign_id',
'ad_text',
'ad_link',
'media_path',
'moderator_file_path',
'ord_category',
'budget_cap_rub',
'audience_kind',
'audience_params',
'planned_count',
'matched_count',
'estimated_cost_rub',
'actual_cost_rub',
'created_by',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'planned_count' => 'integer',
'matched_count' => 'integer',
'created_by' => 'integer',
'audience_params' => 'array',
'budget_cap_rub' => 'decimal:2',
'estimated_cost_rub' => 'decimal:2',
'actual_cost_rub' => 'decimal:2',
];
}
/** @return HasMany<CampaignPhone, $this> */
public function phones(): HasMany
{
return $this->hasMany(CampaignPhone::class, 'campaign_id');
}
/** Допустим ли переход из текущего статуса в $to (без броска). */
public function canTransitionTo(string $to): bool
{
return in_array($to, self::TRANSITIONS[$this->status] ?? [], true);
}
/**
* Перевести кампанию в статус $to и сохранить. Недопустимый переход
* DomainException (статус при этом НЕ меняется). Бросаем глобальное
* исключение, а не доменный класс: слой Model не должен зависеть от слоя
* Exception (deptrac Model: [], ADR-005). Кто ловит (контроллер/джоб)
* может ловить DomainException.
*/
public function transitionTo(string $to): void
{
if (! $this->canTransitionTo($to)) {
throw new DomainException(
"Недопустимый переход статуса кампании: {$this->status}{$to}",
);
}
$this->status = $to;
$this->save();
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Один номер-кандидат кампании клиентской Telegram-рекламы.
*
* Кандидат = отобран нами до загрузки в МТС; реальное «не МТС / есть в Телеграме»
* определяет робот в кабинете. Tenant-aware с RLS.
*
* Источник: миграция client_tg_campaign_phones.
*/
class CampaignPhone extends Model
{
protected $table = 'client_tg_campaign_phones';
protected $fillable = [
'tenant_id',
'campaign_id',
'phone',
'expires_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'expires_at' => 'datetime',
];
}
/** @return BelongsTo<Campaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class, 'campaign_id');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Контакт клиентской базы для Telegram-рекламы (телефон + имя + оператор).
*
* Tenant-aware с RLS.
*
* Источник: миграция client_tg_contacts.
*/
class Contact extends Model
{
protected $table = 'client_tg_contacts';
protected $fillable = [
'tenant_id',
'phone',
'name',
'operator',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Список «не показывать этим номерам» ручной exclude-лист тенанта (по номеру
* телефона). В показах рекламы «отписки» нет (реклама показывается пачке в кабинете
* МТС, а не шлётся адресно); это просто номера, которые клиент не хочет включать
* в аудиторию вырезаются при сборке (TelegramAudienceService).
*
* Tenant-aware с RLS. Источник: миграция client_tg_optouts.
*/
class Optout extends Model
{
protected $table = 'client_tg_optouts';
protected $fillable = [
'tenant_id',
'phone',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Ступень тарифа клиентской Telegram-рекламы ( за показ по объёму).
*
* У нас тариф = потолок/оценка для интерфейса и лимита: итоговую стоимость показа
* считает кабинет МТС. Общая справочная таблица (не tenant-aware, без RLS).
*
* Источник: миграция client_tg_tariffs.
*/
class Tariff extends Model
{
protected $table = 'client_tg_tariffs';
protected $fillable = [
'min_qty',
'price_rub',
];
protected function casts(): array
{
return [
'min_qty' => 'integer',
'price_rub' => 'decimal:2',
];
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Jobs\ClientTg\AccumulateTelegramLeadJob;
use App\Models\Deal;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Авто-режим Telegram: кормит накопитель каждым НОВЫМ лидом (план §Сессия 4, задача 4.2).
*
* 🔴🔴🔴 Observer живёт ВНУТРИ боевого потока приёма лидов (RouteSupplierLeadJob,
* DealController, вебхуки). Он НИКОГДА не должен бросить исключение или сломать
* создание сделки всё тело обёрнуто в try/catch(Throwable) + Log::warning и
* выходит штатно. Провал авто-Telegram не должен ронять лид. Зеркало DealSmsObserver.
*
* Дёшево по построению: НЕ ходит в auto_rule (решение «копить/нет» за накопителем).
* Freshness-guard (без запроса) отсекает массовый/исторический импорт: джоб ставится
* только на лиды с received_at за последние сутки.
*
* Ставит ДЖОБ (а не зовёт накопитель напрямую): приём лида на проде идёт под ролью
* crm_supplier_worker, у которой НЕТ GRANT на client_tg_auto_rule прямой вызов дал
* бы «тихий ноль». Джоб исполняется на очереди под crm_app_user с tenant-контекстом.
*/
class DealTelegramObserver
{
public function created(Deal $deal): void
{
try {
// Freshness-guard: без запроса. Старые/импортные лиды не триггерят авто-режим.
// received_at NOT NULL по схеме; Carbon::parse безопасен и на пустом (=> now()).
if (Carbon::parse($deal->received_at)->lt(now()->subDay())) {
return;
}
AccumulateTelegramLeadJob::dispatch((int) $deal->id, (int) $deal->tenant_id)->afterCommit();
} catch (Throwable $e) {
Log::warning('client_tg.auto_observer_failed', [
'deal_id' => $deal->id ?? null,
'tenant_id' => $deal->tenant_id ?? null,
'error' => $e->getMessage(),
]);
}
}
}
+7
View File
@@ -2,10 +2,12 @@
namespace App\Providers;
use App\Models\Deal;
use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
use App\Models\SalesUser;
use App\Models\User;
use App\Observers\DealTelegramObserver;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\PaymentGatewayManager;
use App\Services\Bot\AitunnelChatClient;
@@ -194,6 +196,11 @@ class AppServiceProvider extends ServiceProvider
);
}
// Telegram-модуль, задача 4.2: авто-режим кормит накопитель каждым новым лидом.
// Observer поверх боевого потока приёма лидов — best-effort, лид не роняет
// (try/catch внутри). См. App\Observers\DealTelegramObserver.
Deal::observe(DealTelegramObserver::class);
// apiv1-rate (приёмка 21.06): публичный read-API сделок (/api/v1/deals)
// прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS
// по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера —
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
/**
* Итог запуска Node-робота (bin/run.js). Зеркалит контракт runner.js:
* успех {ok:true, matched, launched?, campaignId?, actualCostRub?}
* отказ {ok:false, step, reason}
* А также «обёрточные» отказы (таймаут/непарсабельный вывод) тоже ok:false с
* причиной, чтобы воркер не падал.
*
* actualCostRub фактическая стоимость кампании из кабинета, строка вида
* «201.60» (или отсутствует null; робот начнёт класть это поле позже).
*
* moderationStatus вердикт модерации кабинета при чтении статуса кампании
* (режим read-status, задача 3.4/3.5): 'approved' («Одобрена»), 'rejected'
* («Отклонена»), 'moderating' («На модерации» ещё не проверено). null
* робот не читал/не смог прочитать вердикт (обычный запуск кампании).
*
* resubmitted итог режима пересдачи (mode:'resubmit'): true, если робот
* переотправил отклонённую кампанию на модерацию «без оплаты» (submitMode:'live');
* false для песочницы (draft дошёл до подтверждения, не отправлял). Для обычного
* запуска/чтения статуса остаётся false.
*/
final class RobotResult
{
public function __construct(
public readonly bool $ok,
public readonly int $matched = 0,
public readonly bool $launched = false,
public readonly ?string $campaignId = null,
public readonly ?string $step = null,
public readonly ?string $reason = null,
public readonly ?string $actualCostRub = null,
public readonly ?string $moderationStatus = null,
public readonly bool $resubmitted = false,
) {}
/**
* Разбирает JSON робота. Отсутствие/ложь поля ok трактуется как отказ.
*
* @param array<string, mixed> $json
*/
public static function fromRobotJson(array $json): self
{
$ok = ($json['ok'] ?? false) === true;
return new self(
ok: $ok,
matched: (int) ($json['matched'] ?? 0),
launched: (bool) ($json['launched'] ?? false),
campaignId: isset($json['campaignId']) ? (string) $json['campaignId'] : null,
step: isset($json['step']) ? (string) $json['step'] : null,
reason: isset($json['reason']) ? (string) $json['reason'] : null,
actualCostRub: isset($json['actualCostRub']) ? (string) $json['actualCostRub'] : null,
moderationStatus: isset($json['moderationStatus']) ? (string) $json['moderationStatus'] : null,
resubmitted: (bool) ($json['resubmitted'] ?? false),
);
}
/**
* Обёрточный отказ (таймаут, непарсабельный вывод, сбой процесса).
* $campaignId id черновика кабинета, восстановленный из stderr-маркера робота
* (задача 3.1): робот создаёт черновик рано, и его id нужен даже при провале.
*/
public static function failed(string $reason, ?string $step = null, ?string $campaignId = null): self
{
return new self(ok: false, campaignId: $campaignId, step: $step, reason: $reason);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
/**
* Результат сборки аудитории Telegram-кампании.
*
* `$phones` КАНДИДАТЫ (нормализованные «7XXXXXXXXXX», без дублей, без стоп-листа).
* Реальный охват («не МТС» / «есть в Телеграме») известен ТОЛЬКО после загрузки этой
* базы в кабинет МТС роботом (см. спеку §3) поэтому это кандидаты, а не гарантия.
* `$candidatesCount` фиксирует размер до передачи роботу.
*/
final readonly class TelegramAudience
{
/** @param list<string> $phones */
public function __construct(
public array $phones,
public int $candidatesCount,
) {}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\Deal;
use App\Support\PhoneNormalizer;
use Illuminate\Support\Facades\DB;
/**
* Собирает аудиторию Telegram-кампании по её источнику: свежие сделки за N дней /
* своя база / свой список. Возвращает КАНДИДАТОВ (нормализованы, без дублей, без
* стоп-листа). В отличие от СМС БЕЗ маршрута-оператора: «не МТС / есть в Телеграме»
* определит кабинет МТС после загрузки роботом (спека §3).
*/
final class TelegramAudienceService
{
public function build(Campaign $campaign): TelegramAudience
{
return DB::transaction(function () use ($campaign): TelegramAudience {
$tenantId = (int) $campaign->tenant_id;
// RLS-контекст сервис-слоя (см. ClientSmsAudienceBuilder). Явные where(tenant_id)
// ниже — defense-in-depth: prod-воркер (crm_supplier_worker) BYPASSRLS.
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$rawPhones = match ($campaign->audience_kind) {
Campaign::AUDIENCE_DEALS => $this->fromDeals($campaign, $tenantId),
Campaign::AUDIENCE_BASE => $this->fromBase($tenantId),
Campaign::AUDIENCE_LIST => $this->fromList($campaign, $tenantId),
default => [],
};
$candidates = $this->removeOptouts($this->normalizeAndDedupe($rawPhones), $tenantId);
return new TelegramAudience($candidates, count($candidates));
});
}
/** @return list<string> */
private function fromDeals(Campaign $campaign, int $tenantId): array
{
$params = $campaign->audience_params ?? [];
$days = $params['days'] ?? null;
$query = Deal::query()
->where('tenant_id', $tenantId)
->whereNull('duplicate_of_id');
if ($days !== null) {
// received_at в where — и окно по смыслу, и partition pruning (deals партиционирована).
$query->where('received_at', '>=', now()->subDays((int) $days));
}
return $query->pluck('phone')->map(fn ($p) => (string) $p)->all();
}
/** @return list<string> */
private function fromBase(int $tenantId): array
{
return Contact::query()
->where('tenant_id', $tenantId)
->pluck('phone')
->map(fn ($p) => (string) $p)
->all();
}
/** @return list<string> */
private function fromList(Campaign $campaign, int $tenantId): array
{
return CampaignPhone::query()
->where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->pluck('phone')
->map(fn ($p) => (string) $p)
->all();
}
/**
* Нормализует к «голому» 11-значному «7XXXXXXXXXX» и схлопывает дубли (порядок
* первого вхождения сохраняем). Нераспознанные номера отбрасываются.
*
* @param list<string> $phones
* @return list<string>
*/
private function normalizeAndDedupe(array $phones): array
{
$out = [];
$seen = [];
foreach ($phones as $raw) {
$norm = PhoneNormalizer::normalize($raw);
if ($norm === null) {
continue;
}
$bare = substr($norm, 1); // "+7..." → "7..."
if (isset($seen[$bare])) {
continue;
}
$seen[$bare] = true;
$out[] = $bare;
}
return $out;
}
/**
* Список «не показывать этим номерам»: вырезаем из аудитории номера, которые клиент
* пометил как исключаемые (client_tg_optouts). В показах рекламы «отписок» нет это
* ручной exclude-лист тенанта.
*
* @param list<string> $phones
* @return list<string>
*/
private function removeOptouts(array $phones, int $tenantId): array
{
$optouts = [];
foreach (Optout::query()->where('tenant_id', $tenantId)->pluck('phone') as $raw) {
$norm = PhoneNormalizer::normalize((string) $raw);
if ($norm !== null) {
$optouts[substr($norm, 1)] = true; // "+7..." → "7..." — как у кандидатов
}
}
return array_values(array_filter($phones, fn ($p) => ! isset($optouts[$p])));
}
}
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Support\PhoneNormalizer;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Накопитель авто-режима Telegram-рекламы (план §Сессия 4, задача 4.1).
*
* Копит номера новых лидов в один «открытый» черновик авто-кампании; когда наберётся
* пачка ( порога кандидатов по умолчанию 367), доводит смету, СПИСЫВАЕТ клиентскую
* смету с общего баланса (как ручной launch) и ставит кампанию в очередь. Ниже порога
* только копим. `budget_cap_rub` из правила лимит трат в кабинете МТС, не резерв.
*
* 🔴 Отличие от СМС-близнеца: СМС шлёт по одной на каждый лид, Telegram копит ПАЧКУ
* МТС Маркетолог показывает рекламу загруженной базе целиком, а не по одному номеру.
*
* Авто-кампания отличается от ручной дискриминатором `created_by IS NULL` (ручная
* всегда пишет автора). Схему не трогаем переиспользуем существующие таблицы.
*/
class TelegramAutoAccumulator
{
public function __construct(
private readonly TelegramAudienceService $audience,
private readonly TelegramTariffService $tariff,
private readonly TelegramCampaignChargeService $charger,
) {}
/**
* Скармливает накопителю телефон нового лида. Best-effort по построению:
* выключенное/отсутствующее правило и невалидный номер тихий выход.
*/
public function accumulate(int $tenantId, string $phone): void
{
$rule = AutoRule::where('tenant_id', $tenantId)->first();
if ($rule === null || ! $rule->enabled) {
return; // авто-режим выключен
}
$normalized = PhoneNormalizer::normalize($phone);
if ($normalized === null) {
return; // слать некуда
}
$bare = substr($normalized, 1); // "+7…" → "7…"
$campaign = $this->openBatch($tenantId, $rule);
// Дедуп на входе: тот же номер в пачке не задваиваем.
$already = CampaignPhone::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where('phone', $bare)
->exists();
if (! $already) {
CampaignPhone::create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $bare,
]);
}
// Кандидаты (нормализованы, без дублей/стоп-листа) + клиентская смета (с наценкой).
$candidates = $this->audience->build($campaign)->candidatesCount;
$campaign->update([
'planned_count' => $candidates,
'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates),
]);
// Порог пачки — из правила (клиент мог поднять), иначе дефолт из конфига.
$threshold = $rule->batch_threshold ?? (int) config('client_tg.auto_batch_threshold', 367);
if ($candidates < $threshold) {
return; // ещё копим
}
if (! $this->passesSafeguard($tenantId, $rule, $campaign)) {
return; // предохранитель: нет денег / упёрлись в дневной лимит — держим черновиком
}
$campaign->transitionTo(Campaign::STATUS_QUEUED);
RunTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit();
}
/**
* Предохранитель авто-рекламы (план §Этап 5, задача 5.1 + ревью-фикс F3). В
* песочнице деньги не трогаются гейта нет (как ручной launch). В бою:
* - смета пачки (клиентская, с наценкой) + трата за сегодня не должна превышать
* дневной лимит правила (дефолт 0 авто выключено);
* - клиентская смета СПИСЫВАЕТСЯ с общего баланса (charge, как ручной launch)
* не хватило мягко пропускаем (приём лидов не должен падать).
* Всё под `lockForUpdate` строки правила счётчик `spent_today_rub` не гоняется
* двумя параллельными джобами одного тенанта. Не прошла держим черновиком и
* пишем причину; прошла списываем смету и учитываем трату за сегодня.
*/
private function passesSafeguard(int $tenantId, AutoRule $rule, Campaign $campaign): bool
{
if ((bool) config('client_tg.sandbox', true)) {
return true;
}
$cost = (string) $campaign->estimated_cost_rub;
$today = Carbon::now()->toDateString();
return DB::transaction(function () use ($tenantId, $rule, $campaign, $cost, $today): bool {
$locked = AutoRule::where('id', $rule->id)->lockForUpdate()->first();
if ($locked === null) {
return false;
}
$sameDay = $locked->spent_date !== null
&& Carbon::parse($locked->spent_date)->toDateString() === $today;
$spentToday = $sameDay ? (string) $locked->spent_today_rub : '0.00';
if (bccomp(bcadd($spentToday, $cost, 2), (string) $locked->daily_limit_rub, 2) > 0) {
Log::info('client_tg.auto_skipped', [
'reason' => 'daily_limit',
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'cost_rub' => $cost,
'spent_today_rub' => $spentToday,
'daily_limit_rub' => (string) $locked->daily_limit_rub,
]);
return false;
}
// Списываем клиентскую смету с общего баланса симметрично ручному 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,
'cost_rub' => $cost,
]);
return false;
}
$locked->update([
'spent_today_rub' => bcadd($spentToday, $cost, 2),
'spent_date' => $today,
]);
return true;
});
}
/**
* Открытый черновик авто-кампании тенанта (created_by IS NULL, draft, список).
* Нет создаём новый по объявлению из правила. Так после запуска пачки
* следующий лид открывает свежую пачку.
*/
private function openBatch(int $tenantId, AutoRule $rule): Campaign
{
$open = Campaign::where('tenant_id', $tenantId)
->whereNull('created_by')
->where('status', Campaign::STATUS_DRAFT)
->where('audience_kind', Campaign::AUDIENCE_LIST)
->orderByDesc('id')
->first();
if ($open !== null) {
return $open;
}
return Campaign::create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => $rule->ad_text,
'ad_link' => $rule->ad_link,
'ord_category' => $rule->ord_category ?? 'Размещение рекламы',
'budget_cap_rub' => (string) $rule->budget_cap_rub,
'audience_kind' => Campaign::AUDIENCE_LIST,
'audience_params' => null,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => null,
]);
}
}
@@ -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;
}
}
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
use Throwable;
/**
* Мост Laravel Node-робот кабинета МТС (план §Сессия 2, задача 2.2).
*
* У МТС нет API, поэтому кампанию доводит браузер-робот `bots/mts-telegram-ads`.
* Обёртка формирует task.json, запускает `node bin/run.js --task <file>` через
* Symfony Process и разбирает JSON-вывод в RobotResult.
*
* 🔴 Реальный робот идёт ТОЛЬКО в mode:'draft' до Сессии 6 (live закрыт). Обёртка
* mode не навязывает его задаёт джоб (в песочнице всегда 'draft').
*
* Устойчивость воркера: таймаут и непарсабельный вывод НЕ бросаются наружу, а
* превращаются в аккуратный RobotResult::failed очередь не должна падать из-за
* робота. Сетевую/браузерную работу робот делает сам вне какой-либо транзакции.
*/
// Не final: джоб-тесты подменяют раннер моком (Mockery), чтобы не гонять реальный
// node/браузер в очереди.
class TelegramRobotRunner
{
/**
* Нормализует параметры кампании в task.json робота (поля как в
* bots/mts-telegram-ads/src/task.js). Обязательные: mode, phonesFile, adText,
* budgetRub, buttonUrl; опциональные: mediaFile, moderatorFile (документ модератору
* для пересдачи, задача 3.6), clientTag, ordCategory, campaignId (id кампании
* кабинета для режимов read-status/resubmit), submitMode (для mode:'resubmit'
* draft|live; робот в других режимах игнорирует).
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
public function taskPayload(array $params): array
{
return [
'mode' => $params['mode'] ?? 'draft',
'phonesFile' => $params['phonesFile'] ?? null,
'adText' => $params['adText'] ?? null,
'budgetRub' => $params['budgetRub'] ?? null,
'buttonUrl' => $params['buttonUrl'] ?? null,
'mediaFile' => $params['mediaFile'] ?? null,
'moderatorFile' => $params['moderatorFile'] ?? null,
'clientTag' => $params['clientTag'] ?? null,
'ordCategory' => $params['ordCategory'] ?? 'Размещение рекламы',
'campaignId' => $params['campaignId'] ?? null,
'submitMode' => $params['submitMode'] ?? null,
];
}
/**
* Читает вердикт модерации кампании в кабинете по её id (задача 3.4).
* Робот открывает кампанию `$mtsCampaignId` в режиме read-status и возвращает
* RobotResult с `moderationStatus` ('approved'|'rejected'|'moderating') и, при
* отказе, `reason` (текст причины из слайд-модалки «Причины»). Селекторы Part B
* в bots/mts-telegram-ads (режим read-status, задача 3.5). Сетевую/браузерную
* работу робот делает сам, вне транзакции; ошибки RobotResult::failed (опросчик
* оставит кампанию на модерации до следующего цикла).
*/
public function readModeration(string $mtsCampaignId): RobotResult
{
return $this->run(['mode' => 'read-status', 'campaignId' => $mtsCampaignId]);
}
/**
* Запускает робота с заданием и возвращает разобранный результат.
*
* @param array<string, mixed> $params
*/
public function run(array $params): RobotResult
{
$payload = $this->taskPayload($params);
$taskFile = tempnam(sys_get_temp_dir(), 'tg-task-');
// tempnam отдаёт файл без .json — переименовываем ради предсказуемого glob
// и совпадения с ожиданием робота. При сбое переименования работаем с исходным.
$jsonPath = $taskFile.'.json';
if (@rename($taskFile, $jsonPath)) {
$taskFile = $jsonPath;
}
try {
file_put_contents($taskFile, json_encode($payload, JSON_UNESCAPED_UNICODE));
$process = new Process(
[$this->node(), $this->script(), '--task', $taskFile],
$this->cwd(),
);
$process->setTimeout($this->timeout());
$timedOut = false;
try {
$process->run();
} catch (ProcessTimedOutException $e) {
Log::warning('client_tg.robot_timeout', ['reason' => $e->getMessage()]);
$timedOut = true;
}
// Робот печатает маркер MTS_CAMPAIGN_ID=<id> в stderr сразу после создания
// черновика — восстанавливаем id даже при таймауте/непарсабельном выводе
// (задача 3.1), чтобы опросчик/уборщик нашли реально ушедшую кампанию.
$markerId = null;
if (preg_match('/MTS_CAMPAIGN_ID=(\d+)/', $process->getErrorOutput(), $mm)) {
$markerId = $mm[1];
}
if ($timedOut) {
return RobotResult::failed('Робот не ответил за отведённое время (таймаут)', 'timeout', $markerId);
}
$stdout = trim($process->getOutput());
/** @var mixed $decoded */
$decoded = json_decode($stdout, true);
if (! is_array($decoded)) {
$stderr = trim($process->getErrorOutput());
Log::warning('client_tg.robot_unparsable', [
'stdout' => mb_substr($stdout, 0, 500),
'stderr' => mb_substr($stderr, 0, 500),
]);
return RobotResult::failed(
$stderr !== '' ? mb_substr($stderr, 0, 255) : 'Робот вернул непонятный ответ',
'parse',
$markerId,
);
}
// Если робот вернул валидный JSON, но БЕЗ campaignId (например упал после
// создания черновика и ветка отказа не донесла id) — подставляем маркер.
if (! isset($decoded['campaignId']) && $markerId !== null) {
$decoded['campaignId'] = $markerId;
}
/** @var array<string, mixed> $decoded */
return RobotResult::fromRobotJson($decoded);
} catch (Throwable $e) {
Log::error('client_tg.robot_crash', ['reason' => $e->getMessage()]);
return RobotResult::failed(mb_substr($e->getMessage(), 0, 255), 'crash');
} finally {
// task.json — не ПДн (номера лежат в phonesFile, который пишет/чистит джоб),
// но временный файл убираем в любом случае.
if (is_string($taskFile) && file_exists($taskFile)) {
@unlink($taskFile);
}
}
}
private function node(): string
{
$v = config('client_tg.robot.node', 'node');
return is_string($v) && $v !== '' ? $v : 'node';
}
private function script(): string
{
$v = config('client_tg.robot.script');
return is_string($v) && $v !== '' ? $v : base_path('bots/mts-telegram-ads/bin/run.js');
}
private function cwd(): string
{
$v = config('client_tg.robot.cwd');
return is_string($v) && $v !== '' ? $v : base_path('bots/mts-telegram-ads');
}
private function timeout(): float
{
$v = config('client_tg.robot.timeout', 300);
return is_numeric($v) ? (float) $v : 300.0;
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use App\Models\ClientTg\Tariff;
/**
* Ступенчатая цена Telegram-рекламы по объёму показов (client_tg_tariffs).
*
* Наценка НЕ применяется. Наш тариф это ПОТОЛОК/ОЦЕНКА для интерфейса и лимита
* на объявление: итоговую стоимость показа считает кабинет МТС (аудитория × фикс.
* ставка). Здесь предварительная прикидка «сколько примерно выйдет».
*/
final class TelegramTariffService
{
/** Цена за 1 показ для объёма V = строка с наибольшим min_qty <= V (V не меньше 1). */
public function pricePerImpressionRub(int $volume): string
{
$row = Tariff::where('min_qty', '<=', max($volume, 1))
->orderByDesc('min_qty')
->first();
return $row !== null ? (string) $row->price_rub : '0.00';
}
/** Смета: объём показов × цена ступени, bcmath scale 2. */
public function estimateRub(int $impressions): string
{
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);
}
}
+67
View File
@@ -8,6 +8,7 @@ use App\Mail\InvoicePaidNotification;
use App\Mail\NewLeadsDigestMail;
use App\Mail\TopupSuccessNotification;
use App\Mail\ZeroBalancePausedMail;
use App\Models\ClientTg\Campaign;
use App\Models\Deal;
use App\Models\InAppNotification;
use App\Models\Project;
@@ -57,6 +58,12 @@ class NotificationService
/** Эпик 6: объяснение правил сбора (хвост источника, вступление правок и т.п.). */
public const EVENT_PROJECT_RULE = 'project_rule';
/** Сессия 5: Telegram-кампания отклонена модерацией МТС / не запущена. */
public const EVENT_TG_CAMPAIGN_REJECTED = 'tg_campaign_rejected';
/** Задача 3.4: Telegram-кампания одобрена модерацией МТС и запущена. */
public const EVENT_TG_CAMPAIGN_APPROVED = 'tg_campaign_approved';
public const ALL_EVENTS = [
self::EVENT_NEW_LEAD,
self::EVENT_LOW_BALANCE,
@@ -212,6 +219,66 @@ class NotificationService
}
}
/**
* Сессия 5, задача 5.2: клиента уведомляем, что его Telegram-реклама отклонена
* модерацией МТС (или не запустилась). In-app всем активным пользователям
* тенанта, БЕЗ pref-гейта (важное операционное сообщение доходит всегда, как
* notifyProjectRule иначе клиент не узнает, почему реклама не пошла).
*
* Текст причины из campaign.status_reason (свободный текст МТС/робота); если
* пусто нейтральный текст. Заголовок зависит от терминального статуса.
*/
public function notifyTelegramCampaignRejected(Tenant $tenant, Campaign $campaign): void
{
$title = $campaign->status === Campaign::STATUS_REJECTED
? 'Реклама в Телеграме отклонена модерацией'
: 'Реклама в Телеграме: не удалось запустить';
$body = (string) $campaign->status_reason !== ''
? (string) $campaign->status_reason
: 'Причина не указана — откройте кабинет для деталей.';
$users = User::query()
->where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('deleted_at')
->get();
foreach ($users as $user) {
$this->notifyInApp($user, self::EVENT_TG_CAMPAIGN_REJECTED, $title, $body, [
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
]);
}
}
/**
* Задача 3.4: клиента уведомляем, что его Telegram-реклама ОДОБРЕНА модерацией
* МТС и запущена. Зеркало notifyTelegramCampaignRejected: in-app всем активным
* пользователям тенанта, БЕЗ pref-гейта (важное операционное сообщение доходит
* всегда клиент должен знать, что реклама пошла).
*/
public function notifyTelegramCampaignApproved(Tenant $tenant, Campaign $campaign): void
{
$users = User::query()
->where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('deleted_at')
->get();
foreach ($users as $user) {
$this->notifyInApp(
$user,
self::EVENT_TG_CAMPAIGN_APPROVED,
'Реклама в Телеграме одобрена и запущена',
'Модерация МТС одобрила кампанию — показы пошли.',
[
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
],
);
}
}
/**
* INSERT в `in_app_notifications` для bell-icon UI. RLS требует
* `app.current_tenant_id` = user.tenant_id, поэтому SET LOCAL внутри
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Песочница Telegram-рекламы
|--------------------------------------------------------------------------
| По умолчанию ВКЛ (true) робот доводит кампанию только до черновика
| (mode:'draft'), деньги не списываются, «запустить/оплатить» не жмётся.
| Живой режим (mode:'live' + списание) открывается ОСОЗНАННО (Сессия 6)
| выставлением TG_SANDBOX=false после проверки в песочнице.
*/
'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'),
/*
|--------------------------------------------------------------------------
| Порог авто-пачки
|--------------------------------------------------------------------------
| Авто-режим копит новых лидов и запускает кампанию, когда наберётся столько
| кандидатов (МТС показывает рекламу базе, а не по одному номеру). Минимальная
| осмысленная пачка у МТС Маркетолог 367 (спека §5).
*/
'auto_batch_threshold' => (int) env('TG_AUTO_BATCH', 367),
/*
|--------------------------------------------------------------------------
| Окно «зависшей модерации»
|--------------------------------------------------------------------------
| Кампания в статусе `moderating` дольше стольких ЧАСОВ считается зависшей
| (робот не смог прочитать вердикт: сломались селекторы кабинета / id протух).
| Уборщик уводит её в `needs_review` для ручного разбора, БЕЗ возврата брони
| (могла реально показываться). МТС проверяет ~4ч; окно с большим запасом.
*/
'moderation_stuck_hours' => (int) env('TG_MODERATION_STUCK_HOURS', 48),
/*
|--------------------------------------------------------------------------
| Node-робот кабинета МТС (bots/mts-telegram-ads)
|--------------------------------------------------------------------------
| У МТС нет API кампанию доводит браузер-робот. TelegramRobotRunner
| запускает `node <script> --task <file>` в каталоге <cwd>. Воркер этой
| очереди живёт на Windows-машине с живой сессией МТС (профиль браузера).
*/
'robot' => [
'node' => env('TG_ROBOT_NODE', 'node'),
'script' => env('TG_ROBOT_SCRIPT', base_path('bots/mts-telegram-ads/bin/run.js')),
'cwd' => env('TG_ROBOT_CWD', base_path('bots/mts-telegram-ads')),
'timeout' => (int) env('TG_ROBOT_TIMEOUT', 300),
],
];
@@ -0,0 +1,45 @@
<?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_campaigns', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('status', 16)->default('draft');
$table->string('ad_text', 1000);
$table->string('ad_link', 500)->nullable();
$table->string('media_path', 500)->nullable();
$table->string('ord_category', 200)->default('Размещение рекламы');
$table->decimal('budget_cap_rub', 14, 2)->default('0.00');
$table->string('audience_kind', 16)->default('deals');
$table->jsonb('audience_params')->nullable();
$table->integer('planned_count')->default(0);
$table->integer('matched_count')->nullable();
$table->decimal('estimated_cost_rub', 14, 2)->default('0.00');
$table->decimal('actual_cost_rub', 14, 2)->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'created_at']);
});
DB::statement('ALTER TABLE client_tg_campaigns ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_campaigns FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_campaigns');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_campaigns USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE ON client_tg_campaigns TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_campaigns');
}
};
@@ -0,0 +1,35 @@
<?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_campaign_phones', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->unsignedBigInteger('campaign_id');
$table->string('phone', 11);
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
});
DB::statement('ALTER TABLE client_tg_campaign_phones ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_campaign_phones FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_campaign_phones');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_campaign_phones USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, DELETE ON client_tg_campaign_phones TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_campaign_phones');
}
};
@@ -0,0 +1,33 @@
<?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_optouts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('phone', 11);
$table->timestamps();
$table->unique(['tenant_id', 'phone']);
});
DB::statement('ALTER TABLE client_tg_optouts ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_optouts FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_optouts');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_optouts USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, DELETE ON client_tg_optouts TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_optouts');
}
};
@@ -0,0 +1,35 @@
<?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_contacts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('phone', 11);
$table->text('name')->nullable();
$table->text('operator')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'phone']);
});
DB::statement('ALTER TABLE client_tg_contacts ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_contacts FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_contacts');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_contacts USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, DELETE ON client_tg_contacts TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_contacts');
}
};
@@ -0,0 +1,46 @@
<?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_tariffs', function (Blueprint $table) {
$table->id();
$table->integer('min_qty');
$table->decimal('price_rub', 6, 2);
$table->timestamps();
$table->unique('min_qty');
});
DB::table('client_tg_tariffs')->insert([
['min_qty' => 1, 'price_rub' => '0.45', 'created_at' => now(), 'updated_at' => now()],
['min_qty' => 1000, 'price_rub' => '0.42', 'created_at' => now(), 'updated_at' => now()],
['min_qty' => 5000, 'price_rub' => '0.40', 'created_at' => now(), 'updated_at' => now()],
['min_qty' => 10000, 'price_rub' => '0.38', 'created_at' => now(), 'updated_at' => now()],
['min_qty' => 50000, 'price_rub' => '0.36', '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_tariffs 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_tariffs TO crm_admin_user;
END IF;
END $$;
SQL);
}
public function down(): void
{
Schema::dropIfExists('client_tg_tariffs');
}
};
@@ -0,0 +1,44 @@
<?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).
* Хранит объявление и лимит на пачку; накопитель (TelegramAutoAccumulator) по нему
* формирует авто-кампанию, когда наберётся пачка (≥367 кандидатов). Зеркало
* client_sms_auto_rule, но с полями объявления Telegram (ad_link/ord_category/cap).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('client_tg_auto_rule', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->boolean('enabled')->default(false);
$table->string('ad_text', 1000);
$table->string('ad_link', 500)->nullable();
$table->string('ord_category', 200)->default('Размещение рекламы');
$table->decimal('budget_cap_rub', 14, 2)->default('0.00');
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->unique('tenant_id');
});
DB::statement('ALTER TABLE client_tg_auto_rule ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_auto_rule FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_auto_rule');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_auto_rule USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE ON client_tg_auto_rule TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_auto_rule');
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Причина терминального статуса Telegram-кампании (план §Сессия 5, задача 5.2).
*
* `status_reason` свободный текст: причина отказа модерации МТС (regulated-тема,
* недоступный канал и т.п.) или ошибки запуска робота. Ранее причина писалась
* только в журнал (см. RunTelegramCampaignJob) теперь храним, чтобы показать
* клиенту в кабинете и в уведомлении. NULL нет причины (штатные статусы).
*
* RLS не меняется: колонка на существующей таблице client_tg_campaigns, политика
* tenant_isolation уже задана create-миграцией (000001). Табличный GRANT покрывает
* новую колонку автоматически. NB: create-миграция дала GRANT только crm_app_user;
* SELECT служебным ролям (crm_supplier_worker/crm_admin_user) для кросс-тенантных
* джобов добавлен отдельной миграцией 000016 (ревью-фикс F9).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->string('status_reason', 500)->nullable()->after('status');
});
}
public function down(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->dropColumn('status_reason');
});
}
};
@@ -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,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Id кампании МТС для Telegram-кампании (план §Этап 3, задача 3.1).
*
* `mts_campaign_id` числовой id кампании в кабинете МТС, хранимый строкой
* (кабинет отдаёт его в URL вида `.../telegram-a2p/{ID}/message` уже на шаге
* аудитории черновик реально создан). Нужен опросчику вердикта модерации и
* уборщику денег (следующие задачи Этапа 3), чтобы найти реально ушедшую
* кампанию даже когда прогон робота позже упал. Сохраняется рано и при отказе.
* NULL робот ещё не создал черновик.
*
* RLS не меняется: колонка на существующей таблице client_tg_campaigns, политика
* tenant_isolation и GRANT-ы 5 ролям уже заданы миграцией _create_client_tg_campaigns.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->string('mts_campaign_id', 32)->nullable()->after('status_reason');
});
}
public function down(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->dropColumn('mts_campaign_id');
});
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Документ модератору для пересдачи Telegram-кампании (план §Этап 3, задача 3.6).
*
* `moderator_file_path` путь к файлу (лицензия/договор/иной документ), который
* клиент прикладывает при ПЕРЕСДАЧЕ отклонённой модерацией кампании. Робот грузит
* его в поле «Комментарий для модератора» на шаге «Сообщение» кабинета МТС
* (FLOW-FINDINGS.md «Сессия 6»). NULL документ не прикладывали. Как и `media_path`,
* это сырой passthrough роботу (mediaFile/moderatorFile в task.json).
*
* RLS не меняется: колонка на существующей таблице client_tg_campaigns. Политика
* tenant_isolation задана миграцией _create_client_tg_campaigns; GRANT-ы покрыты для
* таблицы (crm_app_user там же, остальные роли глобально db/02_grants.sql). В
* PostgreSQL табличный GRANT распространяется и на новые колонки ре-GRANT не нужен.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->string('moderator_file_path', 500)->nullable()->after('media_path');
});
}
public function down(): void
{
Schema::table('client_tg_campaigns', function (Blueprint $table) {
$table->dropColumn('moderator_file_path');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
*
* daily_limit_rub сколько рублей в день клиент разрешает тратить авто-рассылке;
* дефолт 0 = авто выключено (даже при enabled=true ничего не уйдёт, пока клиент
* не задаст лимит). spent_today_rub/spent_date счётчик потраченного за текущий
* день; накопитель сбрасывает его при смене даты. RLS/GRANT на таблице уже есть.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_tg_auto_rule', function (Blueprint $table) {
$table->decimal('daily_limit_rub', 14, 2)->default('0.00')->after('budget_cap_rub');
$table->decimal('spent_today_rub', 14, 2)->default('0.00')->after('daily_limit_rub');
$table->date('spent_date')->nullable()->after('spent_today_rub');
});
}
public function down(): void
{
Schema::table('client_tg_auto_rule', function (Blueprint $table) {
$table->dropColumn(['daily_limit_rub', 'spent_today_rub', 'spent_date']);
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Клиентский порог пачки авторассылки Telegram (план §Этап 5, задача 5.4).
*
* batch_threshold сколько кандидатов накопить перед отправкой авто-пачки. NULL =
* порог по умолчанию из конфига (client_tg.auto_batch_threshold, 367 минимум МТС
* Маркетолог). Клиент может поднять порог (реже, крупнее пачки), опустить ниже 367
* нельзя (валидация API). RLS/GRANT на таблице уже есть только ADD COLUMN.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('client_tg_auto_rule', function (Blueprint $table) {
$table->unsignedInteger('batch_threshold')->nullable()->after('daily_limit_rub');
});
}
public function down(): void
{
Schema::table('client_tg_auto_rule', function (Blueprint $table) {
$table->dropColumn('batch_threshold');
});
}
};
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Ревью-фикс F9 (находка API I4): служебные роли читают client_tg_campaigns
* КРОСС-ТЕНАНТНО PollTelegramModerationJob и SweepStuckTelegramCampaignsJob
* перечисляют кампании через `pgsql_supplier` (= crm_supplier_worker на проде).
* Create-миграция (000001) выдала GRANT только `crm_app_user`, поэтому на боевом
* кластере эти джобы получили бы 0 строк (тихий ноль: зависшие кампании не добьются,
* модерация не опросится). Даём явный SELECT служебным ролям как уже сделано для
* client_tg_senders (миграция 000010).
*
* 🔴 ВАЖНО: GRANT необходимое, но НЕ достаточное условие. Строковую видимость даёт
* RLS-политика: на проде crm_supplier_worker/crm_admin_user НЕ BYPASSRLS, и
* кросс-тенантный доступ им открывает `db/03_service_bypass_policies.sql`. Он НЕ
* запускается миграциями ПЕРЕзапустить при выкате (см. деплой-чеклист модуля).
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT ON client_tg_campaigns TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT ON client_tg_campaigns TO crm_admin_user;
END IF;
END $$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
REVOKE SELECT ON client_tg_campaigns FROM crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
REVOKE SELECT ON client_tg_campaigns FROM crm_admin_user;
END IF;
END $$;
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-миграциями. Откат не пересоздаёт их (продукт показов их не имеет).
}
};
+39
View File
@@ -737,3 +737,42 @@ export async function markInvoicePaid(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
// --- Клиентская Telegram-реклама: тарифная сетка (задача 4.4) ---------------
// Зеркало SMS-функций выше. Ступень = ₽ за показ по объёму (у нас — потолок/оценка).
/** Ступень тарифа Telegram-рекламы. */
export interface AdminTgTariff {
id: number;
min_qty: number;
price_rub: string;
}
/** Строка редактируемой ступени в PUT /api/admin/telegram/tariffs (без id — набор заменяется целиком). */
export interface AdminTgTariffRow {
min_qty: number;
price_rub: string;
}
/** Ответ GET /api/admin/telegram/tariffs. */
export interface AdminTgTariffsResponse {
tariffs: AdminTgTariff[];
}
/** Ответ PUT /api/admin/telegram/tariffs — заменённый набор ступеней. */
export interface AdminTgTariffsUpdateResponse {
tariffs: AdminTgTariff[];
}
/** GET /api/admin/telegram/tariffs — ступени тарифа. */
export async function fetchTgTariffs(): Promise<AdminTgTariffsResponse> {
const { data } = await apiClient.get<AdminTgTariffsResponse>('/api/admin/telegram/tariffs');
return data;
}
/** PUT /api/admin/telegram/tariffs — заменить весь набор ступеней. */
export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise<AdminTgTariffsUpdateResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminTgTariffsUpdateResponse>('/api/admin/telegram/tariffs', { rows });
return data;
}
+151
View File
@@ -0,0 +1,151 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-модуль клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.2).
*
* Эндпоинты под [auth:sanctum, tenant], префикс /api/telegram. GET'ы не требуют
* CSRF-cookie, мутации (POST) — требуют.
*
* 🔴 Отличие от СМС-близнеца: создание (create) и запуск (launch) РАЗДЕЛЕНЫ.
* create возвращает ЧЕРНОВИК со сметой и числом кандидатов; launch переводит его
* в очередь и запускает робота кабинета МТС (в песочнице — только черновик).
*/
/** Способ выбора аудитории: свежие сделки за период / своя база / свой список. */
export type TelegramAudienceKind = 'deals' | 'base' | 'list';
/** Кампания клиентской Telegram-рекламы (строка списка, POST-ответ и часть GET .../{id}). */
export interface TelegramCampaign {
id: number;
status: string;
status_reason?: string | null;
ad_text: string;
ad_link: string;
media_path: string | null;
/** Документ модератору, приложённый при пересдаче отклонённой кампании (задача 3.6). */
moderator_file_path?: string | null;
ord_category: string;
budget_cap_rub: string;
audience_kind: TelegramAudienceKind;
audience_params: Record<string, unknown> | null;
planned_count: number;
matched_count: number | null;
estimated_cost_rub: string;
actual_cost_rub: string | null;
/** null = авто-кампания (накопитель), иначе id создавшего менеджера. */
created_by?: number | null;
created_at?: string;
}
/** Ответ GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
export interface TelegramIndex {
campaigns: TelegramCampaign[];
sandbox: boolean;
balance_rub: string;
frozen_rub: string;
}
/** Тело POST /api/telegram/campaigns — создание черновика кампании. */
export interface TelegramCreatePayload {
ad_text: string;
ad_link: string;
audience_kind: TelegramAudienceKind;
budget_cap_rub: string;
audience_days?: number;
ord_category?: string;
phones?: string[];
}
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
export async function fetchTelegram(): Promise<TelegramIndex> {
const { data } = await apiClient.get<TelegramIndex>('/api/telegram/campaigns');
return data;
}
/** POST /api/telegram/campaigns — создать ЧЕРНОВИК кампании (деньги/робот не трогаются). */
export async function createTelegram(payload: TelegramCreatePayload): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramCampaign>('/api/telegram/campaigns', payload);
return data;
}
/** GET /api/telegram/campaigns/{id} — детали кампании. */
export async function fetchTelegramCampaign(id: number): Promise<{ campaign: TelegramCampaign }> {
const { data } = await apiClient.get<{ campaign: TelegramCampaign }>(`/api/telegram/campaigns/${id}`);
return data;
}
/** POST /api/telegram/campaigns/{id}/launch — запустить черновик (draft → queued + робот). */
export async function launchTelegram(id: number): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/launch`);
return data;
}
/** Тело POST /api/telegram/campaigns/{id}/resubmit — правки + опц. документ модератору. */
export interface TelegramResubmitPayload {
ad_text: string;
ad_link: string;
ord_category?: string;
/** Лицензия/договор для модератора МТС (png/jpg/pdf ≤10 МБ). */
moderator_file?: File | null;
}
/**
* POST /api/telegram/campaigns/{id}/resubmit — пересдать ОТКЛОНЁННУЮ кампанию
* (rejected → queued). Отправляем multipart, т.к. можно приложить документ модератору.
*/
export async function resubmitTelegram(id: number, payload: TelegramResubmitPayload): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const form = new FormData();
form.append('ad_text', payload.ad_text);
form.append('ad_link', payload.ad_link);
if (payload.ord_category) {
form.append('ord_category', payload.ord_category);
}
if (payload.moderator_file) {
form.append('moderator_file', payload.moderator_file);
}
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/resubmit`, form);
return data;
}
/**
* POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ
* объявления (png/jpg/gif или mp4, ≤50 МБ). Робот отдаёт файл кабинету МТС при запуске.
*/
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
await ensureCsrfCookie();
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). Одно на тенанта: клиент
* из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит.
*/
export interface TelegramAutoRule {
enabled: boolean;
ad_text: string;
ad_link: string;
ord_category: string;
budget_cap_rub: string;
daily_limit_rub: string;
/** Сколько кандидатов накопить перед отправкой пачки; null → порог по умолчанию (367). */
batch_threshold: number | null;
}
/** 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 — сохранить правило авто-рекламы. */
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
await ensureCsrfCookie();
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
return data;
}
@@ -31,7 +31,7 @@ function openAdStub(): void {
adStubOpen.value = true;
}
// B2-2: канал с route (сейчас — Яндекс Аудитория) ведёт на реальный экран через :to
// Канал с route (сейчас — Яндекс Аудитория и Реклама Телеграм) ведёт на реальный экран через :to
// (навигация закрывает «Ещё»); остальные каналы — прежняя заглушка openAdStub.
function onChannelClick(c: AdChannel): void {
if (c.route) {
@@ -80,8 +80,8 @@ const navGroups = computed<NavGroup[]>(() => [
},
{
eyebrow: 'Рекламные возможности',
// B2-2: канал с route (сейчас — Яндекс Аудитория) ведёт на реальный экран
// (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub.
// Канал с route (сейчас — Яндекс Аудитория и Реклама Телеграм) ведёт на реальный
// экран (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub.
// T19: «Рекламный кошелёк» — не канал (в AD_CHANNELS не входит), отдельный
// пункт-ссылка на /advertising/wallet. Число баланса в меню не показываем.
items: [
@@ -9,7 +9,7 @@ export interface AdChannel {
icon: string;
/** data-testid пункта меню — общий для десктопа и мобильного. */
testid: string;
/** Часть B2: если задан — пункт ведёт на реальный роут, не на заглушку. */
/** Если задан — пункт ведёт на реальный экран, а не на заглушку «В разработке». */
route?: string;
}
@@ -25,5 +25,5 @@ export const AD_CHANNELS: readonly AdChannel[] = [
route: '/advertising/yandex',
},
{ title: 'VK Реклама', icon: 'mdi-bullhorn-outline', testid: 'ad-nav-vk' },
{ title: 'Реклама Телеграм', icon: 'mdi-send-outline', testid: 'ad-nav-telegram' },
{ title: 'Реклама Телеграм', icon: 'mdi-send-outline', testid: 'ad-nav-telegram', route: '/advertising/telegram' },
];
+18
View File
@@ -108,6 +108,18 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Dashboard',
},
},
{
path: '/advertising/telegram',
name: 'advertising-telegram',
component: () => import('../views/advertising/AdvertisingTelegramView.vue'),
meta: {
layout: 'app',
title: 'Реклама в Телеграме',
requiresAuth: true,
transition: 'ld-route-fadeup',
devLabel: 'Advertising Telegram',
},
},
{
path: '/deals',
name: 'deals',
@@ -283,6 +295,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminInvoicesView.vue'),
meta: { layout: 'admin', title: 'Счета', requiresAuth: true, devLabel: 'Admin Invoices' },
},
{
path: '/admin/telegram',
name: 'admin-telegram',
component: () => import('../views/admin/AdminTgView.vue'),
meta: { layout: 'admin', title: 'Telegram: тарифы и имя', requiresAuth: true, devLabel: 'Admin Telegram' },
},
{
path: '/admin/leads',
name: 'admin-leads',
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* SaaS-admin → «Клиентская Telegram-реклама»: тарифная сетка (ступени ₽ за показ).
* План §Сессия 4, задача 4.4. Лаконичное зеркало AdminSmsView.
*/
import { onMounted, ref } from 'vue';
import { fetchTgTariffs, updateTgTariffs, type AdminTgTariff } from '../../api/admin';
const tariffs = ref<AdminTgTariff[]>([]);
const loadError = ref(false);
const tariffsSaving = ref(false);
const tariffsSaved = ref(false);
async function load(): Promise<void> {
loadError.value = false;
try {
const data = await fetchTgTariffs();
tariffs.value = data.tariffs;
} catch {
loadError.value = true;
}
}
function addRow(): void {
tariffs.value.push({ id: 0, min_qty: 1, price_rub: '0.00' });
}
function removeRow(index: number): void {
tariffs.value.splice(index, 1);
}
async function saveTariffs(): Promise<void> {
tariffsSaving.value = true;
tariffsSaved.value = false;
try {
const rows = tariffs.value.map((t) => ({
min_qty: Number(t.min_qty),
price_rub: String(t.price_rub),
}));
const res = await updateTgTariffs(rows);
tariffs.value = res.tariffs;
tariffsSaved.value = true;
} finally {
tariffsSaving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="admin-tg">
<h1 class="text-h5 mb-4">Клиентская Telegram-реклама</h1>
<v-alert
v-if="loadError"
type="error"
variant="tonal"
class="mb-4"
data-testid="tg-tariffs-error"
>
Не удалось загрузить тарифы. Обновите страницу.
</v-alert>
<!-- Тарифная сетка: ступени за показ по объёму. -->
<v-card class="mb-6" data-testid="tg-tariffs-card">
<v-card-title>Ступени тарифа ( за показ)</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
Итоговую стоимость показа считает кабинет МТС эта сетка нужна для сметы
в интерфейсе клиента и лимита на объявление.
</p>
<div
v-for="(row, i) in tariffs"
:key="i"
class="d-flex align-center ga-3 mb-2"
:data-testid="`tg-tariff-row-${i}`"
>
<v-text-field
v-model.number="row.min_qty"
label="От объёма (шт.)"
type="number"
density="compact"
hide-details
style="max-width: 180px"
/>
<v-text-field
v-model="row.price_rub"
label="₽ за показ"
density="compact"
hide-details
style="max-width: 160px"
/>
<v-btn
icon="mdi-delete-outline"
variant="text"
size="small"
:data-testid="`tg-tariff-remove-${i}`"
@click="removeRow(i)"
/>
</div>
<v-btn
variant="text"
prepend-icon="mdi-plus"
data-testid="tg-tariff-add"
@click="addRow"
>
Добавить ступень
</v-btn>
</v-card-text>
<v-card-actions>
<v-btn
color="primary"
:loading="tariffsSaving"
data-testid="tg-tariffs-save"
@click="saveTariffs"
>
Сохранить тарифы
</v-btn>
<span v-if="tariffsSaved" class="text-success text-body-2 ml-3">Сохранено</span>
</v-card-actions>
</v-card>
</div>
</template>
@@ -0,0 +1,540 @@
<script setup lang="ts">
/**
* Экран клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.3).
* Близнец `AdvertisingSmsView`, но проще: у МТС нет API — кампанию доводит робот в
* кабинете. Клиент собирает объявление + аудиторию (3 способа) + лимит на объявление
* и жмёт «Запустить» (спека §5). На старте — песочница: реальные деньги/запуск выключены.
*
* Ручной сценарий: create (черновик) → launch (очередь + робот). Статус кампании
* виден человеческим ярлыком; причина отказа появится в Сессии 5.
*/
import { onMounted, onUnmounted, ref, computed, watch } from 'vue';
import {
fetchTelegram,
createTelegram,
launchTelegram,
resubmitTelegram,
uploadTelegramMedia,
type TelegramCampaign,
type TelegramAudienceKind,
type TelegramCreatePayload,
} from '../../api/telegram';
import TelegramAutoRulePanel from './TelegramAutoRulePanel.vue';
const loading = ref(false);
const sandbox = ref(false);
const balanceRub = ref('0.00');
const campaigns = ref<TelegramCampaign[]>([]);
const adText = ref('');
const adLink = ref('');
const audienceKind = ref<TelegramAudienceKind>('deals');
const audienceDays = ref('30');
const phonesText = ref('');
const budgetCap = ref('');
const ordCategory = ref('Размещение рекламы');
// Необязательное медиа объявления (картинка/видео) — у МТС объявление может быть с медиа.
const mediaFile = ref<File | null>(null);
// Двухшаговый поток (спека §5): «Рассчитать» создаёт черновик со сметой/охватом,
// «Запустить» переводит его в очередь. draft — результат последнего расчёта; любая
// правка формы обнуляет его, чтобы не запустить устаревший расчёт.
const draft = ref<TelegramCampaign | null>(null);
const calculating = ref(false);
const submitting = ref(false);
const error = ref('');
/** Человеческий ярлык статуса кампании — клиент видит понятное состояние. */
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
queued: 'В очереди',
running: 'Готовим в кабинете МТС',
draft_ready: 'Черновик готов',
moderating: 'На модерации в МТС',
launched: 'Запущена',
failed: 'Ошибка',
rejected: 'Отклонена модерацией',
cancelled: 'Отменена',
needs_review: 'Проверяем вручную',
};
function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status;
}
/** «Живые» статусы — кампания ещё меняется, за ней стоит следить. */
const LIVE_STATUSES = ['queued', 'running', 'moderating'];
const POLL_INTERVAL_MS = 15000;
const hasLiveCampaigns = computed(() => campaigns.value.some((c) => LIVE_STATUSES.includes(c.status)));
const AUDIENCE_OPTIONS: { value: TelegramAudienceKind; label: string; testid: string }[] = [
{ value: 'deals', label: 'Свежие сделки за период', testid: 'tg-audience-deals' },
{ value: 'base', label: 'Моя база номеров', testid: 'tg-audience-base' },
{ value: 'list', label: 'Свой список номеров', testid: 'tg-audience-list' },
];
/** Форма заполнена настолько, что можно запросить расчёт (смету/охват). */
const canCalc = computed(() => {
if (calculating.value) return false;
if (adText.value.trim() === '' || adLink.value.trim() === '' || budgetCap.value.trim() === '') return false;
// «Свой список»: без номеров считать нечего.
if (audienceKind.value === 'list' && parsePhones().length === 0) return false;
return true;
});
/** Запуск доступен только после успешного расчёта (есть черновик). */
const canLaunch = computed(() => draft.value !== null && !submitting.value);
async function load(): Promise<void> {
loading.value = true;
try {
const data = await fetchTelegram();
sandbox.value = data.sandbox;
balanceRub.value = data.balance_rub;
campaigns.value = data.campaigns;
} finally {
loading.value = false;
}
}
/** Разбирает список номеров из textarea (строки/запятые), убирает пустое. */
function parsePhones(): string[] {
return phonesText.value
.split(/[\n,;]+/)
.map((p) => p.trim())
.filter((p) => p !== '');
}
function buildPayload(): TelegramCreatePayload {
const payload: TelegramCreatePayload = {
ad_text: adText.value.trim(),
ad_link: adLink.value.trim(),
audience_kind: audienceKind.value,
budget_cap_rub: budgetCap.value,
ord_category: ordCategory.value.trim() || 'Размещение рекламы',
};
if (audienceKind.value === 'deals') {
payload.audience_days = Number(audienceDays.value);
}
if (audienceKind.value === 'list') {
payload.phones = parsePhones();
}
return payload;
}
/** «Рассчитать»: создать черновик и показать смету + охват (деньги/робот не трогаются). */
async function calc(): Promise<void> {
error.value = '';
calculating.value = true;
try {
const created = await createTelegram(buildPayload());
// Приложили картинку/видео — грузим к свежему черновику (робот отдаст его кабинету МТС).
draft.value = mediaFile.value
? await uploadTelegramMedia(created.id, mediaFile.value)
: created;
} catch {
error.value = 'Не удалось рассчитать кампанию';
} finally {
calculating.value = false;
}
}
/** «Запустить»: перевести рассчитанный черновик в очередь (спека §5, ручной режим). */
async function launch(): Promise<void> {
if (!draft.value) return;
error.value = '';
submitting.value = true;
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 ? 'Не хватает денег на балансе' : 'Не удалось запустить кампанию';
} finally {
submitting.value = false;
}
}
// Любая правка формы делает прежний расчёт недействительным — прячем смету и
// снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным.
watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => {
draft.value = null;
});
// --- Пересдача отклонённой кампании (задача 4.4) ---
/** Единый текст, когда модерация не прислала причину — действенный, без «круга». */
const REASON_PLACEHOLDER = 'Модерация отклонила без пояснения. Поправьте объявление ниже и пересдайте.';
const resubmitFor = ref<number | null>(null);
const editText = ref('');
const editLink = ref('');
const editOrd = ref('');
const editFile = ref<File | null>(null);
const resubmitting = ref(false);
/** Открыть форму правки для отклонённой кампании — предзаполняем её значениями. */
function startResubmit(c: TelegramCampaign): void {
resubmitFor.value = c.id;
editText.value = c.ad_text;
editLink.value = c.ad_link;
editOrd.value = c.ord_category ?? '';
editFile.value = null;
error.value = '';
}
function cancelResubmit(): void {
resubmitFor.value = null;
editFile.value = null;
}
/** v-file-input отдаёт File | File[] | null — нормализуем к одному файлу. */
function onEditFile(value: File | File[] | null): void {
editFile.value = Array.isArray(value) ? (value[0] ?? null) : value;
}
/** Отправить пересдачу: правки + опц. документ модератору → кампания уходит в очередь. */
async function submitResubmit(): Promise<void> {
if (resubmitFor.value === null) return;
error.value = '';
resubmitting.value = true;
try {
await resubmitTelegram(resubmitFor.value, {
ad_text: editText.value.trim(),
ad_link: editLink.value.trim(),
ord_category: editOrd.value.trim() || undefined,
moderator_file: editFile.value,
});
cancelResubmit();
await load();
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
error.value = status === 409 ? 'Не хватает денег на балансе' : 'Не удалось пересдать кампанию';
} finally {
resubmitting.value = false;
}
}
// Автообновление: пока есть «живые» кампании, сами подтягиваем статус без F5.
// Один интервал; тик опрашивает только когда есть за чем следить. Снимаем при уходе.
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
load();
pollTimer = setInterval(() => {
if (hasLiveCampaigns.value) {
load();
}
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template>
<v-container fluid class="tg-ads pa-6">
<header class="mb-4">
<h1 class="text-h5 mb-1">Реклама в Телеграме по своей базе</h1>
<p class="text-medium-emphasis mb-0">
Соберите объявление, выберите аудиторию и лимит кампанию доведём в кабинете МТС Маркетолог.
</p>
</header>
<v-alert
v-if="sandbox"
data-testid="tg-sandbox-banner"
type="info"
variant="tonal"
class="mb-4"
>
Режим песочницы: объявление уходит только в черновик, реальные деньги и запуск выключены.
</v-alert>
<v-alert v-if="error" data-testid="tg-error" type="error" variant="tonal" class="mb-4">
{{ error }}
</v-alert>
<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 }} </v-card-subtitle>
</v-card-item>
<v-card-text>
<v-textarea
v-model="adText"
data-testid="tg-ad-text"
label="Текст объявления"
density="compact"
variant="outlined"
rows="3"
auto-grow
/>
<v-text-field
v-model="adLink"
data-testid="tg-ad-link"
label="Ссылка (канал / бот / сайт)"
placeholder="https://t.me/…"
density="compact"
variant="outlined"
/>
<div class="text-subtitle-2 mt-2 mb-1">Кому показывать</div>
<v-radio-group v-model="audienceKind" density="compact">
<v-radio
v-for="opt in AUDIENCE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
:data-testid="opt.testid"
/>
</v-radio-group>
<v-text-field
v-if="audienceKind === 'deals'"
v-model="audienceDays"
data-testid="tg-audience-days"
label="За сколько дней брать сделки"
type="number"
min="1"
density="compact"
variant="outlined"
/>
<v-textarea
v-if="audienceKind === 'list'"
v-model="phonesText"
data-testid="tg-phones"
label="Список номеров (по одному в строке)"
density="compact"
variant="outlined"
rows="4"
/>
<v-text-field
v-model="budgetCap"
data-testid="tg-budget-cap"
label="Лимит на объявление, ₽"
type="number"
min="1"
density="compact"
variant="outlined"
/>
<v-text-field
v-model="ordCategory"
data-testid="tg-ord"
label="Категория рекламы (ОРД)"
density="compact"
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"
type="success"
variant="tonal"
density="compact"
class="mt-3 mb-1"
>
Прогноз: спишется около {{ draft.estimated_cost_rub }} , охват ~{{ draft.planned_count }} кандидатов.
</v-alert>
<div class="d-flex flex-wrap ga-3 mt-2 align-center">
<v-btn
data-testid="tg-calc"
color="primary"
variant="outlined"
:loading="calculating"
:disabled="!canCalc"
@click="calc"
>
Рассчитать
</v-btn>
<v-btn
data-testid="tg-launch"
color="primary"
:loading="submitting"
:disabled="!canLaunch"
@click="launch"
>
Запустить
</v-btn>
</div>
</v-card-text>
</v-card>
<h2 class="text-h6 mb-2">Мои кампании</h2>
<v-card variant="outlined">
<v-card-text>
<div data-testid="tg-campaigns">
<v-alert v-if="campaigns.length === 0" type="info" variant="tonal" density="compact">
Пока нет кампаний.
</v-alert>
<v-list v-else>
<v-list-item
v-for="c in campaigns"
:key="c.id"
:title="c.ad_text"
:subtitle="`${statusLabel(c.status)} · кандидатов: ${c.planned_count}`"
data-testid="tg-campaign-row"
>
<v-chip
v-if="c.created_by === null"
:data-testid="`tg-auto-tag-${c.id}`"
size="x-small"
color="primary"
variant="tonal"
class="mt-1 mr-2"
>
авто
</v-chip>
<v-chip
v-if="sandbox"
:data-testid="`tg-sandbox-tag-${c.id}`"
size="x-small"
color="info"
variant="tonal"
class="mt-1 mr-2"
>
песочница
</v-chip>
<v-alert
v-if="c.status === 'moderating'"
type="info"
variant="tonal"
density="compact"
class="mt-1"
:data-testid="`tg-moderating-hint-${c.id}`"
>
Идёт проверка модерацией МТС обычно занимает около 4 часов.
</v-alert>
<v-alert
v-if="c.status_reason && (c.status === 'rejected' || c.status === 'failed')"
type="warning"
variant="tonal"
density="compact"
class="mt-1"
:data-testid="`tg-campaign-reason-${c.id}`"
>
Причина: {{ c.status_reason }}
</v-alert>
<v-alert
v-if="c.status === 'rejected' && !c.status_reason"
type="warning"
variant="tonal"
density="compact"
class="mt-1"
:data-testid="`tg-reason-placeholder-${c.id}`"
>
{{ REASON_PLACEHOLDER }}
</v-alert>
<!-- Пересдача: кнопка инлайн-форма правки resubmit. -->
<template v-if="c.status === 'rejected'">
<v-btn
v-if="resubmitFor !== c.id"
:data-testid="`tg-resubmit-${c.id}`"
color="primary"
variant="outlined"
size="small"
class="mt-2"
@click="startResubmit(c)"
>
Исправить и пересдать
</v-btn>
<div v-else class="mt-2">
<v-textarea
v-model="editText"
data-testid="tg-resubmit-text"
label="Текст объявления"
density="compact"
variant="outlined"
rows="3"
auto-grow
hide-details
class="mb-2"
/>
<v-text-field
v-model="editLink"
data-testid="tg-resubmit-link"
label="Ссылка (канал / бот / сайт)"
density="compact"
variant="outlined"
hide-details
class="mb-2"
/>
<v-text-field
v-model="editOrd"
data-testid="tg-resubmit-ord"
label="Категория рекламы (ОРД)"
density="compact"
variant="outlined"
hide-details
class="mb-2"
/>
<v-file-input
data-testid="tg-resubmit-file"
label="Документ модератору (лицензия/договор, по желанию)"
accept=".png,.jpg,.jpeg,.pdf"
density="compact"
variant="outlined"
hide-details
prepend-icon=""
class="mb-2"
@update:model-value="onEditFile"
/>
<div class="d-flex ga-2">
<v-btn
:data-testid="`tg-resubmit-submit-${c.id}`"
color="primary"
size="small"
:loading="resubmitting"
@click="submitResubmit"
>
Пересдать
</v-btn>
<v-btn size="small" variant="text" @click="cancelResubmit">Отмена</v-btn>
</div>
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
</v-card>
</v-container>
</template>
<style scoped>
/* Форма — не на всю ширину экрана: боковые поля + читаемая колонка, как у соседних экранов кабинета. */
.tg-ads {
max-width: 1100px;
}
</style>
@@ -0,0 +1,176 @@
<script setup lang="ts">
/**
* Панель «Авто-реклама новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4).
*
* Самодостаточна: грузит правило (fetchAutoRule) и даёт клиенту включить авто-режим,
* задать объявление, порог пачки, бюджет на кампанию и дневной лимит трат. Живёт
* внутри AdvertisingTelegramView. Предохранители трат (баланс + дневной лимит) —
* в накопителе (задача 5.1); дефолт дневного лимита 0 = авто выключено.
*/
import { onMounted, ref, computed } from 'vue';
import { fetchAutoRule, saveAutoRule, type TelegramAutoRule } from '../../api/telegram';
const loading = ref(false);
const saving = ref(false);
const saved = ref(false);
const error = ref('');
const enabled = ref(false);
const adText = ref('');
const adLink = ref('');
const ordCategory = ref('Размещение рекламы');
const budgetCap = ref('0.00');
const dailyLimit = ref('0.00');
const threshold = ref<number | null>(null);
const canSave = computed(() => {
if (saving.value) return false;
// При включённом авто объявление обязательно — иначе пачку не из чего собрать.
if (enabled.value && (adText.value.trim() === '' || adLink.value.trim() === '')) return false;
return true;
});
async function load(): Promise<void> {
loading.value = true;
try {
const rule = await fetchAutoRule();
enabled.value = rule.enabled;
adText.value = rule.ad_text;
adLink.value = rule.ad_link;
ordCategory.value = rule.ord_category;
budgetCap.value = rule.budget_cap_rub;
dailyLimit.value = rule.daily_limit_rub;
threshold.value = rule.batch_threshold;
} finally {
loading.value = false;
}
}
function buildPayload(): TelegramAutoRule {
const t = threshold.value;
return {
enabled: enabled.value,
ad_text: adText.value.trim(),
ad_link: adLink.value.trim(),
ord_category: ordCategory.value.trim() || 'Размещение рекламы',
budget_cap_rub: budgetCap.value,
daily_limit_rub: dailyLimit.value,
batch_threshold: t === null || String(t).trim() === '' ? null : Number(t),
};
}
async function save(): Promise<void> {
error.value = '';
saved.value = false;
saving.value = true;
try {
await saveAutoRule(buildPayload());
saved.value = true;
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
error.value = status === 422 ? 'Проверьте поля: порог не ниже 367, лимит не отрицательный' : 'Не удалось сохранить';
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<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-subtitle>
Новые лиды копятся в пачку и автоматически уходят в рекламу, когда наберётся порог.
</v-card-subtitle>
</v-card-item>
<v-card-text>
<v-alert v-if="error" data-testid="tg-auto-error" type="error" variant="tonal" density="compact" class="mb-3">
{{ error }}
</v-alert>
<v-alert v-if="saved" data-testid="tg-auto-saved" type="success" variant="tonal" density="compact" class="mb-3">
Настройки сохранены.
</v-alert>
<v-switch
v-model="enabled"
data-testid="tg-auto-enabled"
label="Включить авто-рекламу"
color="primary"
density="compact"
hide-details
class="mb-2"
/>
<v-textarea
v-model="adText"
data-testid="tg-auto-text"
label="Текст объявления"
density="compact"
variant="outlined"
rows="3"
auto-grow
hide-details
class="mb-3"
/>
<v-text-field
v-model="adLink"
data-testid="tg-auto-link"
label="Ссылка (канал / бот / сайт)"
placeholder="https://t.me/…"
density="compact"
variant="outlined"
hide-details
class="mb-3"
/>
<v-text-field
v-model="threshold"
data-testid="tg-auto-threshold"
label="Порог: сколько кандидатов накопить (минимум 367)"
type="number"
min="367"
density="compact"
variant="outlined"
hide-details
class="mb-3"
/>
<v-text-field
v-model="budgetCap"
data-testid="tg-auto-budget"
label="Бюджет на одну кампанию, ₽"
type="number"
min="1"
density="compact"
variant="outlined"
hide-details
class="mb-3"
/>
<v-text-field
v-model="dailyLimit"
data-testid="tg-auto-daily-limit"
label="Дневной лимит трат, ₽ (0 — авто выключено)"
type="number"
min="0"
density="compact"
variant="outlined"
hide-details
class="mb-3"
/>
<v-btn
data-testid="tg-auto-save"
color="primary"
:loading="saving"
:disabled="!canSave"
@click="save"
>
Сохранить
</v-btn>
</v-card-text>
</v-card>
</template>
+21
View File
@@ -1,6 +1,8 @@
<?php
use App\Jobs\ChargeCampaignSpendJob;
use App\Jobs\ClientTg\PollTelegramModerationJob;
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Jobs\External\RefreshExternalBalancesJob;
use App\Jobs\RecalcAdAudienceJob;
use App\Jobs\Sales\SalesProspectsAdvanceJob;
@@ -316,6 +318,25 @@ 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-модуль, задача 3.3: уборщик зависших кампаний. Кампания в `running`
// дольше 15 минут = мёртвый прогон робота (штатный ≤ 420с). Без mts_campaign_id →
// failed + возврат брони; с id (черновик создан) → needs_review, деньги НЕ трогаем.
// Каждые 10 минут — окно «зависания» 15 мин, добиваем без задержки. В песочнице
// (TG_SANDBOX) деньги не двигаются, только статус.
Schedule::job(new SweepStuckTelegramCampaignsJob)
->everyTenMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob', false, 'Job failed', null));
// Telegram-модуль, задача 3.4: опросчик вердикта модерации МТС. Кампании в
// `moderating` с mts_campaign_id — робот-читалка читает кабинет: «Отклонена» →
// rejected + причина + возврат брони + уведомление; «Одобрена» → launched +
// уведомление; «На модерации» → ждём. МТС проверяет ~4 часа, но опрашиваем чаще,
// чтобы клиент узнал вердикт без задержки. В песочнице (TG_SANDBOX) деньги не двигаются.
Schedule::job(new PollTelegramModerationJob)
->everyFifteenMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\PollTelegramModerationJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\PollTelegramModerationJob', false, 'Job failed', null));
// Рекламный модуль Директа (Часть B1). Джобы сами перечисляют кампании через
// pgsql_supplier (BYPASSRLS) — на дефолтной роли crm_app_user очередь без tenant-
// контекста RLS дала бы 0 строк. Времена МСК, вне пиковых часов и не пересекаются
+22
View File
@@ -93,6 +93,23 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/tenant/requisites')-
->middleware('throttle:30,1');
});
// Клиентская Telegram-реклама «по своей базе» (client_tg_*). RLS требует tenant
// middleware. Создание (store) и запуск (launch) разделены: store — черновик,
// launch — очередь + робот. См. Api\ClientTg\CampaignController.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(function () {
Route::get('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@index');
Route::post('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@store');
Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\ClientTg\CampaignController@show')->whereNumber('id');
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_auto_rule): клиент управляет авто-режимом из кабинета.
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
});
// Reports backend. Schema §13.5 report_jobs. Auth обязательный.
// Этапы 1+2 (CRUD + provider/formatter) + этап 3 (retry/cancel/delete +
// retention cron `reports:cleanup-expired`).
@@ -261,6 +278,11 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure')
->where('id', '[0-9]+');
});
// 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');
});
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
@@ -10,7 +10,7 @@ it('добавляет yandex_creative_id в ad_campaigns как nullable', func
});
it('модель AdCampaign позволяет задать и прочитать yandex_creative_id как integer', function () {
$campaign = new AdCampaign();
$campaign = new AdCampaign;
$campaign->fill(['yandex_creative_id' => '123456789']);
expect($campaign->yandex_creative_id)->toBe(123456789)
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Tariff;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* 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).
*/
uses(DatabaseTransactions::class);
test('GET /api/admin/telegram/tariffs возвращает сидированные ступени', function () {
$r = $this->getJson('/api/admin/telegram/tariffs');
$r->assertStatus(200);
$tariffs = $r->json('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');
});
test('PUT /api/admin/telegram/tariffs заменяет весь набор ступеней', function () {
$r = $this->putJson('/api/admin/telegram/tariffs', [
'rows' => [
['min_qty' => 1, 'price_rub' => '0.50'],
['min_qty' => 5000, 'price_rub' => '0.35'],
],
]);
$r->assertStatus(200);
expect(Tariff::count())->toBe(2);
$rows = Tariff::orderBy('min_qty')->get();
expect($rows[0]->min_qty)->toBe(1);
expect($rows[0]->price_rub)->toBe('0.50');
expect($rows[1]->min_qty)->toBe(5000);
expect($rows[1]->price_rub)->toBe('0.35');
expect(collect($r->json('tariffs'))->pluck('min_qty')->all())->toBe([1, 5000]);
});
test('PUT /api/admin/telegram/tariffs с некорректной ступенью → 422', function () {
$this->putJson('/api/admin/telegram/tariffs', [
'rows' => [['min_qty' => 0, 'price_rub' => '0.50']],
])->assertStatus(422);
$this->putJson('/api/admin/telegram/tariffs', [
'rows' => [['min_qty' => 1, 'price_rub' => '0.00']],
])->assertStatus(422);
// Исходные 5 ступеней не тронуты.
expect(Tariff::count())->toBe(5);
});
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Задача 4.3 уведомление об ОДОБРЕНИИ/запуске Telegram-кампании (зеркало отказа
* 5.2). Опросчик вердикта (задача 3.4) при `approved` зовёт
* notifyTelegramCampaignApproved: in-app всем активным юзерам тенанта, БЕЗ
* pref-гейта (важное операционное сообщение доходит всегда, как отказ).
*/
uses(RefreshDatabase::class);
function tgApproveCampaign(int $tenantId): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_LAUNCHED,
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
}
it('одобрение шлёт in-app всем активным юзерам тенанта с текстом «одобрена/запущена»', function () {
$tenant = Tenant::factory()->create();
$u1 = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$u2 = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::where('user_id', $u1->id)->exists())->toBeTrue()
->and(InAppNotification::where('user_id', $u2->id)->exists())->toBeTrue();
$notif = InAppNotification::where('user_id', $u1->id)->first();
expect($notif->title)->toContain('одобрена')
->and($notif->body)->toContain('показы пошли');
});
it('уведомление об одобрении доходит даже без включённых настроек уведомлений', function () {
$tenant = Tenant::factory()->create();
// Дефолтные настройки фабрики НЕ содержат события tg_campaign_approved —
// проверяем, что уведомление доходит без pref-гейта.
$user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::where('user_id', $user->id)->exists())->toBeTrue();
});
it('неактивный пользователь уведомление об одобрении НЕ получает', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
// 🪤 Считаем уведомления ИМЕННО этого пользователя, а не всю таблицу целиком.
// Глобальный `count()` проходил в одиночку и падал в полном прогоне: любая строка,
// пережившая соседний тест, засчитывалась нам как «уведомление всё-таки ушло».
expect(InAppNotification::where('user_id', $user->id)->count())->toBe(0);
});
it('изоляция тенанта: чужой активный юзер уведомление НЕ получает', function () {
$tenant = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$foreign = User::factory()->create(['tenant_id' => $tenantB->id, 'is_active' => true]);
$campaign = tgApproveCampaign($tenant->id);
app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign);
expect(InAppNotification::where('user_id', $foreign->id)->exists())->toBeFalse();
});
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
/**
* Предстартовый гейт аудитории (находка #2, план §Задача 1.5): в БОЕВОМ режиме
* (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 реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
config(['client_tg.sandbox' => false]);
});
it('<367 кандидатов в бою → launch 422, списания НЕ было, статус остаётся draft', function () {
$this->tenant->update(['balance_rub' => '5000.00']);
for ($i = 0; $i < 10; $i++) {
Contact::create([
'tenant_id' => $this->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 10, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
Queue::fake();
$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);
expect((string) Tenant::find($this->tenant->id)->balance_rub)->toBe('5000.00');
Queue::assertNothingPushed();
});
it('0 кандидатов в бою → launch 422, статус остаётся draft', function () {
$this->tenant->update(['balance_rub' => '5000.00']);
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
Queue::fake();
$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, джоб поставлен, списание произошло', function () {
$this->tenant->update(['balance_rub' => '5000.00']);
for ($i = 0; $i < 400; $i++) {
Contact::create([
'tenant_id' => $this->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 400, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
Queue::fake();
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_QUEUED);
Queue::assertPushed(RunTelegramCampaignJob::class);
// Гейт пройден → списали клиентскую смету (база×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);
});
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAudienceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeTgCampaign(int $tenantId, string $kind, ?array $params = null): Campaign
{
return Campaign::create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'Объявление',
'ad_link' => 'https://liderra.ru',
'media_path' => null,
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => $kind,
'audience_params' => $params,
'created_by' => null,
]);
}
it('собирает кандидатов из сделок (окно, мягкое удаление, мастер-лид)', function () {
$tenant = Tenant::factory()->create();
Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '79990000001',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(2),
]);
// вне окна
Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '79990000002',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(30),
]);
// мягко удалён
$deleted = Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '79990000003',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(1),
]);
$deleted->delete();
// дубликат другого лида
Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '79990000004',
'phone_operator' => 'МТС', 'duplicate_of_id' => 999999, 'received_at' => now()->subDays(1),
]);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_DEALS, ['days' => 5]);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toBe(['79990000001'])
->and($audience->candidatesCount)->toBe(1);
});
it('собирает кандидатов из своей базы (base)', function () {
$tenant = Tenant::factory()->create();
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000011', 'name' => 'Иван', 'operator' => 'Билайн']);
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000012', 'name' => null, 'operator' => null]);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_BASE);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toEqualCanonicalizing(['79990000011', '79990000012'])
->and($audience->candidatesCount)->toBe(2);
});
it('собирает кандидатов из своего списка с учётом срока (list)', function () {
$tenant = Tenant::factory()->create();
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_LIST);
CampaignPhone::create(['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000021', 'expires_at' => null]);
CampaignPhone::create(['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000022', 'expires_at' => now()->addDays(3)]);
// истёкший — не должен попасть
CampaignPhone::create(['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000023', 'expires_at' => now()->subDay()]);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toEqualCanonicalizing(['79990000021', '79990000022'])
->and($audience->candidatesCount)->toBe(2);
});
it('нормализует форматы и схлопывает дубли до голых 11 цифр', function () {
$tenant = Tenant::factory()->create();
Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '89991112233',
'phone_operator' => null, 'received_at' => now()->subDays(1),
]);
Deal::factory()->create([
'tenant_id' => $tenant->id, 'phone' => '+79991112233',
'phone_operator' => 'МегаФон', 'received_at' => now()->subDays(2),
]);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_DEALS, ['days' => 5]);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toBe(['79991112233'])
->and($audience->candidatesCount)->toBe(1);
});
it('применяет стоп-лист (optout)', function () {
$tenant = Tenant::factory()->create();
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000041', 'name' => null, 'operator' => null]);
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000042', 'name' => null, 'operator' => null]);
Optout::create(['tenant_id' => $tenant->id, 'phone' => '79990000042']);
$campaign = makeTgCampaign($tenant->id, Campaign::AUDIENCE_BASE);
$audience = app(TelegramAudienceService::class)->build($campaign);
expect($audience->phones)->toBe(['79990000041'])
->and($audience->candidatesCount)->toBe(1);
});
it('стоп-лист в любом формате вычищает нормализованного кандидата', function () {
$tenant = Tenant::factory()->create();
// кандидаты — голый 11-значный вид (как хранит своя база)
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000041', 'name' => null, 'operator' => null]);
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000042', 'name' => null, 'operator' => null]);
Contact::create(['tenant_id' => $tenant->id, 'phone' => '79990000043', 'name' => null, 'operator' => null]);
// отписки в «грязных» форматах (в рамках реального ограничения схемы phone varchar(11):
// строку "+7 999 000-00-41" туда физически не записать — берём формы, которые влезают)
Optout::create(['tenant_id' => $tenant->id, 'phone' => '89990000041']); // 8-префикс, 11 цифр
Optout::create(['tenant_id' => $tenant->id, 'phone' => '9990000042']); // без кода страны, 10 цифр
$audience = app(TelegramAudienceService::class)->build(
makeTgCampaign($tenant->id, Campaign::AUDIENCE_BASE)
);
expect($audience->phones)->not->toContain('79990000041')
->and($audience->phones)->not->toContain('79990000042')
->and($audience->phones)->toContain('79990000043');
});
it('никогда не смешивает номера разных тенантов', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
Deal::factory()->create([
'tenant_id' => $tenantA->id, 'phone' => '79990000031',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(1),
]);
Deal::factory()->create([
'tenant_id' => $tenantB->id, 'phone' => '79990000032',
'phone_operator' => 'МТС', 'received_at' => now()->subDays(1),
]);
Contact::create(['tenant_id' => $tenantB->id, 'phone' => '79990000033', 'name' => null, 'operator' => null]);
$deals = app(TelegramAudienceService::class)->build(makeTgCampaign($tenantA->id, Campaign::AUDIENCE_DEALS, ['days' => 5]));
$base = app(TelegramAudienceService::class)->build(makeTgCampaign($tenantA->id, Campaign::AUDIENCE_BASE));
expect($deals->phones)->toBe(['79990000031'])
->and($base->phones)->toBe([]);
});
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAutoAccumulator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Авто-режим (план §Сессия 4, задача 4.1): новые лиды копятся в открытый черновик
* авто-кампании; при достижении порога (≥367 кандидатов; в тестах порог занижаем
* через config) черновик формируется в кампанию и ставится в очередь В РАМКАХ
* `budget_cap_rub` из авто-правила. Ниже порога только копим.
*
* Отличие от СМС-близнеца: СМС шлёт по одной на каждый лид, Telegram копит ПАЧКУ
* (МТС показывает базе, а не по одному). Синтетические номера 7999 реальных нет.
*/
uses(RefreshDatabase::class);
function makeTgAutoRule(int $tenantId, bool $enabled = true): AutoRule
{
return AutoRule::create([
'tenant_id' => $tenantId,
'enabled' => $enabled,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
]);
}
function tgAccumulator(): TelegramAutoAccumulator
{
return app(TelegramAutoAccumulator::class);
}
/** @return list<string> */
function tgFakePhones(int $n, int $offset = 0): array
{
$out = [];
for ($i = 1; $i <= $n; $i++) {
// 7999 + 7 цифр — синтетический номер (ПДн реальных клиентов не касаемся).
$out[] = '7999'.str_pad((string) ($offset + $i), 7, '0', STR_PAD_LEFT);
}
return $out;
}
beforeEach(function () {
Queue::fake();
$this->tenant = Tenant::factory()->create();
config(['client_tg.auto_batch_threshold' => 3]); // в тестах порог занижен
});
it('правило выключено — ничего не копит и не запускает', function () {
makeTgAutoRule($this->tenant->id, enabled: false);
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
expect(Campaign::where('tenant_id', $this->tenant->id)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('нет правила вовсе — тихо выходим', function () {
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
expect(Campaign::where('tenant_id', $this->tenant->id)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('ниже порога — копим в один черновик, в очередь не ставим', function () {
makeTgAutoRule($this->tenant->id);
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
tgAccumulator()->accumulate($this->tenant->id, '79990000002');
$campaigns = Campaign::where('tenant_id', $this->tenant->id)->get();
expect($campaigns)->toHaveCount(1);
$draft = $campaigns->first();
expect($draft->status)->toBe(Campaign::STATUS_DRAFT);
expect($draft->audience_kind)->toBe(Campaign::AUDIENCE_LIST);
expect($draft->created_by)->toBeNull(); // авто-кампания — без автора
expect($draft->phones()->count())->toBe(2);
Queue::assertNothingPushed();
});
it('повторный номер не задваивает пачку', function () {
makeTgAutoRule($this->tenant->id);
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
$draft = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($draft->planned_count)->toBe(1); // кандидат один
Queue::assertNothingPushed();
});
it('достигли порога — кампания в очереди, budget_cap из правила соблюдён', function () {
$rule = makeTgAutoRule($this->tenant->id);
foreach (tgFakePhones(3) as $phone) {
tgAccumulator()->accumulate($this->tenant->id, $phone);
}
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_QUEUED);
expect($campaign->planned_count)->toBe(3);
expect((string) $campaign->budget_cap_rub)->toBe((string) $rule->budget_cap_rub);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
});
it('после запуска пачки следующий лид открывает новую пачку', function () {
makeTgAutoRule($this->tenant->id);
foreach (tgFakePhones(3) as $phone) { // первая пачка → в очередь
tgAccumulator()->accumulate($this->tenant->id, $phone);
}
tgAccumulator()->accumulate($this->tenant->id, '79991110001'); // новый лид
$drafts = Campaign::where('tenant_id', $this->tenant->id)
->where('status', Campaign::STATUS_DRAFT)->get();
expect($drafts)->toHaveCount(1);
expect($drafts->first()->phones()->count())->toBe(1);
});
it('порог пачки по умолчанию — 367', function () {
expect(config('client_tg.auto_batch_threshold'))->not->toBeNull();
// Значение по умолчанию читаем из свежесобранного конфига (без тестового override).
$default = require config_path('client_tg.php');
expect($default['auto_batch_threshold'])->toBe(367);
});
@@ -0,0 +1,116 @@
<?php
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\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), денежная модель без кошелька: авто-путь ДОЛЖЕН списывать
* клиентскую смету (estimated_cost_rub, с наценкой) с ОБЩЕГО баланса тенанта
* (tenants.balance_rub) симметрично ручному launch иначе кампания уходит в очередь
* без оплаты. Заморозки/кошелька (AdWallet/AdWalletService) больше нет. Не хватило
* баланса на смету мягкий skip (кампания держится черновиком, приём лидов не падает).
* В песочнице денег не касаемся вовсе.
* Синтетические номера 7999 реальных ПДн нет.
*/
uses(RefreshDatabase::class);
function afRule(int $tenantId, string $cap = '2500.00', string $dailyLimit = '1000.00'): AutoRule
{
return AutoRule::create([
'tenant_id' => $tenantId,
'enabled' => true,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => $cap,
'daily_limit_rub' => $dailyLimit,
]);
}
/** Скармливает n телефонов с заданным сдвигом (для разных пачек — разные номера). */
function afFeed(int $tenantId, int $from, int $to): void
{
for ($i = $from; $i <= $to; $i++) {
app(TelegramAutoAccumulator::class)->accumulate($tenantId, '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT));
}
}
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(['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 () {
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);
// Баланс упал ровно на клиентскую смету, списание отражено проводкой.
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 () {
// Баланс на копейку меньше сметы — списать нельзя.
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);
// Баланс не тронут, ни одной проводки списания.
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 () {
$rule = afRule($this->tenant->id, cap: '2500.00', dailyLimit: '1000.00');
afFeed($this->tenant->id, 1, 3); // пачка 1 → очередь, новая пачка откроется
afFeed($this->tenant->id, 4, 6); // пачка 2 → очередь
Queue::assertPushed(RunTelegramCampaignJob::class, 2);
$rule->refresh();
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 () {
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);
// Ни списаний, ни движения баланса — денег в песочнице не трогаем.
expect((string) $this->tenant->fresh()->balance_rub)->toBe('10000.00')
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\AutoRule;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Клиентское HTTP-API правила авторассылки Telegram (план §Этап 5, задача 5.4).
* Клиент из кабинета включает/выключает авто, задаёт порог пачки, бюджет на кампанию
* и дневной лимит трат. Одно правило на тенанта; scope из $request->user().
*
* Auth/tenant как у CampaignApiTest: Tenant::factory + User(tenant_id) + actingAs.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> валидный payload сохранения правила. */
function autoRulePayload(array $overrides = []): array
{
return array_merge([
'enabled' => true,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
'daily_limit_rub' => '1000.00',
'batch_threshold' => 500,
], $overrides);
}
it('GET /auto-rule без правила отдаёт безопасные значения по умолчанию (авто выключено)', function () {
$this->getJson('/api/telegram/auto-rule')
->assertOk()
->assertJsonPath('enabled', false)
->assertJsonPath('daily_limit_rub', '0.00');
});
it('PUT /auto-rule создаёт правило тенанта', function () {
$this->putJson('/api/telegram/auto-rule', autoRulePayload())
->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('daily_limit_rub', '1000.00')
->assertJsonPath('batch_threshold', 500);
$rule = AutoRule::where('tenant_id', $this->tenant->id)->first();
expect($rule)->not->toBeNull();
expect((string) $rule->daily_limit_rub)->toBe('1000.00');
expect($rule->batch_threshold)->toBe(500);
expect($rule->enabled)->toBeTrue();
});
it('PUT /auto-rule обновляет существующее правило, не плодит второе', function () {
$this->putJson('/api/telegram/auto-rule', autoRulePayload())->assertOk();
$this->putJson('/api/telegram/auto-rule', autoRulePayload(['enabled' => false, 'daily_limit_rub' => '0.00']))
->assertOk()
->assertJsonPath('enabled', false);
expect(AutoRule::where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
it('PUT /auto-rule: порог ниже минимума МТС (367) — 422', function () {
$this->putJson('/api/telegram/auto-rule', autoRulePayload(['batch_threshold' => 100]))
->assertStatus(422)
->assertJsonValidationErrors(['batch_threshold']);
});
it('PUT /auto-rule: отрицательный дневной лимит — 422', function () {
$this->putJson('/api/telegram/auto-rule', autoRulePayload(['daily_limit_rub' => '-5']))
->assertStatus(422)
->assertJsonValidationErrors(['daily_limit_rub']);
});
it('PUT /auto-rule: при включении без текста объявления — 422', function () {
$this->putJson('/api/telegram/auto-rule', autoRulePayload(['ad_text' => '']))
->assertStatus(422)
->assertJsonValidationErrors(['ad_text']);
});
it('GET /auto-rule не видит правило чужого тенанта', function () {
$other = Tenant::factory()->create();
AutoRule::create([
'tenant_id' => $other->id, 'enabled' => true, 'ad_text' => 'Чужой',
'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00', 'daily_limit_rub' => '999.00',
]);
$this->getJson('/api/telegram/auto-rule')
->assertOk()
->assertJsonPath('enabled', false)
->assertJsonPath('daily_limit_rub', '0.00'); // видит СВОИ дефолты, не чужие 999
});
@@ -0,0 +1,179 @@
<?php
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\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;
/**
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
*
* Авто-путь копит номера и при пороге ставит кампанию в очередь. В БОЮ перед
* постановкой должен пройти двойной гейт: (1) сумма за сегодня + клиентская смета
* пачки не превышает дневной лимит правила (по умолчанию 0 авто выключено);
* (2) клиентская смета СПИСЫВАЕТСЯ с общего баланса тенанта (`tenants.balance_rub`,
* как за СМС) не хватило мягкий пропуск. Не прошёл не ставим в очередь,
* кампания остаётся черновиком, пишем причину.
*
* Отдельного рекламного кошелька с заморозкой больше НЕТ: деньги идут с общего
* баланса через TelegramCampaignChargeService (проводка BalanceTransaction
* TYPE_TG_AD_CHARGE). В песочнице деньги не трогаются (как ручной launch)
* гейт не вмешивается. Синтетические номера 7999 реальных ПДн не касаемся.
*/
uses(RefreshDatabase::class);
function safeguardRule(int $tenantId, string $dailyLimit = '1000.00'): AutoRule
{
return AutoRule::create([
'tenant_id' => $tenantId,
'enabled' => true,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
'daily_limit_rub' => $dailyLimit,
]);
}
/** @return list<string> */
function safeguardPhones(int $n): array
{
$out = [];
for ($i = 1; $i <= $n; $i++) {
$out[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
}
return $out;
}
function feed(int $tenantId, int $n): void
{
foreach (safeguardPhones($n) as $phone) {
app(TelegramAutoAccumulator::class)->accumulate($tenantId, $phone);
}
}
/** Сколько сейчас на общем балансе тенанта. */
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(['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 () {
$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();
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
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('в бою при исчерпанном дневном лимите не ставит в очередь (cost = клиентская смета)', function () {
$this->tenant->update(['balance_rub' => '10000.00']); // денег вдоволь
safeguardRule($this->tenant->id, dailyLimit: '0.01'); // лимит заведомо < клиентской сметы
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
$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 () {
$this->tenant->update(['balance_rub' => '10000.00']);
// daily_limit_rub не задан явно → дефолт 0.00
AutoRule::create([
'tenant_id' => $this->tenant->id,
'enabled' => true,
'ad_text' => 'Текст',
'ad_link' => 'https://t.me/x',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
]);
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // ничего не списано
});
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);
$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); // смета записана в трату за день
expect($rule->spent_date->toDateString())->toBe('2026-07-28');
});
it('счётчик за день сбрасывается на новую дату', function () {
$this->tenant->update(['balance_rub' => '10000.00']);
$rule = safeguardRule($this->tenant->id, dailyLimit: $this->cost);
// Вчерашняя трата «под завязку» — но сегодня лимит должен освободиться.
$rule->update(['spent_today_rub' => $this->cost, 'spent_date' => '2026-07-27']);
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe($this->cost); // счётчик пересчитан с нуля за сегодня
});
it('в песочнице предохранитель не вмешивается — ставит в очередь без денег', function () {
config(['client_tg.sandbox' => true]);
// Баланс нулевой (из 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);
});
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Клиентский HTTP-API Telegram-кампаний (план §Сессия 3, задача 3.1) тонкий слой
* над готовым ядром (аудитория/тариф/кошелёк) и джобом RunTelegramCampaignJob.
*
* Отличие от СМС-близнеца: создание кампании (`store`) и запуск (`launch`) РАЗДЕЛЕНЫ.
* `store` создаёт ЧЕРНОВИК (деньги/робот не трогаются) клиент видит смету и число
* кандидатов; отдельный `launch` переводит draft→queued, в бою бронирует лимит на
* объявление (budget_cap_rub) и ставит джоб. Песочница: заморозки нет.
*
* Auth/tenant как у EndpointTest СМС: Tenant::factory + User(tenant_id) + actingAs;
* middleware ['auth:sanctum','tenant']. Синтетические номера 7999 реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> валидный payload создания кампании по списку номеров. */
function tgStorePayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
], $overrides);
}
it('store создаёт ЧЕРНОВИК со сметой и числом кандидатов; робот/деньги не трогаются', function () {
Queue::fake();
$response = $this->postJson('/api/telegram/campaigns', tgStorePayload());
$response->assertCreated()
->assertJsonPath('status', Campaign::STATUS_DRAFT)
->assertJsonPath('planned_count', 2)
->assertJsonPath('audience_kind', 'list');
expect($response->json('estimated_cost_rub'))->toBeString();
// Черновик: джоб не ставится, деньги не списываются.
Queue::assertNothingPushed();
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
it('store подставляет категорию ОРД по умолчанию «Размещение рекламы»', function () {
$this->postJson('/api/telegram/campaigns', tgStorePayload())
->assertCreated()
->assertJsonPath('ord_category', 'Размещение рекламы');
});
it('валидация: без текста/ссылки/аудитории/бюджета — 422', function () {
$this->postJson('/api/telegram/campaigns', [])
->assertStatus(422)
->assertJsonValidationErrors(['ad_text', 'ad_link', 'audience_kind', 'budget_cap_rub']);
});
it('audience_kind=deals без срока (audience_days) отклоняется — не по всей истории', function () {
$this->postJson('/api/telegram/campaigns', tgStorePayload([
'audience_kind' => 'deals',
'phones' => null,
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
});
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' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->getJson('/api/telegram/campaigns')
->assertOk()
->assertJsonPath('sandbox', true)
->assertJsonPath('balance_rub', '100.00')
->assertJsonPath('frozen_rub', '0.00')
->assertJsonCount(1, 'campaigns');
});
it('launch (песочница) переводит draft→queued и ставит джоб; заморозки нет', function () {
Queue::fake();
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_QUEUED);
$this->assertDatabaseHas('client_tg_campaigns', [
'id' => $campaign->id, 'status' => Campaign::STATUS_QUEUED,
]);
Queue::assertPushed(RunTelegramCampaignJob::class);
// Песочница: денег не списывали.
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
it('launch (реальный режим, денег не хватает) → 409, кампания остаётся черновиком', function () {
config(['client_tg.sandbox' => false]);
Queue::fake();
// На балансе меньше клиентской сметы кампании.
$this->tenant->update(['balance_rub' => '1.00']);
// Гейт аудитории (находка #2) срабатывает ДО проверки денег: без ≥367
// кандидатов launch вернул бы 422 раньше, чем дойдёт до списания. Этот тест
// проверяет именно путь «денег не хватает», поэтому засеваем аудиторию
// с запасом над порогом client_tg.auto_batch_threshold (367).
for ($i = 0; $i < 367; $i++) {
Contact::create([
'tenant_id' => $this->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(409);
// Осталась черновиком, джоб не поставлен.
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT);
Queue::assertNothingPushed();
});
it('launch не-черновика (уже queued) → 422, повторно не запускаем', function () {
Queue::fake();
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 3, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(422);
Queue::assertNothingPushed();
});
it('изоляция тенантов: чужие кампании не видны в списке и show чужой → 404', function () {
$mine = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'моя', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$tenantB = Tenant::factory()->create();
$foreign = Campaign::create([
'tenant_id' => $tenantB->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'чужая', 'ad_link' => 'https://t.me/y', 'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00', 'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => $this->user->id,
]);
$ids = collect($this->getJson('/api/telegram/campaigns')->json('campaigns'))->pluck('id')->all();
expect($ids)->toContain($mine->id)->and($ids)->not->toContain($foreign->id);
$this->getJson("/api/telegram/campaigns/{$mine->id}")->assertOk()
->assertJsonPath('campaign.id', $mine->id);
$this->getJson("/api/telegram/campaigns/{$foreign->id}")->assertStatus(404);
});
@@ -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);
});
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
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): единое ОСТОРОЖНОЕ правило возврата денег — как уборщик.
* Если у кампании есть mts_campaign_id (черновик в кабинете реально создан), она МОГЛА
* уйти на модерацию деньги вслепую НЕ возвращаем (иначе оплатим показы из своего
* кармана), помечаем needs_review списание с ОБЩЕГО баланса тенанта держится, refund
* НЕ происходит. Без id чистый провал: failed + возврат списания на баланс.
* Всё в боевом режиме (sandbox=false). Синтетические номера 7999 реальных ПДн нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
function crCampaign(int $tenantId, string $status, ?string $mtsId = null): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => $mtsId,
'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' => '500.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create(['tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'phone' => $phone]);
}
return $campaign;
}
function crMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
/** Сколько возвратов сметы прошло по кампании (сальдо-источник правды — проводки). */
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(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);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->mts_campaign_id)->toBe('2231200')
->and(crRefundCount($campaign->id))->toBe(0) // возврата не было
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00'); // списание держится
});
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(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
crMockRobot(RobotResult::failed('Мало номеров «не МТС»', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and(crRefundCount($campaign->id))->toBe(1) // возврат произошёл
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00'); // баланс восстановлен
});
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(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and(crRefundCount($campaign->id))->toBe(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
});
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(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and(crRefundCount($campaign->id))->toBe(1)
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00');
});
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(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and(crRefundCount($campaign->id))->toBe(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
});
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\AccumulateTelegramLeadJob;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAutoAccumulator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Защитный observer авто-режима Telegram (план §Сессия 4, задача 4.2).
*
* 🔴🔴🔴 Observer живёт ВНУТРИ боевого потока приёма лидов (RouteSupplierLeadJob,
* DealController, вебхуки) он НИКОГДА не должен уронить создание сделки. Всё тело
* try/catch(Throwable)+Log. Дёшев: не ходит в auto_rule (решение «слать/копить» за
* джобом/накопителем); freshness-guard отсекает массовый импорт. Зеркало DealSmsObserver.
*
* Приёма-джоб (AccumulateTelegramLeadJob) уводит работу с роли приёмщика лида
* (crm_supplier_worker, без GRANT на auto_rule) на очередь (crm_app_user) иначе
* на проде auto_rule под supplier-ролью дал бы «тихий ноль».
*/
uses(RefreshDatabase::class);
function makeTgObserverRule(int $tenantId): AutoRule
{
return AutoRule::create([
'tenant_id' => $tenantId,
'enabled' => true,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
]);
}
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
});
it('свежий лид → ставит приёма-джоб накопителя', function () {
Queue::fake();
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000001',
'received_at' => now(),
]);
Queue::assertPushed(AccumulateTelegramLeadJob::class, function ($job) {
return true; // сам факт постановки; свойства джоба приватные
});
});
it('старый/импортный лид → накопитель не трогаем (freshness-guard)', function () {
Queue::fake();
Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000002',
'received_at' => now()->subDays(5),
]);
Queue::assertNotPushed(AccumulateTelegramLeadJob::class);
});
it('приём лида не падает, даже если постановка джоба бросит', function () {
// Подменяем джоб на такой, чей dispatch бросает — observer обязан проглотить.
// Проверяем, что создание сделки всё равно проходит (сделка в БД).
Queue::fake();
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000003',
'received_at' => now(),
]);
expect(Deal::find($deal->id))->not->toBeNull();
});
it('приёма-джоб кормит накопитель телефоном лида', function () {
Queue::fake(); // накопитель внутри джоба поставит RunTelegramCampaignJob, но не при пороге 367
makeTgObserverRule($this->tenant->id);
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000004',
'received_at' => now(),
]);
// Прогоняем джоб «руками» с реальным накопителем.
app()->call([new AccumulateTelegramLeadJob($deal->id, $this->tenant->id), 'handle']);
// Накопитель открыл авто-черновик и положил в него телефон лида.
$draft = Campaign::where('tenant_id', $this->tenant->id)
->whereNull('created_by')
->where('status', Campaign::STATUS_DRAFT)
->first();
expect($draft)->not->toBeNull();
expect($draft->phones()->where('phone', '79990000004')->exists())->toBeTrue();
});
it('приёма-джоб — best-effort: сбой накопителя не пробрасывается', function () {
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000005',
'received_at' => now(),
]);
// Накопитель, который всегда бросает — джоб обязан проглотить (не уронить воркер).
$boom = Mockery::mock(TelegramAutoAccumulator::class);
$boom->shouldReceive('accumulate')->andThrow(new RuntimeException('boom'));
app()->instance(TelegramAutoAccumulator::class, $boom);
$job = new AccumulateTelegramLeadJob($deal->id, $this->tenant->id);
// Не должно бросить.
app()->call([$job, 'handle']);
expect(true)->toBeTrue();
});
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Задача 3.1 РАННЕЕ и НАДЁЖНОЕ сохранение id кампании МТС (mts_campaign_id).
* Робот отдаёт числовой id черновика кабинета даже при последующем падении;
* джоб сохраняет его при ЛЮБОМ исходе (успех/отказ), чтобы опросчик вердикта и
* уборщик денег (следующие задачи Этапа 3) нашли реально ушедшую кампанию.
*
* Робот ЗАМОКАН, песочница (mode=draft, деньги не трогаем). Синтетические
* номера 7999 реальные НИКОГДА.
*/
beforeEach(function () {
config()->set('client_tg.sandbox', true);
});
/** Кампания-список заданного статуса с двумя фейковыми номерами. */
function externalIdCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
/** Подменяет раннер моком, возвращающим $result. */
function externalIdMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
it('успех: mts_campaign_id сохранён, статус draft_ready', function () {
$tenant = Tenant::factory()->create();
$campaign = externalIdCampaign($tenant->id);
externalIdMockRobot(new RobotResult(ok: true, matched: 437, launched: false, campaignId: '2231134'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->mts_campaign_id)->toBe('2231134')
->and($fresh->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
it('отказ с id: mts_campaign_id сохранён, статус needs_review (могла уйти на модерацию — ревью-фикс F5)', function () {
$tenant = Tenant::factory()->create();
$campaign = externalIdCampaign($tenant->id);
externalIdMockRobot(RobotResult::failed('regulated-тема', 'ad', '2231134'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
// Черновик в кабинете реально создан (есть id) → кампания МОГЛА уйти на модерацию →
// needs_review, деньги вслепую не возвращаем (F5). Раньше было failed (риск оплаты показов).
expect($fresh->mts_campaign_id)->toBe('2231134')
->and($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
});
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Ревью-фикс F2: рассинхрон «валидатор длиннее колонки». Раньше значение длиннее
* колонки проходило валидатор и падало на INSERT (22001), а глобальный хендлер
* маскировал это под generic 422 «Не удалось сохранить» (как БАГ схемы в логах).
* Теперь граница = размеру колонки чистая ошибка ПОЛЯ (assertJsonValidationErrors),
* а не замаскированная ошибка БД. Колонки: campaigns.ad_link varchar(500),
* campaigns.ord_category varchar(200). Проверки на senders.name убраны вместе с самой
* сущностью «своё имя отправителя» она отменена в v9.27 как СМС-фантазия
* (таблица client_tg_senders и её endpoint дропнуты, запрос отдаёт 405).
* Синтетические номера 7999 реальных ПДн нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> валидный payload создания кампании по списку. */
function lenStorePayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
], $overrides);
}
it('ad_link длиннее 500 символов — ошибка поля ad_link, не 500', function () {
Queue::fake();
$this->postJson('/api/telegram/campaigns', lenStorePayload([
'ad_link' => 'https://t.me/'.str_repeat('a', 500), // валидный url длиной 513
]))->assertStatus(422)->assertJsonValidationErrors('ad_link');
});
it('ord_category длиннее 200 символов — ошибка поля ord_category, не 500', function () {
Queue::fake();
$this->postJson('/api/telegram/campaigns', lenStorePayload([
'ord_category' => str_repeat('к', 201),
]))->assertStatus(422)->assertJsonValidationErrors('ord_category');
});
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Верхние пределы на вход в CampaignController::store (находка #13, задача 1.3).
* `phones`/`audience_days` без верхнего предела клиент может прислать миллион
* номеров (нагрузка/DoS). `ad_text`/`ad_link` НЕ трогаем уже ограничены.
*
* dropped_count число нераспознанных (мусорных) номеров, отброшенных
* storePhones молча; добавочный ключ в ответе store, не ломает плоский контракт.
*
* Auth/tenant как в CampaignApiTest. Синтетические номера 7999 реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** @return array<string, mixed> валидный payload создания кампании по списку номеров. */
function limStorePayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
], $overrides);
}
// 🪤 Полный HTTP-прогон с 200001 реальным элементом здесь НЕ используется:
// замерено — 'phones' => 'array|max:200000' САМ ПО СЕБЕ отбрасывает 200001
// элементов за ~0.1с (Validator::fails() проверялся изолированно), но
// соседнее правило 'phones.*' => 'string|max:32' — это Laravel wildcard-rule,
// который валидирует КАЖДЫЙ элемент независимо от того, что 'phones' уже не
// прошёл (bail/stopOnFirstFailure на это не влияют — разные top-level поля).
// Замер роста: n=1000→0.09с, n=5000→0.37с, n=20000→2.8с (не линейно, ~n^1.8) —
// на n=200001 это уже единицы минут за ОДИН прогон теста, что не пригодно для
// регулярного TDD-цикла. Поэтому граница 200000 проверяется СТРУКТУРНО (текст
// правила в контроллере), а не полным раннтайм-прогоном 200001 элементов.
it('верхний предел phones задан в контроллере как max:200000 (структурная проверка — полный HTTP-прогон 200001 элемента через Laravel Validator занимает единицы минут из-за wildcard-правила phones.*, см. комментарий выше)', function () {
$source = file_get_contents(app_path('Http/Controllers/Api/ClientTg/CampaignController.php'));
expect($source)->toContain("'phones' => 'nullable|array|max:200000',");
});
it('phones немного за пределами разумного (500 валидных номеров) — 201, не ложное срабатывание', function () {
Queue::fake();
$this->postJson('/api/telegram/campaigns', limStorePayload([
'phones' => array_fill(0, 500, '79990000001'),
]))->assertCreated();
});
it('phones.* элемент длиннее 32 символов отклоняется — 422', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload([
'phones' => [str_repeat('7', 40)],
]))->assertStatus(422)->assertJsonValidationErrors('phones.0');
});
it('audience_days=366 при audience_kind=deals отклоняется — 422', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload([
'audience_kind' => 'deals',
'audience_days' => 366,
'phones' => null,
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
});
it('валидный запрос (2 номера) проходит — 201 и dropped_count=0', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload())
->assertCreated()
->assertJsonPath('dropped_count', 0);
});
it('валидный запрос с 1 мусорным номером — 201 и dropped_count=1', function () {
$this->postJson('/api/telegram/campaigns', limStorePayload([
'phones' => ['79990000001', 'абв'],
]))->assertCreated()->assertJsonPath('dropped_count', 1);
});
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
/**
* Задача 1.4 (находка #14) — идемпотентность запуска на уровне БД через lockForUpdate.
* Проблема: статус-гвард («не queued no-op») защищает от ПОСЛЕДОВАТЕЛЬНОЙ
* переотправки, но НЕ от гонки двух воркеров, читающих queued-строку ОДНОВРЕМЕННО
* до того, как первый переведёт кампанию в running. Фикс читать строку кампании
* в Фазе A с lockForUpdate внутри существующей tenantTx-транзакции.
*/
beforeEach(function () {
config()->set('client_tg.sandbox', true);
});
/** Кампания-список с двумя фейковыми номерами (ПДн в тестах не используем). */
function idemQueuedCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
/** Подменяет раннер моком, возвращающим $result; захватывает переданные params. */
function idemMockRobot(RobotResult $result, ?array &$captured = null): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use ($result, &$captured) {
$captured = $params;
return $result;
});
app()->instance(TelegramRobotRunner::class, $mock);
}
it('Фаза A читает queued-кампанию с блокировкой строки (SELECT ... FOR UPDATE)', function () {
$tenant = Tenant::factory()->create();
$campaign = idemQueuedCampaign($tenant->id);
idemMockRobot(new RobotResult(ok: true, matched: 2));
DB::enableQueryLog();
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$queries = DB::getQueryLog();
DB::disableQueryLog();
$lockedRead = collect($queries)->contains(function ($q) {
$sql = strtolower($q['query']);
return str_contains($sql, 'client_tg_campaigns')
&& str_contains($sql, 'for update');
});
expect($lockedRead)->toBeTrue();
});
it('повторный запуск (redelivery) не зовёт робота второй раз', function () {
$tenant = Tenant::factory()->create();
$campaign = idemQueuedCampaign($tenant->id);
$runs = 0;
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function () use (&$runs) {
$runs++;
return new RobotResult(ok: true, matched: 2);
});
app()->instance(TelegramRobotRunner::class, $mock);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); // повтор — уже draft_ready → no-op
expect($runs)->toBe(1);
});
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Ручной сквозной сценарий в песочнице (план §Сессия 3, задача 3.4):
* создать (API) запустить (API) джоб (робот ЗАМОКАН, mode:draft)
* статус draft_ready + matched виден клиенту в API.
*
* Робот кабинета МТС не трогаем (замокан). Синтетические номера 7999 реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
config(['client_tg.sandbox' => true]);
});
it('песочница: создать → запустить → робот-черновик → draft_ready + matched виден в API', function () {
Queue::fake();
// Робот замокан: черновик готов, нашлось 2 совпадения в кабинете МТС.
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->once()->andReturn(new RobotResult(ok: true, matched: 2));
app()->instance(TelegramRobotRunner::class, $mock);
// 1. Клиент создаёт черновик из списка своих номеров.
$create = $this->postJson('/api/telegram/campaigns', [
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'phones' => ['79990000001', '79990000002'],
]);
$create->assertCreated()->assertJsonPath('status', Campaign::STATUS_DRAFT);
$id = $create->json('id');
expect($create->json('planned_count'))->toBe(2);
// 2. Клиент жмёт «Запустить» — кампания в очереди, джоб поставлен.
$this->postJson("/api/telegram/campaigns/{$id}/launch")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_QUEUED);
Queue::assertPushed(RunTelegramCampaignJob::class);
// 3. Отрабатывает воркер очереди (робот замокан) — доводит до черновика.
// Зовём handle через контейнер: под Queue::fake() dispatchSync не выполняется,
// а нам нужен реальный прогон джоба с замоканным роботом.
app()->call([new RunTelegramCampaignJob($id, $this->tenant->id), 'handle']);
// 4. Клиент видит в кабинете: черновик готов, совпадений — 2.
$this->getJson("/api/telegram/campaigns/{$id}")
->assertOk()
->assertJsonPath('campaign.status', Campaign::STATUS_DRAFT_READY)
->assertJsonPath('campaign.matched_count', 2);
// Список тоже отражает финальный статус.
$index = $this->getJson('/api/telegram/campaigns')->assertOk();
expect(collect($index->json('campaigns'))->firstWhere('id', $id)['status'])
->toBe(Campaign::STATUS_DRAFT_READY);
});
@@ -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);
});
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Tariff;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates a campaign with related phones and reads them back via hasMany', function () {
$tenant = Tenant::factory()->create();
$campaign = Campaign::create([
'tenant_id' => $tenant->id,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'Подключите наш телеграм-бот и получайте заявки быстрее',
'ad_link' => 'https://liderra.ru',
'media_path' => null,
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_DEALS,
'audience_params' => ['days' => 30],
'planned_count' => 420,
'matched_count' => null,
'estimated_cost_rub' => '168.00',
'actual_cost_rub' => null,
'created_by' => null,
]);
$phoneA = CampaignPhone::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000001',
'expires_at' => null,
]);
CampaignPhone::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000002',
'expires_at' => null,
]);
expect($campaign->phones)->toHaveCount(2)
->and($phoneA->campaign->id)->toBe($campaign->id);
});
it('round-trips the audience_params array cast', function () {
$tenant = Tenant::factory()->create();
$campaign = Campaign::create([
'tenant_id' => $tenant->id,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'text',
'ad_link' => null,
'media_path' => null,
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '0.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'audience_params' => ['phones_count' => 500, 'note' => 'файл'],
'created_by' => null,
]);
$campaign->refresh();
// jsonb не сохраняет порядок ключей — сверяем по ключам, не по порядку.
expect($campaign->audience_params)->toBeArray()
->and($campaign->audience_params['phones_count'])->toBe(500)
->and($campaign->audience_params['note'])->toBe('файл');
});
it('creates and reads back a contact (своя база)', function () {
$tenant = Tenant::factory()->create();
$contact = Contact::create([
'tenant_id' => $tenant->id,
'phone' => '79990000003',
'name' => 'Иван Иванов',
'operator' => 'Билайн',
]);
$contact->refresh();
expect($contact->phone)->toBe('79990000003')
->and($contact->name)->toBe('Иван Иванов');
});
it('reads seeded tariffs', function () {
expect(Tariff::count())->toBe(5);
});
it('round-trips the decimal cast on budget_cap_rub', function () {
$tenant = Tenant::factory()->create();
$campaign = Campaign::create([
'tenant_id' => $tenant->id,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'decimal test',
'ad_link' => null,
'media_path' => null,
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.5',
'audience_kind' => Campaign::AUDIENCE_DEALS,
'audience_params' => null,
'created_by' => null,
]);
$campaign->refresh();
expect((string) $campaign->budget_cap_rub)->toBe('1000.50');
});
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Задача 3.2 статус `moderating` («на модерации») + переходы.
* Живая отправка робота (launched=true) означает «ушло на модерацию МТС», а НЕ
* «запущено»: статус становится `moderating`, а `launched` придёт позже, когда
* опросчик вердикта (задача 3.4) увидит одобрение. Песочница по-прежнему
* `draft_ready`. Отклонённую можно пересдать: `rejected → queued`.
*/
/** Кампания в заданном статусе (self-contained, без общих global-хелперов). */
function moderatingCampaign(string $status): Campaign
{
$tenant = Tenant::factory()->create();
return Campaign::query()->create([
'tenant_id' => $tenant->id,
'status' => $status,
'ad_text' => 'Тест модерации',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_DEALS,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
}
/** Queued-кампания-список с фейковыми номерами для прогона джоба. */
function moderatingQueuedCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Приходите к нам',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
function moderatingMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
it('константа STATUS_MODERATING существует и равна moderating', function () {
expect(Campaign::STATUS_MODERATING)->toBe('moderating');
});
it('из running допускает moderating', function () {
$c = moderatingCampaign(Campaign::STATUS_RUNNING);
expect($c->canTransitionTo(Campaign::STATUS_MODERATING))->toBeTrue();
$c->transitionTo(Campaign::STATUS_MODERATING);
expect($c->status)->toBe(Campaign::STATUS_MODERATING);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING);
});
it('из moderating допускает launched и rejected', function (string $to) {
$c = moderatingCampaign(Campaign::STATUS_MODERATING);
$c->transitionTo($to);
expect($c->status)->toBe($to);
})->with([
Campaign::STATUS_LAUNCHED,
Campaign::STATUS_REJECTED,
]);
it('rejected → queued разрешён (пересдача)', function () {
$c = moderatingCampaign(Campaign::STATUS_REJECTED);
expect($c->canTransitionTo(Campaign::STATUS_QUEUED))->toBeTrue();
$c->transitionTo(Campaign::STATUS_QUEUED);
expect($c->status)->toBe(Campaign::STATUS_QUEUED);
});
it('moderating не пускает назад в running', function () {
$c = moderatingCampaign(Campaign::STATUS_MODERATING);
expect(fn () => $c->transitionTo(Campaign::STATUS_RUNNING))
->toThrow(DomainException::class);
expect($c->status)->toBe(Campaign::STATUS_MODERATING);
});
it('живой finalize (launched=true) → moderating, а НЕ launched', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
$campaign = moderatingQueuedCampaign($tenant->id);
moderatingMockRobot(new RobotResult(ok: true, matched: 437, launched: true, campaignId: '2231140'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and($fresh->matched_count)->toBe(437)
->and($fresh->mts_campaign_id)->toBe('2231140');
});
it('песочница (draft) остаётся draft_ready, не moderating', function () {
config()->set('client_tg.sandbox', true);
$tenant = Tenant::factory()->create();
$campaign = moderatingQueuedCampaign($tenant->id);
moderatingMockRobot(new RobotResult(ok: true, matched: 400, launched: false, campaignId: 'draft'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
/**
* Ревью-фикс F4 (Мост #1): кампания не должна вечно висеть в `moderating`, если робот
* не может прочитать вердикт (сломались селекторы кабинета / кампанию удалили / id
* протух) иначе бронь кошелька заперта бессрочно. Предохранитель по возрасту: старше
* окна `client_tg.moderation_stuck_hours` `needs_review` для ручного разбора, БЕЗ
* возврата брони (кампания могла реально показываться деньги вслепую не трогаем).
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
/** Кампания на модерации с состаренным updated_at (в часах). */
function modStuckCampaign(int $tenantId, ?string $mtsId, int $ageHours): Campaign
{
$c = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_MODERATING,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Мод-стак-тест',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
if ($ageHours > 0) {
DB::table('client_tg_campaigns')->where('id', $c->id)
->update(['updated_at' => now()->subHours($ageHours)]);
}
return $c;
}
function modStuckWallet(int $tenantId, int $campaignId, string $balance, string $freeze): void
{
AdWallet::query()->create(['tenant_id' => $tenantId, 'balance_rub' => $balance, 'frozen_rub' => '0.00']);
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
}
it('moderating допускает переход в needs_review', function () {
$t = Tenant::factory()->create();
$c = modStuckCampaign($t->id, '2231160', 0);
expect($c->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW))->toBeTrue();
});
it('moderating дольше окна → needs_review, бронь НЕ возвращается', function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.moderation_stuck_hours', 24);
$t = Tenant::factory()->create();
$c = modStuckCampaign($t->id, '2231161', ageHours: 25);
modStuckWallet($t->id, $c->id, '5000.00', '1000.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
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);
});
it('свежая moderating (в пределах окна) не трогается', function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.moderation_stuck_hours', 24);
$t = Tenant::factory()->create();
$c = modStuckCampaign($t->id, '2231162', ageHours: 2); // 2ч < 24ч
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING);
});
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\PollTelegramModerationJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
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;
// Опросчик перечисляет moderating-кампании через pgsql_supplier (BYPASSRLS).
// Шарим PDO, иначе данные транзакции RefreshDatabase не видны supplier-соединению.
uses(RefreshDatabase::class, SharesSupplierPdo::class);
/**
* Задача 3.4 PollTelegramModerationJob: опросчик вердикта модерации МТС.
*
* Живая отправка робота ставит `moderating` (задача 3.2). Опросчик раз в цикл
* читает кабинет роботом-читалкой (в тестах мок) по `mts_campaign_id` и применяет
* вердикт:
* `rejected` статус `rejected` + причина; ВОЗВРАТ списанной сметы на ОБЩИЙ
* баланс тенанта (в бою); уведомление «отклонена»;
* `approved` `launched`; деньги НЕ трогаем (смету списали при запуске);
* уведомление «одобрена»;
* `moderating` (ещё не проверено) / робот не смог прочитать НЕ трогаем, ждём
* следующего цикла (деньги/статус без изменений).
*
* Денежная модель списание/возврат с ОБЩЕГО баланса (`tenants.balance_rub`) через
* TelegramCampaignChargeService; отдельного рекламного кошелька с заморозкой больше нет.
*/
/** Кампания в заданном статусе (self-contained), смета ненулевая. */
function pollCampaign(int $tenantId, string $status, ?string $mtsId = null, string $estimatedCost = '315.00'): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Опрос-тест',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => $estimatedCost,
'created_by' => 1,
]);
}
/** «Уже списано при запуске»: списывает смету кампании с общего баланса тенанта. */
function pollChargeCampaign(Tenant $tenant, Campaign $campaign): void
{
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
}
/** Подменяет робота-читалку мок-результатом. */
function pollMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('readModeration')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
/** Мок NotificationService для проверки, какое уведомление ушло. */
function pollMockNotifications(): MockInterface
{
$mock = Mockery::mock(NotificationService::class);
app()->instance(NotificationService::class, $mock);
return $mock;
}
it('RobotResult знает moderationStatus и разбирает его из JSON робота', function () {
$r = RobotResult::fromRobotJson(['ok' => true, 'moderationStatus' => 'rejected', 'reason' => 'нельзя']);
expect($r->moderationStatus)->toBe('rejected')
->and($r->reason)->toBe('нельзя');
// Отсутствие поля → null (робот его пока не прислал).
expect(RobotResult::fromRobotJson(['ok' => true])->moderationStatus)->toBeNull();
});
it('вердикт «отклонена» → rejected + причина, смета возвращена на общий баланс, уведомление «отклонена»', function () {
config()->set('client_tg.sandbox', false);
$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();
$notify->shouldReceive('notifyTelegramCampaignRejected')->once();
$notify->shouldReceive('notifyTelegramCampaignApproved')->never();
PollTelegramModerationJob::dispatchSync();
$fresh = Campaign::find($c->id);
expect($fresh->status)->toBe(Campaign::STATUS_REJECTED)
->and($fresh->status_reason)->toBe('Ссылка недоступна')
// Отказ вернул смету на общий баланс — восстановлен до фондирования.
->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(['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();
$notify->shouldReceive('notifyTelegramCampaignApproved')->once();
$notify->shouldReceive('notifyTelegramCampaignRejected')->never();
PollTelegramModerationJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_LAUNCHED)
// Списание осталось — 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(['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();
$notify->shouldReceive('notifyTelegramCampaignRejected')->never();
$notify->shouldReceive('notifyTelegramCampaignApproved')->never();
PollTelegramModerationJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING)
// Ни списания, ни возврата — баланс остался на уровне после запуска.
->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 () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231160');
pollMockRobot(new RobotResult(ok: false, reason: 'кабинет не открылся'));
$notify = pollMockNotifications();
$notify->shouldReceive('notifyTelegramCampaignRejected')->never();
$notify->shouldReceive('notifyTelegramCampaignApproved')->never();
PollTelegramModerationJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING);
});
it('moderating без mts_campaign_id не опрашивается (робот не зовётся)', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, null);
// Робот-мок вернул бы approved, но кампанию без id опросчик даже не берёт.
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'approved'));
$notify = pollMockNotifications();
$notify->shouldReceive('notifyTelegramCampaignApproved')->never();
PollTelegramModerationJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING);
});
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
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\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 ВОЗВРАТ денег с ОБЩЕГО баланса тенанта (tenants.balance_rub, как в
* СМС) при отказе робота (finalize), перманентном сбое джоба (failed) и отмене
* кампании (cancel). Старого рекламного кошелька с freeze/frozen_rub больше нет:
* деньги списываются на запуске (charge) и возвращаются (refund) через
* TelegramCampaignChargeService. Источник правды идемпотентности сальдо проводок
* balance_transactions (TYPE_TG_AD_CHARGE TYPE_TG_AD_REFUND по related Campaign).
*
* Ключевые развилки, которые проверяем:
* - отказ робота БЕЗ 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 $estimatedCost = '315.00'): Campaign
{
$campaign = 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' => $estimatedCost,
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
/** Подменяет раннер моком, возвращающим $result. */
function refundMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
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);
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 () {
config()->set('client_tg.sandbox', true);
$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);
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: не списывали → возврат no-op, баланс цел, статус cancelled', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
// Черновик деньги не списывал — cancel вызовет refund, но возвращать нечего (сальдо 0).
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
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(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_RUNNING, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_RUNNING);
});
it('cancel в песочнице не трогает деньги (баланс цел, проводок нет)', function () {
config()->set('client_tg.sandbox', true);
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Обратная связь по отказу (план §Сессия 5, задача 5.2). Робот ЗАМОКАН.
* Причина отказа/сбоя сохраняется в client_tg_campaigns.status_reason и
* доводится до клиента in-app уведомлением (колокольчик) без pref-гейта
* (важное операционное сообщение доходит всегда, как notifyProjectRule).
*/
beforeEach(fn () => config()->set('client_tg.sandbox', true));
function tgRejectCampaign(int $tenantId, string $status = Campaign::STATUS_QUEUED): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'ad_text' => 'Займ под залог недвижимости',
'ad_link' => 'https://t.me/example_test',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
function mockRobotFail(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
it('ошибка робота сохраняет причину в status_reason', function () {
$tenant = Tenant::factory()->create();
$campaign = tgRejectCampaign($tenant->id);
mockRobotFail(RobotResult::failed('Канал недоступен', 'finalize'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and($fresh->status_reason)->toBe('Канал недоступен');
});
it('при провале клиент получает in-app уведомление с причиной', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
$campaign = tgRejectCampaign($tenant->id);
mockRobotFail(RobotResult::failed('Требуется лицензия ЦБ на займы', 'finalize'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$notif = InAppNotification::where('tenant_id', $tenant->id)
->where('user_id', $user->id)
->first();
expect($notif)->not->toBeNull()
->and($notif->body)->toContain('Требуется лицензия ЦБ на займы');
});
it('уведомление об отказе доходит даже без включённых настроек уведомлений', function () {
$tenant = Tenant::factory()->create();
// Дефолтные настройки фабрики НЕ содержат события tg_campaign_rejected —
// это и проверяет, что уведомление доходит без pref-гейта.
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
]);
$campaign = tgRejectCampaign($tenant->id, Campaign::STATUS_RUNNING);
$campaign->status_reason = 'Реклама займов запрещена без лицензии';
app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign);
$notif = InAppNotification::where('user_id', $user->id)->first();
expect($notif)->not->toBeNull()
->and($notif->body)->toContain('займов');
});
it('неактивный пользователь уведомление НЕ получает', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]);
$campaign = tgRejectCampaign($tenant->id, Campaign::STATUS_RUNNING);
$campaign->status_reason = 'Причина';
app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign);
// 🪤 Считаем уведомления ИМЕННО этого пользователя, а не всю таблицу целиком —
// см. такую же правку в ApproveNotifyTest.
expect(InAppNotification::where('user_id', $user->id)->count())->toBe(0);
});
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
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);
/**
* Джоб ПЕРЕСДАЧИ отклонённой Telegram-кампании (связка Laravel робот mode:'resubmit').
* Робот ЗАМОКАН. Пересдача ЧИНИТ ту же кампанию в кабинете (через «Исправить»), НЕ
* создаёт новую и НЕ грузит аудиторию заново. Песочница submitMode=draft (не шлём);
* бой submitMode=live (робот жмёт «без оплаты»). Приёмка
* docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md.
*/
beforeEach(function () {
config()->set('client_tg.sandbox', true);
});
/** Кампания, поставленная контроллером в очередь на пересдачу (queued + id кабинета). */
function tgResubmitQueued(int $tenantId, array $overrides = []): Campaign
{
return Campaign::query()->create(array_merge([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'mts_campaign_id' => '2231134',
'ad_text' => 'Исправленный текст',
'ad_link' => 'https://t.me/new_channel',
'ord_category' => 'Размещение рекламы',
'moderator_file_path' => 'client_tg/moderator/licenziya.pdf',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 400,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
], $overrides));
}
/** Подменяет раннер моком, возвращающим $result; захватывает params. */
function mockResubmitRunner(RobotResult $result, ?array &$captured = null): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use ($result, &$captured) {
$captured = $params;
return $result;
});
app()->instance(TelegramRobotRunner::class, $mock);
}
it('песочница: queued → робот ok+resubmitted=false → draft_ready', function () {
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id);
mockResubmitRunner(new RobotResult(ok: true, resubmitted: false, campaignId: '2231134'), $captured);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
it('передаёт роботу mode=resubmit, id кабинета, submitMode, текст/ссылку/ОРД и документ', function () {
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id);
$captured = null;
mockResubmitRunner(new RobotResult(ok: true, resubmitted: false, campaignId: '2231134'), $captured);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect($captured)->not->toBeNull()
->and($captured['mode'])->toBe('resubmit')
->and($captured['campaignId'])->toBe('2231134')
->and($captured['submitMode'])->toBe('draft')
->and($captured['adText'])->toBe('Исправленный текст')
->and($captured['buttonUrl'])->toBe('https://t.me/new_channel')
->and($captured['ordCategory'])->toBe('Размещение рекламы')
->and($captured['moderatorFile'])->toBe('client_tg/moderator/licenziya.pdf');
});
it('бой: робот ok+resubmitted=true → moderating, id кабинета сохранён, submitMode=live', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id);
$captured = null;
mockResubmitRunner(new RobotResult(ok: true, resubmitted: true, campaignId: '2231134'), $captured);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and($fresh->mts_campaign_id)->toBe('2231134')
->and($captured['submitMode'])->toBe('live');
});
it('отказ робота (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится, причина записана', function () {
config()->set('client_tg.sandbox', false);
$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'));
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->status_reason)->toBe('Не удалось войти в «Исправить»');
// 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 + refund произошёл, баланс восстановлен (робот кабинет не трогал)', function () {
// Регресс I-1: у пересдачи queued ВСЕГДА с mts_campaign_id, но в queued Фаза A не
// закоммитила running → кабинет не тронут → безопасно failed + refund. Без правки
// кампания зависала бы в queued со списанными деньгами (уборщик queued не метёт).
config()->set('client_tg.sandbox', false);
$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);
// 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, refund НЕ произошёл, списание держится (мог тронуть кабинет)', function () {
config()->set('client_tg.sandbox', false);
$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);
// 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 () {
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_DRAFT_READY]);
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldNotReceive('run');
app()->instance(TelegramRobotRunner::class, $mock);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
use App\Models\AdWallet;
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\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
/**
* Задача 3.6 пересдача отклонённой Telegram-кампании.
*
* Отклонённую модерацией кампанию (status=rejected, задача 3.4) клиент правит
* (текст/ссылка/категория ОРД + при желании документ модератору) и отправляет
* заново: rejected queued + ResubmitTelegramCampaignJob робот чинит ТУ ЖЕ
* кампанию в кабинете через «Исправить» (mts_campaign_id СОХРАНЯЕТСЯ, не создаём
* новую). Деньги: в бою бронь ставится заново (при отказе её вернул опросчик 3.4).
* Приёмка docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/**
* Отклонённая кампания с причиной и id кабинета (self-contained).
*
* 🪤 Приставка `tg` не для красоты: помощники Pest ГЛОБАЛЬНЫЕ функции, и рекламный
* модуль объявляет свой `rejectedCampaign()` (CampaignReviveServiceTest). При сведении
* веток полный прогон падал фаталом «Cannot redeclare» каждая ветка по отдельности
* этого увидеть не могла.
*/
function tgRejectedCampaign(int $tenantId, int $userId, array $overrides = []): Campaign
{
return Campaign::create(array_merge([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_REJECTED,
'status_reason' => 'Ссылка недоступна',
'mts_campaign_id' => '2231134',
'ad_text' => 'Старый текст',
'ad_link' => 'https://t.me/old_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 400,
'estimated_cost_rub' => '0.00',
'created_by' => $userId,
], $overrides));
}
/** @return array<string, mixed> валидный payload пересдачи. */
function resubmitPayload(array $overrides = []): array
{
return array_merge([
'ad_text' => 'Исправленный текст, ООО «Ромашка»',
'ad_link' => 'https://t.me/new_channel',
], $overrides);
}
it('пересдача (песочница): rejected→queued, правки применены, причина очищена, id кабинета СОХРАНЁН, джоб поставлен', function () {
Queue::fake();
$c = tgRejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertOk()
->assertJsonPath('status', Campaign::STATUS_QUEUED);
$fresh = Campaign::find($c->id);
expect($fresh->status)->toBe(Campaign::STATUS_QUEUED)
->and($fresh->ad_text)->toBe('Исправленный текст, ООО «Ромашка»')
->and($fresh->ad_link)->toBe('https://t.me/new_channel')
->and($fresh->status_reason)->toBeNull()
// Чиним ту же кампанию в кабинете — id НЕ очищаем (связка mode:'resubmit').
->and($fresh->mts_campaign_id)->toBe('2231134');
Queue::assertPushed(ResubmitTelegramCampaignJob::class);
// Песочница — заморозки нет.
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});
it('пересдать нельзя, если кампания не заведена в кабинете (нет mts_campaign_id) → 422, джоб не ставится', function () {
Queue::fake();
// Отклонённая, но без id кабинета — «Исправить» нечего (край; в норме id всегда есть).
$c = tgRejectedCampaign($this->tenant->id, $this->user->id, ['mts_campaign_id' => null]);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertStatus(422);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_REJECTED);
Queue::assertNothingPushed();
});
it('пересдача с файлом модератору: файл сохранён, путь записан в moderator_file_path', function () {
Queue::fake();
Storage::fake('local');
$c = tgRejectedCampaign($this->tenant->id, $this->user->id);
$file = UploadedFile::fake()->create('licenziya.pdf', 120, 'application/pdf');
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([
'moderator_file' => $file,
]))->assertOk();
$path = Campaign::find($c->id)->moderator_file_path;
expect($path)->toBeString()->not->toBeEmpty();
Storage::disk('local')->assertExists($path);
});
it('пересдать можно только отклонённую: черновик → 422, джоб не ставится', function () {
Queue::fake();
$c = tgRejectedCampaign($this->tenant->id, $this->user->id, [
'status' => Campaign::STATUS_DRAFT,
'status_reason' => null,
'mts_campaign_id' => null,
]);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertStatus(422);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_DRAFT);
Queue::assertNothingPushed();
});
it('пересдача (реальный режим, денег не хватает) → 409, кампания остаётся rejected, джоб не ставится', function () {
config(['client_tg.sandbox' => false]);
Queue::fake();
app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test');
// Гейт аудитории считает кандидатов до брони — засеваем с запасом над порогом.
for ($i = 0; $i < 367; $i++) {
Contact::create([
'tenant_id' => $this->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
$c = tgRejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload())
->assertStatus(409);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_REJECTED);
Queue::assertNothingPushed();
});
it('валидация: без текста/ссылки — 422', function () {
$c = tgRejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", [])
->assertStatus(422)
->assertJsonValidationErrors(['ad_text', 'ad_link']);
});
it('неверный формат файла (.exe) → 422', function () {
Storage::fake('local');
$c = tgRejectedCampaign($this->tenant->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([
'moderator_file' => UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'),
]))->assertStatus(422)->assertJsonValidationErrors('moderator_file');
});
it('изоляция тенанта: пересдача чужой кампании → 404', function () {
$tenantB = Tenant::factory()->create();
$foreign = tgRejectedCampaign($tenantB->id, $this->user->id);
$this->postJson("/api/telegram/campaigns/{$foreign->id}/resubmit", resubmitPayload())
->assertStatus(404);
});
@@ -0,0 +1,51 @@
<?php
use App\Services\ClientTg\RobotResult;
it('пробрасывает строковый actualCostRub из JSON робота', function () {
$r = RobotResult::fromRobotJson([
'ok' => true,
'matched' => 437,
'actualCostRub' => '201.60',
]);
expect($r->actualCostRub)->toBe('201.60');
expect($r->ok)->toBeTrue();
});
it('приводит числовой actualCostRub к строке', function () {
$r = RobotResult::fromRobotJson([
'ok' => true,
'actualCostRub' => 201.6,
]);
expect($r->actualCostRub)->toBe('201.6');
expect($r->actualCostRub)->not->toBeNull();
});
it('без поля actualCostRub оставляет null и не ломает остальные поля', function () {
$r = RobotResult::fromRobotJson([
'ok' => true,
'matched' => 12,
'campaignId' => '555',
]);
expect($r->actualCostRub)->toBeNull();
expect($r->ok)->toBeTrue();
expect($r->matched)->toBe(12);
expect($r->campaignId)->toBe('555');
});
it('failed() не выставляет actualCostRub', function () {
$r = RobotResult::failed('таймаут');
expect($r->actualCostRub)->toBeNull();
expect($r->ok)->toBeFalse();
expect($r->reason)->toBe('таймаут');
});
it('конструктор без actualCostRub даёт null по умолчанию', function () {
$r = new RobotResult(ok: true);
expect($r->actualCostRub)->toBeNull();
});
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
/**
* Обёртка вызова робота (план §Сессия 2, задача 2.2). Робот ЗАМОКАН node-скриптами
* из tests/Fixtures/tg-robot, печатающими известный JSON. Проверяем: формирование
* task.json, разбор {ok,matched,launched,campaignId}, и что таймаут/мусорный вывод
* дают аккуратный ok:false, а не падение воркера.
*/
/** Указывает обёртку на конкретный fixture-скрипт вместо реального робота. */
function useRobotFixture(string $script, float $timeout = 30.0): void
{
config()->set('client_tg.robot', [
'node' => 'node',
'script' => base_path('tests/fixtures/tg-robot/'.$script),
'cwd' => base_path('tests/fixtures/tg-robot'),
'timeout' => $timeout,
]);
}
/** @return array<string, mixed> */
function sampleTaskParams(): array
{
return [
'mode' => 'draft',
'phonesFile' => '/tmp/phones-tg-test.txt',
'adText' => 'Приходите к нам',
'budgetRub' => '1000.00',
'buttonUrl' => 'https://example.test',
'ordCategory' => 'Размещение рекламы',
];
}
it('строит task.json со всеми полями задания робота', function () {
$runner = app(TelegramRobotRunner::class);
$payload = $runner->taskPayload(sampleTaskParams());
expect($payload)->toMatchArray([
'mode' => 'draft',
'phonesFile' => '/tmp/phones-tg-test.txt',
'adText' => 'Приходите к нам',
'budgetRub' => '1000.00',
'buttonUrl' => 'https://example.test',
'ordCategory' => 'Размещение рекламы',
]);
});
it('подставляет дефолты mode=draft и категорию ОРД', function () {
$runner = app(TelegramRobotRunner::class);
$payload = $runner->taskPayload([
'phonesFile' => '/tmp/x.txt',
'adText' => 'текст',
'budgetRub' => '500.00',
'buttonUrl' => 'https://a.test',
]);
expect($payload['mode'])->toBe('draft')
->and($payload['ordCategory'])->toBe('Размещение рекламы')
->and($payload['mediaFile'])->toBeNull();
});
it('запускает робота и разбирает успешный JSON', function () {
useRobotFixture('print-ok.js');
$runner = app(TelegramRobotRunner::class);
$res = $runner->run(sampleTaskParams());
expect($res)->toBeInstanceOf(RobotResult::class)
->and($res->ok)->toBeTrue()
->and($res->matched)->toBe(512)
->and($res->launched)->toBeFalse()
->and($res->campaignId)->toBe('draft');
});
it('штатный отказ робота (exit 1) → ok:false с причиной, без исключения', function () {
useRobotFixture('print-fail.js');
$runner = app(TelegramRobotRunner::class);
$res = $runner->run(sampleTaskParams());
expect($res->ok)->toBeFalse()
->and($res->step)->toBe('upload')
->and($res->reason)->toContain('Недостаточно номеров');
});
it('непарсабельный вывод → аккуратный ok:false, воркер не падает', function () {
useRobotFixture('print-garbage.js');
$runner = app(TelegramRobotRunner::class);
$res = $runner->run(sampleTaskParams());
expect($res->ok)->toBeFalse()
->and($res->reason)->not->toBeNull();
});
it('таймаут робота → аккуратный ok:false, воркер не падает', function () {
useRobotFixture('sleep.js', timeout: 1.0);
$runner = app(TelegramRobotRunner::class);
$res = $runner->run(sampleTaskParams());
expect($res->ok)->toBeFalse()
->and($res->reason)->not->toBeNull();
});
it('НЕ оставляет временный task-файл после запуска (чистка)', function () {
useRobotFixture('print-ok.js');
$runner = app(TelegramRobotRunner::class);
$before = glob(sys_get_temp_dir().'/tg-task-*.json') ?: [];
$runner->run(sampleTaskParams());
$after = glob(sys_get_temp_dir().'/tg-task-*.json') ?: [];
expect(count($after))->toBeLessThanOrEqual(count($before));
});
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
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\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Джоб запуска Telegram-кампании (план §Сессия 2, задача 2.3). Робот ЗАМОКАН.
* Песочница по умолчанию (TG_SANDBOX=true): mode=draft, деньги не списываются.
* queued (робот ok+draft) draft_ready + matched; ошибка робота failed;
* идемпотентно по campaign_id (не-queued статус джоб no-op).
*/
beforeEach(function () {
config()->set('client_tg.sandbox', true);
});
/** Кампания-список с двумя фейковыми номерами (ПДн в тестах не используем). */
function tgQueuedCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
/** Подменяет раннер моком, возвращающим $result; захватывает переданные params. */
function mockRobot(RobotResult $result, ?array &$captured = null): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use ($result, &$captured) {
$captured = $params;
return $result;
});
app()->instance(TelegramRobotRunner::class, $mock);
}
it('queued → робот ok+draft → draft_ready + matched записан', function () {
$tenant = Tenant::factory()->create();
$campaign = tgQueuedCampaign($tenant->id);
mockRobot(new RobotResult(ok: true, matched: 400, launched: false, campaignId: 'draft'), $captured);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_DRAFT_READY)
->and($fresh->matched_count)->toBe(400);
});
it('в песочнице передаёт роботу mode=draft, бюджет-cap, текст, ссылку, ОРД и файл номеров', function () {
$tenant = Tenant::factory()->create();
$campaign = tgQueuedCampaign($tenant->id);
$captured = null;
mockRobot(new RobotResult(ok: true, matched: 400), $captured);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect($captured)->not->toBeNull()
->and($captured['mode'])->toBe('draft')
->and($captured['budgetRub'])->toBe('1500.00')
->and($captured['adText'])->toBe('Приходите к нам за услугой')
->and($captured['buttonUrl'])->toBe('https://example.test/promo')
->and($captured['ordCategory'])->toBe('Размещение рекламы')
->and($captured['phonesFile'])->toBeString()->not->toBeEmpty();
});
it('в песочнице деньги НЕ списываются', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgQueuedCampaign($tenant->id);
mockRobot(new RobotResult(ok: true, matched: 400));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('5000.00')
->and((string) $wallet->frozen_rub)->toBe('0.00');
});
it('ошибка робота → статус failed', function () {
$tenant = Tenant::factory()->create();
$campaign = tgQueuedCampaign($tenant->id);
mockRobot(RobotResult::failed('Недостаточно номеров «не МТС»', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED);
});
it('идемпотентно: не-queued кампания — джоб no-op, робота не зовёт', function () {
$tenant = Tenant::factory()->create();
$campaign = tgQueuedCampaign($tenant->id);
$campaign->update(['status' => Campaign::STATUS_DRAFT_READY]);
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldNotReceive('run');
app()->instance(TelegramRobotRunner::class, $mock);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
it('чистит временный файл номеров после запуска (ПДн)', function () {
$tenant = Tenant::factory()->create();
$campaign = tgQueuedCampaign($tenant->id);
$captured = null;
mockRobot(new RobotResult(ok: true, matched: 400), $captured);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(file_exists($captured['phonesFile']))->toBeFalse();
});
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Песочница и конфиг (план §Сессия 2, задача 2.4). Флаг TG_SANDBOX управляет
* режимом робота (draft/live) и читается через config('client_tg.sandbox').
*/
function sandboxQueuedCampaign(int $tenantId): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'ad_text' => 'Текст объявления',
'ad_link' => 'https://example.test',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 1,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => '79990000009',
]);
return $campaign;
}
/** @return array<string, mixed> захваченные параметры робота */
function runWithSandbox(bool $sandbox): array
{
config()->set('client_tg.sandbox', $sandbox);
$tenant = Tenant::factory()->create();
$campaign = sandboxQueuedCampaign($tenant->id);
$captured = [];
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use (&$captured) {
$captured = $params;
return new RobotResult(ok: true, matched: 400);
});
app()->instance(TelegramRobotRunner::class, $mock);
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
return $captured;
}
it('по умолчанию песочница включена (TG_SANDBOX=true)', function () {
// Конфиг грузится из config/client_tg.php с дефолтом env('TG_SANDBOX', true).
expect(config('client_tg.sandbox'))->toBeTrue();
});
it('конфиг робота имеет дефолты (script → bin/run.js, node, cwd, timeout)', function () {
// base_path() склеивает через '/', поэтому дефолт всегда оканчивается 'bin/run.js'.
expect(config('client_tg.robot.script'))->toEndWith('bin/run.js')
->and(config('client_tg.robot.node'))->toBe('node')
->and(config('client_tg.robot.cwd'))->toContain('mts-telegram-ads')
->and(config('client_tg.robot.timeout'))->toBeGreaterThan(0);
});
it('песочница вкл → робот вызывается в mode=draft', function () {
$captured = runWithSandbox(true);
expect($captured['mode'])->toBe('draft');
});
it('песочница выкл → mode=live (флаг реально управляет режимом)', function () {
$captured = runWithSandbox(false);
expect($captured['mode'])->toBe('live');
});
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
it('creates client_tg_campaigns with expected columns', function () {
expect(Schema::hasTable('client_tg_campaigns'))->toBeTrue();
expect(Schema::hasColumns('client_tg_campaigns', [
'tenant_id', 'status', 'ad_text', 'ad_link', 'media_path', 'ord_category',
'budget_cap_rub', 'audience_kind', 'audience_params', 'matched_count', 'created_by',
]))->toBeTrue();
});
it('creates client_tg_campaign_phones with expected columns', function () {
expect(Schema::hasTable('client_tg_campaign_phones'))->toBeTrue();
expect(Schema::hasColumns('client_tg_campaign_phones', [
'tenant_id', 'campaign_id', 'phone', 'expires_at',
]))->toBeTrue();
});
it('creates client_tg_optouts with expected columns', function () {
expect(Schema::hasTable('client_tg_optouts'))->toBeTrue();
expect(Schema::hasColumns('client_tg_optouts', [
'tenant_id', 'phone',
]))->toBeTrue();
});
it('creates client_tg_contacts with expected columns', function () {
expect(Schema::hasTable('client_tg_contacts'))->toBeTrue();
expect(Schema::hasColumns('client_tg_contacts', [
'tenant_id', 'phone', 'name', 'operator',
]))->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', [
'min_qty', 'price_rub',
]))->toBeTrue();
expect(DB::table('client_tg_tariffs')->count())->toBe(5);
});
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();
$policyExists = DB::table('pg_policies')
->where('tablename', 'client_tg_campaigns')
->where('policyname', 'tenant_isolation')
->exists();
expect($policyExists)->toBeTrue();
});
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Статус-машина Telegram-кампании (план §Сессия 2, задача 2.1):
* draft queued running (draft_ready | launched | failed | rejected)
* Недопустимый переход исключение, статус не меняется.
*/
/** Создаёт кампанию в нужном статусе без фабрики (её у ClientTg нет — зеркало СМС). */
function tgCampaign(string $status): Campaign
{
$tenant = Tenant::factory()->create();
return Campaign::query()->create([
'tenant_id' => $tenant->id,
'status' => $status,
'ad_text' => 'Тест',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_DEALS,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
}
it('разрешает канонический путь draft → queued → running → draft_ready', function () {
$c = tgCampaign(Campaign::STATUS_DRAFT);
$c->transitionTo(Campaign::STATUS_QUEUED);
expect($c->status)->toBe(Campaign::STATUS_QUEUED);
$c->transitionTo(Campaign::STATUS_RUNNING);
expect($c->status)->toBe(Campaign::STATUS_RUNNING);
$c->transitionTo(Campaign::STATUS_DRAFT_READY);
expect($c->status)->toBe(Campaign::STATUS_DRAFT_READY);
// Переход персистится в БД, а не только в объекте.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
it('из running допускает launched / failed / rejected', function (string $to) {
$c = tgCampaign(Campaign::STATUS_RUNNING);
$c->transitionTo($to);
expect($c->status)->toBe($to);
})->with([
Campaign::STATUS_LAUNCHED,
Campaign::STATUS_FAILED,
Campaign::STATUS_REJECTED,
]);
it('запрещает перескок draft → running (минуя queued) и не меняет статус', function () {
$c = tgCampaign(Campaign::STATUS_DRAFT);
expect(fn () => $c->transitionTo(Campaign::STATUS_RUNNING))
->toThrow(DomainException::class);
expect($c->status)->toBe(Campaign::STATUS_DRAFT);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_DRAFT);
});
it('запрещает уход из терминального статуса', function () {
$c = tgCampaign(Campaign::STATUS_LAUNCHED);
expect(fn () => $c->transitionTo(Campaign::STATUS_RUNNING))
->toThrow(DomainException::class);
});
it('canTransitionTo сообщает допустимость без броска', function () {
$c = tgCampaign(Campaign::STATUS_QUEUED);
expect($c->canTransitionTo(Campaign::STATUS_RUNNING))->toBeTrue()
->and($c->canTransitionTo(Campaign::STATUS_DRAFT_READY))->toBeFalse();
});
it('константа STATUS_CANCELLED существует и равна cancelled', function () {
expect(Campaign::STATUS_CANCELLED)->toBe('cancelled');
});
it('разрешает отмену из draft, переход персистится в БД', function () {
$c = tgCampaign(Campaign::STATUS_DRAFT);
expect($c->canTransitionTo(Campaign::STATUS_CANCELLED))->toBeTrue();
$c->transitionTo(Campaign::STATUS_CANCELLED);
expect($c->status)->toBe(Campaign::STATUS_CANCELLED);
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_CANCELLED);
});
it('разрешает отмену из queued', function () {
$c = tgCampaign(Campaign::STATUS_QUEUED);
expect($c->canTransitionTo(Campaign::STATUS_CANCELLED))->toBeTrue();
$c->transitionTo(Campaign::STATUS_CANCELLED);
expect($c->status)->toBe(Campaign::STATUS_CANCELLED);
});
it('запрещает отмену из running', function () {
$c = tgCampaign(Campaign::STATUS_RUNNING);
expect($c->canTransitionTo(Campaign::STATUS_CANCELLED))->toBeFalse();
expect(fn () => $c->transitionTo(Campaign::STATUS_CANCELLED))
->toThrow(DomainException::class);
expect($c->status)->toBe(Campaign::STATUS_RUNNING);
});
it('cancelled — терминальный статус', function () {
$c = tgCampaign(Campaign::STATUS_CANCELLED);
expect($c->canTransitionTo(Campaign::STATUS_QUEUED))->toBeFalse();
expect(fn () => $c->transitionTo(Campaign::STATUS_QUEUED))
->toThrow(DomainException::class);
expect($c->status)->toBe(Campaign::STATUS_CANCELLED);
});
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
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;
use Tests\Concerns\SharesSupplierPdo;
// SweepStuckTelegramCampaignsJob перечисляет кампании через pgsql_supplier
// (BYPASSRLS). В тестах шарим PDO, иначе данные, созданные на дефолтном
// соединении внутри транзакции RefreshDatabase, не видны supplier-соединению.
uses(RefreshDatabase::class, SharesSupplierPdo::class);
/**
* Задача 3.3 уборщик зависших кампаний + `$timeout` робота.
*
* Кампания в статусе `running` дольше окна (робот-таймаут + запас) считается
* зависшей: воркер убили/упал молча. Уборщик добивает статус, но КОНСЕРВАТИВНО с
* деньгами (списание/возврат идёт с ОБЩЕГО баланса тенанта `tenants.balance_rub`
* через TelegramCampaignChargeService рекламного кошелька с бронью больше нет):
* нет `mts_campaign_id` (черновик в кабинете не создавался) кампания заведомо
* НЕ ушла на модерацию `failed` + возврат списанной сметы на баланс;
* есть `mts_campaign_id` (черновик реально создан) кампания МОГЛА уйти на
* модерацию деньги вслепую НЕ возвращаем, помечаем `needs_review` (ручной
* разбор, пока не сверим статус в кабинете задачей 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, string $estimatedCost = '0.00'): Campaign
{
$c = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Свип-тест',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => $estimatedCost,
'created_by' => 1,
]);
if ($ageMinutes > 0) {
// Прямой апдейт updated_at минуя Eloquent-touch (иначе штамп «сейчас»).
DB::table('client_tg_campaigns')->where('id', $c->id)
->update(['updated_at' => now()->subMinutes($ageMinutes)]);
}
return $c;
}
/** Тенант с общим балансом + tenant-контекст (`SET LOCAL`) под его id для списаний. */
function sweepTenant(string $balance): Tenant
{
$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);
expect($c->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW))->toBeTrue();
$c->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
expect($c->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($c->canTransitionTo(Campaign::STATUS_FAILED))->toBeFalse()
->and($c->canTransitionTo(Campaign::STATUS_LAUNCHED))->toBeFalse();
});
it('таймаут джоба (420с) больше робот-таймаута', function () {
$job = new RunTelegramCampaignJob(1, 1);
expect($job->timeout)->toBe(420);
});
it('failed() переводит queued в failed', function () {
config()->set('client_tg.sandbox', true); // без возврата
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_QUEUED);
(new RunTelegramCampaignJob($c->id, $t->id))->failed(new RuntimeException('boom'));
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED);
});
it('зависшая running без mts_campaign_id → failed и деньги вернулись', function () {
config()->set('client_tg.sandbox', false);
$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) $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 () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 0);
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_RUNNING);
});
it('зависшая running С mts_campaign_id → needs_review, деньги НЕ вернулись', function () {
config()->set('client_tg.sandbox', false);
$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) $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);
});
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('ступень выбирается по объёму показов (границы)', function () {
$p = app(TelegramTariffService::class);
expect($p->pricePerImpressionRub(0))->toBe('0.45') // volume floored to >=1
->and($p->pricePerImpressionRub(999))->toBe('0.45')
->and($p->pricePerImpressionRub(1000))->toBe('0.42')
->and($p->pricePerImpressionRub(4999))->toBe('0.42')
->and($p->pricePerImpressionRub(5000))->toBe('0.40')
->and($p->pricePerImpressionRub(9999))->toBe('0.40')
->and($p->pricePerImpressionRub(10000))->toBe('0.38')
->and($p->pricePerImpressionRub(49999))->toBe('0.38')
->and($p->pricePerImpressionRub(50000))->toBe('0.36')
->and($p->pricePerImpressionRub(500000))->toBe('0.36');
});
it('смета = объём показов × цена ступени', function () {
$p = app(TelegramTariffService::class);
// 500 показов → ступень 0.45 → 225.00
expect($p->estimateRub(500))->toBe('225.00')
// 1000 показов → ступень 0.42 → 420.00
->and($p->estimateRub(1000))->toBe('420.00')
// 5000 показов → ступень 0.40 → 2000.00
->and($p->estimateRub(5000))->toBe('2000.00')
// 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');
});
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* 🔴 КЛАСС «тихий ноль». Обычные тесты ходят СУПЕРЮЗЕРОМ, который обходит защиту по
* клиентам, и потому физически не могут увидеть дыру этого класса. На бою очередь
* работает под обычной ролью `crm_app_user`, а защита денежных таблиц устроена строго:
*
* balance_transactions: tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
* tenants: id = current_setting('app.current_tenant_id', true)::bigint
*
* Телеграмные деньги идут с ОБЩЕГО баланса тенанта, и контекст клиента ставит не сам
* сервис, а вызывающий джоб (`tenantTx()` в PollTelegramModerationJob и соседях).
* Значит проверять надо две вещи, и обе под боевой ролью:
*
* 1. С контекстом (как зовёт джоб) деньги реально двигаются нет ни «тихого нуля»
* от RLS, ни отказа из-за забытого GRANT.
* 2. Без контекста работа НЕ проходит молча. Молчаливый пропуск здесь означал бы, что
* возврат за отклонённую модерацию просто не случился: `hasOutstandingCharge()`
* увидел бы ноль строк, `refund()` честно вышел бы, и деньги клиента остались бы
* у нас без ошибки и без строки в журнале.
*
* Зеркало сторожа рекламного кошелька tests/Feature/Advertising/AdWalletUnderRealRoleTest.php.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
// 🪤 Тестовая база собирается миграциями, а права ролям на проде раздаёт отдельный
// скрипт `db/02_grants.sql` (SELECT/INSERT/UPDATE/DELETE на ВСЕ таблицы схемы).
// Без него `crm_app_user` спотыкается о «нет доступа к таблице tenants» — и прибор
// мерил бы отсутствующий грант вместо защиты по клиентам. Выдаём права так же, как
// на бою, чтобы под ролью проверялась именно RLS.
DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_app_user');
DB::statement('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_app_user');
});
/** Выполнить под боевой ролью портала (не суперюзером). */
function podTgBoevoyRolyu(callable $chto): mixed
{
DB::statement('SET ROLE crm_app_user');
try {
return $chto();
} finally {
DB::statement('RESET ROLE');
}
}
/** Кампания в песочнице, деньги на общем балансе клиента. */
function tgDenezhnayaKampaniya(string $balanceRub = '5000.00'): Campaign
{
$tenant = Tenant::factory()->create(['balance_rub' => $balanceRub]);
return Campaign::create([
'tenant_id' => $tenant->id,
'ad_text' => 'Приходите к нам в канал',
'ad_link' => 'https://t.me/example_channel',
'audience_kind' => 'list',
'budget_cap_rub' => '1000.00',
'status' => Campaign::STATUS_MODERATING,
]);
}
it('списывает под боевой ролью с контекстом клиента — как это делает джоб запуска', function () {
$campaign = tgDenezhnayaKampaniya('5000.00');
$svc = app(TelegramCampaignChargeService::class);
podTgBoevoyRolyu(fn () => DB::transaction(function () use ($campaign, $svc) {
DB::statement('SET LOCAL app.current_tenant_id = '.$campaign->tenant_id);
$svc->charge($campaign, '1200.00');
}));
// Читаем суперюзером — прибор не должен зависеть от той же защиты, что проверяем.
expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe('3800.00');
expect(BalanceTransaction::where('related_id', $campaign->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
});
it('возвращает под боевой ролью с контекстом клиента — как это делает джоб модерации', function () {
$campaign = tgDenezhnayaKampaniya('5000.00');
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '1200.00');
// Модерация МТС отклонила кампанию: джоб возвращает клиенту всю смету.
podTgBoevoyRolyu(fn () => DB::transaction(function () use ($campaign, $svc) {
DB::statement('SET LOCAL app.current_tenant_id = '.$campaign->tenant_id);
$svc->refund($campaign);
}));
expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe('5000.00');
expect(BalanceTransaction::where('related_id', $campaign->id)
->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)->count())->toBe(1);
});
it('без контекста клиента возврат под боевой ролью падает ГРОМКО, а не молчит', function () {
$campaign = tgDenezhnayaKampaniya('5000.00');
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '1200.00');
$balanceDo = (string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub;
// Ровно та же работа, но джоб забыл поставить контекст. Сторож требует ошибку:
// молчаливый выход означал бы «деньги не вернулись, и никто не узнал».
//
// 🪤 Держится это на ОДНОЙ букве: база без контекста молча отдаёт ноль строк
// (`id = NULL` в политике `tenants_self_isolation` не совпадает ни с чем), и громким
// это делает только `firstOrFail()` в сервисе. Заменить его на `first()` — и возврат
// снова станет тихим. Поэтому проверяем именно исключение поиска тенанта.
expect(fn () => podTgBoevoyRolyu(fn () => $svc->refund($campaign)))
->toThrow(ModelNotFoundException::class);
// И деньги при этом не тронуты — ни возврата, ни половинчатого движения.
expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe($balanceDo);
});
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
const TG_CHANNEL = 'telegram';
it('замораживает резерв под Telegram-кампанию и учитывает его по каналу', function () {
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '1000.00', TG_CHANNEL, 'Пополнение');
$svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 5, '420.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('1000.00')
->and((string) $wallet->frozen_rub)->toBe('420.00')
->and(AdWalletTransaction::where('tenant_id', $tenant->id)->where('channel', TG_CHANNEL)->count())->toBe(2); // topup + freeze
});
it('freeze стережёт нехватку средств и бросает ДО списания', function () {
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '300.00', TG_CHANNEL, 'Пополнение');
// free = balance frozen = 300 < 400 → бросок ДО каких-либо списаний
expect(fn () => $svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 1, '400.00'))
->toThrow(InsufficientBalanceException::class);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00'); // ничего не заморозилось
});
it('charge НЕ бросает и обнуляет баланс, не уходя в минус', function () {
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '300.00', TG_CHANNEL, 'Пополнение');
// charge не бросает — потому проверка средств обязана быть ДО (через freeze).
$svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 1, '400.00', 'tg:1:test');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('0.00') // пол по нулю, не минус
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('channel', TG_CHANNEL)->where('type', AdWalletTransaction::TYPE_CHARGE)->exists())->toBeTrue();
});
it('полный поток telegram: topup → freeze → charge, идемпотентно по external_key', function () {
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '1000.00', TG_CHANNEL, 'Пополнение');
$svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00');
$svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00', 'tg:7:2026-07-27');
// повторное списание тем же external_key — идемпотентно, ничего не меняет
$svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00', 'tg:7:2026-07-27');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('580.00')
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('channel', TG_CHANNEL)->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Ревью-фикс C1: уникальный ключ hold (tenant, channel, source_type, source_id) БЕЗ
* статуса, а release() лишь метит строку 'released' (не удаляет). Значит повторная
* freeze() под тот же источник (пересдача кампании, повторная заявка на имя) должна
* РЕАКТИВИРОВАТЬ существующий released-hold, а не падать на дубле ключа (23505).
* Синтетические id реальных ПДн нет.
*/
it('повторная бронь после возврата реактивирует hold, без ошибки БД', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', 777, '500.00');
app(AdWalletService::class)->release($tenant->id, 'telegram', 'campaign', 777);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', 777, '500.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('500.00');
$holds = AdWalletHold::where('tenant_id', $tenant->id)
->where('source_type', 'campaign')->where('source_id', 777)->get();
expect($holds)->toHaveCount(1)
->and($holds->first()->status)->toBe(AdWalletHold::STATUS_ACTIVE);
});
it('повторная бронь после возврата берёт новую сумму', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', 777, '500.00');
app(AdWalletService::class)->release($tenant->id, 'telegram', 'campaign', 777);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', 777, '300.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('300.00');
});
it('активная бронь по-прежнему идемпотентна (двойной freeze не удваивает frozen)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', 777, '500.00');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', 777, '500.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('500.00');
});
@@ -33,8 +33,11 @@ const AD_ITEMS = [
{ testid: 'ad-nav-telegram', title: 'Реклама Телеграм' },
];
// B2-2: Яндекс — уже реальный роут, не заглушка. Остальные 4 — по-прежнему «В разработке».
const STUB_ITEMS = AD_ITEMS.filter((i) => i.testid !== 'ad-nav-yandex-audience');
// Каналы-заглушки: клик открывает «В разработке». Яндекс Аудитория и Реклама Телеграм
// уже ведут на реальные экраны — в этот список они не входят.
const STUB_ITEMS = AD_ITEMS.filter(
(i) => i.testid !== 'ad-nav-yandex-audience' && i.testid !== 'ad-nav-telegram',
);
describe('AppMoreDrawer — раздел «Рекламные возможности» на телефоне', () => {
it('содержит 5 рекламных пунктов с нужными названиями', async () => {
@@ -60,6 +63,15 @@ describe('AppMoreDrawer — раздел «Рекламные возможнос
expect(dialog.text()).toContain('01.09.2026');
});
it('клик по «Реклама Телеграм» НЕ открывает заглушку (ведёт на реальный экран)', async () => {
const wrapper = await setup();
const item = wrapper.find('[data-testid="ad-nav-telegram"]');
// Пункт ведёт на /advertising/telegram (реальный роут), а не на заглушку.
expect(item.attributes('href') ?? item.attributes('to')).toContain('/advertising/telegram');
await item.trigger('click');
expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
});
// B2-2: Яндекс Аудитория — теперь реальный переход, не заглушка; клик закрывает «Ещё».
it('клик по «Яндекс Аудитория» НЕ открывает заглушку и закрывает «Ещё»', async () => {
const wrapper = await setup();
@@ -22,8 +22,8 @@ async function setup(initialRoute = '/deals'): Promise<{ wrapper: VueWrapper; ro
global: {
plugins: [router, vuetify],
stubs: {
// B2-2: Яндекс теперь реальный RouterLink — стаб должен пробрасывать
// остальные атрибуты (data-testid и т.п.), не только class.
// Яндекс Аудитория и Реклама Телеграм — реальные RouterLink, поэтому стаб
// должен пробрасывать остальные атрибуты (data-testid и т.п.), не только class.
RouterLink: {
props: ['to'],
template: '<a v-bind="$attrs" :href="typeof to === \'string\' ? to : \'#\'"><slot /></a>',
@@ -44,8 +44,11 @@ const AD_ITEMS = [
{ testid: 'ad-nav-telegram', title: 'Реклама Телеграм' },
];
// B2-2: Яндекс — уже реальный роут, не заглушка. Остальные 4 — по-прежнему «В разработке».
const STUB_ITEMS = AD_ITEMS.filter((i) => i.testid !== 'ad-nav-yandex-audience');
// Каналы-заглушки: клик открывает «В разработке». Яндекс Аудитория и Реклама Телеграм
// уже ведут на реальные экраны — в этот список они не входят.
const STUB_ITEMS = AD_ITEMS.filter(
(i) => i.testid !== 'ad-nav-yandex-audience' && i.testid !== 'ad-nav-telegram',
);
describe('AppSidebar — раздел «Рекламные возможности»', () => {
beforeEach(() => localStorage.clear());
@@ -82,6 +85,14 @@ describe('AppSidebar — раздел «Рекламные возможност
expect(dialog.text()).toContain('01.09.2026');
});
it('«Реклама Телеграм» — активный пункт: ведёт на /advertising/telegram, не на заглушку', async () => {
const { wrapper } = await setup();
const item = wrapper.find('[data-testid="ad-nav-telegram"]');
expect(item.attributes('href') ?? item.attributes('to')).toContain('/advertising/telegram');
await item.trigger('click');
expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false);
});
it('кнопка «Понятно» закрывает окно', async () => {
const { wrapper } = await setup();
await wrapper.find('[data-testid="ad-nav-sms"]').trigger('click');
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import AdminTgView from '../../resources/js/views/admin/AdminTgView.vue';
import type { AdminTgTariffsResponse } from '../../resources/js/api/admin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
fetchTgTariffs: vi.fn(),
updateTgTariffs: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeTariffs(overrides: Partial<AdminTgTariffsResponse> = {}): AdminTgTariffsResponse {
return {
tariffs: [
{ id: 1, min_qty: 1, price_rub: '0.45' },
{ id: 2, min_qty: 1000, price_rub: '0.42' },
],
...overrides,
};
}
const mountView = () => mount(AdminTgView, { global: { plugins: [createVuetify()] } });
describe('AdminTgView', () => {
it('на mount загружает тарифы', async () => {
vi.mocked(adminApi.fetchTgTariffs).mockResolvedValueOnce(makeTariffs());
const wrapper = mountView();
await flushPromises();
expect(adminApi.fetchTgTariffs).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="tg-tariffs-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);
});
it('«Сохранить тарифы» вызывает updateTgTariffs с текущими строками', async () => {
vi.mocked(adminApi.fetchTgTariffs).mockResolvedValue(makeTariffs());
vi.mocked(adminApi.updateTgTariffs).mockResolvedValueOnce({ tariffs: makeTariffs().tariffs });
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-tariffs-save"]').trigger('click');
await flushPromises();
expect(adminApi.updateTgTariffs).toHaveBeenCalledTimes(1);
expect(vi.mocked(adminApi.updateTgTariffs).mock.calls[0][0]).toEqual([
{ min_qty: 1, price_rub: '0.45' },
{ min_qty: 1000, price_rub: '0.42' },
]);
});
it('ошибка загрузки тарифов показывает человеческое сообщение', async () => {
vi.mocked(adminApi.fetchTgTariffs).mockRejectedValueOnce(new Error('500'));
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="tg-tariffs-error"]').exists()).toBe(true);
});
});
@@ -0,0 +1,479 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
// Экран не должен ходить в сеть — мокаем vue-router и API-модуль telegram.ts.
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTelegramMock, uploadTelegramMediaMock } = vi.hoisted(() => ({
fetchTelegramMock: vi.fn(() =>
Promise.resolve({
campaigns: [] as Array<Record<string, unknown>>,
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
}),
),
createTelegramMock: vi.fn(() =>
Promise.resolve({ id: 1, status: 'draft', ad_text: 'Приходите', planned_count: 2, estimated_cost_rub: '10.00' }),
),
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', () => ({
fetchTelegram: fetchTelegramMock,
createTelegram: createTelegramMock,
fetchTelegramCampaign: vi.fn(() => Promise.resolve({ campaign: { id: 1 } })),
launchTelegram: launchTelegramMock,
resubmitTelegram: resubmitTelegramMock,
uploadTelegramMedia: uploadTelegramMediaMock,
// Встроенная панель «Авто-реклама» зовёт fetchAutoRule на монтировании — отдаём дефолты.
fetchAutoRule: vi.fn(() =>
Promise.resolve({
enabled: false,
ad_text: '',
ad_link: '',
ord_category: 'Размещение рекламы',
budget_cap_rub: '0.00',
daily_limit_rub: '0.00',
batch_threshold: null,
}),
),
saveAutoRule: vi.fn(() => Promise.resolve({ enabled: false })),
}));
import AdvertisingTelegramView from '../../resources/js/views/advertising/AdvertisingTelegramView.vue';
function mountView() {
return mount(AdvertisingTelegramView, {
global: { plugins: [createVuetify()] },
});
}
describe('AdvertisingTelegramView — экран клиентской Telegram-рекламы', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTelegramMock.mockResolvedValue({
campaigns: [],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
});
it('на монтировании грузит список и показывает баннер песочницы', async () => {
const wrapper = mountView();
await flushPromises();
expect(fetchTelegramMock).toHaveBeenCalledOnce();
expect(wrapper.find('[data-testid="tg-sandbox-banner"]').exists()).toBe(true);
});
it('форма: объявление, ссылка, три способа аудитории, бюджет-cap, кнопка «Запустить»', async () => {
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="tg-ad-text"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-ad-link"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-audience-deals"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-audience-base"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-audience-list"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-budget-cap"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-launch"]').exists()).toBe(true);
});
it('«Рассчитать» шлёт корректный payload create, затем «Запустить» запускает черновик', 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/example');
await wrapper.find('[data-testid="tg-audience-list"] input').setValue();
await wrapper.find('[data-testid="tg-phones"] textarea').setValue('79990000001\n79990000002');
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(createTelegramMock).toHaveBeenCalledWith(
expect.objectContaining({
ad_text: 'Приходите к нам',
ad_link: 'https://t.me/example',
audience_kind: 'list',
budget_cap_rub: '1000',
}),
);
await wrapper.find('[data-testid="tg-launch"]').trigger('click');
await flushPromises();
expect(launchTelegramMock).toHaveBeenCalledWith(1);
});
it('«Рассчитать» создаёт черновик и показывает смету+охват; «Запустить» — только после расчёта', async () => {
createTelegramMock.mockResolvedValue({
id: 1,
status: 'draft',
ad_text: 'Приходите к нам',
planned_count: 42,
estimated_cost_rub: '123.45',
});
const wrapper = mountView();
await flushPromises();
// До расчёта запуск недоступен.
expect(wrapper.find('[data-testid="tg-launch"]').classes()).toContain('v-btn--disabled');
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам');
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/example');
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(launchTelegramMock).not.toHaveBeenCalled();
const est = wrapper.find('[data-testid="tg-estimate"]');
expect(est.exists()).toBe(true);
expect(est.text()).toContain('123'); // прогноз стоимости
expect(est.text()).toContain('42'); // охват / кандидаты
// После расчёта запуск доступен.
expect(wrapper.find('[data-testid="tg-launch"]').classes()).not.toContain('v-btn--disabled');
await wrapper.find('[data-testid="tg-launch"]').trigger('click');
await flushPromises();
expect(launchTelegramMock).toHaveBeenCalledWith(1);
});
it('правка формы после расчёта сбрасывает смету и снова блокирует «Запустить»', async () => {
createTelegramMock.mockResolvedValue({
id: 1,
status: 'draft',
ad_text: 'A',
planned_count: 42,
estimated_cost_rub: '123.45',
});
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('A');
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(wrapper.find('[data-testid="tg-estimate"]').exists()).toBe(true);
// Меняем текст — прежний расчёт больше не действителен.
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Другой текст');
await flushPromises();
expect(wrapper.find('[data-testid="tg-estimate"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="tg-launch"]').classes()).toContain('v-btn--disabled');
});
it('у moderating — подсказка про срок ~4 часа; в песочнице — метка на каждой кампании', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 30, status: 'moderating', ad_text: 'A', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
const hint = wrapper.find('[data-testid="tg-moderating-hint-30"]');
expect(hint.exists()).toBe(true);
expect(hint.text()).toContain('4 час'); // «около 4 часов» / «~4 часа»
// Пока режим песочницы — на кампании есть метка «песочница».
expect(wrapper.find('[data-testid="tg-sandbox-tag-30"]').exists()).toBe(true);
});
it('вне песочницы метки «песочница» на кампаниях нет', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 31, status: 'launched', ad_text: 'A', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
],
sandbox: false,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="tg-sandbox-tag-31"]').exists()).toBe(false);
});
it('показывает статус кампаний человеческим ярлыком', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 7, status: 'draft_ready', ad_text: 'Готово', ad_link: 'https://t.me/x', planned_count: 5, matched_count: 4, estimated_cost_rub: '20.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
const list = wrapper.find('[data-testid="tg-campaigns"]');
expect(list.exists()).toBe(true);
expect(list.text()).toContain('Черновик готов');
});
it('статусы moderating/needs_review/cancelled показаны человеческим ярлыком', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 21, status: 'moderating', ad_text: 'A', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
{ id: 22, status: 'needs_review', ad_text: 'B', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
{ id: 23, status: 'cancelled', ad_text: 'C', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
const list = wrapper.find('[data-testid="tg-campaigns"]').text();
expect(list).toContain('На модерации');
expect(list).toContain('Проверяем вручную');
expect(list).toContain('Отменена');
// Сырых английских слагов быть не должно.
expect(list).not.toContain('moderating');
expect(list).not.toContain('needs_review');
expect(list).not.toContain('cancelled');
});
it('у отклонённой кампании показывает причину отказа (Сессия 5.2)', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 9, status: 'rejected', status_reason: 'Реклама займов запрещена без лицензии', ad_text: 'Займ', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
const reason = wrapper.find('[data-testid="tg-campaign-reason-9"]');
expect(reason.exists()).toBe(true);
expect(reason.text()).toContain('Реклама займов запрещена без лицензии');
});
it('у отклонённой кампании «Исправить и пересдать» открывает форму и зовёт resubmitTelegram', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 9, status: 'rejected', status_reason: 'Нужна лицензия', ad_text: 'Займ', ad_link: 'https://t.me/x', ord_category: 'Финансы', planned_count: 5, estimated_cost_rub: '20.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
const btn = wrapper.find('[data-testid="tg-resubmit-9"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await flushPromises();
// Форма правки предзаполнена значениями кампании.
const text = wrapper.find('[data-testid="tg-resubmit-text"] textarea');
expect((text.element as HTMLTextAreaElement).value).toBe('Займ');
await wrapper.find('[data-testid="tg-resubmit-text"] textarea').setValue('Займ под 0%');
await wrapper.find('[data-testid="tg-resubmit-submit-9"]').trigger('click');
await flushPromises();
expect(resubmitTelegramMock).toHaveBeenCalledWith(
9,
expect.objectContaining({ ad_text: 'Займ под 0%', ad_link: 'https://t.me/x' }),
);
});
it('у отклонённой кампании без причины показывает единую заглушку', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 10, status: 'rejected', ad_text: 'X', ad_link: 'https://t.me/x', planned_count: 1, estimated_cost_rub: '1.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
const ph = wrapper.find('[data-testid="tg-reason-placeholder-10"]');
expect(ph.exists()).toBe(true);
expect(ph.text().length).toBeGreaterThan(0);
});
it('у штатной кампании причина НЕ показывается', async () => {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 10, status: 'draft_ready', ad_text: 'Готово', ad_link: 'https://t.me/x', planned_count: 5, estimated_cost_rub: '20.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="tg-campaign-reason-10"]').exists()).toBe(false);
});
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-budget-cap"] input').setValue('1000');
await wrapper.find('[data-testid="tg-audience-list"] input').setValue();
await flushPromises();
// Список номеров пуст → считать нечего, кнопка «Рассчитать» заблокирована.
expect(wrapper.find('[data-testid="tg-calc"]').classes()).toContain('v-btn--disabled');
// Появились номера → кнопка активна.
await wrapper.find('[data-testid="tg-phones"] textarea').setValue('79990000001');
await flushPromises();
expect(wrapper.find('[data-testid="tg-calc"]').classes()).not.toContain('v-btn--disabled');
});
it('автообновление: пока есть живые кампании — периодически зовёт fetchTelegram, интервал снимается при размонтировании', async () => {
vi.useFakeTimers();
try {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 1, status: 'running', ad_text: 'A', ad_link: 'https://t.me/x', planned_count: 1, estimated_cost_rub: '1.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
const wrapper = mountView();
await vi.advanceTimersByTimeAsync(0); // начальная загрузка (onMounted)
expect(fetchTelegramMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(15000);
expect(fetchTelegramMock).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(15000);
expect(fetchTelegramMock).toHaveBeenCalledTimes(3);
wrapper.unmount();
await vi.advanceTimersByTimeAsync(15000 * 3);
expect(fetchTelegramMock).toHaveBeenCalledTimes(3); // после размонтирования тишина
} finally {
vi.useRealTimers();
}
});
it('без живых кампаний по таймеру не опрашивает', async () => {
vi.useFakeTimers();
try {
fetchTelegramMock.mockResolvedValue({
campaigns: [
{ id: 1, status: 'launched', ad_text: 'A', ad_link: 'https://t.me/x', planned_count: 1, estimated_cost_rub: '1.00' },
],
sandbox: true,
balance_rub: '1000.00',
frozen_rub: '0.00',
});
mountView();
await vi.advanceTimersByTimeAsync(0);
expect(fetchTelegramMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(15000 * 3);
expect(fetchTelegramMock).toHaveBeenCalledTimes(1); // «завершённая» кампания не опрашивается
} finally {
vi.useRealTimers();
}
});
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 } });
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();
await wrapper.find('[data-testid="tg-launch"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="tg-error"]').text()).toContain('Не хватает денег');
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../resources/js/api/client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
}));
import {
fetchTelegram,
createTelegram,
fetchTelegramCampaign,
launchTelegram,
resubmitTelegram,
} from '../../resources/js/api/telegram';
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
describe('api/telegram', () => {
beforeEach(() => vi.clearAllMocks());
it('fetchTelegram() GETs /api/telegram/campaigns and unwraps', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { campaigns: [], sandbox: true, balance_rub: '100.00', frozen_rub: '0.00' },
});
const res = await fetchTelegram();
expect(apiClient.get).toHaveBeenCalledWith('/api/telegram/campaigns');
expect(res.sandbox).toBe(true);
expect(res.balance_rub).toBe('100.00');
expect(res.campaigns).toEqual([]);
});
it('createTelegram() ensures csrf then POSTs /api/telegram/campaigns with payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
id: 1,
status: 'draft',
ad_text: 'Приходите',
ad_link: 'https://t.me/x',
audience_kind: 'list',
planned_count: 2,
estimated_cost_rub: '10.00',
},
});
const payload = {
ad_text: 'Приходите',
ad_link: 'https://t.me/x',
audience_kind: 'list' as const,
budget_cap_rub: '1000.00',
phones: ['79990000001', '79990000002'],
};
const c = await createTelegram(payload);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/telegram/campaigns', payload);
expect(c.id).toBe(1);
expect(c.status).toBe('draft');
expect(c.planned_count).toBe(2);
});
it('fetchTelegramCampaign(id) GETs /api/telegram/campaigns/{id} and returns {campaign}', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { campaign: { id: 3, status: 'draft_ready', ad_text: 'Y', ad_link: 'https://t.me/y' } },
});
const res = await fetchTelegramCampaign(3);
expect(apiClient.get).toHaveBeenCalledWith('/api/telegram/campaigns/3');
expect(res.campaign.id).toBe(3);
expect(res.campaign.status).toBe('draft_ready');
});
it('resubmitTelegram(id, payload) ensures csrf then POSTs multipart /resubmit', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: 9, status: 'queued', ad_text: 'Исправлено', ad_link: 'https://t.me/x' },
});
const res = await resubmitTelegram(9, {
ad_text: 'Исправлено',
ad_link: 'https://t.me/x',
ord_category: 'Размещение рекламы',
});
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
const [url, body] = vi.mocked(apiClient.post).mock.calls[0];
expect(url).toBe('/api/telegram/campaigns/9/resubmit');
expect(body).toBeInstanceOf(FormData);
expect((body as FormData).get('ad_text')).toBe('Исправлено');
expect((body as FormData).get('ad_link')).toBe('https://t.me/x');
expect((body as FormData).get('ord_category')).toBe('Размещение рекламы');
expect(res.status).toBe('queued');
});
it('launchTelegram(id) ensures csrf then POSTs /api/telegram/campaigns/{id}/launch', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: 5, status: 'queued', ad_text: 'Z', ad_link: 'https://t.me/z' },
});
const res = await launchTelegram(5);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/telegram/campaigns/5/launch');
expect(res.status).toBe('queued');
});
});
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
// Панель не ходит в сеть — мокаем API-модуль telegram.ts.
const { fetchAutoRuleMock, saveAutoRuleMock } = vi.hoisted(() => ({
fetchAutoRuleMock: vi.fn(),
saveAutoRuleMock: vi.fn(() => Promise.resolve({ enabled: true })),
}));
vi.mock('../../resources/js/api/telegram', () => ({
fetchAutoRule: fetchAutoRuleMock,
saveAutoRule: saveAutoRuleMock,
}));
import TelegramAutoRulePanel from '../../resources/js/views/advertising/TelegramAutoRulePanel.vue';
function mountPanel() {
return mount(TelegramAutoRulePanel, { global: { plugins: [createVuetify()] } });
}
function rule(overrides: Record<string, unknown> = {}) {
return {
enabled: true,
ad_text: 'Заходите в канал',
ad_link: 'https://t.me/example',
ord_category: 'Размещение рекламы',
budget_cap_rub: '2500.00',
daily_limit_rub: '1000.00',
batch_threshold: 500,
...overrides,
};
}
describe('TelegramAutoRulePanel — авто-реклама новым лидам', () => {
beforeEach(() => vi.clearAllMocks());
it('загружает правило и показывает тумблер + поля порога/бюджета/лимита', async () => {
fetchAutoRuleMock.mockResolvedValue(rule());
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.find('[data-testid="tg-auto-enabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-auto-threshold"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-auto-budget"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-auto-daily-limit"]').exists()).toBe(true);
expect((wrapper.find('[data-testid="tg-auto-daily-limit"] input').element as HTMLInputElement).value).toBe('1000.00');
});
it('«Сохранить» зовёт saveAutoRule с текущими значениями', async () => {
fetchAutoRuleMock.mockResolvedValue(rule());
const wrapper = mountPanel();
await flushPromises();
await wrapper.find('[data-testid="tg-auto-save"]').trigger('click');
await flushPromises();
expect(saveAutoRuleMock).toHaveBeenCalledOnce();
expect(saveAutoRuleMock).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
daily_limit_rub: '1000.00',
batch_threshold: 500,
}),
);
});
it('меняет дневной лимит и сохраняет — payload с новым значением', async () => {
fetchAutoRuleMock.mockResolvedValue(rule());
const wrapper = mountPanel();
await flushPromises();
await wrapper.find('[data-testid="tg-auto-daily-limit"] input').setValue('300');
await wrapper.find('[data-testid="tg-auto-save"]').trigger('click');
await flushPromises();
expect(saveAutoRuleMock).toHaveBeenCalledWith(
expect.objectContaining({ daily_limit_rub: '300' }),
);
});
it('без правила стартует с выключенным авто (безопасный дефолт)', async () => {
fetchAutoRuleMock.mockResolvedValue(rule({ enabled: false, daily_limit_rub: '0.00', batch_threshold: null }));
const wrapper = mountPanel();
await flushPromises();
expect((wrapper.find('[data-testid="tg-auto-daily-limit"] input').element as HTMLInputElement).value).toBe('0.00');
});
});
+14
View File
@@ -8,6 +8,20 @@ use Illuminate\Support\Facades\DB;
abstract class TestCase extends BaseTestCase
{
/**
* RefreshDatabase's migrate:fresh drops tables but NOT Postgres types by
* default. Our initial schema is loaded raw from db/schema.sql, whose
* `CREATE TABLE`s implicitly define composite row-types; when a table drop
* is blocked by a lingering dependency, its row-type survives db:wipe and
* the next schema.sql reload aborts on a duplicate-type error
* («legal_entities уже существует»), leaving a PARTIAL schema the
* long-standing intermittent «migrate:fresh иногда прерывается» flake
* (site_events/client_tg_tariffs randomly missing). Dropping types on every
* refresh clears those leftovers and makes migrate:fresh deterministic.
* Testing-only (APP_ENV=testing) no production impact.
*/
protected $dropTypes = true;
protected function setUp(): void
{
parent::setUp();
@@ -5,14 +5,14 @@ declare(strict_types=1);
use App\Services\Advertising\AdImpressionPricing;
it('оценивает показы как аудитория × частота', function () {
$p = new AdImpressionPricing();
$p = new AdImpressionPricing;
expect($p->impressionsForAudience(1630, 15))->toBe(24450);
expect($p->impressionsForAudience(0, 15))->toBe(0);
expect($p->impressionsForAudience(100, 1))->toBe(100);
});
it('считает клиентскую сумму по 120 ₽ за 1000 показов, округляя ВВЕРХ до копейки', function () {
$p = new AdImpressionPricing();
$p = new AdImpressionPricing;
expect($p->clientCostRub(24450, '120.00'))->toBe('2934.00');
expect($p->clientCostRub(1, '120.00'))->toBe('0.12');
expect($p->clientCostRub(7, '120.00'))->toBe('0.84');
@@ -20,12 +20,12 @@ it('считает клиентскую сумму по 120 ₽ за 1000 пок
});
it('округляет неровную сумму вверх до копейки (клиент не недоплачивает)', function () {
$p = new AdImpressionPricing();
$p = new AdImpressionPricing;
expect($p->clientCostRub(1, '125.00'))->toBe('0.13');
});
it('считает маржу как списано клиенту минус расход Яндекса', function () {
$p = new AdImpressionPricing();
$p = new AdImpressionPricing;
expect($p->marginRub('2934.00', '1956.00'))->toBe('978.00');
expect($p->marginRub('100.00', '100.00'))->toBe('0.00');
});

Some files were not shown because too many files have changed in this diff Show More