diff --git a/.gitleaks.toml b/.gitleaks.toml index 77a24a2b..650469d5 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -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 = [ diff --git a/CLAUDE.md b/CLAUDE.md index 85e4aa40..950ab037 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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), #87–89 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), #87–89 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`. diff --git a/app/.env.example b/app/.env.example index 1ba2b266..d994b5f9 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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= diff --git a/app/app/Http/Controllers/Api/Admin/TgTariffController.php b/app/app/Http/Controllers/Api/Admin/TgTariffController.php new file mode 100644 index 00000000..6f8d9ea7 --- /dev/null +++ b/app/app/Http/Controllers/Api/Admin/TgTariffController.php @@ -0,0 +1,61 @@ +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(), + ]); + } +} diff --git a/app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php b/app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php new file mode 100644 index 00000000..1efdf28d --- /dev/null +++ b/app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php @@ -0,0 +1,92 @@ +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 + */ + 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, + ]; + } +} diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php new file mode 100644 index 00000000..3aa08386 --- /dev/null +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -0,0 +1,374 @@ +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 + */ + 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 $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; + } +} diff --git a/app/app/Jobs/ClientTg/AccumulateTelegramLeadJob.php b/app/app/Jobs/ClientTg/AccumulateTelegramLeadJob.php new file mode 100644 index 00000000..40241460 --- /dev/null +++ b/app/app/Jobs/ClientTg/AccumulateTelegramLeadJob.php @@ -0,0 +1,66 @@ +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(), + ]); + } + } +} diff --git a/app/app/Jobs/ClientTg/PollTelegramModerationJob.php b/app/app/Jobs/ClientTg/PollTelegramModerationJob.php new file mode 100644 index 00000000..71f6b293 --- /dev/null +++ b/app/app/Jobs/ClientTg/PollTelegramModerationJob.php @@ -0,0 +1,178 @@ +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(); + }); + } +} diff --git a/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php b/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php new file mode 100644 index 00000000..71be4f22 --- /dev/null +++ b/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php @@ -0,0 +1,268 @@ +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(); + }); + } +} diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php new file mode 100644 index 00000000..9cfed471 --- /dev/null +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -0,0 +1,285 @@ +}|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(); + }); + } +} diff --git a/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php b/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php new file mode 100644 index 00000000..c6f75424 --- /dev/null +++ b/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php @@ -0,0 +1,268 @@ +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(); + }); + } +} diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index fb3f23d7..e144d472 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -46,6 +46,10 @@ class BalanceTransaction extends Model public const TYPE_SMS_CHARGE = 'sms_charge'; + public const TYPE_TG_AD_CHARGE = 'tg_ad_charge'; + + public const TYPE_TG_AD_REFUND = 'tg_ad_refund'; + public $timestamps = false; protected $fillable = [ diff --git a/app/app/Models/ClientTg/AutoRule.php b/app/app/Models/ClientTg/AutoRule.php new file mode 100644 index 00000000..9a2da948 --- /dev/null +++ b/app/app/Models/ClientTg/AutoRule.php @@ -0,0 +1,48 @@ + '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', + ]; + } +} diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php new file mode 100644 index 00000000..54949fe3 --- /dev/null +++ b/app/app/Models/ClientTg/Campaign.php @@ -0,0 +1,166 @@ +|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> + */ + 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 */ + 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(); + } +} diff --git a/app/app/Models/ClientTg/CampaignPhone.php b/app/app/Models/ClientTg/CampaignPhone.php new file mode 100644 index 00000000..618fe703 --- /dev/null +++ b/app/app/Models/ClientTg/CampaignPhone.php @@ -0,0 +1,43 @@ + 'integer', + 'campaign_id' => 'integer', + 'expires_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/ClientTg/Contact.php b/app/app/Models/ClientTg/Contact.php new file mode 100644 index 00000000..c283f8ce --- /dev/null +++ b/app/app/Models/ClientTg/Contact.php @@ -0,0 +1,33 @@ + 'integer', + ]; + } +} diff --git a/app/app/Models/ClientTg/Optout.php b/app/app/Models/ClientTg/Optout.php new file mode 100644 index 00000000..16b63e19 --- /dev/null +++ b/app/app/Models/ClientTg/Optout.php @@ -0,0 +1,32 @@ + 'integer', + ]; + } +} diff --git a/app/app/Models/ClientTg/Tariff.php b/app/app/Models/ClientTg/Tariff.php new file mode 100644 index 00000000..b57a3234 --- /dev/null +++ b/app/app/Models/ClientTg/Tariff.php @@ -0,0 +1,33 @@ + 'integer', + 'price_rub' => 'decimal:2', + ]; + } +} diff --git a/app/app/Observers/DealTelegramObserver.php b/app/app/Observers/DealTelegramObserver.php new file mode 100644 index 00000000..929fe251 --- /dev/null +++ b/app/app/Observers/DealTelegramObserver.php @@ -0,0 +1,49 @@ + 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(), + ]); + } + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index e9bb18e9..4ff1337a 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -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 до аутентификации. Ключ лимитера — diff --git a/app/app/Services/ClientTg/RobotResult.php b/app/app/Services/ClientTg/RobotResult.php new file mode 100644 index 00000000..68c10a46 --- /dev/null +++ b/app/app/Services/ClientTg/RobotResult.php @@ -0,0 +1,72 @@ + $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); + } +} diff --git a/app/app/Services/ClientTg/TelegramAudience.php b/app/app/Services/ClientTg/TelegramAudience.php new file mode 100644 index 00000000..b3320c50 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramAudience.php @@ -0,0 +1,22 @@ + $phones */ + public function __construct( + public array $phones, + public int $candidatesCount, + ) {} +} diff --git a/app/app/Services/ClientTg/TelegramAudienceService.php b/app/app/Services/ClientTg/TelegramAudienceService.php new file mode 100644 index 00000000..bea7ef8b --- /dev/null +++ b/app/app/Services/ClientTg/TelegramAudienceService.php @@ -0,0 +1,132 @@ +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 */ + 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 */ + private function fromBase(int $tenantId): array + { + return Contact::query() + ->where('tenant_id', $tenantId) + ->pluck('phone') + ->map(fn ($p) => (string) $p) + ->all(); + } + + /** @return list */ + 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 $phones + * @return list + */ + 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 $phones + * @return list + */ + 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]))); + } +} diff --git a/app/app/Services/ClientTg/TelegramAutoAccumulator.php b/app/app/Services/ClientTg/TelegramAutoAccumulator.php new file mode 100644 index 00000000..99e654d0 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramAutoAccumulator.php @@ -0,0 +1,192 @@ +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, + ]); + } +} diff --git a/app/app/Services/ClientTg/TelegramCampaignChargeService.php b/app/app/Services/ClientTg/TelegramCampaignChargeService.php new file mode 100644 index 00000000..f9c94ddf --- /dev/null +++ b/app/app/Services/ClientTg/TelegramCampaignChargeService.php @@ -0,0 +1,147 @@ +tenant_id)->lockForUpdate()->firstOrFail(); + + // Идемпотентность по сальдо — строго ПОСЛЕ взятия замка. Есть непогашенное + // списание (charges > refunds) → повтор не списывает. После возврата сальдо + // выравнивается и новое списание (пересдача) проходит. + if ($this->hasOutstandingCharge($campaign)) { + return; + } + + if (bccomp($priceRub, '0', 2) === 0) { + return; + } + + if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($priceRub, '100', 0), + balanceRub: (string) $tenant->balance_rub, + ); + } + + $newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TG_AD_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => Campaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + }); + } + + /** + * Вернуть ранее списанную смету на общий баланс тенанта. Идемпотентно: + * если списания не было или возврат уже сделан — ничего не делает. + */ + public function refund(Campaign $campaign): void + { + DB::transaction(function () use ($campaign): void { + /** @var Tenant $tenant */ + $tenant = Tenant::whereKey($campaign->tenant_id)->lockForUpdate()->firstOrFail(); + + // Нечего возвращать — непогашенного списания нет (списаний не было или все + // уже возвращены). + if (! $this->hasOutstandingCharge($campaign)) { + return; + } + + /** @var BalanceTransaction $chargeTx */ + $chargeTx = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->orderByDesc('id') + ->firstOrFail(); + + // Сумма возврата = модуль ПОСЛЕДНЕГО (непогашенного) списания + // (amount_rub у charge отрицательный). + $amount = ltrim((string) $chargeTx->amount_rub, '-'); + + if (bccomp($amount, '0', 2) === 0) { + return; + } + + $newBalance = bcadd((string) $tenant->balance_rub, $amount, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TG_AD_REFUND, + 'amount_rub' => $amount, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => Campaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + }); + } + + /** Есть ли по кампании непогашенное списание: count(tg_ad_charge) > count(tg_ad_refund). */ + private function hasOutstandingCharge(Campaign $campaign): bool + { + $charges = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->count(); + + $refunds = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_REFUND) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->count(); + + return $charges > $refunds; + } +} diff --git a/app/app/Services/ClientTg/TelegramRobotRunner.php b/app/app/Services/ClientTg/TelegramRobotRunner.php new file mode 100644 index 00000000..e304a9f0 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramRobotRunner.php @@ -0,0 +1,184 @@ +` через + * 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 $params + * @return array + */ + 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 $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= в 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 $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; + } +} diff --git a/app/app/Services/ClientTg/TelegramTariffService.php b/app/app/Services/ClientTg/TelegramTariffService.php new file mode 100644 index 00000000..fb9066cb --- /dev/null +++ b/app/app/Services/ClientTg/TelegramTariffService.php @@ -0,0 +1,44 @@ +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); + } +} diff --git a/app/app/Services/NotificationService.php b/app/app/Services/NotificationService.php index dedb57e6..e2744089 100644 --- a/app/app/Services/NotificationService.php +++ b/app/app/Services/NotificationService.php @@ -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 внутри diff --git a/app/config/client_tg.php b/app/config/client_tg.php new file mode 100644 index 00000000..6da04fc6 --- /dev/null +++ b/app/config/client_tg.php @@ -0,0 +1,62 @@ + 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 + + diff --git a/app/resources/js/views/advertising/AdvertisingTelegramView.vue b/app/resources/js/views/advertising/AdvertisingTelegramView.vue new file mode 100644 index 00000000..4eedb473 --- /dev/null +++ b/app/resources/js/views/advertising/AdvertisingTelegramView.vue @@ -0,0 +1,540 @@ + + + + + diff --git a/app/resources/js/views/advertising/TelegramAutoRulePanel.vue b/app/resources/js/views/advertising/TelegramAutoRulePanel.vue new file mode 100644 index 00000000..71e48f9f --- /dev/null +++ b/app/resources/js/views/advertising/TelegramAutoRulePanel.vue @@ -0,0 +1,176 @@ + + + diff --git a/app/routes/console.php b/app/routes/console.php index ab7d73e0..2e9e7681 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -1,6 +1,8 @@ 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 строк. Времена МСК, вне пиковых часов и не пересекаются diff --git a/app/routes/web.php b/app/routes/web.php index 6ac1aced..92fcadb7 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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). diff --git a/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php index c7f0b243..d88fa7bc 100644 --- a/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php +++ b/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php @@ -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) diff --git a/app/tests/Feature/ClientTg/AdminTgApiTest.php b/app/tests/Feature/ClientTg/AdminTgApiTest.php new file mode 100644 index 00000000..23c58e78 --- /dev/null +++ b/app/tests/Feature/ClientTg/AdminTgApiTest.php @@ -0,0 +1,59 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/ApproveNotifyTest.php b/app/tests/Feature/ClientTg/ApproveNotifyTest.php new file mode 100644 index 00000000..ee26c756 --- /dev/null +++ b/app/tests/Feature/ClientTg/ApproveNotifyTest.php @@ -0,0 +1,86 @@ +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(); +}); diff --git a/app/tests/Feature/ClientTg/AudienceGateTest.php b/app/tests/Feature/ClientTg/AudienceGateTest.php new file mode 100644 index 00000000..5b1580b6 --- /dev/null +++ b/app/tests/Feature/ClientTg/AudienceGateTest.php @@ -0,0 +1,128 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/AudienceServiceTest.php b/app/tests/Feature/ClientTg/AudienceServiceTest.php new file mode 100644 index 00000000..89ae8b61 --- /dev/null +++ b/app/tests/Feature/ClientTg/AudienceServiceTest.php @@ -0,0 +1,169 @@ + $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([]); +}); diff --git a/app/tests/Feature/ClientTg/AutoAccumulatorTest.php b/app/tests/Feature/ClientTg/AutoAccumulatorTest.php new file mode 100644 index 00000000..23964a52 --- /dev/null +++ b/app/tests/Feature/ClientTg/AutoAccumulatorTest.php @@ -0,0 +1,135 @@ + $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 */ +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); +}); diff --git a/app/tests/Feature/ClientTg/AutoFreezeTest.php b/app/tests/Feature/ClientTg/AutoFreezeTest.php new file mode 100644 index 00000000..72c915cf --- /dev/null +++ b/app/tests/Feature/ClientTg/AutoFreezeTest.php @@ -0,0 +1,116 @@ + $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); +}); diff --git a/app/tests/Feature/ClientTg/AutoRuleApiTest.php b/app/tests/Feature/ClientTg/AutoRuleApiTest.php new file mode 100644 index 00000000..6a954d52 --- /dev/null +++ b/app/tests/Feature/ClientTg/AutoRuleApiTest.php @@ -0,0 +1,99 @@ +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 валидный 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 +}); diff --git a/app/tests/Feature/ClientTg/AutoSafeguardTest.php b/app/tests/Feature/ClientTg/AutoSafeguardTest.php new file mode 100644 index 00000000..dbed0f41 --- /dev/null +++ b/app/tests/Feature/ClientTg/AutoSafeguardTest.php @@ -0,0 +1,179 @@ + $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 */ +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); +}); diff --git a/app/tests/Feature/ClientTg/CampaignApiTest.php b/app/tests/Feature/ClientTg/CampaignApiTest.php new file mode 100644 index 00000000..4fc73874 --- /dev/null +++ b/app/tests/Feature/ClientTg/CampaignApiTest.php @@ -0,0 +1,191 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** @return array валидный 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); +}); diff --git a/app/tests/Feature/ClientTg/CampaignChargeServiceTest.php b/app/tests/Feature/ClientTg/CampaignChargeServiceTest.php new file mode 100644 index 00000000..0144c9b5 --- /dev/null +++ b/app/tests/Feature/ClientTg/CampaignChargeServiceTest.php @@ -0,0 +1,118 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/ConservativeReleaseTest.php b/app/tests/Feature/ClientTg/ConservativeReleaseTest.php new file mode 100644 index 00000000..7a7e2198 --- /dev/null +++ b/app/tests/Feature/ClientTg/ConservativeReleaseTest.php @@ -0,0 +1,141 @@ +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'); +}); diff --git a/app/tests/Feature/ClientTg/DealObserverTest.php b/app/tests/Feature/ClientTg/DealObserverTest.php new file mode 100644 index 00000000..5705da52 --- /dev/null +++ b/app/tests/Feature/ClientTg/DealObserverTest.php @@ -0,0 +1,123 @@ + $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(); +}); diff --git a/app/tests/Feature/ClientTg/ExternalIdTest.php b/app/tests/Feature/ClientTg/ExternalIdTest.php new file mode 100644 index 00000000..f9a9dde1 --- /dev/null +++ b/app/tests/Feature/ClientTg/ExternalIdTest.php @@ -0,0 +1,87 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/InputLengthFixTest.php b/app/tests/Feature/ClientTg/InputLengthFixTest.php new file mode 100644 index 00000000..4a428f7b --- /dev/null +++ b/app/tests/Feature/ClientTg/InputLengthFixTest.php @@ -0,0 +1,55 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** @return array валидный 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'); +}); diff --git a/app/tests/Feature/ClientTg/InputLimitsTest.php b/app/tests/Feature/ClientTg/InputLimitsTest.php new file mode 100644 index 00000000..7f2de2f7 --- /dev/null +++ b/app/tests/Feature/ClientTg/InputLimitsTest.php @@ -0,0 +1,88 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** @return array валидный 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); +}); diff --git a/app/tests/Feature/ClientTg/LaunchIdempotencyTest.php b/app/tests/Feature/ClientTg/LaunchIdempotencyTest.php new file mode 100644 index 00000000..b2ad514d --- /dev/null +++ b/app/tests/Feature/ClientTg/LaunchIdempotencyTest.php @@ -0,0 +1,102 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/ManualFlowTest.php b/app/tests/Feature/ClientTg/ManualFlowTest.php new file mode 100644 index 00000000..9601df15 --- /dev/null +++ b/app/tests/Feature/ClientTg/ManualFlowTest.php @@ -0,0 +1,71 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/MediaUploadTest.php b/app/tests/Feature/ClientTg/MediaUploadTest.php new file mode 100644 index 00000000..3ce7107e --- /dev/null +++ b/app/tests/Feature/ClientTg/MediaUploadTest.php @@ -0,0 +1,116 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/ModelsTest.php b/app/tests/Feature/ClientTg/ModelsTest.php new file mode 100644 index 00000000..9a65c499 --- /dev/null +++ b/app/tests/Feature/ClientTg/ModelsTest.php @@ -0,0 +1,114 @@ +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'); +}); diff --git a/app/tests/Feature/ClientTg/ModeratingStatusTest.php b/app/tests/Feature/ClientTg/ModeratingStatusTest.php new file mode 100644 index 00000000..b8a19eba --- /dev/null +++ b/app/tests/Feature/ClientTg/ModeratingStatusTest.php @@ -0,0 +1,135 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/ModerationStuckTest.php b/app/tests/Feature/ClientTg/ModerationStuckTest.php new file mode 100644 index 00000000..aa30498e --- /dev/null +++ b/app/tests/Feature/ClientTg/ModerationStuckTest.php @@ -0,0 +1,84 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/PollModerationTest.php b/app/tests/Feature/ClientTg/PollModerationTest.php new file mode 100644 index 00000000..5160476a --- /dev/null +++ b/app/tests/Feature/ClientTg/PollModerationTest.php @@ -0,0 +1,187 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/RefundOnFailTest.php b/app/tests/Feature/ClientTg/RefundOnFailTest.php new file mode 100644 index 00000000..5c437d0c --- /dev/null +++ b/app/tests/Feature/ClientTg/RefundOnFailTest.php @@ -0,0 +1,154 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/RejectNotifyTest.php b/app/tests/Feature/ClientTg/RejectNotifyTest.php new file mode 100644 index 00000000..0072a9da --- /dev/null +++ b/app/tests/Feature/ClientTg/RejectNotifyTest.php @@ -0,0 +1,115 @@ + 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); +}); diff --git a/app/tests/Feature/ClientTg/ResubmitJobTest.php b/app/tests/Feature/ClientTg/ResubmitJobTest.php new file mode 100644 index 00000000..90e145f3 --- /dev/null +++ b/app/tests/Feature/ClientTg/ResubmitJobTest.php @@ -0,0 +1,178 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/ResubmitTest.php b/app/tests/Feature/ClientTg/ResubmitTest.php new file mode 100644 index 00000000..b2e121ab --- /dev/null +++ b/app/tests/Feature/ClientTg/ResubmitTest.php @@ -0,0 +1,180 @@ +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 валидный 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); +}); diff --git a/app/tests/Feature/ClientTg/RobotResultTest.php b/app/tests/Feature/ClientTg/RobotResultTest.php new file mode 100644 index 00000000..f9e9703a --- /dev/null +++ b/app/tests/Feature/ClientTg/RobotResultTest.php @@ -0,0 +1,51 @@ + 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(); +}); diff --git a/app/tests/Feature/ClientTg/RobotRunnerTest.php b/app/tests/Feature/ClientTg/RobotRunnerTest.php new file mode 100644 index 00000000..6345e8b7 --- /dev/null +++ b/app/tests/Feature/ClientTg/RobotRunnerTest.php @@ -0,0 +1,122 @@ +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 */ +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)); +}); diff --git a/app/tests/Feature/ClientTg/RunCampaignJobTest.php b/app/tests/Feature/ClientTg/RunCampaignJobTest.php new file mode 100644 index 00000000..e9054614 --- /dev/null +++ b/app/tests/Feature/ClientTg/RunCampaignJobTest.php @@ -0,0 +1,141 @@ +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(); +}); diff --git a/app/tests/Feature/ClientTg/SandboxTest.php b/app/tests/Feature/ClientTg/SandboxTest.php new file mode 100644 index 00000000..3d57626e --- /dev/null +++ b/app/tests/Feature/ClientTg/SandboxTest.php @@ -0,0 +1,85 @@ +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 захваченные параметры робота */ +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'); +}); diff --git a/app/tests/Feature/ClientTg/SchemaTest.php b/app/tests/Feature/ClientTg/SchemaTest.php new file mode 100644 index 00000000..3adab255 --- /dev/null +++ b/app/tests/Feature/ClientTg/SchemaTest.php @@ -0,0 +1,58 @@ +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(); +}); diff --git a/app/tests/Feature/ClientTg/StatusMachineTest.php b/app/tests/Feature/ClientTg/StatusMachineTest.php new file mode 100644 index 00000000..064b1450 --- /dev/null +++ b/app/tests/Feature/ClientTg/StatusMachineTest.php @@ -0,0 +1,129 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/SweepStuckTest.php b/app/tests/Feature/ClientTg/SweepStuckTest.php new file mode 100644 index 00000000..ce0b3e75 --- /dev/null +++ b/app/tests/Feature/ClientTg/SweepStuckTest.php @@ -0,0 +1,205 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/TariffServiceTest.php b/app/tests/Feature/ClientTg/TariffServiceTest.php new file mode 100644 index 00000000..6567cba4 --- /dev/null +++ b/app/tests/Feature/ClientTg/TariffServiceTest.php @@ -0,0 +1,52 @@ +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'); +}); diff --git a/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php b/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php new file mode 100644 index 00000000..4cbedba8 --- /dev/null +++ b/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php @@ -0,0 +1,122 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/WalletChannelTest.php b/app/tests/Feature/ClientTg/WalletChannelTest.php new file mode 100644 index 00000000..3d505b4e --- /dev/null +++ b/app/tests/Feature/ClientTg/WalletChannelTest.php @@ -0,0 +1,72 @@ +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); +}); diff --git a/app/tests/Feature/ClientTg/WalletReFreezeTest.php b/app/tests/Feature/ClientTg/WalletReFreezeTest.php new file mode 100644 index 00000000..77dfcbfd --- /dev/null +++ b/app/tests/Feature/ClientTg/WalletReFreezeTest.php @@ -0,0 +1,58 @@ +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'); +}); diff --git a/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts b/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts index 526c2a89..0e661572 100644 --- a/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts +++ b/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts @@ -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(); diff --git a/app/tests/Frontend/AppSidebarAdvertising.spec.ts b/app/tests/Frontend/AppSidebarAdvertising.spec.ts index ed325421..6218c8ff 100644 --- a/app/tests/Frontend/AppSidebarAdvertising.spec.ts +++ b/app/tests/Frontend/AppSidebarAdvertising.spec.ts @@ -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: '', @@ -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'); diff --git a/app/tests/Frontend/admin-tg-view.spec.ts b/app/tests/Frontend/admin-tg-view.spec.ts new file mode 100644 index 00000000..d9b4ad73 --- /dev/null +++ b/app/tests/Frontend/admin-tg-view.spec.ts @@ -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(); + return { + ...orig, + fetchTgTariffs: vi.fn(), + updateTgTariffs: vi.fn(), + }; +}); + +const adminApi = await import('../../resources/js/api/admin'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function makeTariffs(overrides: Partial = {}): 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); + }); +}); diff --git a/app/tests/Frontend/advertising-telegram-view.spec.ts b/app/tests/Frontend/advertising-telegram-view.spec.ts new file mode 100644 index 00000000..31a575b6 --- /dev/null +++ b/app/tests/Frontend/advertising-telegram-view.spec.ts @@ -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>, + 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('Не хватает денег'); + }); +}); diff --git a/app/tests/Frontend/telegram-api.spec.ts b/app/tests/Frontend/telegram-api.spec.ts new file mode 100644 index 00000000..2c1714df --- /dev/null +++ b/app/tests/Frontend/telegram-api.spec.ts @@ -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'); + }); +}); diff --git a/app/tests/Frontend/telegram-auto-rule-panel.spec.ts b/app/tests/Frontend/telegram-auto-rule-panel.spec.ts new file mode 100644 index 00000000..d5f20210 --- /dev/null +++ b/app/tests/Frontend/telegram-auto-rule-panel.spec.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index ea072043..8f0a5001 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -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(); diff --git a/app/tests/Unit/Advertising/AdImpressionPricingTest.php b/app/tests/Unit/Advertising/AdImpressionPricingTest.php index 4487efb6..6e647ff1 100644 --- a/app/tests/Unit/Advertising/AdImpressionPricingTest.php +++ b/app/tests/Unit/Advertising/AdImpressionPricingTest.php @@ -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'); }); diff --git a/app/tests/fixtures/tg-robot/print-fail.js b/app/tests/fixtures/tg-robot/print-fail.js new file mode 100644 index 00000000..e2953008 --- /dev/null +++ b/app/tests/fixtures/tg-robot/print-fail.js @@ -0,0 +1,4 @@ +// Мок робота: штатный отказ (ok:false + step + reason) и exit 1 — как bin/run.js +// при неуспехе. Обёртка должна вернуть аккуратный результат, а НЕ исключение. +console.log(JSON.stringify({ ok: false, step: 'upload', reason: 'Недостаточно номеров «не МТС»' })); +process.exit(1); diff --git a/app/tests/fixtures/tg-robot/print-garbage.js b/app/tests/fixtures/tg-robot/print-garbage.js new file mode 100644 index 00000000..4a3a2072 --- /dev/null +++ b/app/tests/fixtures/tg-robot/print-garbage.js @@ -0,0 +1,4 @@ +// Мок робота: непарсабельный вывод. Обёртка не должна ронять воркер — +// должна вернуть аккуратный ok:false с причиной. +console.log('это совсем не JSON'); +process.exit(0); diff --git a/app/tests/fixtures/tg-robot/print-ok.js b/app/tests/fixtures/tg-robot/print-ok.js new file mode 100644 index 00000000..cacaf15e --- /dev/null +++ b/app/tests/fixtures/tg-robot/print-ok.js @@ -0,0 +1,4 @@ +// Мок робота: печатает успешный JSON-результат, как настоящий bin/run.js. +// Аргументы (--task ) игнорируем — проверяем только разбор вывода. +console.log(JSON.stringify({ ok: true, matched: 512, launched: false, campaignId: 'draft' })); +process.exit(0); diff --git a/app/tests/fixtures/tg-robot/sleep.js b/app/tests/fixtures/tg-robot/sleep.js new file mode 100644 index 00000000..26412fb3 --- /dev/null +++ b/app/tests/fixtures/tg-robot/sleep.js @@ -0,0 +1,5 @@ +// Мок робота: «зависает» дольше таймаута обёртки. Проверяем, что таймаут +// перехвачен и превращён в аккуратный ok:false, а не в падение воркера. +setTimeout(() => { + console.log(JSON.stringify({ ok: true, matched: 1 })); +}, 5000); diff --git a/bots/mts-telegram-ads/.env.example b/bots/mts-telegram-ads/.env.example new file mode 100644 index 00000000..a7fe0518 --- /dev/null +++ b/bots/mts-telegram-ads/.env.example @@ -0,0 +1,18 @@ +# Профиль браузера с живым входом в кабинет МТС +MTS_BROWSER_PROFILE_DIR=C:/liderra/mts-bot-profile +MTS_CABINET_URL=https://marketolog.mts.ru/cabinet/main +MTS_TELEGRAM_URL=https://marketolog.mts.ru/cabinet/campaigns/telegram + +# Почта для алярма/отчёта (Unisender Go SMTP — те же креды, что у портала) +SMTP_HOST=smtp.go.unisender.ru +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +ALARM_FROM=bot@liderra.ru +ALARM_TO=you@example.com + +# Предохранитель: боевой запуск дороже этой суммы (₽) запрещён без ручного подтверждения +BUDGET_CAP_RUB=5000 + +# Темп «по-человечески»: минимальная пауза между действиями, мс +HUMAN_DELAY_MS=800 diff --git a/bots/mts-telegram-ads/.gitignore b/bots/mts-telegram-ads/.gitignore new file mode 100644 index 00000000..7d409cc9 --- /dev/null +++ b/bots/mts-telegram-ads/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +profile/ +screenshots/ +*.log diff --git a/bots/mts-telegram-ads/FLOW-FINDINGS.md b/bots/mts-telegram-ads/FLOW-FINDINGS.md new file mode 100644 index 00000000..5a5670cb --- /dev/null +++ b/bots/mts-telegram-ads/FLOW-FINDINGS.md @@ -0,0 +1,237 @@ +# МТС Телеграм-бот — находки живого прогона (27.07.2026) + +Снято на боевом кабинете marketolog.mts.ru (аккаунт +7 950 XXX-XX-XX, баланс 5010 ₽), +робот на **Edge** (`channel:'msedge'`), профиль `C:/liderra/mts-bot-profile` (вход сохранён). +Все прогоны — **черновики**, кнопки запуска/оплаты не нажимались. Черновики разведки +после сессии удалены (в списке кампаний 0). + +## Доступ (РЕШЕНО, стабильно) + +- **Причина 403 была двойная:** (1) VPN **Happ** гнал `*.mts.ru` за границу (IP 194.67.116.228), + который МТС/Qrator режет; (2) «голый» Chromium ловился анти-ботом. +- **Решение:** робот на настоящем **Edge** (`channel:'msedge'` + снятие меток автоматизации) + и правило в **Happ → Настройки → Правила маршрутизации → профиль «МТС» → Direct**: + строки `marketolog.mts.ru`, `api.a.mts.ru`, `pay.mts.ru`, `identitystatic.mts.ru`, + `sse.marketolog.mts.ru`, **`domain:mts.ru`** (последняя покрывает все). «Использовать + маршрутизацию» = Вкл. Правило переживает регенерацию конфига (в отличие от правки файла). +- Сессия МТС **привязана к IP**: вход делать с прямого IP (77.74.123.226); менять IP после + входа нельзя (сессия слетает → 401 → пустая крутилка). Проверено: 3/3 автозагрузки кабинета. +- Туннель на сервер 185 — **тупик** (датацентровый IP, Qrator режет `/backend/flags`, экран пуст). + +## Флоу визарда Telegram (подтверждён живьём) + +1. `/cabinet/campaigns/telegram` → кнопка **«Создать кампанию»**. +2. **Знакомство**: radio «Нет, я отлично в этом разбираюсь» + карточка сферы «Услуги для бизнеса» → «Продолжить». +3. **Тип аудитории**: карточка «Своя база клиентов» → создаётся черновик, уходим на `/…/creating/audience`. +4. **Аудитория** (`/…/{id}/audience`): + - файл: `input[type="file"]` (первый). Формат номеров `79000000000`, мин. **367** «не МТС». + - 🔑 **МАТЧИНГ АСИНХРОННЫЙ (~10–30с).** Нельзя читать «Не МТС» / жать «Продолжить» сразу. + Верный признак готовности — в правой панели появляется **«Стоимость кампании от X ₽»**. + - фейковые номера считаются как «Не МТС» (для теста годятся): 420 → Не МТС ≈418, стоимость ≈200 ₽. +5. 🔑 **ОБУЧАЮЩИЙ ТУР блокирует переход** на КАЖДОМ шаге (тёмная плашка «Юрий Тихоненков», кнопка + «ДАЛЕЕ» — это **div, не button**). Затемняет всю страницу; `force:true` по «Продолжить» НЕ помогает. + Надо **пройти тур**: кликать по ТЕКСТУ `ДАЛЕЕ`/`Понятно`/`Завершить`/`Готово`/`Закрыть`, пока не исчезнет + (обычно 3 шага). После этого «Продолжить» переключает на `/…/{id}/message`. +6. **Объявление** (`/…/{id}/message`): + - **Текст**: `textarea[placeholder^="Расскажите о боте"]` (0/160). + - **Ссылка «Что рекламируете»**: `input[placeholder^="Ссылка на канал"]`. + - **Медиа Premium** — опционально (Изображение/Видео), CPM +600/+680 ₽. + - ❌ Поля **«Заголовок» НЕТ** (в коде убрать `adTitle`). + - ❌ Поля **«Название компании» НЕТ** — рекламодатель подставляется АВТО: + «Кондратьев Платон Дмитриевич, ИНН 245210851872» (в коде убрать `ordCompanyName`). + - **ОРД обязательно только «Категория товара или услуги»**: `input[name="selectedKktu"]` — + downshift-combobox, нужен настоящий клик по опции (⚠️ флакает: иногда опции не появляются — + докрутить: клик → ввод → ждать список → клик по `role=option`). «Размещение рекламы» — валидная опция. + - Договорные поля (`inn/entityName/contractNumber/contractDate/contractType/…`) — за тумблером + «Юридическая информация» (выкл по умолчанию), **для нас не нужны**. + - Категория ОРД проставляется НАДЁЖНО так: клик по `input[name="selectedKktu"]` → + `fill('')` → `pressSequentially(категория, {delay:60})` → дождаться `role=option` → + клик по опции. Проверено живьём — срабатывает с 1-й попытки, значение поля после + выбора = выбранной категории. Дефолт «Размещение рекламы» — валидная опция. +7. **Стоимость** (`/…/{id}/budget`): стоимость НЕ вводится — она ВЫЧИСЛЯЕТСЯ кабинетом: + аудитория «не МТС» × фикс. ставка **≈400 ₽ / 1000 показов** (пример: 437 × … = 209,76 ₽). + Настраивается (для базовой кампании дефолты годятся): частота показов (радио «1 раз за + кампанию» / «N в сутки»), даты старта/завершения (2 текст-инпута, авто «сегодня → +7д»), + расписание, **🔴 «Автозапуск» ВКЛ по умолчанию** («запустится после модерации автоматически»). +8. **Подтверждение** (`/…/{id}/confirmation`) — ФИНАЛ: сводка (ставка/аудитория/итого), + файл базы, условия (Запуск: Автоматически), поле «Название кампании» (авто), чекбокс + **«Я соглашаюсь с условиями оферты»** и кнопка отправки на модерацию. 🔴 Точка денег. + +## 🔑 Две ключевые грабли (РЕШЕНЫ) + +1. **Коуч-марк перекрывает «Продолжить» на КАЖДОМ шаге** — его div лежит поверх кнопки + (topmost element), поэтому обычный клик и даже `click({force:true})` (мышиный клик по + координатам) попадают в оверлей, а не в кнопку → шаг не переключается. Надёжно работает + только нативный `.click()` прямо на DOM-элементе кнопки (`page.evaluate` → найти по тексту + → `btn.click()`): событие уходит в саму кнопку. Реализовано в `cabinet.js` (`domClickButton`). +2. **В боксе «Не МТС» неразрывный пробел** (код 160) между «Не» и «МТС» — прямое сравнение + `=== 'Не МТС'` (обычный пробел, код 32) не срабатывало. Фикс: нормализация `\s+` (покрывает + nbsp). Реализовано в `readNonMts`. + +## Статус: ФЛОУ ЗАКРЫТ (27.07.2026) + +Полный ЧЕРНОВОЙ прогон `runner` прошёл насквозь: визард → аудитория (matched=437) → +объявление (текст+ссылка+категория ОРД) → стоимость → подтверждение → скрин. Результат +`{ok:true, matched:437, launched:false}`, кнопка отправки НЕ нажата, баланс 5010 ₽ не тронут. +Черновики разведки удалены (0 в списке). + +## Правки кода — ВЫПОЛНЕНО + +- `cabinet.js`: `readNonMts` (nbsp-fix) + поллинг матчинга по числу «Не МТС»; + `domClickButton`/`continueTo` (обход коуч-марка); шаги `submitAudience`→/message, + `submitAd`→/budget, `submitBudget`→/confirmation; `finalize` на /confirmation + (draft=скрин без клика, live=оферта+отправка, за FLOW-CONFIRM); из `fillAd` убраны + `adTitle` и `ordCompanyName` (полей нет / рекламодатель авто). +- `task.js`/`task.example.json`/тесты: убраны `adTitle`, `ordCompanyName`, `cpmRub`; + `budgetRub` оставлен как ПОТОЛОК стоимости для боевого запуска. Юнит-тесты 20/20 зелёные. + +## Разведка Сессии 5 — Фаза 1 (наблюдательная, 27.07.2026) + +Наблюдательный прогон `_recon-confirm.mjs` (черновик, фейк-420, отправка НЕ нажималась). +Снятые селекторы — **проверены живьём** на /message и /confirmation: + +- 🔑 **Файлы к объявлению = «Комментарий для модератора»** (шаг /message): третий `input[type=file]`, + `id` содержит `Комментарий-для-модератора-загрузить`, **accept `.png,.jpeg,.jpg,.pdf`**. Это и есть + канал приложения файлов для Задачи 5.3 (помочь пройти модерацию / при пересдаче). Первые два + file-input — медиа Premium (image/jpeg,png и video/*), к модерации не относятся. +- 🔑 **Чекбокс оферты** (/confirmation): `input#isOfferAccepted` (`type=checkbox`, по умолчанию + `aria-checked=false`). В `finalize` кликать по **`#isOfferAccepted`** напрямую (надёжнее, чем по тексту). +- 🔑 **Финальная кнопка** (/confirmation, футер): текст **«ПРОДОЛЖИТЬ»** (отправка на модерацию). + Текущий regex `finalize` `(отправить на модерацию|запустить|оплатить|продолжить)` — покрывает. +- 🔑 **Выключатель «Автозапуск»** (/budget): `input[data-uniq-name="Автозапуск | переключатель"]` + (id динамический `SwitchGranat2_idNN`), по умолчанию `checked`. Для контрольного захода — + снять галку до /confirmation, чтобы даже при одобрении кампания не запустилась и деньги не ушли. +- URL шага подтверждения: `…/cabinet/campaign/telegram-a2p/{ID}/confirmation` (пример черновика 2231126). +- ⚠️ Кабинет капризничает на открытии (вечная крутилка) — лечится `reload()` + терпением (до ~40с). + Заложить в робота ретрай загрузки. + +**Ещё НЕ снято (нужен реальный отказ):** как и где кабинет показывает **причину отказа** модерации, +и появляется ли поле файлов при пересдаче отклонённой. Требует боевой отправки + ожидания модерации МТС. + +## Разведка Сессии 5 — Фаза 2 (боевая отправка, 27.07.2026) + +🔑 **НАЙДЕН НЕИЗВЕСТНЫЙ РАНЕЕ ШАГ `/payment`** между «Подтверждением» и модерацией. Реальный флоу: +`/confirmation` → кнопка «ПРОДОЛЖИТЬ» → **`/payment` «Подтверждение оплаты»** → отправка на модерацию. +Экран /payment: + +- «На балансе», «Сумма к оплате» (= стоимость кампании), тумблер «Промокод». +- Две кнопки: **«СПИСАТЬ С БАЛАНСА И ОТПРАВИТЬ НА МОДЕРАЦИЮ»** (РЕЗЕРВИРУЕТ сумму; окончательно + спишут по факту показов; при отмене/остановке — возврат на баланс) и + 🔑 **«ОТПРАВИТЬ НА МОДЕРАЦИЮ БЕЗ ОПЛАТЫ»** (0 ₽, без резерва — проверено: баланс 5010 ₽ не изменился). +- Текст: «Чтобы кампания запустилась, после модерации нужно зайти в ЛК и запланировать её» + (подтверждает: при выключенном автозапуске запуск ручной — деньги за показы не уходят сами). + +🔑 **АВТОЗАПУСК влияет на путь оплаты по-другому, чем думали:** отправить на модерацию можно и БЕЗ +списания (кнопка «без оплаты»). Значит для контрольного/тестового прогона деньги = 0 гарантированно, +даже без возни с автозапуском. (Автозапуск всё равно выключаем — чтобы одобренная не стартовала сама.) + +**Отправка на модерацию (кампания 2231132, объявление с несуществующим каналом):** прошла, статус в +списке — **«На модерации»**, баланс не тронут. Всплывающее сообщение МТС: **«Проверка займёт ~4 часа, результат +будет на почте и в личном кабинете»**. Список кампаний: `/cabinet/campaigns/telegram`, колонка +«Статус» = Черновик / На модерации / (ожидаем: Отклонена/Одобрена). + +🔑 **Шаг «Знакомство» — ТОЛЬКО для новичка.** На возвратном аккаунте (после нескольких кампаний) +«Создать кампанию» ведёт СРАЗУ на «Тип аудитории», без «Знакомства». `openTelegramWizard`/`passZnakomstvo` +падают на `waitFor` радио → **робота надо чинить: «Знакомство» проходить, только если оно видимо** +(проверять `radio 'Нет, я отлично в этом разбираюсь'` с коротким таймаутом, иначе сразу «Своя база клиентов»). + +🔑 **Второй тест — реклама «займ под залог» (идея владельца, гарантированный отказ).** Регулируемая +тематика: модерация обязана потребовать лицензию/свидетельство. На шаге /message документ инлайн НЕ +требуется (поле лицензии там нет) → запрос ожидается на МОДЕРАЦИИ. Отправлено без оплаты — **кампания +2231134**, статус «На модерации», баланс не тронут. + +**СЛЕДУЮЩИЙ ШАГ (Part B, через ~4 ч):** дождаться вердикта МТС. Приоритетно по **2231134 (займ)** — +должен дать отказ + ЗАПРОС ДОКУМЕНТА (сразу два куска: причина отказа 5.1 + куда грузить файл 5.3). +Резерв — 2231132 (мёртвая ссылка). Снять: ГДЕ и КАК показана причина (селектор/текст), где/как +приложить документ, кнопка пересдачи. Плюс: убрать тестовые кампании/черновики из списка. + +## Задача 2.0 — билинг-модель МТС (РЕШЕНО из разведки Фазы 2, 28.07.2026) + +Вопрос плана: МТС списывает за «показы своей базе» РАЗОВО или НАКОПИТЕЛЬНО по показам? + +🔑 **Ответ: НАКОПИТЕЛЬНО (резерв → списание по факту показов → возврат остатка).** +Источник — экран `/payment` «Подтверждение оплаты» (см. раздел «Фаза 2» выше): +кнопка «Списать с баланса и отправить на модерацию» **резервирует** сумму, а +**окончательно списывается по факту показов**; при отмене/остановке **остаток +возвращается** на баланс кабинета. Стоимость на старте — «Стоимость кампании **от** +X ₽» (нижняя граница = аудитория «не МТС» × ≈400 ₽/1000 показов). + +**Что это значит для нашего портала (Этап 2 → Этап 3):** + +- В момент успешной отправки кампании показов ещё НЕТ → фактической суммы нет. + Поэтому «списание по факту» (была задача 2.4) на успехе отправки сделать честно + нельзя — оно **переезжает в Этап 3**, к опросу завершения/статуса кампании + (когда кабинет покажет реально открученную стоимость). +- Наш портал уже зеркалит МТС: при запуске **бронирует** потолок (`freeze` в + `launch`), при отказе/сбое/отмене **возвращает** (`release`, Этап 2.3). Это ровно + соответствует «резерв → возврат остатка». +- Задача 2.2 (сделана): робот получит чистую `parseCost()` и поле + `RobotResult.actualCostRub` — заготовка, чтобы позже (Этап 3) прочитать + фактическую стоимость из кабинета и списать её с кошелька клиента. + +## Разведка Сессии 6 — ЭКРАН ОТКАЗА модерации (снято живьём 28.07.2026) + +Вердикт МТС пришёл: кампания **2231134 («займ», гарантированный отказ) — «Отклонена»**. +Разведка read-only (`_check-moderation.mjs`, `_recon-rejection.mjs`, `_recon-reason-text.mjs`), +ничего не отправлялось/не пересдавалось. Снятые факты для Этапа 3: + +- 🔑 **ГДЕ причина**: в списке `/cabinet/campaigns/telegram` у отклонённой строки есть ссылки + **«Исправить»**, **«Причины»**, «Посмотреть настройки». Клик по **«Причины»** открывает + правую **слайд-модалку** (`#slide-modal-root`, контейнер `SlideModalContainer`) с блоком + **«Причины отклонения кампании»** — причина показана **обычным текстом** (не файлом). +- 🔑 **КАК читать**: весь текст = `innerText` панели внутри `#slide-modal-root`. Модалка — + оверлей, перехватывает клики (обычный `.click()` по «Исправить» сквозь неё не проходит — + нужен нативный DOM-клик или сперва закрыть модалку). Модалка также содержит ПОЛНУЮ сводку + кампании (текст, ссылку, аудиторию, **стоимость `201,6 ₽` / бюджет `201,6 ₽`**, период, + «Запуск: Вручную») — отсюда же можно прочитать фактическую стоимость. +- 🔑 **Причина «займа» — правки ТЕКСТА, а не только документ** (реальный вердикт, дословно): + (1) ссылка `t.me/liderra_zaim_probe_7742` недоступна при проверке; (2) указать юрлицо с ОПФ + в конце текста / поле «Юридическая информация» (до 27 символов); (3) добавить предупреждение + про оценку фин.возможностей; (4) не использовать «Деньги за час»; (5) содержание не + соответствует деятельности ЛК → **«предоставьте договор… Документ можно загрузить в поле + "Комментарий для модератора" в блоке "Дополнительная информация" на шаге "Сообщение"»**. +- 🔑 **КУДА грузить документ (пересдача)**: НЕ на экране отказа (там `input[type=file]` = 0). + Пункт 5 модерации сам указывает канал — **поле «Комментарий для модератора»** на шаге + `/message` (это уже известный третий file-input, `id` ~ `Комментарий-для-модератора-загрузить`, + accept `.png,.jpeg,.jpg,.pdf`, см. раздел «Фаза 1»). То есть загрузка документа = часть + РЕДАКТИРОВАНИЯ кампании, а не отдельного экрана отказа. +- 🔑 **КНОПКА пересдачи** = **«Исправить»** (в списке и розовой кнопкой в модалке) → возврат + в редактор кампании (тот же визард, шаги message/budget/confirmation). +- ⚠️ **Ожидание плана скорректировано**: ждали, что «займ» потребует ЗАГРУЗКУ ЛИЦЕНЗИИ как + главную причину. По факту модерация просит в первую очередь **правки текста** (юрлицо, + предупреждение, ссылка, формулировки), а документ — лишь один из 5 пунктов и грузится через + штатное поле «Комментарий для модератора». Отдельного «экрана догрузки документа» НЕТ. + +**Вывод для Этапа 3**: опросчик статуса читает статус из списка кампаний; при «Отклонена» — +причину берёт текстом из слайд-модалки «Причины» (для показа клиенту на экране «реклама +отклонена», Сессия 5.2 уже есть). Возврат брони при `rejected` (задача 3.4) — по факту отказа. +Списание по факту (2.4→Этап 3) — фактическую стоимость видно и в модалке, и на /budget. + +## Задача 3.1b — Уборка осиротевших черновиков (инструмент, 28.07.2026) + +**Проблема:** робот создаёт РЕАЛЬНЫЙ черновик в кабинете уже на шаге «Аудитория». Если прогон +позже упадёт/воркер убьют — черновик осиротеет и копится в списке кампаний (27.07 такие чистили +руками, 2 шт.). Нужен узаконенный инструмент с защитой. + +**Инструмент:** `node bin/cleanup-drafts.js` +- без флага — **РЕЖИМ ПРОСМОТРА (read-only)**: показывает статусы строк и сколько черновиков БЫ + удалил, ничего не трогая; +- `--delete` — удаляет, но только через защиту. + +**Защита (перенесена из проверенного живьём 27.07 ручного скрипта, ядро покрыто юнит-тестом +`test/cleanup-drafts.test.js`):** +- помечаются на удаление ТОЛЬКО строки-черновики (`isDeletableDraftRow`): имя «Telegram по своей + базе» + статус «Черновик», НЕ «На модерации»/«Отклонена»/«Активна», НЕ шапка; +- перед удалением — сверка `selectionIsSafe`: удаляем, только если выбран ≥1 элемент, ВСЕ выбранные + черновики, и НЕ взведён общий select-all. Любое расхождение → аварийный стоп, ничего не удаляется; +- удаление двухшаговое: панельная «УДАЛИТЬ (N)» → модальная «Удалить», затем список перечитывается. + +**Процедура (когда чистить):** после серии упавших прогонов робота (или разово перед боевым +запуском) — сперва `node bin/cleanup-drafts.js` (посмотреть), затем при явном согласии владельца +`node bin/cleanup-drafts.js --delete`. Баланс кабинета от удаления черновиков не меняется. + +## Осталось (боевой запуск — только с разрешения владельца) + +- КОНТРОЛЬНЫЙ живой запуск (mode:'live', Задача 16): снять точный селектор чекбокса оферты, + текст финальной кнопки и диалог подтверждения на /confirmation. Сейчас `finalize` live — + best-effort с FLOW-CONFIRM. Заполнить SMTP в `.env` перед автономной боевой работой. diff --git a/bots/mts-telegram-ads/README.md b/bots/mts-telegram-ads/README.md new file mode 100644 index 00000000..93c1bd11 --- /dev/null +++ b/bots/mts-telegram-ads/README.md @@ -0,0 +1,229 @@ +# МТС-бот «Реклама в Telegram по своей базе» + +## 1. Что это + +Робот сам заходит в кабинет **МТС Маркетолог** (marketolog.mts.ru) и создаёт +рекламную кампанию в Telegram по загруженной базе телефонов клиентов. Работает +на Windows-сервере рядом с мостом `LiderraMtsBridge` (тот же сервер, что для +СМС МТС). У робота два режима: **«черновик»** (доходит до кнопки запуска, +ничего не тратит) и **«боевой»** (нажимает кнопку и списывает деньги с баланса +кабинета). + +## 2. ⚠️ Важные предупреждения (прочитать первым) + +- **Кабинет МТС — это реальные деньги.** На балансе кабинета лежат настоящие + рубли. Робот в режиме «боевой» их тратит. Пока идёт наладка — запускать + **только «черновик»**. +- **Живой вход в кабинет = доступ к деньгам.** Профиль браузера бота (папка, + указанная в `MTS_BROWSER_PROFILE_DIR`) — это как ключ от кассы: хранить на + сервере, не копировать, никуда не выкладывать, не отправлять по почте/в чат. +- **Телефоны клиентов — персональные данные (152-ФЗ).** Робот держит их + только во временном файле на время прогона и удаляет сразу после — файл не + остаётся на диске и не попадает в git. В боевых кампаниях использовать + только базу с согласием клиентов на рекламные звонки/сообщения, **не старые + лиды без согласия**. + +## 3. Разовая настройка (один раз) + +Установка зависимостей и браузера: + +```bash +cd bots/mts-telegram-ads +npm install +npx playwright install chromium +``` + +Скопировать `.env.example` в `.env` и заполнить: + +| Переменная | Что это | +|---|---| +| `MTS_BROWSER_PROFILE_DIR` | папка на диске, где браузер бота хранит вход в кабинет (профиль) | +| `MTS_CABINET_URL` | адрес главной страницы кабинета МТС Маркетолог | +| `MTS_TELEGRAM_URL` | адрес раздела «Реклама в Telegram» в кабинете | +| `SMTP_HOST`, `SMTP_PORT` | адрес и порт почтового сервера для отправки писем (Unisender Go) | +| `SMTP_USER`, `SMTP_PASS` | логин и пароль для отправки писем (те же креды, что у портала) | +| `ALARM_FROM` | с какого адреса приходят письма-алярмы/отчёты | +| `ALARM_TO` | на какой адрес слать письма-алярмы/отчёты | +| `BUDGET_CAP_RUB` | потолок бюджета в рублях: боевой запуск дороже этой суммы робот запрещает сам | +| `HUMAN_DELAY_MS` | пауза между действиями робота в кабинете (мс) — «по-человечески», не чтобы сайт заподозрил робота | + +**Разовый вход в кабинет.** Один раз нужно открыть браузер бота в видимом +режиме и вручную залогиниться (телефон/пароль + код по СМС) — после этого +вход запомнится в профиле браузера, и робот дальше сможет заходить сам. +Команда: + +```bash +node bin/login.js +``` + +(или `npm run login`). Откроется окно браузера на странице кабинета — войти +руками, как обычно (телефон/пароль + код по СМС). После того как в окне +появится кабинет, вход сохранён в профиле браузера — окно можно закрыть. Это +действие делает **владелец** (у него доступ к телефону/паролю кабинета). + +## 4. Файл с номерами + +- **Формат:** по одному номеру в строке, вид `79000000000` (11 цифр). Робот + сам приводит к этому виду номера вида «+7…», «8…», убирает лишние символы и + повторы. +- **Требование кабинета:** минимум **367 номеров «не МТС»**. Реклама в + Telegram по своей базе показывается только абонентам НЕ-МТС (по МТС-номерам + из той же базы можно отправить СМС — в боте это выключено). +- **Куда класть файл:** в папку `screenshots/` (она в `.gitignore` — номера не + попадут в git). В примере задания путь `screenshots/test-phones.txt`. +- **Где взять тестовую базу:** XLSX-экспорт лидов из старого кабинета + поставщика crm.bp-gr.ru — эту выгрузку делает владелец. + +## 5. Формат задания (task.json) + +| Поле | Обязательное | Что это | +|---|---|---| +| `mode` | да | `draft` (черновик, безопасно) или `live` (боевой, тратит деньги) | +| `phonesFile` | да | путь к файлу с номерами | +| `adText` | да | текст объявления (до 160 символов) | +| `adTitle` | да | заголовок объявления (до 40 символов) | +| `buttonUrl` | да | ссылка — что рекламируем (канал/бот/сайт) | +| `budgetRub` | да | бюджет кампании в рублях | +| `cpmRub` | да | цена за 1000 показов, рубли | +| `ordCompanyName` | да | название компании/сервиса — для маркировки рекламы (ОРД) | +| `ordCategory` | нет | категория товара/услуги для ОРД, по умолчанию «Размещение рекламы» | +| `mediaFile` | нет | путь к картинке/видео объявления | +| `clientTag` | нет | своя метка для учёта (не используется кабинетом) | + +Пример (файл `task.example.json`): + +```json +{ + "mode": "draft", + "phonesFile": "screenshots/test-phones.txt", + "adText": "Подключите наш телеграм-бот и получайте заявки быстрее", + "adTitle": "Лидерра — заявки на автопилоте", + "buttonUrl": "https://liderra.ru", + "budgetRub": 1000, + "cpmRub": 400, + "ordCategory": "Размещение рекламы", + "ordCompanyName": "Лидерра" +} +``` + +## 6. Запуск + +**Черновик (безопасно, ничего не тратит):** + +```bash +node bin/run.js --task task.example.json +``` + +Робот дойдёт до кнопки запуска, снимет скриншот шага и **не нажмёт** её. На +почту (`ALARM_TO`) придёт отчёт. + +**Боевой (тратит деньги):** то же самое, но в задании `"mode": "live"`. Запускать +**только с разрешения владельца** и в пределах потолка `BUDGET_CAP_RUB` — робот +сам сверяет бюджет задания с потолком и отказывается запускать, если бюджет +больше. + +Результат прогона печатается в консоль в виде JSON, например +`{"ok": true, "matched": 996, "launched": false}` — `matched` это сколько +номеров «не МТС» нашлось, `launched` — нажал ли робот кнопку запуска. + +## 6.1. Пересдача отклонённой кампании (`mode: "resubmit"`) + +Если модерация МТС **отклонила** кампанию, робот может её починить: войти в +редактор через «Исправить», внести исправления и снова отправить на модерацию +**без оплаты** (0 ₽ — деньги не списываются). + +Что исправляем — на выбор (в ответ на причину отказа): приложить документ +модератору (лицензию/справку) и/или поправить текст/ссылку/категорию ОРД. Нужна +**хотя бы одна** правка — иначе тот же контент снова отклонят. Номера/бюджет не +нужны — они у кампании уже есть. + +| Поле | Обязательное | Что это | +|---|---|---| +| `mode` | да | `resubmit` | +| `campaignId` | да | номер кампании, которую чиним (из кабинета) | +| `submitMode` | да | `draft` (дойти до конца, **не** отправлять) или `live` (отправить без оплаты) | +| `moderatorFile` | нет* | путь к документу для модератора (pdf/картинка) | +| `adText` | нет* | новый текст объявления | +| `buttonUrl` | нет* | новая ссылка (канал/бот/сайт) | +| `ordCategory` | нет* | новая категория ОРД | + +\* хотя бы одно из полей-правок (`moderatorFile`/`adText`/`buttonUrl`/`ordCategory`) обязательно. + +```json +{ + "mode": "resubmit", + "campaignId": "2231134", + "submitMode": "live", + "moderatorFile": "screenshots/licenziya.pdf", + "adText": "Исправленный текст объявления" +} +``` + +`submitMode: "live"` реально отправляет кампанию на повторную модерацию (бесплатно) — +запускать **только с разрешения владельца**. Результат: `{"ok": true, +"campaignId": "2231134", "resubmitted": true}`. + +## 7. Присмотр за входом (keep-alive) + +Команда `node bin/keepalive.js` тихо проверяет, жив ли вход в кабинет. Если +вход слетел — робот сам отправляет письмо-алярм на `ALARM_TO`. + +Чтобы проверка шла сама каждые 15 минут, добавить задачу в Планировщик +заданий Windows (пример команды, **путь заменить на реальный** на сервере): + +```powershell +schtasks /create /tn "LiderraMtsTgKeepAlive" /tr "node C:\путь\bots\mts-telegram-ads\bin\keepalive.js" /sc minute /mo 15 /ru СИСТЕМА +``` + +## 8. Что делать при алярме на почте + +Письмо приходит с темой вида «[МТС-бот] АЛЯРМ на шаге «...»» и приложенным +скриншотом. Частые причины: + +- **«Вход слетел — нужен повторный логин»** — повторить разовый вход из + раздела 3. +- **«недостаточно номеров не-МТС»** (меньше 367) — нужна база побольше. +- **«Шаг «Стоимость» ещё не размечен»** — это ожидаемо на сегодня, см. раздел 9. + +## 9. Текущее состояние / что ещё не готово (честно) + +Реализованы и проверены (юнит-тесты ядра — все зелёные, см. `npm test`) шаги: + +- вход в визард кампании; +- выбор «Своя база клиентов»; +- загрузка базы и подсчёт «не МТС»; +- проверка порога **367 «не МТС»** (ниже — честный алярм «недостаточно + номеров не-МТС», кампанию не создаём) и переход со шага «Аудитория» на + «Объявление» (кнопка «Продолжить», с само-проверкой, что попали на нужный + шаг, а не заполняем объявление вслепую на чужой странице); +- заполнение объявления (текст, заголовок, ссылка, блок ОРД); +- финал: черновик — стоп перед кнопкой, боевой — нажатие кнопки. + +**Не готово:** + +- Шаг **«Стоимость»** (бюджет/CPM) в кабинете пока не размечен — точные + места на экране, куда робот должен кликать, ещё не сняты вживую. До этого + робот на этом шаге штатно останавливается и шлёт алярм — это ожидаемо, не + поломка. +- Часть полей объявления (точное место поля «Заголовок», «Название компании» + для ОРД, поле для картинки/видео) помечены в коде как требующие + подтверждения — они снимаются на первом реальном черновике. +- Приёмка «боевого» режима (один контрольный запуск на минимальном бюджете) — + только с явного разрешения владельца. + +## 10. Устройство (кратко) + +| Файл | Назначение | +|---|---| +| `src/config.js` | читает `.env`, проверяет, что все нужные настройки заданы | +| `src/task.js` | читает и проверяет задание (task.json) | +| `src/phones.js` | приводит номера к формату `79...`, убирает мусор и повторы | +| `src/budget.js` | проверяет, что бюджет боевого запуска не выше потолка | +| `src/mailer.js` | отправка писем-алярмов и отчётов на почту | +| `src/browser.js` | открывает браузер с сохранённым профилем (живым входом) | +| `src/session.js` | проверяет, жив ли вход в кабинет | +| `src/cabinet.js` | шаги визарда кабинета: вход, загрузка базы, объявление, финал | +| `src/runner.js` | весь прогон целиком: собирает шаги по порядку, ловит ошибки, шлёт алярм/отчёт, чистит временный файл с номерами | +| `bin/run.js` | команда запуска одного задания | +| `bin/keepalive.js` | команда проверки живого входа (для Планировщика Windows) | +| `docs/cabinet-flow.md` | подробная карта кабинета МТС — какие кнопки/поля где находятся | diff --git a/bots/mts-telegram-ads/bin/cleanup-drafts.js b/bots/mts-telegram-ads/bin/cleanup-drafts.js new file mode 100644 index 00000000..04d57946 --- /dev/null +++ b/bots/mts-telegram-ads/bin/cleanup-drafts.js @@ -0,0 +1,46 @@ +// Уборка осиротевших ЧЕРНОВИКОВ Telegram-кампаний в кабинете МТС. +// +// node bin/cleanup-drafts.js — ПРОСМОТР (read-only): показать, что +// было бы удалено. Ничего не трогает. +// node bin/cleanup-drafts.js --delete — удалить черновики (с защитой-сверкой). +// +// Защита: удаляются ТОЛЬКО строки-черновики. Если в выборку попало что-то кроме +// черновика или взведён общий select-all — аварийный стоп, ничего не удаляется. +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { cleanupDrafts } from '../src/cleanup-drafts.js'; + +const apply = process.argv.includes('--delete'); + +const config = loadConfig(); +const { context, page } = await openBrowser(config, { headless: false }); +try { + const report = await cleanupDrafts(page, config, { apply }); + + console.log('Статусы строк ДО:', JSON.stringify(report.before)); + console.log('Помечено черновиков:', report.picked, '| сверка:', JSON.stringify(report.audit)); + + if (!apply) { + console.log( + report.safe + ? `РЕЖИМ ПРОСМОТРА: было бы удалено черновиков — ${report.picked}. Для удаления добавь --delete.` + : 'РЕЖИМ ПРОСМОТРА: сверка НЕ безопасна (см. выше) — с --delete инструмент остановится.', + ); + } else if (report.aborted) { + console.log('🛑 СТОП: выделены не только черновики (или взведён select-all) — ничего не удалено.'); + process.exitCode = 2; + } else { + console.log('Статусы строк ПОСЛЕ:', JSON.stringify(report.after)); + console.log( + (report.after || []).some((s) => /черновик/i.test(s)) + ? '⚠️ Черновики ещё видны — проверь кабинет.' + : '✅ Черновиков не осталось.', + ); + } +} catch (e) { + console.error('ОШИБКА:', e.message); + process.exitCode = 1; +} finally { + await context.close(); +} diff --git a/bots/mts-telegram-ads/bin/keepalive.js b/bots/mts-telegram-ads/bin/keepalive.js new file mode 100644 index 00000000..2f56755f --- /dev/null +++ b/bots/mts-telegram-ads/bin/keepalive.js @@ -0,0 +1,30 @@ +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; + +const config = loadConfig(); + +let context; +try { + const b = await openBrowser(config, { headless: true }); + context = b.context; + const alive = await isLoggedIn(b.page, config); + if (!alive) { + const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); + try { + await mailer.alarm({ step: 'keepalive', reason: 'Вход в кабинет слетел — нужен повторный логин', screenshotPath: null }); + } catch {} + } + console.log(alive ? 'ALIVE' : 'DEAD'); +} catch (e) { + console.error('keepalive: ошибка проверки входа —', e.message); + try { + const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); + await mailer.alarm({ step: 'keepalive', reason: `Не удалось проверить вход: ${e.message}`, screenshotPath: null }); + } catch {} + process.exitCode = 1; +} finally { + try { await context?.close(); } catch {} +} diff --git a/bots/mts-telegram-ads/bin/login.js b/bots/mts-telegram-ads/bin/login.js new file mode 100644 index 00000000..af771793 --- /dev/null +++ b/bots/mts-telegram-ads/bin/login.js @@ -0,0 +1,27 @@ +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; + +// Разовый вход: открывает браузер бота на странице кабинета. Владелец логинится +// вручную (телефон/пароль + СМС). Сессия сохраняется в профиле (MTS_BROWSER_PROFILE_DIR). +// Окно НЕ закрываем — оставляем открытым для ручного входа. +const config = loadConfig(); +const { context, page } = await openBrowser(config, {}); +// Самопроверка выхода в интернет: печатаем IP, с которым реально пойдёт окно. +// Должен быть прямой IP машины (77.74.x), а НЕ прокси — иначе кабинет даст 403. +try { + await page.goto('https://api.ipify.org?format=text', { waitUntil: 'domcontentloaded', timeout: 20000 }); + console.log('Выход в интернет с IP: ' + (await page.locator('body').innerText()).trim()); +} catch { /* не критично — просто пропускаем подсказку */ } +// Открываем кабинет: через Edge Qrator пускает (401 «войдите»), 403 больше нет. +await page.goto(config.cabinetUrl, { waitUntil: 'domcontentloaded' }).catch(() => {}); +console.log(''); +console.log('=== ВХОД ==='); +console.log('1) В открывшемся окне Edge войди в кабинет МТС Маркетолог (телефон/пароль + СМС).'); +console.log('2) Когда увидишь кабинет — ПРОСТО ЗАКРОЙ окно. Вход сохранится в профиль.'); +console.log('(Окно само не закроется — держим открытым, пока ты его не закроешь.)'); +// Держим процесс живым, пока окно браузера открыто. Без этого Node завершается +// сразу после goto и закрывает окно раньше, чем владелец успеет войти (окно +// «мелькает» и исчезает). Событие close ловим — закрытие окна = штатный выход. +await new Promise((resolve) => context.on('close', resolve)); +console.log('Окно закрыто, вход сохранён в профиль. Готово.'); diff --git a/bots/mts-telegram-ads/bin/run.js b/bots/mts-telegram-ads/bin/run.js new file mode 100644 index 00000000..e14cc8c4 --- /dev/null +++ b/bots/mts-telegram-ads/bin/run.js @@ -0,0 +1,48 @@ +import 'dotenv/config'; +import { readFileSync } from 'node:fs'; +import { loadConfig } from '../src/config.js'; +import { parseTask, parseResubmitTask } from '../src/task.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; +import { runTask, runReadStatus, runResubmit } from '../src/runner.js'; + +const argIdx = process.argv.indexOf('--task'); +const taskPath = argIdx === -1 ? undefined : process.argv[argIdx + 1]; +if (argIdx === -1 || !taskPath) { + console.error('Использование: node bin/run.js --task task.json'); + process.exit(2); +} + +const config = loadConfig(); +const rawTask = JSON.parse(readFileSync(taskPath, 'utf8')); + +// Режим read-status (задача 3.5): чтение вердикта модерации по id кампании — +// без номеров/отправки/почты. Идёт ДО parseTask (тот требует поля кампании: +// phonesFile/adText/budgetRub/buttonUrl, которых у read-status нет). +if (rawTask.mode === 'read-status') { + if (!rawTask.campaignId) { + console.error('read-status: не задан campaignId'); + process.exit(2); + } + const res = await runReadStatus(config, rawTask.campaignId); + console.log(JSON.stringify(res, null, 2)); + process.exit(res.ok ? 0 : 1); +} + +// Режим resubmit (R6, пересдача отклонённой кампании): вход «Исправить» → документ +// модератору → отправка без оплаты. Как read-status, идёт ДО parseTask (у пересдачи +// свои обязательные поля: campaignId/moderatorFile/submitMode, а не поля кампании). +if (rawTask.mode === 'resubmit') { + const task = parseResubmitTask(rawTask); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const res = await runResubmit(config, task, { timestamp }); + console.log(JSON.stringify(res, null, 2)); + process.exit(res.ok ? 0 : 1); +} + +const task = parseTask(rawTask); +const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); +const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + +const res = await runTask(config, task, mailer, { timestamp }); +console.log(JSON.stringify(res, null, 2)); +process.exit(res.ok ? 0 : 1); diff --git a/bots/mts-telegram-ads/docs/cabinet-flow.md b/bots/mts-telegram-ads/docs/cabinet-flow.md new file mode 100644 index 00000000..28ca7b0e --- /dev/null +++ b/bots/mts-telegram-ads/docs/cabinet-flow.md @@ -0,0 +1,299 @@ +# Разметка визарда «Реклама в Telegram → по своей базе» в кабинете МТС Маркетолог + +> Результат Задачи 1 (разведка). Источник для селекторов в `src/cabinet.js` +> (Задачи 10–12). Разметка снята с ЖИВОГО кабинета 26.07.2026 в режиме +> только-чтение; черновик, созданный при разведке (ID 2229823), удалён, +> баланс не изменился (5 010 ₽ до и после). + +**База:** `https://marketolog.mts.ru` +**Логин кабинета (юрлицо):** ИП Кондратьев (тот же, что для СМС МТС). +**Модель оплаты:** CPM. «Рассылка по своей аудитории» → «Показы в Telegram» = **0,49 ₽**, +плюс «SMS на номера МТС» от 5,03 ₽ (для абонентов МТС из базы). Цены с НДС. +**Модерация:** 4–6 ч, свои модераторы. Маркировка ОРД автоматом (МедиаСкаут → ЕРИР). + +--- + +## 0. Общая механика, важная для робота + +- **Визард сам создаёт черновик**, как только выбран «Тип аудитории». В URL + появляется числовой ID кампании: `/cabinet/campaign/telegram-a2p/{ID}/audience`. + Если робот прервётся после этого шага — в списке останется «Черновик» + (см. §8 — как найти и удалить). +- **Coach-mark оверлей.** На шагах включён тур подсказок (карточки «Юрий + Тихоненков / Иван Фёдоров», кнопка «Далее»). Оверлей + (`div[class*="CoachMark"][class*="overlayClickBlocker"]`) **перехватывает клики** + и не даёт прыгать по степперу вперёд. Робот должен идти по кнопкам самого + шага («Продолжить»/«Далее» внутри контента), а не по номерам степпера, ИЛИ + сперва погасить подсказки (radio «Нет, я отлично в этом разбираюсь» на шаге 1 + уменьшает подсказки; отдельная кнопка «Подсказки» в заголовке шага). +- **Прыжки по степперу вперёд заблокированы** до выполнения текущего шага — форвард + идёт только через кнопку шага. Назад — можно (клик по пройденному пункту степпера + или кнопка «Назад»). +- **Баланс** виден в шапке всегда: `5 010 ₽` (текст-узел рядом с кнопками шапки). + Робот сверяет баланс до/после — в режиме черновик он НЕ должен меняться. + +--- + +## 1. Вход в визард + +- **Стартовый URL:** `https://marketolog.mts.ru/cabinet/campaigns/telegram` + (промо-лендинг «Реклама в Telegram Ads»). +- **Кнопка старта:** `getByRole('button', { name: 'Создать кампанию' })`. + → переводит на `/cabinet/campaign/telegram` (шаг 1). +- Если у аккаунта уже есть кампании/черновики — на этом URL вместо промо + показывается **список кампаний** (см. §8); кнопка «Создать кампанию» тогда + в шапке блока (`getByRole('button', { name: 'Создать кампанию' })`), рядом + «Создать из файла» (массовое создание из Excel — вне scope MVP). + +## Степпер (поток «по своей базе») + +`Знакомство → Тип аудитории → Аудитория → Объявление → Стоимость → Подтверждение → Оплата` + +> Примечание: для потока «своя база» третий шаг называется **«Аудитория»** +> (в потоке Big Data он называется «Таргеты»). + +--- + +## 2. Шаг «Знакомство» + +**URL:** `/cabinet/campaign/telegram` + +Поля: + +- Radio «Вам нужна помощь в запуске рекламы?»: + `getByRole('radio', { name: 'Да, у меня пока мало опыта' })` (по умолчанию checked) + / `getByRole('radio', { name: 'Нет, я отлично в этом разбираюсь' })`. + Для робота предпочтительно **«Нет…»** — меньше coach-mark подсказок. +- «Выберите сферу бизнеса» — сетка чекбоксов сфер (Красота, Спорт, Медицина…, + «Услуги для бизнеса», «Другое» и т.д.). **Можно выбрать категории только из + ОДНОЙ сферы** (после выбора одной — остальные становятся `disabled`, показывается + баннер «Можно выбрать категории только из одной сферы»). При клике по сфере + раскрываются её подкатегории (тоже чекбоксы), часть отмечается автоматически. + Есть строка поиска сферы: `getByRole('textbox', { name: 'Поиск' })`. +- Ссылка-справка «Какой бизнес нельзя рекламировать». + +**Кнопка перехода:** `getByRole('button', { name: 'Продолжить' })` +→ `/cabinet/campaign/telegram-type` (шаг 2). + +> Для робота: выбрать одну сферу (например «Услуги для бизнеса» или «Другое»), +> дождаться раскрытия подкатегорий (минимум одна отметится), нажать «Продолжить». +> Сфера влияет только на подсказки/модерацию, не на механику загрузки базы. + +--- + +## 3. Шаг «Тип аудитории» — РАЗВИЛКА + +**URL:** `/cabinet/campaign/telegram-type` + +Три карточки выбора: + +1. **«Аудитория Big Data МТС»** — таргетинг по демографии/гео/интересам, абоненты МТС. +2. **«Аудитория Telegram»** — по каналам/тематикам/поиску/чат-ботам, не зависит от оператора. +3. **«Своя база клиентов»** ← **НАШ ВАРИАНТ.** Текст карточки: + *«Сможете загрузить номера клиентов — покажем рекламу в Telegram абонентам + не МТС. Абонентам МТС можем отправить SMS»*. + +**Селектор нашей карточки** (устойчивее по видимому тексту): +`getByText('Своя база клиентов')` (клик по карточке) — родительский `[cursor=pointer]`. + +**Действие:** клик по карточке «Своя база клиентов» +→ **создаётся черновик**, переход на `/cabinet/campaign/telegram-a2p/{ID}/audience` +(шаг 3). `{ID}` — числовой (в разведке был `2229823`). + +> 🔑 Фиксация №1: именно этот шаг подтверждает feasibility — «своя база» для +> Телеги в нашем кабинете доступна без отдельного PRO-доступа. + +--- + +## 4. Шаг «Аудитория» — ЗАГРУЗКА БАЗЫ (ключевой) + +**URL:** `/cabinet/campaign/telegram-a2p/{ID}/audience` + +### Требования к файлу (как показаны в UI) + +- Форматы: **TXT, CSV, XLS или XLSX, до 50 МБ**. +- Формат номера: **`79000000000`** (11 цифр, без «+», без разделителей). +- **Минимум 367 номеров «не МТС»** (порог из UI на 26.07.2026; возможно + зависит от тарифа/минимального бюджета — перепроверять по факту). +- Шаблон: ссылка «Скачать шаблон» → `/assets/file-templates/phones.xlsx`. +- Баннер-предупреждение: «В загруженном файле должны быть номера «не МТС»» + (по своей базе реклама в Telegram показывается только абонентам НЕ-МТС; + абонентам МТС — можно отправить SMS отдельно). + +### Зона загрузки + +- Текст drop-зоны: «Переместите файл сюда или **загрузите вручную**». + Триггер ручной загрузки: `getByText('загрузите вручную')` (первый — основной список). +- Скрытый файловый инпут: `page.locator('input[type="file"]')` — для + Playwright `setInputFiles(path)` (клик по «загрузите вручную» не обязателен, + можно сразу `setInputFiles` на скрытый input). Точный индекс инпута уточнить + при первой загрузке (Задача 10): на шаге ДВА инпута — основной список и + «Исключить номера». +- Опция «**Исключить номера**» — чекбокс `getByRole('checkbox', { name: 'Исключить номера' })`, + раскрывает вторую зону загрузки (стоп-лист). В MVP не используем. + +### Где появляется «сколько нашлось» (счётчик матчинга) — ПОДТВЕРЖДЕНО живым прогоном + +После загрузки под зоной появляется строка «**Получатели**: N» (сколько строк +принято после дедупа МТС), а панель справа «**Аудитория**» показывает разбивку: + +- «**МТС**»: N (этим Телега НЕ показывается; можно послать SMS) +- «**Не МТС**»: N ← **это `matched`** — аудитория показа в Telegram +- ниже: блок «Показы в Telegram / Всем абонентам, кроме МТС» → «Аудитория: N (=Не МТС)» + и «**Стоимость: X ₽**» (= Не МТС × 0,49 ₽), плюс итог «Стоимость кампании от X ₽». + +Пример живого прогона (1740 номеров из реальной базы, 26.07.2026): Получатели +**1 709**, МТС **713**, Не МТС **996**, стоимость показов **478,08 ₽**. Порог 367 +пройден → кнопки разблокировались. + +Робот читает «Не МТС» как `matched`. **Если «Не МТС» < 367 — кнопка перехода +остаётся заблокированной → робот шлёт алярм «недостаточно номеров не-МТС».** + +Появляется также тумблер «**Отправить SMS абонентам МТС**» +(`getByRole('checkbox', { name: 'Отправить SMS абонентам МТС' })`) — по умолчанию +ВЫКЛ; в MVP не трогаем (иначе добавится SMS-стоимость на 713 МТС-номеров). +Кнопка «**Очистить**» рядом со списком сбрасывает загрузку. + +### Кнопки шага + +- `getByRole('button', { name: 'Назад' })` +- `getByRole('button', { name: 'Продолжить' })` — **disabled**, пока не загружен + валидный файл и не набран порог 367 «не-МТС». Клик см. §11 (coach-mark). + +Переход ведёт на `/cabinet/campaign/telegram-a2p/{ID}/message` (шаг «Объявление»). + +--- + +## 5. Шаг «Объявление» — РАЗМЕЧЕНО живьём (`/message`) + +Поля (селекторы устойчивее по placeholder, refs плавают между рендерами): + +- **Текст** — `textarea[placeholder^="Расскажите о боте"]`, счётчик «0 / 160» + (UI-лимит 160, хотя `maxlength`=1000). Правила модерации: **без грамматических + ошибок, без «!», без слов заглавными, без фамильярности/сленга/латиницы** (латиница — бренд). + Есть пикер эмодзи. +- **Заголовок объявления** — отдельное обязательное поле, счётчик «0 / 40». +- **Ссылка «Что рекламируете»** — `input[placeholder^="Ссылка на канал"]` + (канал / бот / пост в Telegram / сайт). Обязательное. +- **Медиа Premium** (опционально) — кнопки «Изображение»/«Видео», зона загрузки + `input[type=file]`. CPM с картинкой 600 ₽, с видео 680 ₽. JPEG/PNG до 25 МБ, + 640×360…5120×2880. +- **Дополнительно** (по чекбоксам): «Юридическая информация» (≤27), «Комментарий + для модератора». +- **Блок «Информация для ОРД»** — ОБЯЗАТЕЛЬНЫЙ, автомаркировка МедиаСкаут→ЕРИР: + - баннер «Рекламодатель по умолчанию: Кондратьев Платон Дмитриевич, ИНН 245210851872»; + - **«Категория товара или услуги»** — обязательный **downshift-combobox** + (`input[name="selectedKktu"]`), для теста подходит «Размещение рекламы». + ⚠️ капризный: см. §11 (реальный клик по опции, не синтетический); + - **«Название компании, сервиса, товара или услуги»** — обязательное текстовое поле; + - чекбокс «**Я — посредник**» (`name`-поля агентства: `innAgency`, `entityNameAgency`, + `contractNumber`, `contractType`, `contractSubjectType`, `contractActionType`, + `vat`) — включать, если реклама за клиента (важно для будущего клиентского + модуля Лидерры; в MVP-боте по умолчанию ВЫКЛ, рекламодатель = наш ИП). +- Справа — живой предпросмотр объявления в Telegram. +- Кнопка `button[data-uniq-name="Продолжить | кнопка"]` (текст «Продолжить») + → шаг «Стоимость». Валидируется весь блок (текст + заголовок + ссылка + + категория + название) — при незаполненном обязательном поле у него красный «!». + +## 6. Шаг «Стоимость» — ДОРАЗМЕТИТЬ на первом реальном черновике + +Пока НЕ размечен по шагам/селекторам: при разметке 26.07 упёрлись в объём +обязательного ОРД-блока шага «Объявление» (см. §5) — до «Стоимости» дойти не +успели, чтобы не гонять лишний раз реальные ПДн в кабинет. Известно из +лендинга/прайса (см. §заголовок, §4): + +- CPM (минимум **75 ₽ без НДС**; при бюджете <5 000 ₽ фактический CPM ≈ 400 ₽/1000; + при бюджете ≥5 000 ₽ — от 75 до 99 999 ₽). +- Бюджет (включает НДС). +- Управление частотой показа (сколько раз показать одному пользователю) — + опционально. + +Селекторы полей бюджета/CPM/частоты снять при ПЕРВОМ реальном прогоне бота в +режиме черновик (это штатное место — Задача 11/12 плана: «прогон против живого +кабинета в режиме черновик»). + +## 7. Шаг «Подтверждение» и «Оплата» — ДОРАЗМЕТИТЬ там же + +- «Подтверждение» — сводка кампании, кнопка запуска/отправки на модерацию + (искать `getByRole('button', { name: /запустить|на модерацию|оплатить/i })`). +- **Режим черновик:** дойти до кнопки запуска, снять скриншот, НЕ жать. +- **Режим боевой:** сверить бюджет ≤ потолка, нажать запуск → на модерацию. +- «Оплата» — списание с баланса кабинета. Точную кнопку/подтверждение снять + при контрольном боевом запуске (Задача 16, только с разрешения владельца). + +> Селекторы шагов 6–7 — единственная незакрытая часть разметки. Всё до «Объявления» +> включительно проверено живым прогоном. + +--- + +## 8. Список кампаний и удаление черновика (для keep-alive / очистки) + +**URL списка:** `/cabinet/campaigns/telegram` (когда есть хотя бы одна кампания — +вместо промо показывается таблица; при пустом списке — промо-лендинг). + +Колонки таблицы: Название · Статус · Вид · Тип аудитории · CPM без НДС · Бюджет · +Показы · Клики · CTR · CPC · Подписчики · CR · CPF · Расход в Telegram · +Отправлено SMS · Цена SMS MTC · Расход в SMS · Общий расход. + +Строка черновика: + +- Название вида «`DD.MM Telegram по своей базе`», статус «**Черновик**», + тип аудитории «**Свой список**», ссылка «Продолжить настройку» + → `/cabinet/campaigns/telegram/{ID}`. + +Удаление: + +1. Отметить чекбокс строки (`input[type="checkbox"]` в строке). +2. Внизу появляется панель: «Дублировать (N)» / «**Удалить (N)**» + (`getByRole('button', { name: /Удалить \(\d+\)/ })`). +3. Диалог «Удалить выбранные кампании? Их нельзя будет восстановить» → + `getByRole('button', { name: 'Удалить', exact: true })`. +4. Строка исчезает; при последней кампании возвращается промо-лендинг. + +> Верификация в разведке: черновик 2229823 удалён этим путём, баланс 5 010 ₽ +> не изменился. + +--- + +## 9. Тест-база (РЕШЕНО) и остаток + +- **Тест-база — есть.** Владелец выгружает XLSX-экспорт лидов из старого кабинета + поставщика `crm.bp-gr.ru` (учётка `lkomega.ru`, креды в `app/.env` `SUPPLIER_*`). + 1740 номеров дали 996 «не-МТС» (> 367). Реальные ПДн: держать во временном файле + ВНЕ git (только каталог `screenshots/` бота — в .gitignore), не логировать, чистить + после. Для БОЕВЫХ кампаний — база с согласием клиента (152-ФЗ), не старые лиды. +- **Порог 367** — на 26.07 показан как фикс; перепроверять по факту. +- **Остаток разметки:** только селекторы шагов 6–7 (Стоимость/Подтверждение) — + снять при первом реальном черновике бота. + +--- + +## 10. Скриншоты разведки (рабочие артефакты, не в репозитории) + +Сняты в каталог вывода Playwright 26.07.2026: `mts-tg-step1-znakomstvo.png`, +`mts-tg-step2-tip-auditorii.png`, `mts-tg-step3-auditoriya-upload.png`, +`mts-tg-step3-uploaded-counts.png` (счётчик 713/996), `mts-tg-step4-obyavlenie.png`. + +--- + +## 11. Грабли автоматизации (ОБЯЗАТЕЛЬНО для `cabinet.js`) + +1. **Coach-mark оверлей.** На шагах активен тур подсказок; его прозрачный слой + `div[class*="overlayClickBlocker"]` перехватывает клики по кнопкам/полям. + - НЕ удалять контейнеры `[class*="CoachMark"]`/`[class*="couchmark"]` — вместе с + ними удаляется форма шага (проверено — снесло всю форму «Объявления»). + - Рабочий приём: удалить ТОЛЬКО блокер `[class*="overlayClickBlocker"]` + (это один прозрачный div, форму не трогает) ИЛИ жать кнопки Playwright'ом с + `{ force: true }`. В боте — предпочесть `force:true` на целевых кнопках. +2. **Downshift-combobox «Категория ОРД»** (`input[name="selectedKktu"]`). + Синтетический `.click()`/установка `.value` НЕ проходят валидацию формы — + нужен **настоящий** клик Playwright по опции меню (`#downshift-1-item-*` или + по тексту), поэтому оверлей (п.1) должен быть снят/обойдён force'ом. Опции — + в `#downshift-*-menu [role="option"]`. +3. **Поля текста** (`textarea`, `input`) — Playwright `.fill()` работает даже под + оверлеем (не требует pointer). Заголовок/текст/ссылку/название заполнять `.fill()`. +4. **`Продолжить`** — `button[data-uniq-name="Продолжить | кнопка"]`; жать + `force:true`. Не переходит, если в блоке есть незаполненное обязательное поле + (у поля красный «!»). +5. **Reload сбрасывает** введённый текст (клиентский стейт), но серверный черновик + (аудитория, ID) сохраняется. Тур подсказок после reload перезапускается. diff --git a/bots/mts-telegram-ads/package-lock.json b/bots/mts-telegram-ads/package-lock.json new file mode 100644 index 00000000..8b4d47b9 --- /dev/null +++ b/bots/mts-telegram-ads/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "mts-telegram-ads-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mts-telegram-ads-bot", + "version": "0.1.0", + "dependencies": { + "dotenv": "^16.4.0", + "nodemailer": "^6.9.0", + "playwright": "^1.47.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/bots/mts-telegram-ads/package.json b/bots/mts-telegram-ads/package.json new file mode 100644 index 00000000..d9037e88 --- /dev/null +++ b/bots/mts-telegram-ads/package.json @@ -0,0 +1,17 @@ +{ + "name": "mts-telegram-ads-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "run:task": "node bin/run.js", + "keepalive": "node bin/keepalive.js", + "login": "node bin/login.js" + }, + "dependencies": { + "playwright": "^1.47.0", + "nodemailer": "^6.9.0", + "dotenv": "^16.4.0" + } +} diff --git a/bots/mts-telegram-ads/src/browser.js b/bots/mts-telegram-ads/src/browser.js new file mode 100644 index 00000000..f4c38610 --- /dev/null +++ b/bots/mts-telegram-ads/src/browser.js @@ -0,0 +1,73 @@ +import { chromium } from 'playwright'; + +export async function openBrowser(config, { headless = false } = {}) { + // Анти-бот МТС (Qrator) режет «голый» Chromium (Playwright) — отдаёт 403, хотя + // обычный Chrome/Яндекс с этой же машины кабинет пускает. Поэтому: + // 1) channel:'chrome' — управляем НАСТОЯЩИМ установленным Google Chrome + // (родной движок, родной User-Agent, TLS-отпечаток как у людей), + // 2) убираем метки автоматизации (--enable-automation / navigator.webdriver / + // AutomationControlled), которые выдают робота. + // UA не подменяем — у настоящего Chrome он и так честный (подмена = лишний признак). + const stealthArgs = ['--disable-blink-features=AutomationControlled']; + const launchOpts = { + // Edge (msedge.exe) — его процесс прописан в Happ «мимо VPN» (direct), поэтому + // mts.ru идёт с прямого IP 77.74 (кабинет пускает), а не через заграницу Happ (403). + // Chrome/Яндекс владельца при этом не тронуты. + channel: 'msedge', + headless, + viewport: { width: 1440, height: 900 }, + locale: 'ru-RU', + ignoreDefaultArgs: ['--enable-automation'], + args: [...stealthArgs], + }; + if (config.proxy) { + // Выход через сервер конкурентного поля (185.182.110.115): локальный SOCKS от + // постоянного SSH-туннеля. МТС-кабинет видит чистый IP этого сервера (пускает — 401), + // а не наш IP машины/Happ (режет — 403). Браузер коннектится к 127.0.0.1 (туннель), + // сам трафик выходит уже на сервере — перехват *.mts.ru локальным VPN (Happ) не мешает. + launchOpts.proxy = { server: config.proxy }; + if (config.proxyUser) launchOpts.proxy.username = config.proxyUser; + if (config.proxyPass) launchOpts.proxy.password = config.proxyPass; + } else { + // Без прокси — идём НАПРЯМУЮ, мимо прокси. МТС-кабинет режет IP прокси + // (xray/Happ на 127.0.0.1) и отдаёт 403. Playwright берёт прокси из переменных + // окружения HTTP(S)_PROXY (их подставляет терминал VS Code) — вычищаем их, тогда + // браузер выходит с реальным IP машины (77.74.x, которому доступ открыт). + // Системный прокси не трогаем — он нужен другим приложениям. + for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy']) { + delete process.env[k]; + } + process.env.NO_PROXY = '*'; + launchOpts.args.push('--no-proxy-server'); // страховка от системного прокси Windows + } + + const context = await chromium.launchPersistentContext(config.profileDir, launchOpts); + const page = context.pages()[0] ?? await context.newPage(); + return { context, page }; +} + +export async function humanPause(config) { + await new Promise((r) => setTimeout(r, config.humanDelayMs)); +} + +// 🔑 Устойчивый переход на страницу кабинета МТС. Кабинет — тяжёлый SPA и ЧАСТО +// виснет на «пустой крутилке» при открытии: тело страницы остаётся пустым 30–40с, +// а лечится перезагрузкой (проверено живьём 27.07.2026). Без этого штатные +// `page.goto` + короткий `waitFor` дают ЛОЖНЫЙ вывод «вход слетел» / «шаг не +// открылся», хотя сессия жива и надо было просто подождать/перезагрузить. +// +// Ждём, пока страница реально отрисуется (текст тела длиннее порога — крутилка +// текста почти не даёт), с несколькими попытками и одной перезагрузкой в середине. +// Возвращает true, если страница ожила; false — если так и осталась пустой. +export async function gotoStable(page, url, config, opts = {}) { + const { minBodyLen = 50, tries = 6, perTryMs = 7000 } = opts; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {}); + const bodyLen = async () => (await page.locator('body').innerText().catch(() => '')).trim().length; + const reloadAt = Math.ceil(tries / 2); + for (let i = 1; i <= tries; i++) { + if ((await bodyLen()) >= minBodyLen) return true; + await page.waitForTimeout(perTryMs); + if (i === reloadAt) await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {}); + } + return (await bodyLen()) >= minBodyLen; +} diff --git a/bots/mts-telegram-ads/src/budget.js b/bots/mts-telegram-ads/src/budget.js new file mode 100644 index 00000000..70a487fc --- /dev/null +++ b/bots/mts-telegram-ads/src/budget.js @@ -0,0 +1,6 @@ +export function assertWithinCap(budgetRub, capRub) { + if (!Number.isFinite(capRub) || capRub <= 0) + throw new Error(`Потолок бюджета задан некорректно (${capRub}) — боевой запуск запрещён`); + if (budgetRub > capRub) + throw new Error(`Бюджет ${budgetRub}₽ превышает потолок ${capRub}₽ — боевой запуск запрещён`); +} diff --git a/bots/mts-telegram-ads/src/cabinet.js b/bots/mts-telegram-ads/src/cabinet.js new file mode 100644 index 00000000..5d1fd115 --- /dev/null +++ b/bots/mts-telegram-ads/src/cabinet.js @@ -0,0 +1,646 @@ +import { humanPause, gotoStable } from './browser.js'; +import { debugShotsEnabled } from './shots.js'; + +// Чистая утилита: вытаскивает денежную сумму из текста кабинета вида +// «Стоимость кампании от 201,6 ₽» и нормализует её в строку-десятичную дробь с +// двумя знаками после точки («201.60»). Запятая→точка, разделители тысяч (в т.ч. +// НЕРАЗРЫВНЫЙ пробел, код 160) убираются, дробная часть дополняется/обрезается до +// 2 знаков БЕЗ округления. Нет числа / пустой / null / undefined → null. +// +// Сознательно вынесена ОТДЕЛЬНОЙ чистой функцией (без DOM/page), покрытой +// юнит-тестами: её будет вызывать будущий шаг чтения фактической стоимости из +// кабинета (Этап 3 / живой прогон), когда селектор строки стоимости подтвердят +// живьём. К DOM-потоку (submitBudget/finalize) НЕ подключена намеренно. +export function parseCost(text) { + if (typeof text !== 'string') return null; + // nbsp (код 160) → обычный пробел, чтобы разделители тысяч ловились единообразно. + const cleaned = text.replace(/ /g, ' '); + // Первое число: цифры с внутренними пробелами-разделителями + опц. дробь через , или . + const m = cleaned.match(/\d[\d ]*(?:[.,]\d+)?/); + if (!m) return null; + const noSpace = m[0].replace(/ /g, ''); + const [intPart, fracRaw = ''] = noSpace.split(/[.,]/); + // Дробную часть строково дополняем/обрезаем до 2 знаков (без некорректного округления). + const frac = (fracRaw + '00').slice(0, 2); + return `${intPart}.${frac}`; +} + +// Достаёт числовой id кампании МТС из URL кабинета +// («.../telegram-a2p/2231134/message» → «2231134»). Нет совпадения → null. +export function parseCampaignId(url) { + if (typeof url !== 'string') return null; + const m = url.match(/telegram-a2p\/(\d+)/); + return m ? m[1] : null; +} + +// Чистый парсер статус-текста кабинета (задача 3.5). Статус кампании показан +// словом в списке /cabinet/campaigns/telegram (снято живьём — FLOW-FINDINGS.md +// «Сессия 6»): Черновик / На модерации / Отклонена / Одобрена / Активна. +// Принимает как чистый статус, так и всю строку-блоб ряда (название + статус + +// ссылки-действия), и приводит к канону для Laravel-опросчика (задача 3.4): +// 'rejected' — «Отклонена»; +// 'approved' — «Одобрена» ИЛИ «Активна» (модерация пройдена, показы идут); +// 'moderating'— «На модерации» (ещё проверяют); +// 'draft' — «Черновик» (на модерацию ещё не уходила); +// null — статус не распознан / пустой / не строка. +// Опросчик всё, что НЕ approved/rejected, трактует как «ждём» — draft/null безопасны. +// +// Порядок проверок: терминальные вердикты (rejected/approved) — ПЕРВЫМИ, чтобы в +// строке-блобе с прозой они побеждали слово «модераци…» (напр. «не прошла +// модерацию Отклонена» → rejected). +export function parseModerationStatus(text) { + if (typeof text !== 'string') return null; + // nbsp (код 160) → пробел; нижний регистр; схлопываем пробелы — сравнение по подстроке. + const s = text.replace(/\s+/g, ' ').trim().toLowerCase(); + if (s === '') return null; + if (s.includes('отклонен')) return 'rejected'; + if (s.includes('одобрен') || s.includes('активн')) return 'approved'; + if (s.includes('модераци')) return 'moderating'; + if (s.includes('черновик')) return 'draft'; + return null; +} + +// Порог «не МТС» из UI кабинета (шаг «Аудитория»): ниже этого числа кнопка +// «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md. +export const MIN_NON_MTS = 367; + +// 🔴 Ревью-фикс F6: кнопки, которые ТРАТЯТ живые деньги клиента / запускают +// кампанию — робот НЕ жмёт их НИКОГДА (ни в draft, ни в live). Реальная отправка/ +// оплата на шаге /payment («Списать с баланса…») wire-ится в Сессии 6 под флаг. +// Основы («оплатит»/«списат»/«запуст») ловят словоформы, но НЕ «без оплаты» (0 ₽). +const FORBIDDEN_BUTTON_RE = /(оплатит|списат|запуст)/i; + +// Разрешённая кнопка продвижения на шаге подтверждения (точное совпадение, без +// денежных слов). На /confirmation это «ПРОДОЛЖИТЬ». +const SUBMIT_BUTTON_RE = /^(отправить на модерацию|продолжить)$/i; + +// Чистый предикат: текст кнопки — из чёрного списка (трата денег/запуск)? +export function isForbiddenButtonText(text) { + if (typeof text !== 'string') return false; + return FORBIDDEN_BUTTON_RE.test(text.replace(/\s+/g, ' ').trim()); +} + +// Кнопка БЕСПЛАТНОЙ отправки на модерацию (0 ₽) на шаге /payment: «Отправить на +// модерацию без оплаты» (подтверждено живьём A2, 28.07.2026 — баланс не тронут). +// Именно её целит submitWithoutPayment при пересдаче. Слово «оплаты» здесь НЕ +// денежная трата (см. FORBIDDEN_BUTTON_RE — основа «оплатит», а тут «без оплаты»). +const NO_PAYMENT_SUBMIT_RE = /отправить на модерацию без оплаты/i; + +// Чистый предикат: текст кнопки — «отправить на модерацию без оплаты»? +export function isNoPaymentSubmitText(text) { + if (typeof text !== 'string') return false; + return NO_PAYMENT_SUBMIT_RE.test(text.replace(/\s+/g, ' ').trim()); +} + +// Ревью-фикс F8: сверка фактической стоимости кампании из кабинета с потолком. +// actualRub — строка из parseCost («201.60») или число; null/undefined/нечитаемо → +// сверять нечего (no-op; решение блокировать при нечитаемости — на вызывающем). +// Читаемая стоимость выше потолка → бросаем (боевой запуск запрещён). +export function assertCostWithinCap(actualRub, capRub) { + if (actualRub === null || actualRub === undefined) return; + const actual = Number(actualRub); + if (!Number.isFinite(actual)) return; + if (!Number.isFinite(capRub) || capRub <= 0) + throw new Error(`Потолок бюджета задан некорректно (${capRub}) — боевой запуск запрещён`); + if (actual > capRub) + throw new Error(`Фактическая стоимость ${actual}₽ превышает потолок ${capRub}₽ — боевой запуск запрещён`); +} + +// Матчит href ссылки на кампанию с её id. Живой прогон A1 (28.07.2026) показал: в +// СПИСКЕ кампаний ссылка = `/cabinet/campaigns/telegram/{id}`, а на детальной/визард- +// странице = `/…/telegram-a2p/{id}/…`. Ловим ОБА варианта; id как часть большего +// числа не считается (граница `\D`/конец). Regex-исходник переиспользуется внутри +// page.evaluate (readModerationStatus) — держать синхронным с этой функцией. +export function campaignHrefRe(id) { + return `telegram(?:-a2p)?/${id}(?:\\D|$)`; +} + +export function hrefMatchesCampaignId(href, id) { + if (typeof href !== 'string') return false; + return new RegExp(campaignHrefRe(id)).test(href); +} + +// Открывает список кампаний и читает вердикт модерации кампании по её id +// (задача 3.5). Возвращает { moderationStatus, reason } — moderationStatus уже в +// каноне parseModerationStatus, reason (текст причины) заполняется только при +// 'rejected'. Статус НЕ распознан / ряд не найден → бросаем (опросчик оставит +// кампанию на модерации до следующего цикла). +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A1, 28.07.2026) на реальном отказе кампании 2231134: +// robot вернул {ok:true, moderationStatus:'rejected', reason:<полный текст 5 пунктов +// модерации>}. При этом живой прогон вскрыл и починил два бага локатора: (1) в СПИСКЕ +// ссылка = `/cabinet/campaigns/telegram/{id}`, а НЕ `/telegram-a2p/{id}` (тот — на +// детальной/визард-странице) → matcher расширен на оба (campaignHrefRe); (2) строка +// списка — грид из div, контейнер со статусом ряда на ~8 уровней выше ссылки → подъём +// поднят с 6 до 10 (возврат на ПЕРВОМ предке со статусом, выше склеиваются две кампании).> +export async function readModerationStatus(page, config, mtsCampaignId) { + const id = String(mtsCampaignId); + + const alive = await gotoStable(page, config.telegramUrl, config); + if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)'); + await humanPause(config); + + // Текст ряда кампании: цепляемся за ссылку-действие с id в href, поднимаемся до + // контейнера ряда, где есть слово-статус. Возвращаем его innerText (блоб ряда). + const rowText = await page.evaluate((cid) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); + const anchor = [...document.querySelectorAll('a[href]')] + .find((a) => byId.test(a.getAttribute('href') || '')); + const STATUS = /(отклонен|одобрен|активн|модераци|черновик)/i; + // Строка списка — грид из div (НЕ tr/li). Контейнер со статусом ряда — на ~8 + // уровней выше ссылки (снято живьём A1 28.07.2026); поднимаемся до 10. Возвращаем + // на ПЕРВОМ предке со статусом (тесный ряд): выше склеиваются две кампании. + let el = anchor; + for (let up = 0; up < 10 && el; up++, el = el.parentElement) { + const t = norm(el.innerText); + if (STATUS.test(t)) return t; + } + return anchor ? norm(anchor.closest('tr,li,[role=row]')?.innerText || '') : null; + }, id); + + const moderationStatus = parseModerationStatus(rowText); + if (moderationStatus === null) { + throw new Error(`Статус кампании ${id} не определён в списке (ряд не найден или статус не распознан)`); + } + if (moderationStatus !== 'rejected') { + return { moderationStatus, reason: null }; + } + + // Отклонена → причина из слайд-модалки. Клик по «Причины» в ряду кампании + // нативным DOM-кликом (модалка-оверлей перехватывает мышиный клик). + const opened = await page.evaluate((cid) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); + const anchor = [...document.querySelectorAll('a[href]')] + .find((a) => byId.test(a.getAttribute('href') || '')); + let row = anchor; + for (let up = 0; up < 10 && row; up++, row = row.parentElement) { + if (/(отклонен|одобрен|активн|модераци|черновик)/i.test(norm(row.innerText))) break; + } + const scope = row || document; + const link = [...scope.querySelectorAll('a,button,[role=button]')] + .find((n) => /^причины$/i.test(norm(n.innerText))); + if (!link) return false; + link.click(); + return true; + }, id); + + let reason = null; + if (opened) { + await page.locator('#slide-modal-root').waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); + await humanPause(config); + reason = await page.evaluate(() => { + const root = document.querySelector('#slide-modal-root'); + const full = (root?.innerText || '').replace(/\r/g, '').trim(); + if (!full) return null; + // Отрезаем от заголовка «Причины отклонения кампании», если он есть. + const i = full.search(/Причины отклонения кампании/i); + return (i >= 0 ? full.slice(i) : full).trim().slice(0, 2000) || null; + }); + } + + return { moderationStatus, reason }; +} + +// Вход в редактор ОТКЛОНЁННОЙ кампании через «Исправить» (robot-режим пересдачи, +// R3). Открывает список, находит СТРОКУ кампании по её id (scope как в +// readModerationStatus — по campaignHrefRe), нативным кликом жмёт «Исправить» в этом +// ряду (модалка-оверлей перехватывает мышиный клик, нужен .click() по DOM-элементу), +// дожидается редактора на шаге «Сообщение» (/telegram-a2p/{id}/message). Ряд/кнопка +// не найдены или не тот URL → падаем громко. +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): вход «Исправить» кампании 2231134 открыл +// редактор сразу на /message с предзаполненными текстом/ссылкой/ОРД. +export async function openResubmitEditor(page, config, mtsCampaignId) { + const id = String(mtsCampaignId); + + const alive = await gotoStable(page, config.telegramUrl, config); + if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)'); + await humanPause(config); + + const clicked = await page.evaluate((cid) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); + const anchor = [...document.querySelectorAll('a[href]')] + .find((a) => byId.test(a.getAttribute('href') || '')); + if (!anchor) return 'ряд-не-найден'; + // Поднимаемся до контейнера ряда (грид из div — как в readModerationStatus). + let row = anchor; + for (let up = 0; up < 10 && row; up++, row = row.parentElement) { + if (/(отклонен|одобрен|активн|модераци|черновик)/i.test(norm(row.innerText))) break; + } + const scope = row || document; + const btn = [...scope.querySelectorAll('a,button,[role=button]')] + .find((n) => /^исправить$/i.test(norm(n.innerText))); + if (!btn) return 'кнопка-Исправить-не-найдена'; + btn.click(); + return 'clicked'; + }, id); + + if (clicked !== 'clicked') { + throw new Error(`Пересдача: не удалось войти в «Исправить» кампании ${id} (${clicked})`); + } + + // Редактор открывается на шаге «Сообщение». Убеждаемся в правильном id и шаге — + // иначе (открылась чужая/не та кампания) НЕ трогаем её поля. + const urlRe = new RegExp(`telegram-a2p/${id}/message`); + try { + await page.waitForURL(urlRe, { timeout: 30000 }); + } catch { + throw new Error(`Пересдача: после «Исправить» ожидали редактор /telegram-a2p/${id}/message, а URL = ${page.url()}`); + } + // Само-верификация: на шаге «Сообщение» есть поле текста объявления. + await page.locator('textarea[placeholder^="Расскажите о боте"]').waitFor({ timeout: 30000 }); + await humanPause(config); +} + +// Полный флоу визарда (подтверждён живьём 27.07.2026): +// Знакомство → Тип («Своя база») → /audience → /message → /budget → /confirmation +// +// 🔑 Ключевая грабля: обучающий коуч-марк («Юрий Тихоненков») физически +// перекрывает кнопку «Продолжить» на КАЖДОМ шаге — его div лежит поверх кнопки +// (topmost element). Обычный клик и даже click({force:true}) — это МЫШИНЫЙ клик +// по координатам, он попадает в оверлей, а не в кнопку, и шаг не переключается. +// Надёжно работает только нативный .click() прямо на DOM-элементе кнопки: +// событие уходит в саму кнопку, коуч-марк ни при чём (см. domClickButton). + +// Нативный клик по кнопке с текстом, подходящим под textRe (обходит коуч-марк). +// Возвращает текст нажатой кнопки, 'disabled' если она неактивна, 'forbidden' если +// это денежная/запускающая кнопка (F6 — не жмём НИКОГДА), либо null. +async function domClickButton(page, textRe) { + return page.evaluate(({ reSrc, forbidSrc }) => { + const re = new RegExp(reSrc, 'i'); + const forbid = new RegExp(forbidSrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => re.test((b.innerText || '').trim())); + if (!btn) return null; + if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'disabled'; + // 🔴 Защита F6: даже если regex совпал, кнопку, ТРАТЯЩУЮ деньги/запускающую, + // не жмём — падаем наружу (сработает при смене вёрстки/слиянии шагов МТС). + if (forbid.test((btn.innerText || '').trim())) return 'forbidden'; + btn.click(); + return (btn.innerText || '').trim(); + }, { reSrc: textRe.source, forbidSrc: FORBIDDEN_BUTTON_RE.source }); +} + +// Жмём «Продолжить» (обходя коуч-марк) и убеждаемся, что перешли на ожидаемый +// URL. Иначе падаем громко с диагностикой — не заполняем «не ту» страницу. +async function continueTo(page, config, urlRe, stepName) { + const res = await domClickButton(page, /^продолжить$/); + if (res === 'disabled') throw new Error(`Кнопка «Продолжить» неактивна на шаге «${stepName}» — форма не заполнена/не готова`); + if (res === null) throw new Error(`Кнопка «Продолжить» не найдена на шаге «${stepName}»`); + try { + await page.waitForURL(urlRe, { timeout: 30000 }); + } catch { + throw new Error(`После «Продолжить» не перешли на ${urlRe} (шаг «${stepName}»); текущий URL: ${page.url()}`); + } + await humanPause(config); +} + +// Шаг «Знакомство»: «я разбираюсь» + одна сфера бизнеса, затем «Продолжить». +// Радио/сфера кликаются force (коуч-марк их не блокирует), переход — domClick. +// +// 🔑 «Знакомство» показывается ТОЛЬКО новичку. На возвратном аккаунте (после +// первой кампании) «Создать кампанию» ведёт СРАЗУ на «Тип аудитории» — шага +// «Знакомство» нет. Тогда молча пропускаем (проверено живым прогоном 27.07.2026: +// без этой проверки openTelegramWizard падал на waitFor радио). +async function passZnakomstvo(page, config) { + const radio = page.getByRole('radio', { name: 'Нет, я отлично в этом разбираюсь' }); + const shown = await radio.waitFor({ state: 'visible', timeout: 4000 }).then(() => true).catch(() => false); + if (!shown) return; + await radio.click({ force: true }); + await humanPause(config); + await page.getByText('Услуги для бизнеса').first().click({ force: true }); + await humanPause(config); + const res = await domClickButton(page, /^продолжить$/); + if (res === null) throw new Error('Кнопка «Продолжить» не найдена на шаге «Знакомство»'); + await humanPause(config); +} + +// Шаг «Тип аудитории»: «Своя база клиентов» — клик создаёт черновик и уводит +// на /cabinet/campaign/telegram-a2p/{ID}/audience. +async function chooseOwnBase(page, config) { + await page.getByText('Своя база клиентов').click({ force: true }); + await humanPause(config); +} + +// Открывает визард Telegram-кампании и доводит страницу до шага «Аудитория». +export async function openTelegramWizard(page, config) { + // Устойчивая загрузка списка кампаний (кабинет часто виснет на крутилке). + await gotoStable(page, config.telegramUrl, config); + await page.getByRole('button', { name: 'Создать кампанию' }).click({ force: true }); + await humanPause(config); + + await passZnakomstvo(page, config); + await chooseOwnBase(page, config); + + // Убеждаемся, что попали на шаг «Аудитория» (создался черновик). + try { + await page.waitForURL(/\/audience$/, { timeout: 30000 }); + } catch { + throw new Error(`Не дошли до шага «Аудитория»; текущий URL: ${page.url()}`); + } +} + +// Читает число из бокса «Не МТС» правой панели «Аудитория» (это и есть +// аудитория показа в Telegram — МТС-номера туда не попадают). Возвращает число +// или null, если бокс ещё не заполнен (матчинг не завершился). +async function readNonMts(page) { + return page.evaluate(() => { + // 🔑 В заголовке бокса «Не МТС» между словами стоит НЕРАЗРЫВНЫЙ пробел + // (код 160), а не обычный (32) — прямое сравнение с 'Не МТС' не срабатывает. + // Нормализуем: \s покрывает и nbsp. Ищем лист-узел «Не МТС», поднимаемся до + // контейнера, где рядом стоит число: «Не МТС 437». + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const labels = [...document.querySelectorAll('div,span,p')] + .filter((el) => el.children.length === 0 && norm(el.textContent) === 'Не МТС'); + for (const label of labels) { + let el = label; + for (let up = 0; up < 3 && el; up++, el = el.parentElement) { + const m = norm(el.innerText).match(/Не МТС\s*(\d[\d ]*)/); + if (m) { + const n = Number(m[1].replace(/\D/g, '')); + if (n > 0) return n; + } + } + } + // Запасной путь: по всему тексту страницы (\s матчит nbsp). «Не МТС» с + // заглавной буквы встречается только в боксе, в прозе — строчное «не МТС». + const mb = (document.body.innerText || '').match(/Не\s*МТС[\s\S]{0,8}?(\d[\d\s]*)/); + if (mb) { + const n = Number(mb[1].replace(/\D/g, '')); + if (n > 0) return n; + } + return null; + }); +} + +// Загружает файл с базой телефонов на шаге «Аудитория», дожидается асинхронного +// матчинга и возвращает число «Не МТС» — аудиторию показа. +export async function uploadAudience(page, config, phonesFilePath) { + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(phonesFilePath); + + // Матчинг «свой список ↔ база МТС» асинхронный (10–30с). Признак готовности — + // появление числа в боксе «Не МТС». Поллим до 90с. + let matched = null; + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + matched = await readNonMts(page).catch(() => null); + if (Number.isFinite(matched) && matched > 0) break; + await new Promise((r) => setTimeout(r, 2000)); + } + if (!Number.isFinite(matched) || matched <= 0) { + throw new Error('Матчинг аудитории не завершился за 90с — число «Не МТС» не появилось'); + } + + // Санити «дёшево»: «Не МТС» не может превышать «Получатели» (всего принято + // номеров). Если «Получатели» не распарсилось — просто пропускаем проверку. + const recip = page.getByText('Получатели', { exact: true }).first(); + if (await recip.isVisible().catch(() => false)) { + const rtxt = await recip.locator('xpath=..').innerText().catch(() => ''); + const rm = rtxt.match(/\d[\d\s]*/); + const recipients = rm ? Number(rm[0].replace(/\D/g, '')) : NaN; + if (Number.isFinite(recipients) && matched > recipients) { + throw new Error(`Прочитано «Не МТС»=${matched}, но это больше «Получатели»=${recipients} — число считано неверно`); + } + } + return matched; +} + +// Переход «Аудитория» → «Объявление». +export async function submitAudience(page, config) { + await continueTo(page, config, /\/message$/, 'Аудитория'); + // Само-верификация: на шаге объявления есть поле текста. + await page.locator('textarea[placeholder^="Расскажите о боте"]').waitFor({ timeout: 30000 }); +} + +// Заполняет шаг «Объявление» (/message): текст, ссылку «Что рекламируете», +// (опц.) медиа Premium и ОБЯЗАТЕЛЬНУЮ категорию ОРД. Кнопку «Продолжить» НЕ +// жмёт — переход делает submitAd (своя граница ответственности). +// +// Подтверждено живьём: поля «Заголовок» НЕТ, поля «Название компании» НЕТ — +// рекламодатель (наш ИП «Кондратьев Платон Дмитриевич, ИНН 245210851872») и +// маркировка ОРД подставляются кабинетом автоматически. +export async function fillAd(page, config, task) { + await fillAdText(page, config, task.adText); + if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile); + await fillAdLink(page, config, task.buttonUrl); + await selectOrdCategory(page, config, task.ordCategory); + // Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): без файла — шаг не трогаем. + if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile); +} + +// Текст объявления (textarea «Расскажите о боте…»). +export async function fillAdText(page, config, text) { + await page.locator('textarea[placeholder^="Расскажите о боте"]').fill(text); + await humanPause(config); +} + +// Медиа объявления (картинка/видео Premium) — отдельный от документа модератору +// file-input (accept image/video). +export async function fillAdMedia(page, config, mediaFile) { + await page + .locator('input[type="file"][accept*="image"], input[type="file"][accept*="video"]') + .setInputFiles(mediaFile); + await humanPause(config); +} + +// Ссылка «Что рекламируете» (канал/бот/сайт). +export async function fillAdLink(page, config, url) { + await page.locator('input[placeholder^="Ссылка на канал"]').fill(url); + await humanPause(config); +} + +// Категория ОРД — обязательное поле блока «Информация для ОРД». +// downshift-combobox: клик → очистить → ввод по буквам → дождаться опций → клик по +// опции. Синтетический ввод/выбор валидацию не проходит — нужен настоящий клик по +// элементу списка (подтверждено живьём: срабатывает с 1-й попытки; значение поля +// после выбора = выбранной категории). +export async function selectOrdCategory(page, config, category) { + const kktu = page.locator('input[name="selectedKktu"]'); + await kktu.click({ force: true }); + await humanPause(config); + await kktu.fill(''); + await kktu.pressSequentially(category, { delay: 60 }); + + await page.getByRole('option').first().waitFor({ timeout: 10000 }); + let option = page.getByRole('option', { name: category, exact: true }).first(); + if (!(await option.isVisible().catch(() => false))) option = page.getByRole('option').first(); + await option.click({ force: true }); + await humanPause(config); + + const chosen = await kktu.inputValue().catch(() => ''); + if (!chosen || /не выбрана/i.test(chosen)) { + throw new Error(`Категория ОРД не проставилась (значение поля: "${chosen}")`); + } +} + +// Правка полей объявления при ПЕРЕСДАЧЕ (robot-режим resubmit): в редакторе +// отклонённой кампании (шаг «Сообщение») перезаписывает ТОЛЬКО заданные поля — +// текст/ссылку/категорию ОРД — как ответ на причину отказа. null-поля не трогаем +// (у кампании они уже заполнены). Документ модератору грузится отдельно +// (attachModeratorDoc). Переиспользует те же проверенные хелперы, что и fillAd. +export async function editResubmitFields(page, config, task) { + if (task.adText) await fillAdText(page, config, task.adText); + if (task.buttonUrl) await fillAdLink(page, config, task.buttonUrl); + if (task.ordCategory) await selectOrdCategory(page, config, task.ordCategory); +} + +// Грузит документ в поле «Комментарий для модератора» блока «Дополнительная +// информация» шага «Сообщение». Это ТРЕТИЙ file-input шага — единственный, +// принимающий pdf (медиа-инпуты объявления выше — image/video). Общий для первичного +// создания (fillAd) и пересдачи (runResubmit). +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): документ загружен в поле «Комментарий для +// модератора» кампании 2231134 (accept `.png,.jpeg,.jpg,.pdf`). +export async function attachModeratorDoc(page, config, moderatorFile) { + const docInput = page.locator('input[type="file"][accept*="pdf"]').first(); + await docInput.setInputFiles(moderatorFile); + await humanPause(config); +} + +// Переход «Объявление» → «Стоимость» (/budget). +export async function submitAd(page, config) { + await continueTo(page, config, /\/budget$/, 'Объявление'); +} + +// Переход «Стоимость» → «Подтверждение» (/confirmation). +// +// На шаге /budget стоимость НЕ задаётся полем «бюджет» — она ВЫЧИСЛЯЕТСЯ +// кабинетом: аудитория «не МТС» × фиксированная ставка (≈400 ₽ / 1000 показов). +// Настраиваемое здесь (частота показов, даты старта/завершения, расписание, +// «Автозапуск») для базовой кампании остаётся на дефолтах: показать 1 раз, +// даты «сегодня → +7 дней», Автозапуск ВКЛ (запуск после модерации). +// +export async function submitBudget(page, config) { + await continueTo(page, config, /\/confirmation$/, 'Стоимость'); +} + +// Финализация на шаге «Подтверждение» (/confirmation): сводка кампании, поле +// названия (авто), чекбокс оферты и кнопка отправки на модерацию. +// +// Режим 'draft' — предохранитель: доходим до финала, снимаем скриншот (главный +// артефакт «дошли до кнопки, не нажали»), НИЧЕГО не отправляем, деньги/модерация +// не трогаются. Режим 'live' — соглашаемся с офертой и отправляем на модерацию. +export async function finalize(page, config, mode, screenshotPath) { + // Убеждаемся, что мы действительно на «Подтверждении». + try { + await page.waitForURL(/\/confirmation$/, { timeout: 30000 }); + } catch { + throw new Error(`finalize: ожидали шаг «Подтверждение» (/confirmation), а URL = ${page.url()}`); + } + await page.getByText('Я соглашаюсь с условиями оферты').first().waitFor({ timeout: 20000 }); + + // Скриншот шага — ДО любого клика, в обеих ветках. ПДн-гейт (152-ФЗ): + // полноэкранный скрин кабинета может содержать телефоны клиентской базы — + // снимаем ТОЛЬКО при явном TG_DEBUG_SHOTS, по умолчанию пропускаем. + if (debugShotsEnabled(config)) { + await page.screenshot({ path: screenshotPath, fullPage: true }); + } + + const campaignId = parseCampaignId(page.url()) ?? 'unknown'; + + if (mode === 'draft') { + return { launched: false, campaignId }; + } + // Защита от боевой отправки по ошибке: клик по финальной кнопке допустим + // ТОЛЬКО при явном mode === 'live'. Иначе — падаем громко. + if (mode !== 'live') { + throw new Error(`finalize: неизвестный режим "${mode}" — ожидается 'draft' или 'live'`); + } + + // LIVE: согласие с офертой + «Продолжить» на /confirmation. + // Оферта — точный чекбокс `#isOfferAccepted` (снят живьём 27.07.2026). + await page.locator('#isOfferAccepted').click({ force: true }); + await humanPause(config); + + // F8: перед продвижением к оплате сверяем фактическую стоимость с потолком, если + // её видно в сводке. Нечитаемо (селектор сводки живьём не подтверждён) → не + // блокируем: реального списания на этом шаге НЕТ (/payment wire-ится в Сессии 6). + // + try { + const costText = await page.getByText(/Стоимость|Итого/i).first().innerText({ timeout: 3000 }); + assertCostWithinCap(parseCost(costText), Number(config.budgetCapRub)); + } catch (e) { + if (/потолок/i.test(e.message)) throw e; // над потолком / некорректный потолок — стоп + // иначе сводку не прочитали — идём дальше (best-effort) + } + + // F6: целим ТОЛЬКО кнопку продолжения/отправки на модерацию; денежные кнопки + // domClickButton вернёт как 'forbidden' — падаем громко, а не тратим. + const clicked = await domClickButton(page, SUBMIT_BUTTON_RE); + if (!clicked || clicked === 'disabled' || clicked === 'forbidden') { + throw new Error(`finalize live: кнопка отправки не найдена/неактивна/денежная (результат: ${clicked})`); + } + await humanPause(config); + + // 🔑 После «Продолжить» — шаг /payment «Подтверждение оплаты». Робот дальше НЕ идёт: + // реальная отправка/оплата («Списать с баланса…» / «…без оплаты») wire-ится в + // Сессии 6. F7: кампания на модерацию ещё НЕ ушла → launched:false, stoppedAt:'payment' + // (НЕ врём про запуск — иначе портал заморозит деньги за «запущенную» кампанию, + // висящую неоплаченной). Не дошли до /payment → падаем громко. + try { + await page.waitForURL(/\/payment$/, { timeout: 30000 }); + } catch { + throw new Error(`finalize live: после подтверждения ожидали /payment, а URL = ${page.url()}`); + } + return { launched: false, campaignId, stoppedAt: 'payment' }; +} + +// Отправка кампании на модерацию БЕЗ ОПЛАТЫ (0 ₽) на шаге /payment — финал robot- +// режима пересдачи (R4). Целит ТОЛЬКО кнопку «Отправить на модерацию без оплаты» +// (isNoPaymentSubmitText); денежные («Списать с баланса…», «Оплатить») НЕ жмёт +// никогда — двойная защита через isForbiddenButtonText. Убеждаемся, что ДО клика мы +// на /payment; после клика — что ушли с /payment (кампания встала на модерацию). +// Кнопки без-оплаты нет / она денежная / не на /payment → падаем громко. +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): клик «без оплаты» кампании 2231134 — +// баланс не тронут, кампания вернулась в «На модерации». +export async function submitWithoutPayment(page, config) { + try { + await page.waitForURL(/\/payment$/, { timeout: 30000 }); + } catch { + throw new Error(`submitWithoutPayment: ожидали шаг /payment, а URL = ${page.url()}`); + } + await humanPause(config); + + const res = await page.evaluate((noPaySrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const noPay = new RegExp(noPaySrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => noPay.test(norm(b.innerText))); + if (!btn) return { status: 'кнопка-без-оплаты-не-найдена' }; + if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') + return { status: 'disabled' }; + return { status: 'ready', text: norm(btn.innerText) }; + }, NO_PAYMENT_SUBMIT_RE.source); + + if (res.status !== 'ready') { + throw new Error(`submitWithoutPayment: кнопка «без оплаты» ${res.status}`); + } + // Двойная защита: даже если regex без-оплаты совпал, текст кнопки не должен быть + // денежным (защита от смены вёрстки МТС, слияния «списать» и «без оплаты»). + if (isForbiddenButtonText(res.text)) { + throw new Error(`submitWithoutPayment: кнопка «${res.text}» опознана как денежная — не жму`); + } + + await page.evaluate((noPaySrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const noPay = new RegExp(noPaySrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => noPay.test(norm(b.innerText))); + if (btn) btn.click(); + }, NO_PAYMENT_SUBMIT_RE.source); + + // После отправки кабинет уводит со /payment (в список / на страницу кампании). + try { + await page.waitForURL((url) => !/\/payment$/.test(url.toString()), { timeout: 30000 }); + } catch { + throw new Error(`submitWithoutPayment: после клика «без оплаты» всё ещё на /payment (${page.url()})`); + } + await humanPause(config); + return { submitted: true }; +} diff --git a/bots/mts-telegram-ads/src/cleanup-drafts.js b/bots/mts-telegram-ads/src/cleanup-drafts.js new file mode 100644 index 00000000..51e7040b --- /dev/null +++ b/bots/mts-telegram-ads/src/cleanup-drafts.js @@ -0,0 +1,183 @@ +// Уборка осиротевших ЧЕРНОВИКОВ Telegram-кампаний в кабинете МТС. +// +// Зачем: робот создаёт реальный черновик в кабинете уже на шаге «Аудитория». +// Если прогон позже упадёт/воркер убьют — черновик осиротеет и копится в списке +// (27.07.2026 такие чистили руками). Здесь — узаконенный инструмент с защитой: +// удаляем ТОЛЬКО строки-черновики, НИКОГДА «на модерации»/«активна»/«отклонена», +// с обязательной сверкой ПЕРЕД удалением и аварийным стопом при расхождении. +// +// Чистые предикаты (без DOM/page) вынесены сюда и покрыты юнит-тестами; их +// вызывает браузерная обёртка cleanupDrafts() и CLI bin/cleanup-drafts.js. + +import { gotoStable } from './browser.js'; + +// Строка кампании «Telegram по своей базе» (наш единственный вид кампаний). +const CAMPAIGN_NAME_RE = /Telegram по своей базе/i; +// Шапка таблицы кабинета (колоночные заголовки). +const HEADER_RE = /CPM без НДС|Название Статус Вид/i; + +// Нормализация текста строки: любые пробелы (вкл. неразрывный, код 160) → один +// обычный пробел, обрезка краёв. Нет строки → пустая строка. +function norm(s) { + return (typeof s === 'string' ? s : '').replace(/\s+/g, ' ').trim(); +} + +// Строка — шапка ИЛИ агрегат select-all, а не отдельная кампания. Признаки: +// • нет имени кампании (заголовок раздела, служебная строка), ИЛИ +// • явная колоночная шапка, ИЛИ +// • смешаны РАЗНЫЕ статусы в одном тексте (и «на модерации», и «черновик») — +// так выглядит innerText контейнера-обёртки над всем списком (select-all). +export function isHeaderOrAggregateRow(text) { + const t = norm(text); + if (!CAMPAIGN_NAME_RE.test(t)) return true; + if (HEADER_RE.test(t)) return true; + return /модерации/i.test(t) && /черновик/i.test(t); +} + +// Удаляемый черновик: строка кампании «Telegram по своей базе» со статусом +// «черновик», при этом НЕ «на модерации» и НЕ шапка/агрегат. Только такие строки +// инструмент имеет право пометить на удаление. +export function isDeletableDraftRow(text) { + const t = norm(text); + return ( + CAMPAIGN_NAME_RE.test(t) && + /черновик/i.test(t) && + !/модерации/i.test(t) && + !isHeaderOrAggregateRow(t) + ); +} + +// Сверка выбора ПЕРЕД удалением. Удалять безопасно только когда: выбран хотя бы +// один элемент, ВСЕ выбранные — черновики, и НЕ взведён общий select-all. Любое +// нарушение → false (браузерная обёртка обязана аварийно остановиться). +export function selectionIsSafe(audit) { + if (!audit || !Array.isArray(audit.checkedStatuses)) return false; + const { checkedStatuses, selectAll } = audit; + return ( + checkedStatuses.length > 0 && + checkedStatuses.every((s) => /черновик/i.test(s)) && + !selectAll + ); +} + +// ─── Браузерная обёртка (glue, без юнит-теста) ───────────────────────────── +// Портирована с проверенного живьём 27.07.2026 ручного скрипта; критический гейт +// перед удалением проходит через selectionIsSafe() выше (покрыт юнит-тестом). + +// Регэкспы для скана внутри page.evaluate (браузерный контекст — импорт сюда не +// доходит; критическую сверку всё равно делает Node-предикат selectionIsSafe). +const SCAN_RE_SRC = { + name: 'Telegram по своей базе', + anyStat: '(на модерации|черновик|отклонен|отклонён|одобрен|активн)', + header: 'CPM без НДС|Название Статус Вид', +}; + +// Распознанные статусы строк-кампаний (дедуп) — для отчёта ДО/ПОСЛЕ. +function readCampaignStatuses(page) { + return page.evaluate((reSrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const STAT = new RegExp(reSrc.anyStat, 'i'); + const out = []; + const seen = new Set(); + for (const el of document.querySelectorAll('div,li,tr')) { + const t = norm(el.innerText); + if (t.length < 8 || t.length > 200) continue; + const st = t.match(STAT); + if (st && /Telegram|своей базе/i.test(t)) { + const k = t.slice(0, 60); + if (seen.has(k)) continue; + seen.add(k); + out.push(st[1]); + } + } + return out; + }, SCAN_RE_SRC); +} + +// Ставит галочки ТОЛЬКО на строках-черновиках. Возвращает число отмеченных. +function selectDraftCheckboxes(page) { + return page.evaluate((reSrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const NAME = new RegExp(reSrc.name, 'i'); + const ANYSTAT = new RegExp(reSrc.anyStat, 'i'); + const HEADER = new RegExp(reSrc.header, 'i'); + let picked = 0; + for (const cb of document.querySelectorAll('input[type="checkbox"]')) { + let el = cb; + for (let i = 0; i < 10 && el; i++) { + const t = norm(el.innerText); + if (NAME.test(t) && ANYSTAT.test(t)) break; + el = el.parentElement; + } + const t = norm(el ? el.innerText : ''); + if (NAME.test(t) && /черновик/i.test(t) && !/модерации/i.test(t) && !HEADER.test(t)) { + cb.click(); + picked += 1; + } + } + return picked; + }, SCAN_RE_SRC); +} + +// Снимает фактически взведённые галочки: статусы отмеченных строк + признак +// «взведён общий select-all». Node-гейт selectionIsSafe() решает go/stop. +function auditCheckedRows(page) { + return page.evaluate((reSrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const NAME = new RegExp(reSrc.name, 'i'); + const ANYSTAT = new RegExp(reSrc.anyStat, 'i'); + const HEADER = new RegExp(reSrc.header, 'i'); + const checkedStatuses = []; + let selectAll = false; + for (const cb of document.querySelectorAll('input[type="checkbox"]')) { + let el = cb; + for (let i = 0; i < 10 && el; i++) { + const t = norm(el.innerText); + if (NAME.test(t) && ANYSTAT.test(t)) break; + el = el.parentElement; + } + const t = norm(el ? el.innerText : ''); + const isHdr = !NAME.test(t) || HEADER.test(t) || (/модерации/i.test(t) && /черновик/i.test(t)); + if (isHdr) { + if (cb.checked) selectAll = true; + continue; + } + if (cb.checked) checkedStatuses.push((t.match(ANYSTAT) || ['?'])[0]); + } + return { checkedStatuses, selectAll }; + }, SCAN_RE_SRC); +} + +// Основной сценарий уборки. По умолчанию apply=false — РЕЖИМ ПРОСМОТРА: показывает +// статусы строк и что БЫ пометили, НИЧЕГО не удаляя. apply=true — выделяет +// черновики, сверяет (selectionIsSafe), и только при безопасной сверке жмёт +// панельную «УДАЛИТЬ (N)» → модальную «Удалить», затем перечитывает список. +// Возвращает отчёт; при небезопасной сверке — aborted:true, удаление НЕ идёт. +export async function cleanupDrafts(page, config, { apply = false } = {}) { + await gotoStable(page, config.telegramUrl, config); + await page.getByText('Реклама в Telegram').first().waitFor({ timeout: 20000 }).catch(() => {}); + await page.waitForTimeout(2500); + + const before = await readCampaignStatuses(page); + const picked = await selectDraftCheckboxes(page); + await page.waitForTimeout(800); + const audit = await auditCheckedRows(page); + const safe = selectionIsSafe(audit); + + if (!apply) { + return { mode: 'preview', before, picked, audit, safe, deleted: false }; + } + if (!safe) { + return { mode: 'apply', before, picked, audit, safe: false, aborted: true, deleted: false }; + } + + await page.getByRole('button', { name: /удалить \(/i }).first().click(); + await page.waitForTimeout(1200); + const confirm = page.getByRole('button', { name: /^\s*удалить\s*$/i }).first(); + await confirm.waitFor({ state: 'visible', timeout: 8000 }); + await confirm.click(); + await page.waitForTimeout(3000); + + const after = await readCampaignStatuses(page); + return { mode: 'apply', before, after, picked, audit, safe: true, deleted: true }; +} diff --git a/bots/mts-telegram-ads/src/config.js b/bots/mts-telegram-ads/src/config.js new file mode 100644 index 00000000..2d563f7a --- /dev/null +++ b/bots/mts-telegram-ads/src/config.js @@ -0,0 +1,38 @@ +function required(env, key) { + const v = env[key]; + if (v === undefined || v === '') throw new Error(`Не задана переменная окружения: ${key}`); + return v; +} + +function requiredPositiveNumber(env, key) { + const n = Number(required(env, key)); + if (!Number.isFinite(n) || n <= 0) throw new Error(`Переменная окружения ${key} должна быть положительным числом, получено: ${env[key]}`); + return n; +} + +export function loadConfig(env = process.env) { + return { + profileDir: required(env, 'MTS_BROWSER_PROFILE_DIR'), + cabinetUrl: required(env, 'MTS_CABINET_URL'), + telegramUrl: required(env, 'MTS_TELEGRAM_URL'), + smtp: { + host: required(env, 'SMTP_HOST'), + port: Number(required(env, 'SMTP_PORT')), + user: required(env, 'SMTP_USER'), + pass: required(env, 'SMTP_PASS'), + }, + alarmFrom: required(env, 'ALARM_FROM'), + alarmTo: required(env, 'ALARM_TO'), + budgetCapRub: requiredPositiveNumber(env, 'BUDGET_CAP_RUB'), + humanDelayMs: Number(env.HUMAN_DELAY_MS ?? '800'), + // Прокси браузера (мобильный proxy.market, российский IP) — опционально. + // Пусто → браузер идёт напрямую. Формат MTS_PROXY: socks5://host:port или + // http://host:port. Логин/пароль (если нужны) — MTS_PROXY_USER / MTS_PROXY_PASS. + proxy: env.MTS_PROXY || null, + proxyUser: env.MTS_PROXY_USER || null, + proxyPass: env.MTS_PROXY_PASS || null, + // ПДн-гейт (152-ФЗ): полноэкранный скрин кабинета может содержать телефоны + // клиентской базы. По умолчанию НЕ снимаем/не шлём — только по явному флагу. + debugShots: Boolean(env.TG_DEBUG_SHOTS), + }; +} diff --git a/bots/mts-telegram-ads/src/mailer.js b/bots/mts-telegram-ads/src/mailer.js new file mode 100644 index 00000000..2d543862 --- /dev/null +++ b/bots/mts-telegram-ads/src/mailer.js @@ -0,0 +1,28 @@ +import nodemailer from 'nodemailer'; + +export function createMailer(transport, { from, to }) { + return { + async alarm({ step, reason, screenshotPath }) { + await transport.sendMail({ + from, to, + subject: `[МТС-бот] АЛЯРМ на шаге «${step}»`, + text: `Робот остановился.\nШаг: ${step}\nПричина: ${reason}\nСмотри скриншот во вложении.`, + attachments: screenshotPath ? [{ path: screenshotPath }] : [], + }); + }, + async report({ campaignId, matched, mode }) { + await transport.sendMail({ + from, to, + subject: `[МТС-бот] Готово (${mode})`, + text: `Кампания обработана.\nID: ${campaignId}\nНашлось номеров: ${matched}\nРежим: ${mode}`, + }); + }, + }; +} + +export function smtpTransport(smtp) { + return nodemailer.createTransport({ + host: smtp.host, port: smtp.port, secure: false, + auth: { user: smtp.user, pass: smtp.pass }, + }); +} diff --git a/bots/mts-telegram-ads/src/phones.js b/bots/mts-telegram-ads/src/phones.js new file mode 100644 index 00000000..0046d1b4 --- /dev/null +++ b/bots/mts-telegram-ads/src/phones.js @@ -0,0 +1,17 @@ +export function normalizePhones(text) { + const seen = new Set(); + const numbers = []; + let dropped = 0; + for (const line of String(text).split(/\r?\n/)) { + const t = line.trim(); + if (t === '') continue; + let digits = t.replace(/\D/g, ''); + if (digits.length === 11 && digits.startsWith('8')) digits = '7' + digits.slice(1); + if (digits.length === 10) digits = '7' + digits; + if (digits.length !== 11 || !digits.startsWith('7')) { dropped++; continue; } + if (seen.has(digits)) continue; + seen.add(digits); + numbers.push(digits); + } + return { numbers, total: numbers.length, dropped }; +} diff --git a/bots/mts-telegram-ads/src/runner.js b/bots/mts-telegram-ads/src/runner.js new file mode 100644 index 00000000..0afb7f4d --- /dev/null +++ b/bots/mts-telegram-ads/src/runner.js @@ -0,0 +1,144 @@ +import { openBrowser } from './browser.js'; +import { isLoggedIn } from './session.js'; +import { assertWithinCap } from './budget.js'; +import { normalizePhones } from './phones.js'; +import { + openTelegramWizard, uploadAudience, submitAudience, fillAd, submitAd, submitBudget, finalize, + parseCampaignId, readModerationStatus, MIN_NON_MTS, + openResubmitEditor, attachModeratorDoc, editResubmitFields, submitWithoutPayment, +} from './cabinet.js'; +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs'; +import { debugShotsEnabled, alarmScreenshot } from './shots.js'; + +// Режим read-status (задача 3.5): открывает кабинет и читает вердикт модерации +// кампании по её id. Ни номеров, ни отправки, ни почты — ТОЛЬКО чтение. Возвращает +// robot-JSON { ok, moderationStatus, reason?, campaignId } — его разбирает +// RobotResult.fromRobotJson на стороне Laravel (задача 3.4). Ошибка чтения → +// { ok:false, reason } (опросчик оставит кампанию на модерации до следующего цикла). +export async function runReadStatus(config, campaignId) { + const id = String(campaignId); + let page, context; + try { + ({ context, page } = await openBrowser(config, {})); + if (!(await isLoggedIn(page, config))) throw new Error('Вход слетел — нужен повторный логин'); + const { moderationStatus, reason } = await readModerationStatus(page, config, id); + return { ok: true, campaignId: id, moderationStatus, reason }; + } catch (e) { + return { ok: false, step: 'read-status', reason: e.message, campaignId: id }; + } finally { + // Гарантированное закрытие браузера — единственный залогиненный профиль не + // должен остаться висеть. ПДн тут нет (номера не грузятся), чистить нечего. + try { await context?.close(); } catch {} + } +} + +// Режим resubmit (R5, robot-режим пересдачи): чинит ОТКЛОНЁННУЮ кампанию по id — +// вход через «Исправить» → документ модератору → отправка на модерацию. Повторяет +// доказанный живьём A2 путь. ПДн тут НЕ грузятся (аудитория уже в кампании) — чистить +// нечего. submitMode:'draft' — предохранитель (доходим до /confirmation, не шлём); +// 'live' — жмём «Отправить на модерацию без оплаты» (0 ₽). Возвращает robot-JSON. +export async function runResubmit(config, task, { timestamp }) { + const id = String(task.campaignId); + const screenshot = `screenshots/resubmit-${id}-${timestamp}.png`; + let page, context, step = 'start'; + try { + mkdirSync('screenshots', { recursive: true }); + ({ context, page } = await openBrowser(config, {})); + step = 'session'; + if (!(await isLoggedIn(page, config))) throw new Error('Вход слетел — нужен повторный логин'); + + step = 'resubmit-open'; await openResubmitEditor(page, config, id); + // Вносим исправления в редакторе «Сообщение»: правки текста/ссылки/ОРД (если + // заданы) и/или документ модератору. Хоть одно есть — гарантирует parseResubmitTask. + step = 'edit-fields'; await editResubmitFields(page, config, task); + step = 'attach-doc'; if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile); + step = 'submit-ad'; await submitAd(page, config); // «Сообщение» → «Стоимость» (/budget) + step = 'submit-budget'; await submitBudget(page, config); // «Стоимость» → «Подтверждение» (/confirmation) + step = 'finalize'; await finalize(page, config, task.submitMode, screenshot); + + if (task.submitMode !== 'live') { + // draft-предохранитель: до финала дошли, на модерацию НЕ отправляли. + return { ok: true, campaignId: id, resubmitted: false, stoppedAt: 'confirmation' }; + } + + // live: на /payment жмём «Отправить на модерацию без оплаты» (0 ₽). + step = 'submit-nopay'; await submitWithoutPayment(page, config); + return { ok: true, campaignId: id, resubmitted: true }; + } catch (e) { + return { ok: false, step, reason: e.message, campaignId: id }; + } finally { + // Гарантированное закрытие браузера — единственный залогиненный профиль не должен + // висеть. ПДн тут нет (номера не грузятся), временных файлов нет — чистить нечего. + try { await context?.close(); } catch {} + } +} + +export async function runTask(config, task, mailer, { timestamp }) { + const screenshot = `screenshots/campaign-${timestamp}.png`; + let page, context, step = 'start'; + // Путь к временному файлу с номерами объявляем ДО try, чтобы finally + // видел его при любом исходе (успех/ошибка/ранний throw). + let preparedPath; + let campaignId; + try { + // Каталог артефактов/временных файлов — в .gitignore, создаём при первом + // запуске (recursive: true идемпотентен — если каталог уже есть, ничего не делает). + mkdirSync('screenshots', { recursive: true }); + + if (task.mode === 'live') assertWithinCap(task.budgetRub, config.budgetCapRub); + + const norm = normalizePhones(readFileSync(task.phonesFile, 'utf8')); + if (norm.total === 0) throw new Error('В файле нет валидных номеров'); + preparedPath = `screenshots/phones-${timestamp}.txt`; + writeFileSync(preparedPath, norm.numbers.join('\n'), 'utf8'); + + ({ context, page } = await openBrowser(config, {})); + step = 'session'; + if (!(await isLoggedIn(page, config))) throw new Error('Вход слетел — нужен повторный логин'); + + step = 'wizard'; await openTelegramWizard(page, config); + step = 'upload'; const matched = await uploadAudience(page, config, preparedPath); + if (matched < MIN_NON_MTS) { + throw new Error(`Недостаточно номеров «не МТС»: ${matched} < ${MIN_NON_MTS} — кампанию не создать`); + } + step = 'submit-audience'; await submitAudience(page, config); + // Черновик уже создан — у него есть id в URL. Сохраняем рано и печатаем маркер + // в stderr, чтобы Laravel-обёртка получила id даже при последующем падении/таймауте + // (stdout занят финальным JSON — маркер шлём именно в stderr). + campaignId = parseCampaignId(page.url()); + if (campaignId) process.stderr.write(`MTS_CAMPAIGN_ID=${campaignId}\n`); + step = 'ad'; await fillAd(page, config, task); + step = 'submit-ad'; await submitAd(page, config); // «Объявление» → «Стоимость» (/budget) + step = 'submit-budget'; await submitBudget(page, config); // «Стоимость» → «Подтверждение» (/confirmation) + step = 'finalize'; const res = await finalize(page, config, task.mode, screenshot); + + // Письмо-отчёт — best-effort: запуск уже состоялся, сбой отправки письма + // НЕ должен превращать успешный результат в ok:false. + try { await mailer.report({ campaignId: res.campaignId ?? 'draft', matched, mode: task.mode }); } catch {} + // Ранний id — запасной, id из finalize (res.campaignId) приоритетен: ...res идёт + // ПОСЛЕ и перекрывает. + return { ok: true, matched, campaignId, ...res }; + } catch (e) { + // ПДн-гейт (152-ФЗ): полноэкранный скрин кабинета может содержать телефоны + // клиентской базы — снимаем и шлём письмом ТОЛЬКО при явном TG_DEBUG_SHOTS, + // по умолчанию скрин не делаем и алярм уходит без вложения. + if (debugShotsEnabled(config)) { + try { await page?.screenshot({ path: screenshot, fullPage: true }); } catch {} + } + // Прикладываем скриншот к алярму, только если отладка включена и файл + // реально создан — иначе nodemailer падает на несуществующем вложении (ENOENT). + const shot = alarmScreenshot(config, screenshot, existsSync(screenshot)); + // Сам алярм — тоже best-effort: сбой SMTP не должен мешать finally + // (закрытию браузера/чистке ПДн) и не должен превращаться в throw. + try { await mailer.alarm({ step, reason: e.message, screenshotPath: shot }); } catch {} + return { ok: false, step, reason: e.message, campaignId }; + } finally { + // Гарантированное закрытие браузера: единственный залогиненный профиль + // не должен оставаться висеть открытым, даже если alarm/report упали выше. + try { await context?.close(); } catch {} + // Чистка ПДн: preparedPath содержит реальные телефоны клиентов (152-ФЗ). + // Удаляем временный файл с номерами после использования независимо от + // исхода задачи. Скриншот НЕ трогаем — он артефакт отладки/алярма. + try { if (preparedPath) unlinkSync(preparedPath); } catch {} + } +} diff --git a/bots/mts-telegram-ads/src/session.js b/bots/mts-telegram-ads/src/session.js new file mode 100644 index 00000000..16a199ba --- /dev/null +++ b/bots/mts-telegram-ads/src/session.js @@ -0,0 +1,20 @@ +import { gotoStable } from './browser.js'; + +// Признаки «залогинен»: виден баланс ₽ и меню кабинета. +// Признак «выкинуло»: кнопка входа / отсутствие баланса. +// +// 🔑 Кабинет часто виснет на пустой крутилке при открытии — сначала грузим +// устойчиво (gotoStable: перезагрузка + терпение), иначе короткий waitFor даёт +// ЛОЖНОЕ «вход слетел» на живой сессии (инцидент 27.07.2026). Только когда +// страница реально отрисовалась, отсутствие меню значит настоящий разлогин. +export async function isLoggedIn(page, config) { + await gotoStable(page, config.cabinetUrl, config); + // «Рассылки и звонки» — стабильный пункт меню авторизованного кабинета + const menu = page.getByRole('button', { name: 'Рассылки и звонки' }); + try { + await menu.waitFor({ timeout: 10000 }); + return true; + } catch { + return false; + } +} diff --git a/bots/mts-telegram-ads/src/shots.js b/bots/mts-telegram-ads/src/shots.js new file mode 100644 index 00000000..6171aa58 --- /dev/null +++ b/bots/mts-telegram-ads/src/shots.js @@ -0,0 +1,11 @@ +// ПДн-гейт полноэкранных скринов кабинета (могут содержать телефоны базы, 152-ФЗ). +// По умолчанию НЕ снимаем и НЕ шлём письмом; включаем только явным TG_DEBUG_SHOTS. +export function debugShotsEnabled(config) { + return Boolean(config && config.debugShots); +} + +// Что приложить к письму-аляруму: путь скрина ТОЛЬКО если отладка включена и файл реально есть. +export function alarmScreenshot(config, screenshotPath, fileExists) { + if (!debugShotsEnabled(config)) return null; + return fileExists ? screenshotPath : null; +} diff --git a/bots/mts-telegram-ads/src/task.js b/bots/mts-telegram-ads/src/task.js new file mode 100644 index 00000000..ce5c5a8f --- /dev/null +++ b/bots/mts-telegram-ads/src/task.js @@ -0,0 +1,66 @@ +const MODES = ['draft', 'live']; + +// Пересдача отклонённой кампании (mode:'resubmit', задача 3.6 → robot-mode). +// Вход в редактор через «Исправить» → внести исправления → отправить на модерацию +// без оплаты (0 ₽). Что исправляем — на выбор (в ответ на причину отказа): +// • moderatorFile — документ модератору (лицензия/справка) в поле «Комментарий»; +// • adText / buttonUrl / ordCategory — правка текста/ссылки/категории ОРД. +// Нужна ХОТЬ ОДНА правка: пересдача без изменений = тот же контент → снова отказ. +// НЕ требует phonesFile/budgetRub: аудитория и стоимость у кампании уже есть. +// Обязательное: campaignId (какую чинить), submitMode ∈ {draft, live} (без дефолта, +// чтобы боевую отправку «без оплаты» нельзя было запустить случайно). Незаданные +// поля правок → null (редактор оставляет их как есть). +export function parseResubmitTask(raw) { + const req = (k) => { + if (raw[k] === undefined || raw[k] === '' || raw[k] === null) + throw new Error(`Пересдача: не задано поле ${k}`); + return raw[k]; + }; + const campaignId = String(req('campaignId')); + const submitMode = req('submitMode'); + if (!MODES.includes(submitMode)) + throw new Error(`Пересдача: submitMode должен быть draft|live, получено ${submitMode}`); + + const opt = (k) => (raw[k] === undefined || raw[k] === '' || raw[k] === null ? null : raw[k]); + const moderatorFile = opt('moderatorFile'); + const adText = opt('adText'); + const buttonUrl = opt('buttonUrl'); + const ordCategory = opt('ordCategory'); + if (!moderatorFile && !adText && !buttonUrl && !ordCategory) { + throw new Error('Пересдача: нужна хоть одна правка — документ модератору (moderatorFile) или новый текст/ссылка/категория ОРД'); + } + + return { mode: 'resubmit', campaignId, submitMode, moderatorFile, adText, buttonUrl, ordCategory }; +} + +export function parseTask(raw) { + const req = (k) => { + if (raw[k] === undefined || raw[k] === '' || raw[k] === null) + throw new Error(`Задание: не задано поле ${k}`); + return raw[k]; + }; + const mode = req('mode'); + if (!MODES.includes(mode)) throw new Error(`Задание: mode должен быть draft|live, получено ${mode}`); + // budgetRub — ПОТОЛОК допустимой стоимости для боевого запуска (assertWithinCap). + // Саму цену кабинет ВЫЧИСЛЯЕТ сам (аудитория × фикс. ставка ≈400 ₽/1000), задать + // её полем нельзя — поэтому cpmRub убран (был бессмысленным вводом). + const budgetRub = Number(req('budgetRub')); + if (!(budgetRub > 0)) throw new Error('Задание: budgetRub должен быть > 0'); + return { + phonesFile: req('phonesFile'), + adText: req('adText'), + budgetRub, + buttonUrl: req('buttonUrl'), + mediaFile: raw.mediaFile ?? null, + // Документ модератору при ПЕРЕСДАЧЕ отклонённой кампании (задача 3.6): грузится + // в поле «Комментарий для модератора» на шаге «Сообщение». null — не приложили. + moderatorFile: raw.moderatorFile ?? null, + clientTag: raw.clientTag ?? null, + mode, + // Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — только категория + // ОРД (дефолт «Размещение рекламы», подтверждён живьём). Полей «Заголовок» и + // «Название компании» в кабинете НЕТ — рекламодатель (наш ИП) и маркировка + // подставляются автоматически, поэтому adTitle/ordCompanyName убраны. + ordCategory: raw.ordCategory ?? 'Размещение рекламы', + }; +} diff --git a/bots/mts-telegram-ads/task.example.json b/bots/mts-telegram-ads/task.example.json new file mode 100644 index 00000000..acf50658 --- /dev/null +++ b/bots/mts-telegram-ads/task.example.json @@ -0,0 +1,8 @@ +{ + "mode": "draft", + "phonesFile": "screenshots/test-phones.txt", + "adText": "Подключите наш телеграм-бот и получайте заявки быстрее", + "buttonUrl": "https://liderra.ru", + "budgetRub": 1000, + "ordCategory": "Размещение рекламы" +} diff --git a/bots/mts-telegram-ads/test/budget.test.js b/bots/mts-telegram-ads/test/budget.test.js new file mode 100644 index 00000000..1bc2efb4 --- /dev/null +++ b/bots/mts-telegram-ads/test/budget.test.js @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assertWithinCap } from '../src/budget.js'; + +test('пропускает бюджет в пределах потолка', () => { + assert.doesNotThrow(() => assertWithinCap(1000, 5000)); +}); + +test('бросает при превышении потолка', () => { + assert.throws(() => assertWithinCap(6000, 5000), /потолок/i); +}); + +test('ровно потолок — допустимо', () => { + assert.doesNotThrow(() => assertWithinCap(5000, 5000)); +}); + +test('бросает при NaN потолке', () => { + assert.throws(() => assertWithinCap(1000, NaN), /потолок|некорректн/i); +}); + +test('бросает при нулевом потолке', () => { + assert.throws(() => assertWithinCap(1000, 0), /потолок|некорректн/i); +}); diff --git a/bots/mts-telegram-ads/test/cabinet-cost.test.js b/bots/mts-telegram-ads/test/cabinet-cost.test.js new file mode 100644 index 00000000..47a7baef --- /dev/null +++ b/bots/mts-telegram-ads/test/cabinet-cost.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCost } from '../src/cabinet.js'; + +test('запятая-десятичная дробь дополняется до 2 знаков', () => { + assert.equal(parseCost('Стоимость кампании от 201,6 ₽'), '201.60'); +}); + +test('тысячи с обычным пробелом-разделителем убираются', () => { + assert.equal(parseCost('Стоимость кампании от 1 209,76 ₽'), '1209.76'); +}); + +test('тысячи с НЕразрывным пробелом (код 160) убираются', () => { + assert.equal(parseCost('Стоимость кампании от 1 209,76 ₽'), '1209.76'); +}); + +test('целое число получает .00', () => { + assert.equal(parseCost('от 200 ₽'), '200.00'); +}); + +test('без пробела перед знаком рубля', () => { + assert.equal(parseCost('Стоимость кампании от 201,6₽'), '201.60'); +}); + +test('точка-разделитель дробной части тоже принимается', () => { + assert.equal(parseCost('от 201.6 ₽'), '201.60'); +}); + +test('лишние пробелы по краям игнорируются', () => { + assert.equal(parseCost(' от 200 ₽ '), '200.00'); +}); + +test('нет цифр → null', () => { + assert.equal(parseCost('Стоимость кампании'), null); +}); + +test('пустая строка → null', () => { + assert.equal(parseCost(''), null); +}); + +test('null → null', () => { + assert.equal(parseCost(null), null); +}); + +test('undefined → null', () => { + assert.equal(parseCost(undefined), null); +}); + +test('дробь длиннее 2 знаков обрезается без некорректного округления', () => { + assert.equal(parseCost('от 201,609 ₽'), '201.60'); +}); diff --git a/bots/mts-telegram-ads/test/cabinet-guards.test.js b/bots/mts-telegram-ads/test/cabinet-guards.test.js new file mode 100644 index 00000000..b19ca0ac --- /dev/null +++ b/bots/mts-telegram-ads/test/cabinet-guards.test.js @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText } from '../src/cabinet.js'; + +// A-фикс: локатор строки кампании в СПИСКЕ. Живой прогон A1 показал: список +// использует href `/cabinet/campaigns/telegram/{id}`, а детальная/визард-страница — +// `/telegram-a2p/{id}/…`. readModerationStatus должен матчить ОБА варианта. +test('href списка /campaigns/telegram/{id} матчит id', () => { + assert.equal(hrefMatchesCampaignId('/cabinet/campaigns/telegram/2231134', '2231134'), true); +}); + +test('href визарда /telegram-a2p/{id}/confirmation матчит id', () => { + assert.equal(hrefMatchesCampaignId('/cabinet/campaign/telegram-a2p/2231134/confirmation', '2231134'), true); +}); + +test('другой id не матчит', () => { + assert.equal(hrefMatchesCampaignId('/cabinet/campaigns/telegram/2231132', '2231134'), false); +}); + +test('id как часть большего числа не матчит', () => { + assert.equal(hrefMatchesCampaignId('/cabinet/campaigns/telegram/22311340', '2231134'), false); +}); + +test('не строка → false', () => { + assert.equal(hrefMatchesCampaignId(null, '2231134'), false); +}); + +// F6 — чёрный список кнопок, ТРАТЯЩИХ деньги / запускающих кампанию. Робот их не +// жмёт НИКОГДА (реальная отправка/оплата на /payment wire-ится в Сессии 6). +test('«Оплатить» — запрещённая кнопка', () => { + assert.equal(isForbiddenButtonText('Оплатить'), true); +}); + +test('«Списать с баланса и отправить на модерацию» — запрещённая (реальная трата)', () => { + assert.equal(isForbiddenButtonText('Списать с баланса и отправить на модерацию'), true); +}); + +test('«Запустить кампанию» — запрещённая', () => { + assert.equal(isForbiddenButtonText('Запустить кампанию'), true); +}); + +test('«Продолжить» — разрешённая (шаг /confirmation)', () => { + assert.equal(isForbiddenButtonText('Продолжить'), false); +}); + +test('«Отправить на модерацию без оплаты» — разрешённая (0 ₽, не трата)', () => { + assert.equal(isForbiddenButtonText('Отправить на модерацию без оплаты'), false); +}); + +test('не строка → false', () => { + assert.equal(isForbiddenButtonText(null), false); +}); + +// F8 — сверка фактической стоимости из кабинета с потолком. +test('стоимость в пределах потолка — не бросает', () => { + assert.doesNotThrow(() => assertCostWithinCap('201.60', 5000)); +}); + +test('стоимость ровно потолок — не бросает', () => { + assert.doesNotThrow(() => assertCostWithinCap('5000.00', 5000)); +}); + +test('стоимость выше потолка — бросает', () => { + assert.throws(() => assertCostWithinCap('6000.00', 5000), /потолок|превыша/i); +}); + +test('нечитаемая стоимость (null) — не бросает (сверять нечего)', () => { + assert.doesNotThrow(() => assertCostWithinCap(null, 5000)); +}); + +test('некорректный потолок при читаемой стоимости — бросает', () => { + assert.throws(() => assertCostWithinCap('100.00', 0), /потолок|некорректн/i); +}); + +// R2 (robot-режим пересдачи) — кнопка бесплатной отправки на модерацию (0 ₽). +// submitWithoutPayment целит ТОЛЬКО её; денежные («Списать…», «Оплатить») — никогда. +test('«Отправить на модерацию без оплаты» — кнопка бесплатной отправки', () => { + assert.equal(isNoPaymentSubmitText('Отправить на модерацию без оплаты'), true); +}); + +test('регистр/пробелы не мешают распознать без-оплаты', () => { + assert.equal(isNoPaymentSubmitText(' ОТПРАВИТЬ НА МОДЕРАЦИЮ БЕЗ ОПЛАТЫ '), true); +}); + +test('«Списать с баланса и отправить на модерацию» — НЕ без-оплаты (это трата)', () => { + assert.equal(isNoPaymentSubmitText('Списать с баланса и отправить на модерацию'), false); +}); + +test('«Оплатить» — НЕ без-оплаты', () => { + assert.equal(isNoPaymentSubmitText('Оплатить'), false); +}); + +test('пустое/не строка — не без-оплаты', () => { + assert.equal(isNoPaymentSubmitText(''), false); + assert.equal(isNoPaymentSubmitText(null), false); +}); + +// Сверка двух предикатов: кнопка без-оплаты НЕ должна попадать в чёрный список +// денежных, а денежная «Списать…» — должна (двойная защита submitWithoutPayment). +test('без-оплаты не запрещена, «Списать…» запрещена', () => { + assert.equal(isForbiddenButtonText('Отправить на модерацию без оплаты'), false); + assert.equal(isForbiddenButtonText('Списать с баланса и отправить на модерацию'), true); +}); diff --git a/bots/mts-telegram-ads/test/cleanup-drafts.test.js b/bots/mts-telegram-ads/test/cleanup-drafts.test.js new file mode 100644 index 00000000..5f9863f2 --- /dev/null +++ b/bots/mts-telegram-ads/test/cleanup-drafts.test.js @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isDeletableDraftRow, + isHeaderOrAggregateRow, + selectionIsSafe, +} from '../src/cleanup-drafts.js'; + +// Примеры строк списка кампаний кабинета МТС (innerText, как его видит робот). +const DRAFT = 'Кампания Telegram по своей базе Черновик 437 показов'; +const MODERATION = 'Кампания Telegram по своей базе На модерации 437 показов'; +const REJECTED = 'Займ Telegram по своей базе Отклонена Причины'; +const ACTIVE = 'Кампания Telegram по своей базе Активна 1200 показов'; +const HEADER = 'Название Статус Вид кампании CPM без НДС'; +const OTHER = 'Реклама в Telegram'; // заголовок раздела, не строка кампании +// Агрегат select-all: innerText контейнера склеивает ВСЕ строки — есть и «Черновик», и «На модерации». +const AGGREGATE = 'Telegram по своей базе Черновик 437 Telegram по своей базе На модерации 900'; + +test('isDeletableDraftRow: черновик кампании — удаляемый', () => { + assert.equal(isDeletableDraftRow(DRAFT), true); +}); + +test('isDeletableDraftRow: «на модерации» — НЕ удаляем', () => { + assert.equal(isDeletableDraftRow(MODERATION), false); +}); + +test('isDeletableDraftRow: «отклонена» — НЕ удаляем (только черновики)', () => { + assert.equal(isDeletableDraftRow(REJECTED), false); +}); + +test('isDeletableDraftRow: «активна» — НЕ удаляем', () => { + assert.equal(isDeletableDraftRow(ACTIVE), false); +}); + +test('isDeletableDraftRow: шапка списка — НЕ удаляем', () => { + assert.equal(isDeletableDraftRow(HEADER), false); +}); + +test('isDeletableDraftRow: чужая строка без имени кампании — НЕ удаляем', () => { + assert.equal(isDeletableDraftRow(OTHER), false); +}); + +test('isDeletableDraftRow: агрегат select-all (черновик + модерация) — НЕ удаляем', () => { + assert.equal(isDeletableDraftRow(AGGREGATE), false); +}); + +test('isDeletableDraftRow: пустое/не строка → false', () => { + assert.equal(isDeletableDraftRow(''), false); + assert.equal(isDeletableDraftRow(null), false); + assert.equal(isDeletableDraftRow(undefined), false); +}); + +test('isHeaderOrAggregateRow: шапка — да', () => { + assert.equal(isHeaderOrAggregateRow(HEADER), true); +}); + +test('isHeaderOrAggregateRow: агрегат (смешаны модерация+черновик) — да', () => { + assert.equal(isHeaderOrAggregateRow(AGGREGATE), true); +}); + +test('isHeaderOrAggregateRow: чужая строка без имени — да (не строка кампании)', () => { + assert.equal(isHeaderOrAggregateRow(OTHER), true); +}); + +test('isHeaderOrAggregateRow: обычный черновик — нет', () => { + assert.equal(isHeaderOrAggregateRow(DRAFT), false); +}); + +test('selectionIsSafe: выбраны только черновики, select-all снят → безопасно', () => { + assert.equal(selectionIsSafe({ checkedStatuses: ['Черновик', 'Черновик'], selectAll: false }), true); +}); + +test('selectionIsSafe: среди выбранных есть «На модерации» → СТОП', () => { + assert.equal(selectionIsSafe({ checkedStatuses: ['Черновик', 'На модерации'], selectAll: false }), false); +}); + +test('selectionIsSafe: взведён select-all → СТОП', () => { + assert.equal(selectionIsSafe({ checkedStatuses: ['Черновик'], selectAll: true }), false); +}); + +test('selectionIsSafe: пустой выбор → СТОП', () => { + assert.equal(selectionIsSafe({ checkedStatuses: [], selectAll: false }), false); +}); + +test('selectionIsSafe: битый вход → СТОП', () => { + assert.equal(selectionIsSafe({ selectAll: false }), false); + assert.equal(selectionIsSafe(null), false); +}); diff --git a/bots/mts-telegram-ads/test/config.test.js b/bots/mts-telegram-ads/test/config.test.js new file mode 100644 index 00000000..e8185fe8 --- /dev/null +++ b/bots/mts-telegram-ads/test/config.test.js @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; + +test('loadConfig собирает поля из env', () => { + const env = { + MTS_BROWSER_PROFILE_DIR: '/p', MTS_CABINET_URL: 'https://c', + MTS_TELEGRAM_URL: 'https://t', SMTP_HOST: 'h', SMTP_PORT: '587', + SMTP_USER: 'u', SMTP_PASS: 'p', ALARM_FROM: 'a@b', ALARM_TO: 'c@d', + BUDGET_CAP_RUB: '5000', HUMAN_DELAY_MS: '800', + }; + const c = loadConfig(env); + assert.equal(c.profileDir, '/p'); + assert.equal(c.budgetCapRub, 5000); + assert.equal(c.humanDelayMs, 800); +}); + +test('loadConfig падает при отсутствии обязательного поля', () => { + assert.throws(() => loadConfig({}), /MTS_BROWSER_PROFILE_DIR/); +}); + +test('loadConfig: debugShots выключен по умолчанию (переменная не задана)', () => { + const env = { + MTS_BROWSER_PROFILE_DIR: '/p', MTS_CABINET_URL: 'https://c', + MTS_TELEGRAM_URL: 'https://t', SMTP_HOST: 'h', SMTP_PORT: '587', + SMTP_USER: 'u', SMTP_PASS: 'p', ALARM_FROM: 'a@b', ALARM_TO: 'c@d', + BUDGET_CAP_RUB: '5000', HUMAN_DELAY_MS: '800', + }; + const c = loadConfig(env); + assert.equal(c.debugShots, false); +}); + +test('loadConfig: debugShots включён при TG_DEBUG_SHOTS=1', () => { + const env = { + MTS_BROWSER_PROFILE_DIR: '/p', MTS_CABINET_URL: 'https://c', + MTS_TELEGRAM_URL: 'https://t', SMTP_HOST: 'h', SMTP_PORT: '587', + SMTP_USER: 'u', SMTP_PASS: 'p', ALARM_FROM: 'a@b', ALARM_TO: 'c@d', + BUDGET_CAP_RUB: '5000', HUMAN_DELAY_MS: '800', TG_DEBUG_SHOTS: '1', + }; + const c = loadConfig(env); + assert.equal(c.debugShots, true); +}); diff --git a/bots/mts-telegram-ads/test/mailer.test.js b/bots/mts-telegram-ads/test/mailer.test.js new file mode 100644 index 00000000..3e9e12c9 --- /dev/null +++ b/bots/mts-telegram-ads/test/mailer.test.js @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMailer } from '../src/mailer.js'; + +function fakeTransport() { + const sent = []; + return { sent, sendMail: async (m) => { sent.push(m); return { messageId: 'x' }; } }; +} + +test('alarm формирует письмо со скриншотом', async () => { + const t = fakeTransport(); + const mailer = createMailer(t, { from: 'a@b', to: 'c@d' }); + await mailer.alarm({ step: 'upload', reason: 'капча', screenshotPath: '/s.png' }); + assert.equal(t.sent.length, 1); + assert.match(t.sent[0].subject, /алярм/i); + assert.match(t.sent[0].text, /капча/); + assert.equal(t.sent[0].attachments[0].path, '/s.png'); +}); + +test('report формирует письмо об успехе', async () => { + const t = fakeTransport(); + const mailer = createMailer(t, { from: 'a@b', to: 'c@d' }); + await mailer.report({ campaignId: '123', matched: 640, mode: 'draft' }); + assert.match(t.sent[0].subject, /готово/i); + assert.match(t.sent[0].text, /640/); +}); diff --git a/bots/mts-telegram-ads/test/parse-campaign-id.test.js b/bots/mts-telegram-ads/test/parse-campaign-id.test.js new file mode 100644 index 00000000..43385e12 --- /dev/null +++ b/bots/mts-telegram-ads/test/parse-campaign-id.test.js @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCampaignId } from '../src/cabinet.js'; + +test('шаг /message: id из URL кабинета', () => { + assert.equal( + parseCampaignId('https://marketolog.mts.ru/cabinet/campaign/telegram-a2p/2231134/message'), + '2231134', + ); +}); + +test('шаг /audience: id тот же', () => { + assert.equal(parseCampaignId('.../telegram-a2p/2231132/audience'), '2231132'); +}); + +test('шаг /confirmation: id тот же', () => { + assert.equal(parseCampaignId('.../telegram-a2p/2231132/confirmation'), '2231132'); +}); + +test('URL без telegram-a2p → null', () => { + assert.equal(parseCampaignId('https://marketolog.mts.ru/cabinet/campaign'), null); +}); + +test('пустая строка → null', () => { + assert.equal(parseCampaignId(''), null); +}); + +test('null → null', () => { + assert.equal(parseCampaignId(null), null); +}); + +test('undefined → null', () => { + assert.equal(parseCampaignId(undefined), null); +}); + +test('число (не строка) → null', () => { + assert.equal(parseCampaignId(123), null); +}); diff --git a/bots/mts-telegram-ads/test/phones.test.js b/bots/mts-telegram-ads/test/phones.test.js new file mode 100644 index 00000000..d792f9ea --- /dev/null +++ b/bots/mts-telegram-ads/test/phones.test.js @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizePhones } from '../src/phones.js'; + +test('нормализует и дедуплицирует номера', () => { + const raw = '+7 (912) 345-67-89\n8 912 345 67 89\n79161112233\nмусор\n'; + const r = normalizePhones(raw); + assert.deepEqual(r.numbers, ['79123456789', '79161112233']); + assert.equal(r.total, 2); + assert.equal(r.dropped, 1); // строка "мусор" +}); + +test('8XXXXXXXXXX приводится к 7XXXXXXXXXX', () => { + const r = normalizePhones('89161112233'); + assert.deepEqual(r.numbers, ['79161112233']); +}); + +test('пустой ввод даёт 0 номеров', () => { + const r = normalizePhones(' \n '); + assert.equal(r.total, 0); +}); diff --git a/bots/mts-telegram-ads/test/read-status.test.mjs b/bots/mts-telegram-ads/test/read-status.test.mjs new file mode 100644 index 00000000..b6a4b2ba --- /dev/null +++ b/bots/mts-telegram-ads/test/read-status.test.mjs @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseModerationStatus } from '../src/cabinet.js'; + +// Задача 3.5 — робот читает вердикт модерации из кабинета МТС. Статус кампании +// показан ТЕКСТОМ в списке /cabinet/campaigns/telegram (Черновик / На модерации / +// Отклонена / Одобрена / Активна — снято живьём, FLOW-FINDINGS.md «Сессия 6»). +// parseModerationStatus — чистый парсер: текст статуса (или всю строку-блоб ряда) +// → канон для Laravel-опросчика: 'approved'|'rejected'|'moderating'|'draft'|null. +// Контракт RobotResult.moderationStatus (approved|rejected|moderating|null): всё, +// что НЕ approved/rejected, опросчик трактует как «ждём» — 'draft'/null безопасны. + +test('«Отклонена» → rejected', () => { + assert.equal(parseModerationStatus('Отклонена'), 'rejected'); +}); + +test('«На модерации» → moderating', () => { + assert.equal(parseModerationStatus('На модерации'), 'moderating'); +}); + +test('«Одобрена» → approved', () => { + assert.equal(parseModerationStatus('Одобрена'), 'approved'); +}); + +test('«Активна» (запущена после модерации) → approved', () => { + assert.equal(parseModerationStatus('Активна'), 'approved'); +}); + +test('«Черновик» → draft', () => { + assert.equal(parseModerationStatus('Черновик'), 'draft'); +}); + +test('регистр и лишние пробелы не мешают', () => { + assert.equal(parseModerationStatus(' ОТКЛОНЕНА '), 'rejected'); + assert.equal(parseModerationStatus('на модерации'), 'moderating'); +}); + +test('НЕРАЗРЫВНЫЙ пробел (код 160) внутри «На модерации» тоже ловится', () => { + assert.equal(parseModerationStatus('На модерации'), 'moderating'); +}); + +test('статус найден внутри длинной строки-блоба ряда списка', () => { + // innerText ряда содержит название, статус и ссылки-действия разом. + assert.equal( + parseModerationStatus('Займ_проба Отклонена Исправить Причины Посмотреть настройки'), + 'rejected', + ); + assert.equal( + parseModerationStatus('Моя кампания На модерации Посмотреть настройки'), + 'moderating', + ); +}); + +test('отклонение приоритетнее модерации, если оба слова попали в блоб', () => { + // Строка-блоб может содержать и «модераци…» (в прозе), и терминальный «Отклонена». + assert.equal( + parseModerationStatus('Не прошла модерацию Отклонена Причины'), + 'rejected', + ); +}); + +test('неизвестный текст → null', () => { + assert.equal(parseModerationStatus('Приостановлена'), null); + assert.equal(parseModerationStatus('какой-то текст'), null); +}); + +test('пустая строка / null / undefined / не строка → null', () => { + assert.equal(parseModerationStatus(''), null); + assert.equal(parseModerationStatus(null), null); + assert.equal(parseModerationStatus(undefined), null); + assert.equal(parseModerationStatus(123), null); +}); diff --git a/bots/mts-telegram-ads/test/resubmit-task.test.js b/bots/mts-telegram-ads/test/resubmit-task.test.js new file mode 100644 index 00000000..31334095 --- /dev/null +++ b/bots/mts-telegram-ads/test/resubmit-task.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseResubmitTask } from '../src/task.js'; + +// Пересдача (mode:'resubmit', задача 3.6 → robot-mode): чинит ОТКЛОНЁННУЮ кампанию +// по её id и повторно шлёт на модерацию без оплаты (0 ₽). Что чиним — на выбор: +// документ модератору (moderatorFile) и/или правка текста/ссылки/категории ОРД. +const base = { + mode: 'resubmit', + campaignId: '2231134', + submitMode: 'draft', + moderatorFile: 'C:/tmp/licenziya.pdf', +}; + +test('parseResubmitTask принимает задание с документом модератору', () => { + const t = parseResubmitTask(base); + assert.equal(t.campaignId, '2231134'); + assert.equal(t.moderatorFile, 'C:/tmp/licenziya.pdf'); + assert.equal(t.submitMode, 'draft'); +}); + +test('parseResubmitTask требует campaignId (какую кампанию чинить)', () => { + const { campaignId, ...rest } = base; + assert.throws(() => parseResubmitTask(rest), /campaignId/); +}); + +test('parseResubmitTask требует submitMode draft|live (без дефолта — чтобы live не случился сам)', () => { + const { submitMode, ...rest } = base; + assert.throws(() => parseResubmitTask(rest), /submitMode/); +}); + +test('parseResubmitTask отвергает чужой submitMode', () => { + assert.throws(() => parseResubmitTask({ ...base, submitMode: 'xxx' }), /submitMode/); +}); + +test('parseResubmitTask принимает submitMode live', () => { + const t = parseResubmitTask({ ...base, submitMode: 'live' }); + assert.equal(t.submitMode, 'live'); +}); + +test('parseResubmitTask пробрасывает правки текста/ссылки/ОРД', () => { + const t = parseResubmitTask({ + mode: 'resubmit', + campaignId: '2231134', + submitMode: 'draft', + adText: 'Исправленный текст', + buttonUrl: 'https://t.me/liderra', + ordCategory: 'Другая категория', + }); + assert.equal(t.adText, 'Исправленный текст'); + assert.equal(t.buttonUrl, 'https://t.me/liderra'); + assert.equal(t.ordCategory, 'Другая категория'); +}); + +test('parseResubmitTask принимает правку ТОЛЬКО текста, без документа', () => { + const t = parseResubmitTask({ + mode: 'resubmit', campaignId: '2231134', submitMode: 'draft', adText: 'Новый текст', + }); + assert.equal(t.adText, 'Новый текст'); + assert.equal(t.moderatorFile, null); +}); + +test('parseResubmitTask требует ХОТЬ ОДНО исправление (документ или правку поля)', () => { + // Пересдача без единой правки = тот же контент → снова отказ. Бессмысленно. + assert.throws( + () => parseResubmitTask({ mode: 'resubmit', campaignId: '2231134', submitMode: 'draft' }), + /исправлен|документ|текст/i, + ); +}); + +test('parseResubmitTask: незаданные поля правок → null', () => { + const t = parseResubmitTask(base); // только moderatorFile + assert.equal(t.adText, null); + assert.equal(t.buttonUrl, null); + assert.equal(t.ordCategory, null); +}); + +test('parseResubmitTask НЕ требует phonesFile/budgetRub (аудитория и стоимость уже у кампании)', () => { + const t = parseResubmitTask(base); + assert.equal(t.phonesFile, undefined); + assert.equal(t.budgetRub, undefined); +}); diff --git a/bots/mts-telegram-ads/test/shots.test.js b/bots/mts-telegram-ads/test/shots.test.js new file mode 100644 index 00000000..9cb635f1 --- /dev/null +++ b/bots/mts-telegram-ads/test/shots.test.js @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { debugShotsEnabled, alarmScreenshot } from '../src/shots.js'; + +test('debugShotsEnabled: включено при debugShots true', () => { + assert.equal(debugShotsEnabled({ debugShots: true }), true); +}); + +test('debugShotsEnabled: выключено по умолчанию (пустой конфиг)', () => { + assert.equal(debugShotsEnabled({}), false); +}); + +test('debugShotsEnabled: выключено при undefined конфиге', () => { + assert.equal(debugShotsEnabled(undefined), false); +}); + +test('alarmScreenshot: отладка выключена — null, даже если файл есть', () => { + assert.equal(alarmScreenshot({}, 'p.png', true), null); +}); + +test('alarmScreenshot: отладка включена и файл есть — путь к файлу', () => { + assert.equal(alarmScreenshot({ debugShots: true }, 'p.png', true), 'p.png'); +}); + +test('alarmScreenshot: отладка включена, но файла нет — null', () => { + assert.equal(alarmScreenshot({ debugShots: true }, 'p.png', false), null); +}); diff --git a/bots/mts-telegram-ads/test/task.test.js b/bots/mts-telegram-ads/test/task.test.js new file mode 100644 index 00000000..6888fd26 --- /dev/null +++ b/bots/mts-telegram-ads/test/task.test.js @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTask } from '../src/task.js'; + +const base = { + phonesFile: 'phones.txt', adText: 'Короткий текст', + budgetRub: 1000, buttonUrl: 'https://site.ru', + mode: 'draft', +}; + +test('parseTask принимает корректное задание', () => { + const t = parseTask(base); + assert.equal(t.mode, 'draft'); + assert.equal(t.budgetRub, 1000); +}); + +test('parseTask отвергает неизвестный режим', () => { + assert.throws(() => parseTask({ ...base, mode: 'xxx' }), /mode/); +}); + +test('parseTask требует phonesFile', () => { + const { phonesFile, ...rest } = base; + assert.throws(() => parseTask(rest), /phonesFile/); +}); + +test('parseTask отвергает пустой текст', () => { + assert.throws(() => parseTask({ ...base, adText: '' }), /adText/); +}); + +test('parseTask требует budgetRub > 0', () => { + assert.throws(() => parseTask({ ...base, budgetRub: 0 }), /budgetRub/); +}); + +test('parseTask подставляет дефолт ordCategory', () => { + const t = parseTask(base); + assert.equal(t.ordCategory, 'Размещение рекламы'); +}); + +test('parseTask пробрасывает заданную ordCategory', () => { + const t = parseTask({ ...base, ordCategory: 'Другая категория' }); + assert.equal(t.ordCategory, 'Другая категория'); +}); + +test('parseTask НЕ требует adTitle/ordCompanyName (полей в кабинете нет)', () => { + // Задание без adTitle/ordCompanyName/cpmRub должно проходить. + const t = parseTask(base); + assert.equal(t.adTitle, undefined); + assert.equal(t.ordCompanyName, undefined); + assert.equal(t.cpmRub, undefined); +}); + +test('parseTask пробрасывает moderatorFile (документ модератору, пересдача 3.6)', () => { + const t = parseTask({ ...base, moderatorFile: 'C:/tmp/licenziya.pdf' }); + assert.equal(t.moderatorFile, 'C:/tmp/licenziya.pdf'); +}); + +test('parseTask: без moderatorFile → null (документ не приложили)', () => { + const t = parseTask(base); + assert.equal(t.moderatorFile, null); +}); diff --git a/cspell-words.txt b/cspell-words.txt index 7f80f94c..dbf16fc4 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2431,6 +2431,55 @@ cvtjpq hgq sar sca +CPM +CPF +MTC +ЕРИР +Таргеты +алярм +инпут +инпута +Тихоненков +доразметить +капслок +Qrator +крутилка +датацентровый +флакает +поллинг +ОПФ +опросится +тенантные +mattpocock +апстрима +задвоение +задвоения +apiv +eto +kak +licenziya +rabotaet +svyazka +Свип +абонплата +админских +ассертим +бэклогом +джобами +дозаполнение +досыл +задваиваем +закоммитила +залогиненных +мигрированных +платформо +преfflight +приложённый +ресейлит +сидированные +слагов +схлопывает +фирмо reklamnyy koshelek chast @@ -2440,3 +2489,29 @@ kanal Директа яндексовы поллинг +reklama +otkazy +okno +peredachi +pokazy +medijnaya +konstruktor +ulogin +moderacii +otkaz +otkaza +razvernuto +zakryt +reklama +otkazy +okno +peredachi +pokazy +medijnaya +konstruktor +ulogin +moderacii +otkaz +otkaza +razvernuto +zakryt diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index f3db81c4..a08bb84e 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -4,7 +4,263 @@ **Файл схемы:** `schema.sql` — консолидированный DDL. **Версия схемы и метрики (таблицы / партиции / индексы / RLS-политики / функции / триггеры) — канон в header самого `schema.sql`**, здесь они не дублируются: файл ведут параллельные ветки, и любое число, вписанное сюда, устаревает молча. Прежняя шапка держала счётчик «тридцать записей» — их давно больше; счётчик убран, чтобы не врать. Номер версии (v8.85) со `schema.sql` совпадал и убран не как ошибочный, а чтобы не поддерживать его в двух местах. -⚠️ **Рекламный модуль в `schema.sql` не консолидирован.** Записи v9.x ведут таблицы `ad_*`, которых в теле `schema.sql` нет вовсе — их DDL живёт только в дельта-миграциях. Идти за версией v9.x в header `schema.sql` бесполезно: там своя нумерация v8.x. Правка шапки от 29.07.2026, к содержанию записей отношения не имеет. +⚠️ **Рекламный и телеграмный модули в `schema.sql` не консолидированы.** Записи v9.x ведут таблицы `ad_*` и `client_tg_*`, которых в теле `schema.sql` нет вовсе — их DDL живёт только в дельта-миграциях. Идти за версией v9.x в header `schema.sql` бесполезно: там своя нумерация v8.x (на 29.07.2026 — v8.85, и слияние телеграма её не двигает). Правка шапки от 29.07.2026, к содержанию записей отношения не имеет. + +> **Перенумерация (29.07.2026):** записи ниже (v9.18–v9.27) сделаны на ветке `feat/client-telegram-ads` +> параллельно с боевым main. Их прежние номера (v8.86–v8.95) **столкнулись** с боевыми (рекламный +> кошелёк и Яндекс-канал), поэтому при сведении они перенумерованы. Содержание не менялось. + +## v9.27 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон) + +Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки +сообщений по номеру, отписок и шаблонов сообщений (владельцем подтверждено). Эти сущности были механически +скопированы из СМС-модуля. Убраны. + +- **Дроп таблиц** (миграция `app/database/migrations/2026_07_28_000017_drop_client_tg_sms_legacy_tables.php`, + `DROP TABLE IF EXISTS … CASCADE`): + - `client_tg_senders` (было v9.19) — «своё имя/бренд отправителя» + помесячная абонплата; + - `client_tg_settings` (было v9.18) — обе колонки (`name_fee_rub_per_month`, `name_debt_grace_days`) + обслуживали только имя; + - `client_tg_messages` (было v9.18) — «по-получательские записи доставки» (копия `sales_sms_messages`), + без Eloquent-модели, в рабочем коде не использовалась; + - `client_tg_templates` (было v9.18) — «шаблоны сообщений», не использовались нигде. + Их **create-миграции удалены** (000003/000006/000008/000010) — на свежей базе таблицы не создаются, DROP — + no-op; на уже мигрированных dev/prod убирает. +- **CHECK `balance_transactions_type_check` −= `tg_name_fee`** — тип был зарезервирован под отменённую + плату за имя (кусок 3 спеки отменён). В теле `schema.sql` (~л.2745) и в up/down миграции + `2026_07_28_000001` строка `tg_name_fee` убрана; остаются `tg_ad_charge`/`tg_ad_refund`. +- **НЕ тронуто:** реальные таблицы показов (`client_tg_campaigns`/`_campaign_phones`/`_contacts`/`_tariffs`/ + `_auto_rule`) и `client_tg_optouts` (переосмыслен как ручной список «не показывать этим номерам»). + +**Данные:** таблицы работали только в песочнице (реальных денег/строк по имени не было). Дроп необратим +(`down` не пересоздаёт — продукт показов их не имеет). + +**RLS:** удаление таблиц снимает и их политики/GRANT-ы вместе с ними; политики оставшихся таблиц не меняются. +**rls-reviewer:** удаление RLS-таблиц + CHECK-only правка — прогнать при сведении ветки. + +--- + +## v9.26 (2026-07-28) — Telegram-деньги: CHECK balance_transactions.type += tg_ad_charge/tg_ad_refund + +> NB: изначально запись добавляла и `tg_name_fee`, но он убран в v9.27 (плата за имя — отменённая фантазия). + +Кусок 1 денежной модели Telegram-рекламы (спека +`docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md`): реклама оплачивается с +**общего баланса** тенанта (как СМС), поэтому в журнал денег добавлены два вида проводки. + +- **CHECK `balance_transactions_type_check` += два значения:** + - `tg_ad_charge` — списание клиентской сметы (с наценкой 40%) при запуске Telegram-кампании + (`TelegramCampaignChargeService::charge`, зеркало `SmsChargeService`); + - `tg_ad_refund` — возврат при отказе модерации / сбое до кабинета (`::refund`, идемпотентный). + + Одной миграцией, чтобы на прод-кластер `ALTER … CHECK` ехал ОДИН раз. Миграция + `app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php` + (up = полный список **включая** уже действующий `sms_charge`; down возвращает к списку с `sms_charge`). +- **Свёрнуто в тело `schema.sql`** (`CREATE TABLE balance_transactions`, ~л.2745): CHECK приведён к + реальному состоянию БД — заодно **добавлен пропущенный** `sms_charge` (миграция 2026_07_23_100100 + расширяла констрейнт `ALTER`-ом, но в тело `schema.sql` значение не свернули) + два новых типа. + Прежняя цепочка `'migration' → 'autopodbor_charge'` теперь `→ … → 'tg_ad_refund'`. + +**Данные:** необратимо после первого списания `type='tg_ad_charge'` (как прецеденты `sms_charge` / +`autopodbor_charge`). `migrate:fresh` пересоздаст с полным списком. + +**RLS:** политики и колонки НЕ меняются — только доменный CHECK на `type`. Таблица уже tenant-aware +(`tenant_isolation`), новые проводки пишутся в контексте тенанта (`SET LOCAL app.current_tenant_id`), +как `sms_charge`. **rls-reviewer:** прогнан — **CLEAN** (CHECK-only, изоляция не затронута). + +--- + +## v9.25 (2026-07-28) — Telegram-модуль, ревью-фикс F9: SELECT служебным ролям на client_tg_campaigns + +Правка после сводного код-ревью ветки (находка API I4 / капкан `srv_bypass`). Только GRANT — таблиц/колонок/политик не добавляет. + +- **GRANT SELECT ON client_tg_campaigns → crm_supplier_worker, crm_admin_user** (role-guarded `IF EXISTS`). + `PollTelegramModerationJob` и `SweepStuckTelegramCampaignsJob` перечисляют кампании **кросс-тенантно** + через `pgsql_supplier` (= `crm_supplier_worker` на проде). Create-миграция (000001, v9.18) выдала GRANT + только `crm_app_user` → на боевом кластере (служебные роли **НЕ BYPASSRLS**) джобы получили бы 0 строк + (тихий ноль: зависшие кампании не добьются, модерация не опросится). Даём явный SELECT — как уже сделано + для `client_tg_senders` (v9.19 / миграция 000010). Миграция + `app/database/migrations/2026_07_28_000016_grant_service_roles_client_tg_campaigns.php`. +- Заодно исправлен **ложный комментарий** в миграции 000011 (утверждал «GRANT-ы 5 ролям уже заданы» — на + деле create-миграция дала только `crm_app_user`). + +**RLS:** политика `tenant_isolation` и колонки НЕ меняются. 🔴 GRANT — необходимое, но НЕ достаточное: +строковую видимость служебным ролям (НЕ BYPASSRLS на проде) даёт `db/03_service_bypass_policies.sql`. +Он **не запускается миграциями** — **ПЕРЕзапустить при выкате** (деплой-чеклист модуля), иначе кросс-тенантные +джобы всё равно увидят 0. На dev/testing роли — superuser/BYPASSRLS, поведение не меняется. + +**rls-reviewer:** прогнан — **CLEAN**. GRANT-only, tenant-изоляция не ослаблена (SELECT сам строки не +открывает); SELECT-only достаточно — статусы джобы пишут под `crm_app_user` на дефолтном соединении, не +через supplier-роль; `srv_bypass` в `03_service_bypass_policies.sql` покрывает таблицу автоматически при +ре-ране (итерирует все RLS-таблицы). Паттерн совпадает с 000010 (senders). + +--- + +## v9.24 (2026-07-28) — Telegram-модуль, Этап 5.4: клиентский порог пачки авторассылки (client_tg_auto_rule.batch_threshold) + +Клиентский порог пачки авторассылки (Этап 5, задача 5.4 плана +`docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). + +- **client_tg_auto_rule.batch_threshold** — новая колонка `integer NULL` (после `daily_limit_rub`): + сколько кандидатов накопить перед отправкой авто-пачки. **NULL = порог по умолчанию** из конфига + (`client_tg.auto_batch_threshold`, 367 — минимум МТС Маркетолог). Клиент из кабинета может поднять + порог (реже, крупнее пачки); опустить ниже 367 API не даёт (валидация `min:367`). Накопитель + (`TelegramAutoAccumulator`) предпочитает `batch_threshold` правила, иначе берёт дефолт конфига. + Миграция `app/database/migrations/2026_07_28_000015_add_batch_threshold_to_client_tg_auto_rules.php`. + +**RLS:** не меняется — колонка на существующей таблице `client_tg_auto_rule`. Политика `tenant_isolation` +и GRANT (`SELECT, INSERT, UPDATE` для `crm_app_user`) заданы миграцией `_create_client_tg_auto_rule` (v9.18). +Табличный GRANT в PostgreSQL распространяется на новые колонки — ре-GRANT не нужен. Новых политик/GRANT-ов нет. + +**rls-reviewer:** прогнан на миграции — чисто, RLS-рисков нет (nullable-колонка данных на tenant-скоуп-таблице, tenant_id и политика без изменений). + +--- + +## v9.23 (2026-07-28) — Telegram-модуль, Этап 5.1: предохранители авторассылки (client_tg_auto_rule.daily_limit_rub + счётчик за день) + +Дневной лимит и счётчик трат авто-рассылки (Этап 5, задача 5.1 плана +`docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). + +- **client_tg_auto_rule.daily_limit_rub** — новая колонка `decimal(14,2) NOT NULL DEFAULT 0.00` (после + `budget_cap_rub`): сколько рублей в день клиент разрешает тратить авто-рассылке. **Дефолт 0 = авто + выключено** (даже при `enabled=true` пачка не уходит, пока клиент не задаст положительный лимит). +- **client_tg_auto_rule.spent_today_rub** — `decimal(14,2) NOT NULL DEFAULT 0.00` (после `daily_limit_rub`): + сумма, потраченная авто-рассылкой за текущий день. +- **client_tg_auto_rule.spent_date** — `date NULL` (после `spent_today_rub`): дата счётчика; накопитель + сбрасывает `spent_today_rub` при смене даты. + +Накопитель (`TelegramAutoAccumulator`) в БОЮ перед постановкой пачки в очередь проверяет: смета пачки +умещается И в свободный остаток кошелька (`balance − frozen`), И в `daily_limit_rub` за вычетом трат за +сегодня. Не прошла — держит черновиком, пишет `Log::info('client_tg.auto_skipped', reason)`. В песочнице +гейта нет (деньги не трогаются, как ручной launch). Миграция +`app/database/migrations/2026_07_28_000014_add_daily_limit_to_client_tg_auto_rules.php`. + +**RLS:** не меняется — три колонки на существующей таблице `client_tg_auto_rule`. Политика `tenant_isolation` +и GRANT (`SELECT, INSERT, UPDATE` для `crm_app_user`) заданы миграцией `_create_client_tg_auto_rule` (v9.18). +Табличный GRANT в PostgreSQL распространяется на новые колонки — ре-GRANT не нужен. Новых политик/GRANT-ов нет. + +**rls-reviewer:** прогнан на миграции — чисто, RLS-рисков нет (только колонки данных на tenant-скоуп-таблице, tenant_id и политика без изменений). + +--- + +## v9.22 (2026-07-28) — Telegram-модуль, Этап 3.6: документ модератору для пересдачи (client_tg_campaigns.moderator_file_path) + +Пересдача отклонённой модерацией кампании (Этап 3, задача 3.6 плана +`docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). + +- **client_tg_campaigns.moderator_file_path** — новая колонка `varchar(500) NULL` (после `media_path`): + путь к документу (лицензия/договор/иной), который клиент прикладывает при ПЕРЕСДАЧЕ отклонённой + кампании. Робот грузит его в поле «Комментарий для модератора» на шаге «Сообщение» кабинета МТС. + Как и `media_path`, это сырой passthrough роботу (`moderatorFile` в task.json). NULL — документ не + прикладывали. Миграция + `app/database/migrations/2026_07_28_000013_add_moderator_file_path_to_client_tg_campaigns.php`. + +**RLS:** не меняется — колонка на существующей таблице `client_tg_campaigns`. Политика `tenant_isolation` +задана миграцией `_create_client_tg_campaigns` (v9.18); GRANT-ы покрыты для таблицы (`crm_app_user` — в той +же миграции, остальные 4 роли — глобально `db/02_grants.sql` + `03_service_bypass_policies.sql`). Табличный +GRANT в PostgreSQL распространяется и на новые колонки — ре-GRANT не нужен. Новых политик/GRANT-ов нет. + +**rls-reviewer:** прогнан на миграции — чисто, 6/6 применимых пунктов, RLS-рисков нет (только nullable-колонка данных, не tenant-скоуп). + +--- + +## v9.21 (2026-07-28) — Telegram-модуль, Этап 3.1: id кампании МТС (client_tg_campaigns.mts_campaign_id) + +Раннее и надёжное сохранение id кампании МТС (Этап 3, задача 3.1 плана +`docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). + +- **client_tg_campaigns.mts_campaign_id** — новая колонка `varchar(32) NULL` (после `status_reason`): + числовой id кампании в кабинете МТС, хранимый строкой. Кабинет отдаёт его в URL уже на шаге + аудитории (черновик реально создан), поэтому робот сохраняет id рано и даже при последующем + падении. Джоб пишет колонку при ЛЮБОМ исходе (успех/отказ), чтобы опросчик вердикта модерации и + уборщик денег (следующие задачи Этапа 3) нашли реально ушедшую кампанию. NULL — робот ещё не + создал черновик. Миграция + `app/database/migrations/2026_07_28_000012_add_mts_campaign_id_to_client_tg_campaigns.php`. + +**RLS:** не меняется — колонка на существующей таблице `client_tg_campaigns`; политика `tenant_isolation` +и GRANT-ы 5 ролям заданы миграцией `_create_client_tg_campaigns` (v9.18). Новых политик/GRANT-ов нет. + +**rls-reviewer:** не требуется (нет изменения политик/ролей/tenant-колонок — только nullable-колонка данных). + +--- + +## v9.20 (2026-07-27) — Telegram-модуль, Сессия 5.2: причина отказа (client_tg_campaigns.status_reason) + +Обратная связь по отказу (Сессия 5, задача 5.2 плана +`docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md`). + +- **client_tg_campaigns.status_reason** — новая колонка `varchar(500) NULL` (после `status`): свободный + текст причины терминального статуса — отказ модерации МТС (regulated-тема, недоступный канал) или + ошибка запуска робота. Ранее причина писалась только в журнал (`RunTelegramCampaignJob`) — теперь + хранится, чтобы показать клиенту в кабинете и в in-app уведомлении. Миграция + `app/database/migrations/2026_07_27_000011_add_status_reason_to_client_tg_campaigns.php`. + +**RLS:** не меняется — колонка на существующей таблице `client_tg_campaigns`; политика `tenant_isolation` +и GRANT-ы 5 ролям заданы миграцией `_create_client_tg_campaigns` (v9.18). Новых политик/GRANT-ов нет. + +**rls-reviewer:** не требуется (нет изменения политик/ролей/tenant-колонок — только nullable-колонка данных). + +--- + +## v9.19 (2026-07-27) — Telegram-модуль, Сессия 4: авто-режим + своё имя (client_tg_auto_rule, client_tg_senders) + +Продолжение клиентского модуля Telegram-рекламы (Сессия 4 плана +`docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md`), задачи 4.1 (авто-режим) и 4.3 (своё имя). + +- **client_tg_auto_rule** — правило авто-режима (одно на тенанта, `UNIQUE tenant_id`): `enabled`, + `ad_text`, `ad_link`, `ord_category`, `budget_cap_rub` (лимит на пачку-объявление), `updated_by`. + Миграция `app/database/migrations/2026_07_27_000009_create_client_tg_auto_rule.php`. Зеркало + `client_sms_auto_rule`. По нему `TelegramAutoAccumulator` копит новых лидов и формирует авто-кампанию + при наборе пачки (≥367 кандидатов, порог `client_tg.auto_batch_threshold`). +- **client_tg_senders** — заявка на своё имя/бренд (одно на тенанта, `UNIQUE tenant_id`): `name`, + `status` (pending/active/suspended/rejected/cancelled), `monthly_fee_rub`, `note`, `requested_by`, + `approved_at`, `paid_until`, `debt_since`. Миграция `..._000010_create_client_tg_senders.php`. + Лёгкое зеркало `client_sms_senders` (без документов/операторов — согласование в Телеграме уточняется + живой разведкой §8). Помесячная оплата — `ChargeTgNameFeeJob` (долг > grace → `suspended`). + +**RLS:** обе таблицы — `ENABLE`+`FORCE ROW LEVEL SECURITY` + политика `tenant_isolation` через +`current_setting('app.current_tenant_id', true)`. + +- `client_tg_auto_rule` — GRANT `crm_app_user` `SELECT, INSERT, UPDATE` (клиент-редактируемая + конфигурация, пишется накопителем под `crm_app_user`; supplier/admin GRANT-ы не нужны). +- `client_tg_senders` — GRANT `crm_app_user` `SELECT, INSERT, UPDATE`; `crm_supplier_worker` `SELECT` + (помесячный джоб перечисляет активные имена кросс-тенантно, UPDATE делает `crm_app_user` под + tenant-контекстом); `crm_admin_user` `SELECT, UPDATE` (approve/reject). + +**rls-reviewer:** PASS (структура/политика/GRANT консистентны с модулем и СМС-близнецом). + +**🔴 При выкате на кластер** (Сессия 6.3): ПЕРЕзапустить `db/03_service_bypass_policies.sql` для +новых RLS-таблиц Сессии 4 (иначе «тихий ноль» служебным ролям). + +## v9.18 (2026-07-27) — Клиентский модуль «Реклама в Телеграме»: ядро таблиц client_tg_* + +Бэкенд-ядро клиентского модуля Telegram-рекламы «по своей базе» — зеркало `client_sms_*` +(Сессия 1 плана `docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md`). 8 новых таблиц, +миграции `app/database/migrations/2026_07_27_000001..000008_create_client_tg_*.php`: + +- **client_tg_campaigns** — кампания: `ad_text`, `ad_link`, `media_path`, `ord_category`, + `budget_cap_rub` (лимит трат на объявление), `audience_kind` (deals/base/list), `audience_params` (jsonb), + `status`, `planned_count`/`matched_count` (matched заполнит робот в Сессии 2), оценки стоимости, `created_by`. +- **client_tg_campaign_phones** — номера кампании (кандидаты до загрузки в МТС). +- **client_tg_messages** — по-получательские записи (зеркало SMS). +- **client_tg_optouts** — стоп-лист номеров. +- **client_tg_contacts** — своя база контактов клиента. +- **client_tg_templates** — шаблоны объявлений. +- **client_tg_tariffs** — ступенчатый тариф (₽ за показ; посев 5 ступеней 0.45→0.36). +- **client_tg_settings** — настройки (`name_fee_rub_per_month`, `name_debt_grace_days`; посев 1 строка). + +**RLS:** 6 арендатор-таблиц (campaigns/phones/messages/optouts/contacts/templates) — `ENABLE`+`FORCE +ROW LEVEL SECURITY` + политика `tenant_isolation` через `current_setting('app.current_tenant_id', true)`; +GRANT `crm_app_user` (набор прав как у SMS-близнеца). `client_tg_tariffs`/`client_tg_settings` — справочные, +без RLS, guarded-GRANT (`DO $$ IF EXISTS pg_roles $$`): `crm_app_user` SELECT, `crm_admin_user` полный CRUD. +rls-reviewer: **PASS 8/8**. + +**🔴 При выкате на кластер** (Сессия 6.3): ПЕРЕзапустить `db/03_service_bypass_policies.sql` (+ `02_grants.sql`) +— новые RLS-таблицы иначе дадут служебным ролям «тихий ноль». Справочные (tariffs/settings) без RLS — +srv_bypass их намеренно не трогает. + +Схема `schema.sql` этими миграциями **не переконсолидирована** — как и рекламные `ad_*`, таблицы +`client_tg_*` живут только в дельта-миграциях (см. врезку в шапке файла). ## v9.17 — 28.07.2026 — служебная роль получает право ПИСАТЬ в ленту: INSERT + нумератор @@ -643,6 +899,7 @@ v8.99–v9.03. ## v9.02 (2026-07-26) — Реклама «за показы», Часть 4 — адрес сайта клиента и id медийного объявления `ad_campaigns` — добавлены две колонки: + - `landing_url` (`VARCHAR(1024)` nullable) — адрес сайта клиента, куда ведёт баннер по клику; вписывает **клиент** в мастере кампании, уходит в Директ как `Href` медийного объявления `CpmBannerAdBuilderAd`. - `yandex_ad_id` (`BIGINT UNSIGNED` nullable) — id единственного медийного объявления в Директе (в модели diff --git a/db/schema.sql b/db/schema.sql index 65ae86df..58dfb81b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -2747,7 +2747,10 @@ CREATE TABLE balance_transactions ( 'chargeback_writedown', -- Ю-3: списание непокрытой части chargeback в долг тенанта 'chargeback_repayment', -- Ю-3: погашение долга через /billing 'migration', -- Billing v2 Spec A: конвертация balance_leads → balance_rub - 'autopodbor_charge' -- v8.62: списание за прогон автоподбора (AutopodborChargeService; миграция 2026_06_28_110100) + 'autopodbor_charge', -- v8.62: списание за прогон автоподбора (AutopodborChargeService; миграция 2026_06_28_110100) + 'sms_charge', -- списание за СМС-рассылку (SmsChargeService; миграция 2026_07_23_100100) + 'tg_ad_charge', -- списание клиентской сметы Telegram-рекламы (TelegramCampaignChargeService; миграция 2026_07_28_000001) + 'tg_ad_refund' -- возврат сметы Telegram-рекламы при отказе модерации / сбое (миграция 2026_07_28_000001) )), amount_rub DECIMAL(12,2) DEFAULT 0, amount_leads INT DEFAULT 0, diff --git a/docs/Plugin_stack_rules_v1.md b/docs/Plugin_stack_rules_v1.md index 28f4073d..7ef8115d 100644 --- a/docs/Plugin_stack_rules_v1.md +++ b/docs/Plugin_stack_rules_v1.md @@ -1,8 +1,10 @@ -# Plugin Stack Rules — Superpowers + Frontend Design (v3.24) +# Plugin Stack Rules — Superpowers + Frontend Design (v3.25) -**Дата:** 14.06.2026 +**Дата:** 28.07.2026 **Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0–R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16). +**v3.25** — grilling: R10.1 Блок 1 note +**grilling** #90 (вендоренный скил `mattpocock/skills`, MIT, user-level `~/.claude/skills/grilling/SKILL.md` — единственная копия, проектная удалена во избежание задвоения; только Markdown, без кода/хуков/сети → gate-постура не требуется; тело апстрима не модифицируется, надстройка проекта отделена заголовком). Категория **discovery-tooling** — не новая подкатегория, вторая позиция 12-й рядом с #55 discovery-interview; вне R6.0/R6.1/R14. ADR-021 (GR1 разрез с #55 по наличию решения; GR2 ↔ brainstorming #19; GR3 вендоринг). Связано: Tooling Прил. Н v2.26 (§4.63, §0 счётчик 87→88), Pravila v1.45 (§13.2), CLAUDE.md v2.49. + **v3.24** — research-tooling (Perplexity Pack): R10.1 Блок 3 +3 MCP-сервера **perplexity** #87 / **exa** #88 / **firecrawl** #89 (внешняя веб-разведка, READ-ONLY, платные API — ключи только в env, gate read_only `bfc1f575`). Новая 20-я off-phase подкатегория **research-tooling**; R15.6 +research-tooling. ADR-019 (RT1–RT9). User-level/external MCP, не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.25 (§4.60–§4.62 + §0 счётчик 84→87), Pravila v1.44 (§13.2 +абзац), CLAUDE.md v2.47 (§3.3 +#87-89; §0 cross-refs); план `docs/superpowers/plans/2026-06-14-perplexity-pack-plan3-router-registry.md`. **v3.23** — knowledge-graph-tooling: R10.1 Блок 1 note +**graphifyy** #86 (user-level скил `~/.claude/skills/graphify/SKILL.md`, CLI `graphifyy`, строит knowledge graph портала; активация `/graphify `; артефакты `graphify-out*/` gitignored). Новая 19-я off-phase подкатегория **knowledge-graph-tooling** (раздел A12/graph карты). User-level скил, не project-level → вне R6.0/R6.1/R14. R15.6 +knowledge-graph-tooling. ADR-017 (KG1–KG5). Содержательных изменений R0–R16: 0. Связано: Tooling v2.24 (§4.59 + §0 счётчик 83→84), Pravila v1.43 (§13.2 +абзац), CLAUDE.md v2.31 (§3.3 +#86; §0 cross-refs). @@ -477,6 +479,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин **Блок 1 — note (v3.23):** **graphifyy** (Tooling #86) — user-level скил `~/.claude/skills/graphify/SKILL.md` (`/graphify` активация), CLI `graphifyy` (npm, строит knowledge graph из docs+code); **не** project-level и **не** в `enabledPlugins` (user-level — вне `enabledPlugins` проекта). Артефакты `graphify-out*/` в корне — gitignored. Категория **knowledge-graph-tooling** (19-я off-phase подкатегория), вне R6.0/R6.1/R14. ADR-017 (KG1–KG5: границы ↔ context7 #60 / Boost #10 / openapi-mcp #47 / Sentry #34 / adr-kit #36 + mermaid #37). +**Блок 1 — note (v3.25):** **grilling** (Tooling #90) — **вендоренный** сторонний скил из `mattpocock/skills` (MIT, `skills/productivity/grilling`), установлен **user-level** `~/.claude/skills/grilling/SKILL.md`; **не** project-level, **не** через marketplace и **не** в `enabledPlugins` (user-level — вне `enabledPlugins` проекта). Единственная копия: проектная удалена, чтобы скил не задваивался в списке (предупреждение самого апстрима о параллельной установке плагином и файлами). Только Markdown, исполняемого кода/хуков/сети нет — gate-постура не требуется. Тело апстрима **не модифицируется**, надстройка проекта отделена заголовком «Добавлено нами» (протокол `docs/grilling/`, «сначала необратимое», остановка по пустому фронту развилок); обновления апстрима — ручной сверкой (GR3). Как вендоренный — **в ignorePaths** линтеров (паттерн mermaid-skill/CCPM/Data Scientist/marketingskills), а физически лежит вне репозитория, поэтому lefthook его и не видит. Категория **discovery-tooling** (12-я off-phase подкатегория, вторая позиция рядом с #55), вне R6.0/R6.1/R14. ADR-021 (GR1 ↔ discovery-interview #55 по наличию решения; GR2 ↔ brainstorming #19; GR3 вендоринг). + **Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие. #### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`) @@ -923,6 +927,8 @@ R16 — evidence-сбор, не правило выбора. R0–R15 продо ## История версий +- **v3.25 (2026-07-28)** — grilling: R10.1 Блок 1 note +grilling #90 (вендоренный скил `mattpocock/skills` MIT, user-level `~/.claude/skills/grilling/`, допрос по уже имеющемуся решению). Не новая подкатегория — вторая позиция discovery-tooling рядом с #55. ADR-021 (GR1–GR3). Связано: Tooling v2.26, Pravila v1.45, CLAUDE.md v2.49. + - **v3.23 (2026-05-27)** — knowledge-graph-tooling: R10.1 Блок 1 note +graphifyy #86 (user-level скил, CLI `graphifyy`, knowledge graph портала, активация `/graphify`, артефакты `graphify-out*/` gitignored); R15.6 +knowledge-graph-tooling; 19-я off-phase подкатегория; user-level → вне R6.0/R6.1/R14. ADR-017 (KG1–KG5). Содержательных изменений R0–R16: 0. Связано: Tooling v2.24 (§4.59 + §0 83→84), Pravila v1.43 (§13.2), CLAUDE.md v2.31 (§3.3 +#86). - **v3.22 (2026-05-25, cross-ref update)** — §0 cross-ref string Pravila v1.39+→**v1.42+** (Pravila §17.7 «Coverage announcement» добавлена — правило аннотировать каждую non-conversation задачу `coverage: :`). Содержательных изменений R0–R16: 0. Связано: Pravila v1.42, Tooling v2.23, CLAUDE.md v2.28. diff --git a/docs/Pravila_raboty_Claude_v1_1.md b/docs/Pravila_raboty_Claude_v1_1.md index 074c0e14..5a285de9 100644 --- a/docs/Pravila_raboty_Claude_v1_1.md +++ b/docs/Pravila_raboty_Claude_v1_1.md @@ -1,10 +1,12 @@ # Правила работы Claude в проекте «Лидерра» -**Версия:** v1.44 (14.06.2026) -**Дата:** 14.06.2026 +**Версия:** v1.45 (28.07.2026) +**Дата:** 28.07.2026 **Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже. **Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ. +**Что изменилось в v1.45 относительно v1.44:** grilling — **§13.2 +абзац «Off-phase discovery-tooling — вторая позиция»**: #90 `grilling` (вендоренный скил `mattpocock/skills`, MIT, user-level `~/.claude/skills/grilling/SKILL.md`, вне репозитория — единственная копия). Безжалостный допрос по **уже имеющемуся** решению: обход дерева решений, один вопрос за раз, рекомендуемый ответ на каждый, факты ищутся самостоятельно, работа не начинается до подтверждения. Не новая подкатегория — вторая позиция 12-й (discovery-tooling) рядом с #55. Как **вендоренный** скил регистрируется здесь в §13.2, **не** в §12.2 (карта Superpowers-скилов). Границы — ADR-021 (GR1 ↔ #55 discovery-interview по наличию решения; GR2 ↔ #19 brainstorming; GR3 вендоринг). Связка L1: #55 → brainstorming → **#90** → writing-plans → subagent-driven-development. Архитектурных изменений §§1–17: 0. Связано: Tooling v2.26+, PSR_v1 v3.25+, CLAUDE.md v2.49+. + **Что изменилось в v1.43 относительно v1.42:** knowledge-graph-tooling integration — **§13.2 +абзац «Off-phase knowledge-graph-tooling»**: #86 graphifyy (CLI, установка `uv tool install graphifyy`, user-level skill `~/.claude/skills/graphify/SKILL.md` через `/graphify install --platform claude`; активация через `/graphify <команда>` — query / path / explain / update / build; артефакты `graphify-out*/` обязательно в `.gitignore`). 19-я off-phase подкатегория, не UI → вне R6.0/R6.1/R14. Границы — ADR-017 (KG1–KG5: ↔ context7 #60 / Boost #10 / openapi-mcp #47 / Sentry MCP #34 / adr-kit #36 / mermaid #37). Архитектурных изменений §§1–17: 0. Связано: Tooling v2.24+, PSR_v1 v3.23+, CLAUDE.md v2.32+. **Что изменилось в v1.42 относительно v1.41:** LLM-first router overhaul Phase 3 deferred follow-up #1 — **§17.7 «Coverage announcement» добавлен**. Правило: в каждом ответе на non-conversation задачу Claude обязан показать coverage-пометку в формате `coverage: :` рядом с первым tool-вызовом или в начале текста. 6 каналов: `skill:` / `node:` / `chain:` / `hook:` / `agent:` / `direct:`. Observability layer (не enforcement) — фиксирует **намерение** выбора канала, дополняет машинный гейт `tools/router-tool-gate.mjs` который ловит **факт**. Отсутствие пометки на non-conversation эпизоде — сигнал для C5 контролёра в STATUS.md, не блокирует коммит. Граница с routing-тегом §16.7: routing-тег только для `user_directed_method`, coverage-пометка — всегда для non-conversation. Cross-ref: реестр узлов `docs/registry/nodes.yaml`, цепочки `docs/routing-off-phase.md`, парсер `tools/observer-transcript-parser.mjs` (schema v4.4+ — реализация следующим коммитом). Архитектурных изменений §§1–16: 0. Связано: §17.1–17.6 (база §17 из v1.41), §16.4 (missed-activation = симметричный отчёт о пропусках §17), spec `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md`, memory `project_brain_overhaul.md`. @@ -649,6 +651,7 @@ P0 = блокер старта спринта или регуляторного | **v1.33** | **19.05.2026** | Observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` \| `user_directed_method` \| `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из вариантов, предложенных Claude в предыдущем ходе). §16.7 +абзац «Граница `user_chose_from_options`»: routing-gate НЕ блокирует этот kind — выбор из choice-space, сформулированного самим Claude, не навязанный извне метод; routing-тег не обязателен (детектор `tools/observer-choice-detector.mjs` детерминированный). Spec §11 `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`. Связано: CLAUDE.md v2.20. Архитектурных изменений в §§1–15: 0. | | **v1.42** | **25.05.2026** | LLM-first router overhaul Phase 3 deferred follow-up #1: **+§17.7 «Coverage announcement»** — правило аннотировать каждую non-conversation задачу coverage-пометкой `coverage: :` (6 каналов: skill/node/chain/hook/agent/direct). Observability layer (не enforcement) — фиксирует **намерение** выбора канала, дополняет машинный гейт §17.4 который ловит **факт**. Граница с routing-тегом §16.7: routing-тег только для `user_directed_method`, coverage-пометка — всегда для non-conversation. C5 controller фиксирует отсутствие пометки в STATUS.md, не блокирует коммит. Cross-ref: реестр `docs/registry/nodes.yaml`, цепочки `docs/routing-off-phase.md`, парсер `tools/observer-transcript-parser.mjs` (schema v4.4+ — реализация следующим коммитом deferred #2). Связано: spec `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md`, memory `project_brain_overhaul.md`. NB: записи таблицы v1.34–v1.41 не дотянуты предыдущими сессиями (известный дрейф); шапка `«Что изменилось в v1.NN»` авторитетна для этого периода. Архитектурных изменений §§1–16: 0. | | **v1.43** | **27.05.2026** | knowledge-graph-tooling: §13.2 +абзац «Off-phase knowledge-graph-tooling» — формализован узел #86 graphifyy (user-level скил `~/.claude/skills/graphify/SKILL.md`, CLI `graphifyy`, строит knowledge graph портала из docs+code; активация `/graphify `; артефакты `graphify-out*/` gitignored) как девятнадцатая off-phase подкатегория, отдельная от всех предыдущих; источник — user-level скил, не project-level → вне PSR_v1 R6.0/R6.1/R14; границы ADR-017 (KG1–KG5: ↔ context7 #60 / Boost #10 / openapi-mcp #47 / Sentry #34 / adr-kit #36 + mermaid #37). Связано: Tooling Прил.Н v2.24 (§4.59 + §0 счётчик 83→84), PSR_v1 v3.23 (R10.1 Блок 1 note +graphifyy; R15.6 +knowledge-graph-tooling), CLAUDE.md v2.31 (§3.3 +#86; §0 cross-refs). Через прямой Edit — worktree-эксцепшн §5 п.10. Архитектурных изменений в §§1–17 (кроме §13.2): 0. | +| **v1.45** | **28.07.2026** | grilling: §13.2 +абзац «Off-phase discovery-tooling — вторая позиция» — формализован узел #90 `grilling` (вендоренный скил `mattpocock/skills`, MIT, user-level `~/.claude/skills/grilling/SKILL.md` вне репозитория; допрос по УЖЕ имеющемуся решению — дерево решений, один вопрос за раз, рекомендуемый ответ на каждый). Новой подкатегории не заводит — вторая позиция 12-й (discovery-tooling) рядом с #55. Разрез с #55 по **наличию решения** (ADR-021 GR1); GR2 ↔ #19 brainstorming; GR3 вендоринг без модификации тела апстрима. Надстройка проекта: протокол `docs/grilling/` (Решили / Отрезали и почему / Осталось открытым, переживает компакт), «сначала необратимое», остановка по пустому фронту развилок. Связка L1 между brainstorming и writing-plans; классификация `planning` вес 0.8. Связано: Tooling Прил.Н v2.26 (§4.63 + §0 счётчик 87→88), PSR_v1 v3.25, CLAUDE.md v2.49, ADR-021. Архитектурных изменений в §§1–17 (кроме §13.2): 0. | --- @@ -736,6 +739,8 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta **Off-phase discovery-tooling (v1.26, 18.05.2026):** скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/` — как `audit-portal`/`regression`/`process-modeling`/`process-analysis`) — структурированное интервью-discovery до проектирования: режим FEATURE (JTBD-интервью заказчика — вскрывает проблему, отдаёт discovery-brief в `brainstorming`), режим SYSTEM (интервью-ориентация по мета-слою проекта — карта/CLAUDE.md/MEMORY/Открытые_вопросы/Tooling/git log). **Двенадцатая** off-phase подкатегория. Не UI → вне R6.0/R6.1/R14 PSR_v1. Как **проектный** скил (не Superpowers-скил) регистрируется здесь в §13.2, **не** в §12.2 (карта Superpowers-скилов) — триггерится штатным механизмом using-superpowers по `description` (триггер-eval 20/20). Дубль с `process-analysis` #53 исключён разрезом по слою-источнику; границы — ADR-009 (DI1–DI6). Регулируется PSR_v1 R10.1 Блок 1 note (self-authored project-скил). Установлен 18.05.2026 на ветке `worktree-discovery-interview`; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. +**Off-phase discovery-tooling — вторая позиция (v1.45, 28.07.2026):** скил `grilling` (Tooling #90, §4.63; **вендоренный** скил из `mattpocock/skills`, MIT, `skills/productivity/grilling`; установлен user-level `~/.claude/skills/grilling/SKILL.md` — вне репозитория, единственная копия: проектная удалена во избежание задвоения) — безжалостный допрос по **уже имеющемуся** плану/решению: обход дерева решений ветка за веткой, по одному вопросу за раз, **с рекомендуемым ответом на каждый вопрос**, факты ищутся самостоятельно в окружении, работа не начинается до явного подтверждения общего понимания. Новой подкатегории **не заводит** — вторая позиция 12-й (discovery-tooling) рядом с #55. Не UI → вне R6.0/R6.1/R14 PSR_v1. Как **вендоренный** скил регистрируется здесь в §13.2, **не** в §12.2 (карта Superpowers-скилов). Дубль с #55 `discovery-interview` исключён разрезом по **наличию решения**: grilling куёт решение, которое у заказчика уже есть (наводящий рекомендуемый ответ обязателен), discovery-interview вскрывает проблему, когда решения ещё нет (наводящие ответы там запрещены). Надстройка проекта поверх немодифицированного тела апстрима (отделена заголовком «Добавлено нами»): протокол `docs/grilling/ГГГГ-ММ-ДД-<тема>.md` — Решили / Отрезали и почему / Осталось открытым, пишется по ходу и перечитывается после компакта; порядок обхода «сначала необратимое»; критерий остановки — пустой фронт развилок с объявлением вслух; «слушай, не защищай». Связка L1: #55 → `brainstorming` → **#90** → `writing-plans` → `subagent-driven-development`; классификация `planning` вес 0.8 (ниже первичных решателей). Как вендоренный — **в ignorePaths линтеров** (прецедент mermaid-skill/CCPM/Data Scientist, LINT1) и живёт вне репозитория, поэтому в резервную копию проекта не попадает. Границы — ADR-021 (GR1–GR3). Регулируется PSR_v1 R10.1 Блок 1. Установлен 28.07.2026; ADR `docs/adr/ADR-021-grilling-tooling.md`. + **Off-phase authoring-tooling + dev-support (v1.28, 18.05.2026):** 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level — формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин фактически включён без формализации в правилах» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Подкатегория **authoring-tooling** (тринадцатая, создание Claude-артефактов): #56 `skill-creator` (Tooling §4.31; конструктор standalone-скилов), #57 `plugin-dev` (§4.32; конструктор marketplace-плагинов — 8 sub-skills + 3 агента), #58 `hookify` (§4.33; генератор хуков). Подкатегория **dev-support** (четырнадцатая, поддержка/документация Claude-разработки): #59 `claude-code-setup` (§4.34; рекомендатель Claude Code automations, read-only), #60 `context7` (§4.35; актуальная документация библиотек). Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. **hookify** — особое правило: вызов только по явному `/hookify`, перед генерацией хука обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json` (перезапись 6-компонентной economy/skill-discipline архитектуры запрещена — конфликт-аудит HK1; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`). Границы D2–D5 — ADR-010. Регулируется PSR_v1 R10.1 Блок 1. Установлены 18.05.2026 на ветке `feat/anthropic-dev-tooling`; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. **Off-phase finance-tooling (C6+C7, v1.34, 20.05.2026):** Инструменты разделов C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и налоги» карты — #61 `finance` plugin (Tooling §4.36; marketplace `finance@knowledge-work-plugins`, Anthropic Verified, 8 скилов; homed C7, cross-ref C6; РФ-применимость: ✅ reconciliation/variance, ⚠️ US-GAAP-скилы частично, ❌ SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 `billing-audit` (Tooling §4.37; self-authored project-скил `.claude/skills/billing-audit/` — денежные инварианты биллинга C6: сохранение суммы bcmath, идемпотентность, tier-резолюция, дрейф reconcile, charge_source), #63 `ru-tax-accounting` (Tooling §4.38; self-authored project-скил `.claude/skills/ru-tax-accounting/` — РСБУ/НК РФ контекст C7: НДС/УСН, налоговая база, выгрузки бухгалтеру; закрывает РФ-gap US-плагина). Плюс reuse-классификация существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (Boost/Pest/Larastan/Sentry/Redis/PM metrics-review/data-scientist/operations/process-*/context7) — без новых номеров. **Пятнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы billing-audit/ru-tax-accounting **линтуются** (не в ignorePaths, LINT1). Границы — ADR-012 (граница C6↔C7: начисление клиенту vs учёт/налоги компании; FIN1–FIN8). Регулируется PSR_v1 R10.1 Блок 1 (finance plugin) + note (2 self-authored скила). Установлено 20.05.2026 на ветке `worktree-finance-tooling-c6-c7`; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`. diff --git a/docs/Tooling_v8_3.md b/docs/Tooling_v8_3.md index 3916fff3..682e49f7 100644 --- a/docs/Tooling_v8_3.md +++ b/docs/Tooling_v8_3.md @@ -1,10 +1,10 @@ # Приложение Н — Tooling, скиллы и плагины Claude (v8.3) -**Дата:** 27.05.2026 -**Версия:** 2.25 (Прил. Н v2.25 — research-tooling (Perplexity Pack): §4.60 #87 perplexity MCP + §4.61 #88 exa MCP + §4.62 #89 firecrawl MCP — новая **20-я off-phase подкатегория research-tooling** (внешние MCP веб-разведки, READ-ONLY, платные API — ключи только в env); §0 счётчик 84→87 (104→107 total), off-phase row +54→+57; cross-ref Pravila v1.44+ / PSR_v1 v3.24+ / CLAUDE.md v2.47+; ADR-019 (RT1–RT9). Узлы: #87 perplexity (`@perplexity-ai/mcp-server` — ранжированный ответ-с-источниками sonar) + #88 exa (`exa-mcp-server` — семантическое/нейро обнаружение) + #89 firecrawl (`firecrawl-mcp` — глубокое чтение/обход). Gate-постура read_only зафиксирована в коде (commit `bfc1f575`, `tools/mcp-tool-classifier.mjs` +тест); IS9-вет `docs/research/research-vet.md` — все три ПРИНЯТ; связка L17 (research chain). Plan `docs/superpowers/plans/2026-06-14-perplexity-pack-plan3-router-registry.md`. **Прил. Н v2.24 наследие** — knowledge-graph-tooling: §4.59 (#86) graphifyy — 19-я off-phase подкатегория **knowledge-graph-tooling** (skill-based CLI, user-level `~/.claude/skills/graphify/`); §0 счётчик 83→84 (103→104 total), off-phase row +53→+54; cross-ref Pravila v1.43+ / PSR_v1 v3.23+ / CLAUDE.md v2.32+; ADR-017 (KG1–KG5). Граничные правила: KG1 ↔ context7 #60 (внутренний codebase vs внешние SDK-доки), KG2 ↔ Boost #10 (static graph vs runtime queries), KG3 ↔ openapi-mcp #47 (весь проект vs один спек), KG4 ↔ Sentry MCP #34 (структурные связи vs runtime errors), KG5 ↔ adr-kit #36 / mermaid #37 (auto-discovery vs manual authoring). Spike `spike/graphify-2026-05-27`: 3 фазы (docs/ + .claude/ + app/) → ultimate combined граф 6305 узлов / 6753 рёбер / 1009 communities (93% EXTRACTED / 7% INFERRED). Артефакты `graphify-out*/` обязательно gitignored. **Прил. Н v2.23 наследие** — C1 marketing-tooling: §4.49–§4.58 (#74–#83) — 18-я off-phase подкатегория **marketing-tooling** (раздел C1 «Маркетинг и лидогенерация»); §0 счётчик 73→83 (93→103 total), off-phase row +43→+53; cross-ref Pravila v1.42+ / PSR_v1 v3.22+ / CLAUDE.md v2.28+; ADR-015 (MKT1–MKT10). Узлы: #74 marketing plugin (Anthropic, 8 скилов — первичный решатель C1) + #75 marketingskills (вендоренный скил-набор, MIT, 40 скилов — материал, модель UPM) + #76 brand-voice (Anthropic partner Tribe AI, 3 скила — вербальный бренд) + #77 marketing-ru (self-authored project-скил, РФ-специфика C1) + #78 Яндекс.Метрика MCP (`atomkraft/yandex-metrika-mcp`, MIT, READ-ONLY, выбран по IS9-вету из 3 кандидатов) + #79 Яндекс.Директ+Wordstat MCP (`SvechaPVL/yandex-mcp`, MIT — **только Wordstat-инструменты; Direct-мутации НЕ активированы** per IS9) + #80 Telegram MCP (`chigwell/telegram-mcp`, Apache-2.0, выделенный аккаунт) + #81 Postiz (`gitroomhq/postiz-app`, AGPL-3.0, внутренний self-host без модификаций и дистрибуции) + #82 DataForSEO MCP (**DEFERRED** — платный, после Б-1) + #83 Unisender Go MCP (**DEFERRED** — нет готового upstream-сервера, своя обёртка по потребности). IS9-вет выполнен: `docs/security/marketing-vet.md` — все 5 внешних PASS/PASS-with-conditions; VK standalone MCP отброшен (0★, право тратить бюджет). Plan `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`. **Прил. Н v2.22 наследие** — pg_audit #28 + pg_anonymizer #29 **установлены на боевом liderra.ru 22.05.2026** (расширения PostgreSQL фазы 3, ранее недоступные на dev native-Windows): §5.1 attribute-блоки #28/#29 + §6 compliance-таблица + §10.4 шаг 2 → ✅ прод; setup-док `docs/security/pgaudit-anonymizer-setup.md`; cross-ref Pravila v1.38 / PSR_v1 v3.21 / CLAUDE.md v2.26; счётчики без изменений (#28/#29 уже в phase-3). **Прил. Н v2.21 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 **установлены портативно 21.05.2026** (без choco — Go 1.26.3/Temurin JRE 17 zip с проверкой SHA256, всё в `bin/*` gitignored) → статус PENDING INSTALL снят в §4.43/§4.45 (dormant true→false) + §4.48 уточнён; setup-доки `docs/security/zap-setup.md` + `docs/security/ward-setup.md`; счётчики/состав реестра без изменений (73 формализованных / 93 total); cross-ref Pravila v1.38 / PSR_v1 v3.21 / CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`. **v2.20 наследие:** Прил. Н v2.20 — A8 infosec-tooling: §4.43 #68 OWASP ZAP (MCP add-on, глубокая боевая DAST — **PENDING INSTALL**: нет Java) + §4.44 #69 Nuclei (CLI-бинарь `bin/nuclei.exe` v3.8.0, известные уязвимости — УСТАНОВЛЕН+verified; CLI не MCP) + §4.45 #70 Ward (CLI Go-бинарь, безопасность настроек Laravel — ЗАМЕНИЛ Enlightn abandoned/L13, **PENDING INSTALL**: нет Go) + §4.46 #71 pdn-152fz-audit + §4.47 #72 threat-model + §4.48 #73 security-go-live (self-authored project-скилы) — 17-я off-phase подкатегория **infosec-tooling**, раздел A8; §0 счётчик 67→73 (87→93 total), off-phase row +37→+43; cross-ref Pravila v1.37 / PSR_v1 v3.20 / CLAUDE.md v2.24; ADR-014 (IS1–IS9). **v2.19 наследие:** A1 backend-tooling: §4.39 #64 Rector + rector-laravel (Composer dev-dep, авто-рефакторинг/version-upgrade, manual/CI постура — dry-run baseline 16 файлов, не блокирующий) + §4.40 #65 PHP Insights (Composer dev-dep, метрики complexity/architecture, on-demand/CI, SyntaxCheck removed — Windows subprocess-краш) + §4.41 #66 laravel-backend-patterns (self-authored project-скил, backend-конвенции Лидерры) + §4.42 #67 NightOwl (self-hosted runtime-телеметрия — **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ) — 16-я off-phase подкатегория **backend-tooling**, раздел A1; §0 счётчик 63→67 (83→87 total), off-phase row +33→+37; cross-ref Pravila v1.35 / PSR_v1 v3.19 / CLAUDE.md v2.22; ADR-013. **v2.18 наследие:** finance-tooling (C6+C7): §4.36 #61 finance plugin (homed C7, cross-ref C6; US-GAAP частично, SOX not-applicable РФ, warehouse-MCP DEFERRED) + §4.37 #62 billing-audit (C6, self-authored) + §4.38 #63 ru-tax-accounting (C7, self-authored) — 15-я off-phase подкатегория finance-tooling; §0 счётчик 60→63 (80→83 total), off-phase row +30→+33; cross-ref Pravila v1.34 / PSR_v1 v3.18 / CLAUDE.md v2.21; ADR-012. **v2.17 наследие:** applied 9-attribute row template per ADR-011 brain-governance spec §4.1. §0.1 row template +Атрибуты blocks для 58 узлов: §2.4 dump phase-0 (9 nodes #1-9 incl. dormant #1 PG MCP), §3.5 dump phase-1 (9 nodes #10-18 incl. dormant #17 pg_partman), §4.1-§4.4 inline phase-2 (7 nodes #19-23+#24+#30), §5.1 dump phase-3 (5 nodes #25-29), §4.5-§4.17 inline off-phase #31-42 incl. ruflo §4.10 dormant, §4.18-§4.35 inline off-phase #43-60. Структурный реестр для router procedure step 3 (`docs/router-procedure.md` v1.0). Связано: ADR-011, spec/plan brain-governance, Pravila v1.33 / PSR_v1 v3.17 / CLAUDE.md v2.20. **v2.16 наследие:** компакция «мозга» — SYSTEM-аудит 18.05.2026 findings 2/3/6/7: §0 +«КАНОН СЧЁТЧИКОВ» anchor — Прил. Н §0 объявлен единственным источником числовых счётчиков тулчейна, CLAUDE.md / Pravila §13.2 / PSR_v1 R10.1 переведены на пин-ссылку (finding 3 — устранение дрейфа счётчиков); §12 заголовок «вне 35 формализованных позиций» → «вне реестра» — убран stale-счётчик. Счётчики тулчейна без изменений — 60 формализованных + 20 ruflo = 80 total. План `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`. **v2.15 наследие:** ruflo isolation — §4.10 +status block: ruflo отключён от активного потока Claude 18.05.2026 без удаления артефактов; live-связи hooks/MCP/daemon выключены; план реактивации в Pravila §14.9 + memory `feedback_ruflo_isolated.md`; счётчики тулчейна без изменений — 60 формализованных позиций + 20 ruflo plugins = 80 total. Rec2 SYSTEM-аудита 18.05.2026. **v2.14 наследие:** authoring-tooling + dev-support — формализованы 5 Anthropic dev-плагинов уже включённых в `~/.claude/settings.json` user-level: #56 skill-creator / #57 plugin-dev / #58 hookify (новая 13-я off-phase подкатегория authoring-tooling — §4.31/§4.32/§4.33) + #59 claude-code-setup / #60 context7 (новая 14-я off-phase подкатегория dev-support — §4.34/§4.35); §0 счётчик 55→60 (75→80 total), off-phase row +25→+30. hookify HK1 — hard-rule pre-check на коллизию с economy/skill-discipline хуками, закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Конфликт-аудит — ADR-010 (SC1–SC3 / PD1–PD3 / HK1–HK3 / CCS1 / CTX1–CTX2). Связано: PSR_v1 v3.13, Pravila v1.28, CLAUDE.md v2.15; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. **v2.13 наследие:** discovery-tooling — формализован #55 discovery-interview (self-authored project-скил `.claude/skills/discovery-interview/`, два режима — FEATURE интервью заказчика перед фичей + SYSTEM ориентация по мета-слою) как новая 12-я off-phase подкатегория discovery-tooling — §4.30; §0 счётчик 54→55 (74→75 total), off-phase row +24→+25. Триггер-eval 20/20. Конфликт-аудит — ADR-009 DI1–DI6: разрез по слою-источнику с process-analysis #53. Связано: PSR_v1 v3.12, Pravila v1.26, CLAUDE.md v2.13; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. **v2.12 наследие:** C10 business-process — формализованы #51 operations (Claude Code marketplace-плагин `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified — 9 скилов process-doc/process-optimization/change-request/capacity-plan/compliance-tracking/risk-assessment/runbook/status-report/vendor-review, 0 lifecycle-хуков) + #52 process-modeling + #53 process-analysis (self-authored project-скилы `.claude/skills/`, BPMN-моделирование to-be + as-is discovery) + #54 n8n-mcp (`czlonkowski/n8n-mcp`, MIT, workflow-движок — **DEFERRED**, у портала нет n8n) как новая 11-я off-phase подкатегория business-process — §4.26/§4.27/§4.28/§4.29; §0 счётчик 50→54 (70→74 total), off-phase row +20→+24. Конфликт-аудит — OPS1 (process-doc↔mermaid рендер-SoT), OPS5 (operations generic ↔ self-authored stack-grounded), N8N1 (n8n-mcp DEFERRED — n8n не в стеке портала), LINT1 (self-authored скилы линтуются, не в ignorePaths). Связано: PSR_v1 v3.11, Pravila v1.25, CLAUDE.md v2.12; план `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. **v2.11 наследие:** ADR-006 Decision-4 icon-path boundary mirror — §4.20 +UI3: Lucide-иконки канонически через `lucide-vue-next` + Vuetify `IconSet` (CTO-19), raw-SVG `get_icon` Universal Icons MCP — только для не-Lucide коллекций; источник — ADR-006 поправка 17.05.2026. Счётчики без изменений — 50 формализованных позиций / 70 total. Связано: CLAUDE.md v2.11, ADR-006. **v2.10 наследие:** A11 ml-ai-tooling — формализованы #48 promptfoo (npm devDependency `promptfoo` v0.121.11, MIT — CLI-eval LLM-промптов) + #49 Data Scientist skill (вендоренный сторонний скил `.claude/skills/data-scientist/`, классический ML-воркфлоу, код MIT / контент CC BY 4.0) + #50 Jupyter MCP (`datalayer/jupyter-mcp-server`, исполняемые ноутбуки — **DEFERRED**, требует Python ML-окружения) как новая 10-я off-phase подкатегория ml-ai-tooling — §4.23/§4.24/§4.25; §0 счётчик 47→50 (67→70 total), off-phase row +17→+20. Конфликт-аудит — ML1 (promptfoo платные вызовы → только вручную/CI, никогда в хук), ML3 (Data Scientist skill вендорен → lefthook exclude), ML7 (bus-factor → вендоринг). Связано: PSR_v1 v3.10, Pravila v1.24, CLAUDE.md v2.10; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. **v2.9 наследие:** A3 integration-tooling — формализован #47 openapi-mcp-server (`@ivotoby/openapi-mcp-server`, npm, v1.14.0, MIT) как 9-я off-phase подкатегория integration-tooling — §4.22; §0 счётчик 46→47 (66→67 total), off-phase row +16→+17. Связано: PSR_v1 v3.9, Pravila v1.23, CLAUDE.md v2.9; план `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. **v2.8 наследие:** A4 design-tooling — формализованы #44 Figma MCP (remote MCP, extract-only, DEFERRED — нет Figma-аккаунта) + #45 Universal Icons MCP (`mcp-universal-icons`, MIT) + #46 Design plugin (Anthropic Verified) как новая 8-я off-phase подкатегория design-tooling — §4.19/§4.20/§4.21; §0 счётчик 43→46 (63→66 total), off-phase row +13→+16. Связано: PSR_v1 v3.8, Pravila v1.22, CLAUDE.md v2.8; план `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. **v2.7 наследие:** A6-расширение — deptrac: формализован #43 deptrac (Composer dev-dependency `deptrac/deptrac` v4.6.1, BSD-3-Clause) как 4-й инструмент off-phase подкатегории architecture-tooling — архитектурный fitness-гейт направления зависимостей / границ слоёв; врезан как lefthook pre-commit job 10; конфиг `app/deptrac.yaml` (13 слоёв), первый прогон 0 нарушений → baseline-файл не нужен (red-green доказан); §4.18 новый; §0 счётчик 42→43 (62→63 total), off-phase row +12→+13. Связано: PSR_v1 v3.7, Pravila v1.21, CLAUDE.md v2.7; план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`. **v2.6 наследие:** C9 project-management — формализованы #41 CCPM (vendored standalone skill) + #42 product-management (Anthropic-verified marketplace plugin) как новая 7-я off-phase подкатегория «project-management» — §4.16/§4.17; §0 счётчик 40→42 (60→62 total); off-phase row +10→+12. Связано: PSR_v1 v3.6, Pravila v1.20, CLAUDE.md v2.6; план `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`. **v2.5 наследие:** фактическая правка #40 Security Guidance — это **блокирующий** PreToolUse-хук (`sys.exit(2)`), не warn-only: при первом за сессию срабатывании уязвимого паттерна блокирует правку (одноразовый speed-bump, retry проходит). §4.15 переписан, SG1 уточнён, +SG2 — Windows-починка python3-резолва (`python3.exe`-шим). Счётчики не меняются — 40 позиций. Связано: PSR_v1 v3.5, Pravila v1.19, CLAUDE.md v2.5; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. **v2.4 наследие:** D3 audit-security — формализованы #39 Trail of Bits Skills (субсет 8 audit-плагинов, marketplace `trailofbits`, CC-BY-SA-4.0) + #40 Security Guidance (Anthropic PreToolUse-хук) как новая 6-я off-phase подкатегория «audit-security» — §4.14/§4.15; §0 счётчик 38→40 (58→60 total); off-phase row +8→+10. Связано: PSR_v1 v3.4, Pravila v1.18, CLAUDE.md v2.4; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. **v2.3 наследие:** A6 architecture-tooling — формализованы 3 инструмента раздела A6 карты «Архитектура систем»: **#36 adr-kit** (ADR-решения + `adr-judge` gate), **#37 mermaid-skill** (C4-диаграммы), **#38 architecture-patterns** (паттерны) — новые §4.11–4.13, новая пятая off-phase подкатегория «architecture-tooling»; §0 счётчик 35→38 формализованных позиций (55→58 total), §0 table row off-phase +5→+8. Связано: PSR_v1 v3.3, Pravila v1.17, CLAUDE.md v2.3; план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`. **v2.2 наследие:** §4.10 реколлаж — ruflo переописан из «entry-point иерархии» в «advisory/automation-подсистему» (декларация приведена к рантайму: рой idle, 0 задач); заголовок §4.10 + «Архитектурная роль» переписаны; §0 table row + «Категории off-phase tools» + «Назначение» обновлены; §13 +v2.2 entry. Связано: Pravila v1.16, PSR_v1 v3.2, CLAUDE.md v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`. **v2.1 наследие:** §4.10 +абзац «Queen trigger»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), explicit hard-rule Pravila §14, enforcement-хук `tools/ruflo-queen-hook.mjs`. Связано: spec/plan `docs/superpowers/{specs,plans}/2026-05-15-ruflo-queen-trigger-and-delegation*`, Pravila v1.15, CLAUDE.md v2.1, PSR_v1 v3.1. **v2.0 наследие:** Ruflo big-bang — major bump: добавлен **orchestration layer (ruflo)** как четвёртая off-phase подкатегория. §0 +ruflo orchestration row: 35 формализованных позиций + 20 ruflo plugins = 55 total; новая §4.10 «Orchestration layer (ruflo)». Связано: spec/plan 2026-05-15, Pravila v1.14, PSR_v1 v3.0, CLAUDE.md v2.0.) +**Дата:** 28.07.2026 +**Версия:** 2.26 (Прил. Н v2.26 — grilling: §4.63 #90 grilling — вендоренный скил допроса по готовому решению (`mattpocock/skills`, MIT, user-level `~/.claude/skills/grilling/`), вторая позиция 12-й off-phase подкатегории **discovery-tooling** рядом с #55 discovery-interview; новой подкатегории не заводит; §0 счётчик 87→88 (107→108 total), off-phase row +57→+58; cross-ref Pravila v1.45+ / PSR_v1 v3.25+ / CLAUDE.md v2.48+; ADR-021 (GR1–GR3). Разрез GR1 ↔ #55 по **наличию решения**: grilling куёт уже имеющееся решение (рекомендуемый ответ на каждый вопрос обязателен), discovery-interview вскрывает проблему (наводящие ответы запрещены). Надстройка проекта поверх немодифицированного оригинала: протокол `docs/grilling/` (Решили / Отрезали и почему / Осталось открытым, пишется по ходу, перечитывается после компакта), порядок «сначала необратимое», остановка по пустому фронту развилок с объявлением вслух. Врезан в связку L1 между brainstorming и writing-plans; классификация `planning` вес 0.8 (ниже первичных решателей). Без кода/хуков/сети — gate-постура не требуется. **Прил. Н v2.25 наследие** — research-tooling (Perplexity Pack): §4.60 #87 perplexity MCP + §4.61 #88 exa MCP + §4.62 #89 firecrawl MCP — новая **20-я off-phase подкатегория research-tooling** (внешние MCP веб-разведки, READ-ONLY, платные API — ключи только в env); §0 счётчик 84→87 (104→107 total), off-phase row +54→+57; cross-ref Pravila v1.44+ / PSR_v1 v3.24+ / CLAUDE.md v2.47+; ADR-019 (RT1–RT9). Узлы: #87 perplexity (`@perplexity-ai/mcp-server` — ранжированный ответ-с-источниками sonar) + #88 exa (`exa-mcp-server` — семантическое/нейро обнаружение) + #89 firecrawl (`firecrawl-mcp` — глубокое чтение/обход). Gate-постура read_only зафиксирована в коде (commit `bfc1f575`, `tools/mcp-tool-classifier.mjs` +тест); IS9-вет `docs/research/research-vet.md` — все три ПРИНЯТ; связка L17 (research chain). Plan `docs/superpowers/plans/2026-06-14-perplexity-pack-plan3-router-registry.md`. **Прил. Н v2.24 наследие** — knowledge-graph-tooling: §4.59 (#86) graphifyy — 19-я off-phase подкатегория **knowledge-graph-tooling** (skill-based CLI, user-level `~/.claude/skills/graphify/`); §0 счётчик 83→84 (103→104 total), off-phase row +53→+54; cross-ref Pravila v1.43+ / PSR_v1 v3.23+ / CLAUDE.md v2.32+; ADR-017 (KG1–KG5). Граничные правила: KG1 ↔ context7 #60 (внутренний codebase vs внешние SDK-доки), KG2 ↔ Boost #10 (static graph vs runtime queries), KG3 ↔ openapi-mcp #47 (весь проект vs один спек), KG4 ↔ Sentry MCP #34 (структурные связи vs runtime errors), KG5 ↔ adr-kit #36 / mermaid #37 (auto-discovery vs manual authoring). Spike `spike/graphify-2026-05-27`: 3 фазы (docs/ + .claude/ + app/) → ultimate combined граф 6305 узлов / 6753 рёбер / 1009 communities (93% EXTRACTED / 7% INFERRED). Артефакты `graphify-out*/` обязательно gitignored. **Прил. Н v2.23 наследие** — C1 marketing-tooling: §4.49–§4.58 (#74–#83) — 18-я off-phase подкатегория **marketing-tooling** (раздел C1 «Маркетинг и лидогенерация»); §0 счётчик 73→83 (93→103 total), off-phase row +43→+53; cross-ref Pravila v1.42+ / PSR_v1 v3.22+ / CLAUDE.md v2.28+; ADR-015 (MKT1–MKT10). Узлы: #74 marketing plugin (Anthropic, 8 скилов — первичный решатель C1) + #75 marketingskills (вендоренный скил-набор, MIT, 40 скилов — материал, модель UPM) + #76 brand-voice (Anthropic partner Tribe AI, 3 скила — вербальный бренд) + #77 marketing-ru (self-authored project-скил, РФ-специфика C1) + #78 Яндекс.Метрика MCP (`atomkraft/yandex-metrika-mcp`, MIT, READ-ONLY, выбран по IS9-вету из 3 кандидатов) + #79 Яндекс.Директ+Wordstat MCP (`SvechaPVL/yandex-mcp`, MIT — **только Wordstat-инструменты; Direct-мутации НЕ активированы** per IS9) + #80 Telegram MCP (`chigwell/telegram-mcp`, Apache-2.0, выделенный аккаунт) + #81 Postiz (`gitroomhq/postiz-app`, AGPL-3.0, внутренний self-host без модификаций и дистрибуции) + #82 DataForSEO MCP (**DEFERRED** — платный, после Б-1) + #83 Unisender Go MCP (**DEFERRED** — нет готового upstream-сервера, своя обёртка по потребности). IS9-вет выполнен: `docs/security/marketing-vet.md` — все 5 внешних PASS/PASS-with-conditions; VK standalone MCP отброшен (0★, право тратить бюджет). Plan `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`. **Прил. Н v2.22 наследие** — pg_audit #28 + pg_anonymizer #29 **установлены на боевом liderra.ru 22.05.2026** (расширения PostgreSQL фазы 3, ранее недоступные на dev native-Windows): §5.1 attribute-блоки #28/#29 + §6 compliance-таблица + §10.4 шаг 2 → ✅ прод; setup-док `docs/security/pgaudit-anonymizer-setup.md`; cross-ref Pravila v1.38 / PSR_v1 v3.21 / CLAUDE.md v2.26; счётчики без изменений (#28/#29 уже в phase-3). **Прил. Н v2.21 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 **установлены портативно 21.05.2026** (без choco — Go 1.26.3/Temurin JRE 17 zip с проверкой SHA256, всё в `bin/*` gitignored) → статус PENDING INSTALL снят в §4.43/§4.45 (dormant true→false) + §4.48 уточнён; setup-доки `docs/security/zap-setup.md` + `docs/security/ward-setup.md`; счётчики/состав реестра без изменений (73 формализованных / 93 total); cross-ref Pravila v1.38 / PSR_v1 v3.21 / CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`. **v2.20 наследие:** Прил. Н v2.20 — A8 infosec-tooling: §4.43 #68 OWASP ZAP (MCP add-on, глубокая боевая DAST — **PENDING INSTALL**: нет Java) + §4.44 #69 Nuclei (CLI-бинарь `bin/nuclei.exe` v3.8.0, известные уязвимости — УСТАНОВЛЕН+verified; CLI не MCP) + §4.45 #70 Ward (CLI Go-бинарь, безопасность настроек Laravel — ЗАМЕНИЛ Enlightn abandoned/L13, **PENDING INSTALL**: нет Go) + §4.46 #71 pdn-152fz-audit + §4.47 #72 threat-model + §4.48 #73 security-go-live (self-authored project-скилы) — 17-я off-phase подкатегория **infosec-tooling**, раздел A8; §0 счётчик 67→73 (87→93 total), off-phase row +37→+43; cross-ref Pravila v1.37 / PSR_v1 v3.20 / CLAUDE.md v2.24; ADR-014 (IS1–IS9). **v2.19 наследие:** A1 backend-tooling: §4.39 #64 Rector + rector-laravel (Composer dev-dep, авто-рефакторинг/version-upgrade, manual/CI постура — dry-run baseline 16 файлов, не блокирующий) + §4.40 #65 PHP Insights (Composer dev-dep, метрики complexity/architecture, on-demand/CI, SyntaxCheck removed — Windows subprocess-краш) + §4.41 #66 laravel-backend-patterns (self-authored project-скил, backend-конвенции Лидерры) + §4.42 #67 NightOwl (self-hosted runtime-телеметрия — **DEFERRED**: native-Windows нет pcntl/posix, OSS без MCP, hosted 152-ФЗ) — 16-я off-phase подкатегория **backend-tooling**, раздел A1; §0 счётчик 63→67 (83→87 total), off-phase row +33→+37; cross-ref Pravila v1.35 / PSR_v1 v3.19 / CLAUDE.md v2.22; ADR-013. **v2.18 наследие:** finance-tooling (C6+C7): §4.36 #61 finance plugin (homed C7, cross-ref C6; US-GAAP частично, SOX not-applicable РФ, warehouse-MCP DEFERRED) + §4.37 #62 billing-audit (C6, self-authored) + §4.38 #63 ru-tax-accounting (C7, self-authored) — 15-я off-phase подкатегория finance-tooling; §0 счётчик 60→63 (80→83 total), off-phase row +30→+33; cross-ref Pravila v1.34 / PSR_v1 v3.18 / CLAUDE.md v2.21; ADR-012. **v2.17 наследие:** applied 9-attribute row template per ADR-011 brain-governance spec §4.1. §0.1 row template +Атрибуты blocks для 58 узлов: §2.4 dump phase-0 (9 nodes #1-9 incl. dormant #1 PG MCP), §3.5 dump phase-1 (9 nodes #10-18 incl. dormant #17 pg_partman), §4.1-§4.4 inline phase-2 (7 nodes #19-23+#24+#30), §5.1 dump phase-3 (5 nodes #25-29), §4.5-§4.17 inline off-phase #31-42 incl. ruflo §4.10 dormant, §4.18-§4.35 inline off-phase #43-60. Структурный реестр для router procedure step 3 (`docs/router-procedure.md` v1.0). Связано: ADR-011, spec/plan brain-governance, Pravila v1.33 / PSR_v1 v3.17 / CLAUDE.md v2.20. **v2.16 наследие:** компакция «мозга» — SYSTEM-аудит 18.05.2026 findings 2/3/6/7: §0 +«КАНОН СЧЁТЧИКОВ» anchor — Прил. Н §0 объявлен единственным источником числовых счётчиков тулчейна, CLAUDE.md / Pravila §13.2 / PSR_v1 R10.1 переведены на пин-ссылку (finding 3 — устранение дрейфа счётчиков); §12 заголовок «вне 35 формализованных позиций» → «вне реестра» — убран stale-счётчик. Счётчики тулчейна без изменений — 60 формализованных + 20 ruflo = 80 total. План `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`. **v2.15 наследие:** ruflo isolation — §4.10 +status block: ruflo отключён от активного потока Claude 18.05.2026 без удаления артефактов; live-связи hooks/MCP/daemon выключены; план реактивации в Pravila §14.9 + memory `feedback_ruflo_isolated.md`; счётчики тулчейна без изменений — 60 формализованных позиций + 20 ruflo plugins = 80 total. Rec2 SYSTEM-аудита 18.05.2026. **v2.14 наследие:** authoring-tooling + dev-support — формализованы 5 Anthropic dev-плагинов уже включённых в `~/.claude/settings.json` user-level: #56 skill-creator / #57 plugin-dev / #58 hookify (новая 13-я off-phase подкатегория authoring-tooling — §4.31/§4.32/§4.33) + #59 claude-code-setup / #60 context7 (новая 14-я off-phase подкатегория dev-support — §4.34/§4.35); §0 счётчик 55→60 (75→80 total), off-phase row +25→+30. hookify HK1 — hard-rule pre-check на коллизию с economy/skill-discipline хуками, закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Конфликт-аудит — ADR-010 (SC1–SC3 / PD1–PD3 / HK1–HK3 / CCS1 / CTX1–CTX2). Связано: PSR_v1 v3.13, Pravila v1.28, CLAUDE.md v2.15; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. **v2.13 наследие:** discovery-tooling — формализован #55 discovery-interview (self-authored project-скил `.claude/skills/discovery-interview/`, два режима — FEATURE интервью заказчика перед фичей + SYSTEM ориентация по мета-слою) как новая 12-я off-phase подкатегория discovery-tooling — §4.30; §0 счётчик 54→55 (74→75 total), off-phase row +24→+25. Триггер-eval 20/20. Конфликт-аудит — ADR-009 DI1–DI6: разрез по слою-источнику с process-analysis #53. Связано: PSR_v1 v3.12, Pravila v1.26, CLAUDE.md v2.13; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. **v2.12 наследие:** C10 business-process — формализованы #51 operations (Claude Code marketplace-плагин `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified — 9 скилов process-doc/process-optimization/change-request/capacity-plan/compliance-tracking/risk-assessment/runbook/status-report/vendor-review, 0 lifecycle-хуков) + #52 process-modeling + #53 process-analysis (self-authored project-скилы `.claude/skills/`, BPMN-моделирование to-be + as-is discovery) + #54 n8n-mcp (`czlonkowski/n8n-mcp`, MIT, workflow-движок — **DEFERRED**, у портала нет n8n) как новая 11-я off-phase подкатегория business-process — §4.26/§4.27/§4.28/§4.29; §0 счётчик 50→54 (70→74 total), off-phase row +20→+24. Конфликт-аудит — OPS1 (process-doc↔mermaid рендер-SoT), OPS5 (operations generic ↔ self-authored stack-grounded), N8N1 (n8n-mcp DEFERRED — n8n не в стеке портала), LINT1 (self-authored скилы линтуются, не в ignorePaths). Связано: PSR_v1 v3.11, Pravila v1.25, CLAUDE.md v2.12; план `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. **v2.11 наследие:** ADR-006 Decision-4 icon-path boundary mirror — §4.20 +UI3: Lucide-иконки канонически через `lucide-vue-next` + Vuetify `IconSet` (CTO-19), raw-SVG `get_icon` Universal Icons MCP — только для не-Lucide коллекций; источник — ADR-006 поправка 17.05.2026. Счётчики без изменений — 50 формализованных позиций / 70 total. Связано: CLAUDE.md v2.11, ADR-006. **v2.10 наследие:** A11 ml-ai-tooling — формализованы #48 promptfoo (npm devDependency `promptfoo` v0.121.11, MIT — CLI-eval LLM-промптов) + #49 Data Scientist skill (вендоренный сторонний скил `.claude/skills/data-scientist/`, классический ML-воркфлоу, код MIT / контент CC BY 4.0) + #50 Jupyter MCP (`datalayer/jupyter-mcp-server`, исполняемые ноутбуки — **DEFERRED**, требует Python ML-окружения) как новая 10-я off-phase подкатегория ml-ai-tooling — §4.23/§4.24/§4.25; §0 счётчик 47→50 (67→70 total), off-phase row +17→+20. Конфликт-аудит — ML1 (promptfoo платные вызовы → только вручную/CI, никогда в хук), ML3 (Data Scientist skill вендорен → lefthook exclude), ML7 (bus-factor → вендоринг). Связано: PSR_v1 v3.10, Pravila v1.24, CLAUDE.md v2.10; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. **v2.9 наследие:** A3 integration-tooling — формализован #47 openapi-mcp-server (`@ivotoby/openapi-mcp-server`, npm, v1.14.0, MIT) как 9-я off-phase подкатегория integration-tooling — §4.22; §0 счётчик 46→47 (66→67 total), off-phase row +16→+17. Связано: PSR_v1 v3.9, Pravila v1.23, CLAUDE.md v2.9; план `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. **v2.8 наследие:** A4 design-tooling — формализованы #44 Figma MCP (remote MCP, extract-only, DEFERRED — нет Figma-аккаунта) + #45 Universal Icons MCP (`mcp-universal-icons`, MIT) + #46 Design plugin (Anthropic Verified) как новая 8-я off-phase подкатегория design-tooling — §4.19/§4.20/§4.21; §0 счётчик 43→46 (63→66 total), off-phase row +13→+16. Связано: PSR_v1 v3.8, Pravila v1.22, CLAUDE.md v2.8; план `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. **v2.7 наследие:** A6-расширение — deptrac: формализован #43 deptrac (Composer dev-dependency `deptrac/deptrac` v4.6.1, BSD-3-Clause) как 4-й инструмент off-phase подкатегории architecture-tooling — архитектурный fitness-гейт направления зависимостей / границ слоёв; врезан как lefthook pre-commit job 10; конфиг `app/deptrac.yaml` (13 слоёв), первый прогон 0 нарушений → baseline-файл не нужен (red-green доказан); §4.18 новый; §0 счётчик 42→43 (62→63 total), off-phase row +12→+13. Связано: PSR_v1 v3.7, Pravila v1.21, CLAUDE.md v2.7; план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`. **v2.6 наследие:** C9 project-management — формализованы #41 CCPM (vendored standalone skill) + #42 product-management (Anthropic-verified marketplace plugin) как новая 7-я off-phase подкатегория «project-management» — §4.16/§4.17; §0 счётчик 40→42 (60→62 total); off-phase row +10→+12. Связано: PSR_v1 v3.6, Pravila v1.20, CLAUDE.md v2.6; план `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`. **v2.5 наследие:** фактическая правка #40 Security Guidance — это **блокирующий** PreToolUse-хук (`sys.exit(2)`), не warn-only: при первом за сессию срабатывании уязвимого паттерна блокирует правку (одноразовый speed-bump, retry проходит). §4.15 переписан, SG1 уточнён, +SG2 — Windows-починка python3-резолва (`python3.exe`-шим). Счётчики не меняются — 40 позиций. Связано: PSR_v1 v3.5, Pravila v1.19, CLAUDE.md v2.5; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. **v2.4 наследие:** D3 audit-security — формализованы #39 Trail of Bits Skills (субсет 8 audit-плагинов, marketplace `trailofbits`, CC-BY-SA-4.0) + #40 Security Guidance (Anthropic PreToolUse-хук) как новая 6-я off-phase подкатегория «audit-security» — §4.14/§4.15; §0 счётчик 38→40 (58→60 total); off-phase row +8→+10. Связано: PSR_v1 v3.4, Pravila v1.18, CLAUDE.md v2.4; план `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. **v2.3 наследие:** A6 architecture-tooling — формализованы 3 инструмента раздела A6 карты «Архитектура систем»: **#36 adr-kit** (ADR-решения + `adr-judge` gate), **#37 mermaid-skill** (C4-диаграммы), **#38 architecture-patterns** (паттерны) — новые §4.11–4.13, новая пятая off-phase подкатегория «architecture-tooling»; §0 счётчик 35→38 формализованных позиций (55→58 total), §0 table row off-phase +5→+8. Связано: PSR_v1 v3.3, Pravila v1.17, CLAUDE.md v2.3; план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`. **v2.2 наследие:** §4.10 реколлаж — ruflo переописан из «entry-point иерархии» в «advisory/automation-подсистему» (декларация приведена к рантайму: рой idle, 0 задач); заголовок §4.10 + «Архитектурная роль» переписаны; §0 table row + «Категории off-phase tools» + «Назначение» обновлены; §13 +v2.2 entry. Связано: Pravila v1.16, PSR_v1 v3.2, CLAUDE.md v2.2; spec `docs/superpowers/specs/2026-05-16-ruflo-hierarchy-factual-recollage-design.md`. **v2.1 наследие:** §4.10 +абзац «Queen trigger»: триггер queen/королева → безусловный route через ruflo Queen (`hive-mind spawn --claude`), explicit hard-rule Pravila §14, enforcement-хук `tools/ruflo-queen-hook.mjs`. Связано: spec/plan `docs/superpowers/{specs,plans}/2026-05-15-ruflo-queen-trigger-and-delegation*`, Pravila v1.15, CLAUDE.md v2.1, PSR_v1 v3.1. **v2.0 наследие:** Ruflo big-bang — major bump: добавлен **orchestration layer (ruflo)** как четвёртая off-phase подкатегория. §0 +ruflo orchestration row: 35 формализованных позиций + 20 ruflo plugins = 55 total; новая §4.10 «Orchestration layer (ruflo)». Связано: spec/plan 2026-05-15, Pravila v1.14, PSR_v1 v3.0, CLAUDE.md v2.0.) **Предыдущая версия:** 1.17 (13.05.2026 day +1 — формализация retrospective двух off-phase MCP debug-инструментов установленных на feat/claude-automation `6f7e7d7` + `bd4ec48` после merge PR #3 в main `cc5f63b`: §0 счётчик off-phase 3 → 5, итого 33 → 35; §4.8 новый — #34 Sentry MCP; §4.9 новый — #35 Redis MCP. Категория debug-runtime, отдельная от UI-пула.) **Адресат:** Claude + разработчики проекта Лидерра -**Назначение:** единый источник истины по 84 формализованным позициям тулчейна + 20 ruflo orchestration plugins = 104 total (29 «активных» номеров фаз + 54 off-phase инструментов-резерв в категориях UI-пул, инфраструктура, debug-runtime, architecture-tooling, audit-security, project-management, design-tooling, integration-tooling, ml-ai-tooling, business-process, discovery-tooling, authoring-tooling, dev-support, finance-tooling, backend-tooling, infosec-tooling, marketing-tooling, knowledge-graph-tooling — UPM, 21st, claude-md-management, Sentry MCP, Redis MCP, adr-kit, mermaid-skill, architecture-patterns, Trail of Bits Skills, Security Guidance, CCPM, product-management, deptrac, Figma MCP, Universal Icons MCP, Design plugin, openapi-mcp-server, promptfoo, Data Scientist skill, Jupyter MCP, operations, process-modeling, process-analysis, n8n-mcp, discovery-interview, skill-creator, plugin-dev, hookify, claude-code-setup, context7, finance plugin, billing-audit, ru-tax-accounting, Rector, PHP Insights, laravel-backend-patterns, NightOwl, OWASP ZAP, Nuclei, Ward, pdn-152fz-audit, threat-model, security-go-live, marketing plugin, marketingskills, brand-voice, marketing-ru, Яндекс.Метрика MCP, Яндекс.Директ+Wordstat MCP, Telegram MCP, Postiz, DataForSEO MCP, Unisender Go MCP, graphifyy; +1 заменённый PG MCP исторически; +ruflo advisory/automation-подсистема — 20 plugins, см. §4.10), скиллам Claude Code, MCP-серверам и плагинам, используемым в проекте. Зафиксирован выбор, объяснено, что заменяет что, и в какой фазе вводится каждый инструмент. +**Назначение:** единый источник истины по 88 формализованным позициям тулчейна + 20 ruflo orchestration plugins = 108 total (29 «активных» номеров фаз + 58 off-phase инструментов-резерв в категориях UI-пул, инфраструктура, debug-runtime, architecture-tooling, audit-security, project-management, design-tooling, integration-tooling, ml-ai-tooling, business-process, discovery-tooling, authoring-tooling, dev-support, finance-tooling, backend-tooling, infosec-tooling, marketing-tooling, knowledge-graph-tooling, research-tooling — UPM, 21st, claude-md-management, Sentry MCP, Redis MCP, adr-kit, mermaid-skill, architecture-patterns, Trail of Bits Skills, Security Guidance, CCPM, product-management, deptrac, Figma MCP, Universal Icons MCP, Design plugin, openapi-mcp-server, promptfoo, Data Scientist skill, Jupyter MCP, operations, process-modeling, process-analysis, n8n-mcp, discovery-interview, skill-creator, plugin-dev, hookify, claude-code-setup, context7, finance plugin, billing-audit, ru-tax-accounting, Rector, PHP Insights, laravel-backend-patterns, NightOwl, OWASP ZAP, Nuclei, Ward, pdn-152fz-audit, threat-model, security-go-live, marketing plugin, marketingskills, brand-voice, marketing-ru, Яндекс.Метрика MCP, Яндекс.Директ+Wordstat MCP, Telegram MCP, Postiz, DataForSEO MCP, Unisender Go MCP, graphifyy, perplexity MCP, exa MCP, firecrawl MCP, grilling; +1 заменённый PG MCP исторически; +ruflo advisory/automation-подсистема — 20 plugins, см. §4.10), скиллам Claude Code, MCP-серверам и плагинам, используемым в проекте. Зафиксирован выбор, объяснено, что заменяет что, и в какой фазе вводится каждый инструмент. > **Связано:** > @@ -81,10 +81,10 @@ | **1 — старт Laravel** | `composer create-project laravel/laravel` | **17** | +9 новых, −1 заменённый (PostgreSQL MCP → Laravel Boost) | | **2 — старт frontend** | первый коммит в `resources/js/` (Vue 3 + Vuetify 3) | **24** | +7 (включая #30 Frontend Design plugin, добавлен post-MVP в v1.10) | | **3 — pre-production** | ~спринт 12, перед публичным релизом | **29** | +5 | -| **off-phase tools** | по факту включения в `~/.claude/settings.json` / `~/.claude.json` / `.mcp.json` / `.claude/skills/` / `composer.json` / `package.json` | **+57** | #31 UPM (UI-резерв), #32 21st Magic MCP (UI-генератор), #33 claude-md-management (инфраструктура CLAUDE.md edits), #34 Sentry MCP (debug self-hosted Sentry в Yandex Cloud), #35 Redis MCP (debug Memurai/Redis runtime), #36 adr-kit (ADR-решения, architecture-tooling), #37 mermaid-skill (C4-диаграммы), #38 architecture-patterns (паттерны), #39 Trail of Bits Skills (8 audit-плагинов, audit-security), #40 Security Guidance (inline security warn-hook), #41 CCPM (PRD→эпик→issue→код трассируемость, project-management), #42 product-management (product-strategy церемонии, project-management), #43 deptrac (архитектурный fitness-гейт, architecture-tooling), #44 Figma MCP (извлечение дизайн-токенов, design-tooling, DEFERRED), #45 Universal Icons MCP (SVG-иконки, design-tooling), #46 Design plugin (дизайн-критика/UX, design-tooling), #47 openapi-mcp-server (introspection OpenAPI/REST, integration-tooling), #48 promptfoo (CLI-eval LLM-промптов, ml-ai-tooling), #49 Data Scientist skill (классический ML-воркфлоу, ml-ai-tooling), #50 Jupyter MCP (исполняемые ноутбуки, ml-ai-tooling, DEFERRED), #51 operations (документирование/оптимизация/change-mgmt бизнес-процессов, business-process), #52 process-modeling (BPMN-моделирование to-be, business-process), #53 process-analysis (as-is discovery/узкие места, business-process), #54 n8n-mcp (workflow-движок, business-process, DEFERRED), #55 discovery-interview (интервью-discovery FEATURE/SYSTEM, discovery-tooling), #56 skill-creator (конструктор скилов, authoring-tooling), #57 plugin-dev (конструктор плагинов, authoring-tooling), #58 hookify (генератор хуков, authoring-tooling), #59 claude-code-setup (рекомендатель автоматизаций, dev-support), #60 context7 (документация библиотек, dev-support), #61 finance plugin (финансы/бухгалтерия US-GAAP, finance-tooling), #62 billing-audit (денежные инварианты биллинга, finance-tooling), #63 ru-tax-accounting (РСБУ/НК РФ контекст, finance-tooling), #64 Rector (авто-рефакторинг/version-upgrade PHP, backend-tooling), #65 PHP Insights (метрики качества/сложности кода, backend-tooling), #66 laravel-backend-patterns (backend-конвенции Лидерры, backend-tooling), #67 NightOwl (runtime-телеметрия self-hosted, backend-tooling, DEFERRED), #68 OWASP ZAP (глубокая боевая DAST, infosec-tooling, MCP, установлен 21.05), #69 Nuclei (известные уязвимости, infosec-tooling, CLI), #70 Ward (безопасность настроек Laravel, infosec-tooling, CLI, установлен 21.05), #71 pdn-152fz-audit (аудит ПДн/152-ФЗ, infosec-tooling), #72 threat-model (STRIDE угрозы going-public, infosec-tooling), #73 security-go-live (go-live security-gate, infosec-tooling), #74 marketing plugin (первичный решатель C1, marketing-tooling), #75 marketingskills (резерв-библиотека фреймворков, marketing-tooling), #76 brand-voice (вербальный бренд, marketing-tooling), #77 marketing-ru (РФ-специфика маркетинга, marketing-tooling), #78 Яндекс.Метрика MCP (веб-аналитика READ-ONLY, marketing-tooling), #79 Яндекс.Директ+Wordstat MCP (Wordstat only, marketing-tooling), #80 Telegram MCP (постинг/управление каналами, marketing-tooling), #81 Postiz (планировщик соцсетей self-hosted, marketing-tooling), #82 DataForSEO MCP (SEO-данные РФ, marketing-tooling, DEFERRED), #83 Unisender Go MCP (email-рассылки обёртка, marketing-tooling, DEFERRED), #86 graphifyy (knowledge graph портала docs+code, knowledge-graph-tooling), #87 perplexity MCP (веб-разведка/ранжированный ответ sonar, research-tooling), #88 exa MCP (семантическое обнаружение, research-tooling), #89 firecrawl MCP (глубокое чтение/обход, research-tooling) | +| **off-phase tools** | по факту включения в `~/.claude/settings.json` / `~/.claude.json` / `.mcp.json` / `.claude/skills/` / `composer.json` / `package.json` | **+58** | #31 UPM (UI-резерв), #32 21st Magic MCP (UI-генератор), #33 claude-md-management (инфраструктура CLAUDE.md edits), #34 Sentry MCP (debug self-hosted Sentry в Yandex Cloud), #35 Redis MCP (debug Memurai/Redis runtime), #36 adr-kit (ADR-решения, architecture-tooling), #37 mermaid-skill (C4-диаграммы), #38 architecture-patterns (паттерны), #39 Trail of Bits Skills (8 audit-плагинов, audit-security), #40 Security Guidance (inline security warn-hook), #41 CCPM (PRD→эпик→issue→код трассируемость, project-management), #42 product-management (product-strategy церемонии, project-management), #43 deptrac (архитектурный fitness-гейт, architecture-tooling), #44 Figma MCP (извлечение дизайн-токенов, design-tooling, DEFERRED), #45 Universal Icons MCP (SVG-иконки, design-tooling), #46 Design plugin (дизайн-критика/UX, design-tooling), #47 openapi-mcp-server (introspection OpenAPI/REST, integration-tooling), #48 promptfoo (CLI-eval LLM-промптов, ml-ai-tooling), #49 Data Scientist skill (классический ML-воркфлоу, ml-ai-tooling), #50 Jupyter MCP (исполняемые ноутбуки, ml-ai-tooling, DEFERRED), #51 operations (документирование/оптимизация/change-mgmt бизнес-процессов, business-process), #52 process-modeling (BPMN-моделирование to-be, business-process), #53 process-analysis (as-is discovery/узкие места, business-process), #54 n8n-mcp (workflow-движок, business-process, DEFERRED), #55 discovery-interview (интервью-discovery FEATURE/SYSTEM, discovery-tooling), #56 skill-creator (конструктор скилов, authoring-tooling), #57 plugin-dev (конструктор плагинов, authoring-tooling), #58 hookify (генератор хуков, authoring-tooling), #59 claude-code-setup (рекомендатель автоматизаций, dev-support), #60 context7 (документация библиотек, dev-support), #61 finance plugin (финансы/бухгалтерия US-GAAP, finance-tooling), #62 billing-audit (денежные инварианты биллинга, finance-tooling), #63 ru-tax-accounting (РСБУ/НК РФ контекст, finance-tooling), #64 Rector (авто-рефакторинг/version-upgrade PHP, backend-tooling), #65 PHP Insights (метрики качества/сложности кода, backend-tooling), #66 laravel-backend-patterns (backend-конвенции Лидерры, backend-tooling), #67 NightOwl (runtime-телеметрия self-hosted, backend-tooling, DEFERRED), #68 OWASP ZAP (глубокая боевая DAST, infosec-tooling, MCP, установлен 21.05), #69 Nuclei (известные уязвимости, infosec-tooling, CLI), #70 Ward (безопасность настроек Laravel, infosec-tooling, CLI, установлен 21.05), #71 pdn-152fz-audit (аудит ПДн/152-ФЗ, infosec-tooling), #72 threat-model (STRIDE угрозы going-public, infosec-tooling), #73 security-go-live (go-live security-gate, infosec-tooling), #74 marketing plugin (первичный решатель C1, marketing-tooling), #75 marketingskills (резерв-библиотека фреймворков, marketing-tooling), #76 brand-voice (вербальный бренд, marketing-tooling), #77 marketing-ru (РФ-специфика маркетинга, marketing-tooling), #78 Яндекс.Метрика MCP (веб-аналитика READ-ONLY, marketing-tooling), #79 Яндекс.Директ+Wordstat MCP (Wordstat only, marketing-tooling), #80 Telegram MCP (постинг/управление каналами, marketing-tooling), #81 Postiz (планировщик соцсетей self-hosted, marketing-tooling), #82 DataForSEO MCP (SEO-данные РФ, marketing-tooling, DEFERRED), #83 Unisender Go MCP (email-рассылки обёртка, marketing-tooling, DEFERRED), #86 graphifyy (knowledge graph портала docs+code, knowledge-graph-tooling), #87 perplexity MCP (веб-разведка/ранжированный ответ sonar, research-tooling), #88 exa MCP (семантическое обнаружение, research-tooling), #89 firecrawl MCP (глубокое чтение/обход, research-tooling), #90 grilling (допрос по готовому решению, discovery-tooling) | | **ruflo advisory/automation-подсистема** (off-phase, post-MVP 2026-05-15) | `npx ruflo@latest init` + `.mcp.json` ruflo entry | **+20 plugins** | `ruflo` v3.7.0-alpha.38+ + 20 plugins (`@claude-flow/*`, IPFS-registry) — advisory/automation-подсистема; orchestration подкатегория off-phase (см. §4.10) | -**Итого формализованных позиций:** 87 (29 активных по фазам + 57 off-phase + 1 заменённый PG MCP исторически) + 20 ruflo orchestration plugins = **107 total**. Полный перечень — §2–§5 (по фазам) + §4.5/§4.6/§4.7/§4.8/§4.9/§4.11/§4.12/§4.13/§4.14/§4.15/§4.16/§4.17/§4.18/§4.19/§4.20/§4.21/§4.22/§4.23/§4.24/§4.25/§4.26/§4.27/§4.28/§4.29/§4.30/§4.31/§4.32/§4.33/§4.34/§4.35/§4.36/§4.37/§4.38/§4.39/§4.40/§4.41/§4.42/§4.43/§4.44/§4.45/§4.46/§4.47/§4.48/§4.49/§4.50/§4.51/§4.52/§4.53/§4.54/§4.55/§4.56/§4.57/§4.58/§4.59/§4.60/§4.61/§4.62 (off-phase) + §4.10 (ruflo orchestration). Карта «когда что использовать» — §7. Что НЕ ставим и почему — §9. +**Итого формализованных позиций:** 88 (29 активных по фазам + 58 off-phase + 1 заменённый PG MCP исторически) + 20 ruflo orchestration plugins = **108 total**. Полный перечень — §2–§5 (по фазам) + §4.5/§4.6/§4.7/§4.8/§4.9/§4.11/§4.12/§4.13/§4.14/§4.15/§4.16/§4.17/§4.18/§4.19/§4.20/§4.21/§4.22/§4.23/§4.24/§4.25/§4.26/§4.27/§4.28/§4.29/§4.30/§4.31/§4.32/§4.33/§4.34/§4.35/§4.36/§4.37/§4.38/§4.39/§4.40/§4.41/§4.42/§4.43/§4.44/§4.45/§4.46/§4.47/§4.48/§4.49/§4.50/§4.51/§4.52/§4.53/§4.54/§4.55/§4.56/§4.57/§4.58/§4.59/§4.60/§4.61/§4.62/§4.63 (off-phase) + §4.10 (ruflo orchestration). Карта «когда что использовать» — §7. Что НЕ ставим и почему — §9. **КАНОН СЧЁТЧИКОВ.** Числовые счётчики формализованных позиций и off-phase подкатегорий тулчейна каноничны здесь — в Прил. Н §0. CLAUDE.md, Pravila §13.2 и PSR_v1 R10.1 ссылаются сюда формулировкой «реестр и счётчики — Tooling Прил. Н §0» и не дублируют числа. Введено SYSTEM-аудитом 18.05.2026 (finding 3) для устранения класса «арифметический дрейф счётчиков» (ловился аудитами v1.85/v1.86). @@ -457,6 +457,7 @@ Existing prose follows the table. | #87 | perplexity MCP | off-phase | active | | #88 | exa MCP | off-phase | active | | #89 | firecrawl MCP | off-phase | active | +| #90 | grilling | off-phase | active | @@ -1697,6 +1698,22 @@ Existing prose follows the table. **Граница research-tooling (ADR-019, RT1–RT9):** открытый веб (практики/нормы/конкуренты); ≠ context7 #60 (SDK-доки) / openapi #47 (наш REST) / Boost #10 (Laravel) / Sentry #34 + Redis #35 (runtime прод) / graphify #86 (внутренний граф) / GitHub #3 (репо). Внутри пака: perplexity #87 (ответ) → exa #88 (обнаружение) → firecrawl #89 (чтение) — связка L17. Платные API, ключи только в env, без авто-трат (RT8). IS9-вет `docs/research/research-vet.md` — все три ПРИНЯТ. +### 4.63. grilling — допрос по готовому решению (off-phase, discovery-tooling) + +**Атрибуты:** + +| id | name | kind | phase | subcategory | triggers | boundaries | dormant | last-touched | +|---|---|---|---|---|---|---|---|---| +| #90 | grilling | skill | off-phase | discovery-tooling | «погрилль меня», «grill me», «grilling», «обстреляй план», «прожарь идею», «допроси меня», «проверь моё решение вопросами» | ADR-021 (GR1 ↔ discovery-interview #55 / GR2 ↔ superpowers:brainstorming #19 / GR3 вендоринг) | false | 2026-07-28 | + +**Роль:** инструмент **#90**. Вендоренный скил из [mattpocock/skills](https://github.com/mattpocock/skills) (MIT, `skills/productivity/grilling`), установлен user-level — `~/.claude/skills/grilling/SKILL.md`, вне репозитория. Безжалостный допрос по **уже имеющемуся** плану или решению: обход дерева решений ветка за веткой, **по одному вопросу за раз**, к каждому вопросу — свой рекомендуемый ответ (заказчику достаточно подтвердить или поправить). Факты ищет сам в окружении, к заказчику несёт только решения. Не приступает к работе до явного подтверждения общего понимания. + +**Надстройка проекта** (тело оригинала не модифицируется, всё добавленное — отдельным блоком): протокол на диске `docs/grilling/ГГГГ-ММ-ДД-<тема>.md` с тремя разделами — **Решили** / **Отрезали и почему** / **Осталось открытым**, пишется по ходу после каждого подтверждённого ответа и перечитывается после компакта контекста; порядок обхода «сначала необратимое» (деньги и списания, схема БД, что уходит клиенту) — косметика в конец очереди; критерий остановки — **не осталось незаданных развилок**, объявляется вслух (собственное ощущение «вроде всё ясно» основанием не является); «слушай, не защищай» — критику существующего не оправдывать. + +**Категория:** off-phase, discovery-tooling — вторая позиция 12-й off-phase подкатегории (рядом с #55 discovery-interview), новой подкатегории не заводит. Не UI → вне R6.0/R6.1/R14 PSR_v1. PSR_v1 R10.1 Блок 3. Локальный текстовый скил: без кода, хуков, субагентов и сетевых обращений — gate-постура не требуется. + +**Граница discovery-tooling (ADR-021, GR1–GR3):** GR1 ↔ #55 discovery-interview — разрез по **наличию решения**: grilling куёт решение, которое у заказчика **уже есть** (рекомендуемый ответ на каждый вопрос обязателен), discovery-interview вскрывает **проблему**, когда решения ещё нет (наводящие ответы там прямо запрещены). GR2 ↔ #19 superpowers:brainstorming — brainstorming порождает варианты решения, grilling обстреливает выбранный до пустого фронта развилок; связка L1 (`#55` → brainstorming → **#90** → writing-plans → subagent-driven-development). GR3 — вендоринг: апстрим MIT, обновления подтягиваются вручную сверкой с `skills/productivity/grilling/SKILL.md`; надстройка проекта отделена заголовком, чтобы происхождение всегда было различимо. + --- ## 5. Фаза 3 — pre-production (+5 новых, итого 29 активных) @@ -2041,6 +2058,10 @@ Vuetify-тема — `liderraLight` и `liderraDark` — определена в --- +*Прил. Н v2.26 от 28.07.2026 — grilling: §4.63 #90 grilling — вендоренный скил «безжалостного допроса» по УЖЕ имеющемуся плану/решению (`mattpocock/skills`, MIT, `skills/productivity/grilling`), установлен user-level `~/.claude/skills/grilling/` — вне репозитория, единственная копия (проектная удалена во избежание задвоения). Вторая позиция 12-й off-phase подкатегории **discovery-tooling** рядом с #55 discovery-interview; новой подкатегории не заводит. §0 счётчик 87→88 (107→108 total), off-phase row +57→+58. ADR-021 (GR1–GR3): GR1 разрез с #55 по **наличию решения** — grilling куёт готовое решение (рекомендуемый ответ на каждый вопрос обязателен), discovery-interview вскрывает проблему (наводящие ответы запрещены); GR2 ↔ #19 brainstorming (порождение вариантов vs обстрел выбранного); GR3 вендоринг — тело апстрима не модифицируется, надстройка проекта отделена заголовком. Надстройка: протокол `docs/grilling/ГГГГ-ММ-ДД-<тема>.md` (Решили / Отрезали и почему / Осталось открытым; пишется по ходу, перечитывается после компакта — закрывает класс «договорённость сгорела при компакте»), порядок обхода «сначала необратимое», критерий остановки — пустой фронт развилок с объявлением вслух, «слушай, не защищай». Врезан в связку L1 между brainstorming и writing-plans; классификация `planning` вес 0.8 (ниже первичных решателей #19/#41/#42). Без кода, хуков, субагентов и сети — gate-постура не требуется. Попутно снят застарелый рассинхрон шапки «Назначение» (числа 84/104 от v2.24 → 88/108, дописаны research-tooling и #87–#90). Cross-ref Pravila v1.45 / PSR_v1 v3.25 / CLAUDE.md v2.49.* + +--- + *Прил. Н v2.24 от 27.05.2026 — knowledge-graph-tooling: §4.59 #86 graphifyy (CLI `uv tool install graphifyy`, user-level skill `~/.claude/skills/graphify/`, activation `/graphify <команда>`, артефакты `graphify-out*/` gitignored) — 19-я off-phase подкатегория **knowledge-graph-tooling**; §0 счётчик 83→84 (103→104 total), off-phase row +53→+54; «Назначение» 83→84 позиции + +knowledge-graph-tooling + graphifyy. ADR-017 (KG1–KG5). Spike `spike/graphify-2026-05-27`: 3 фазы → combined граф 6305 узлов / 6753 рёбер / 1009 communities (93% EXTRACTED / 7% INFERRED). cross-ref Pravila v1.43+ / PSR_v1 v3.23+ / CLAUDE.md v2.32+.* *Прил. Н v2.23 от 25.05.2026 (cross-ref update) — §0 cross-ref string Pravila v1.41+→**v1.42+** (Pravila §17.7 «Coverage announcement» добавлена). Счётчики #1-#83 без изменений. Связано: Pravila v1.42, CLAUDE.md v2.28, PSR_v1 v3.22.* diff --git a/docs/adr/ADR-021-grilling-tooling.md b/docs/adr/ADR-021-grilling-tooling.md new file mode 100644 index 00000000..5d51baa2 --- /dev/null +++ b/docs/adr/ADR-021-grilling-tooling.md @@ -0,0 +1,123 @@ +# ADR-021: Grilling — допрос по готовому решению (discovery-tooling, #90) + +- **Status:** Accepted +- **Date:** 2026-07-28 +- **Deciders:** Дмитрий + +## Context + +Заказчик 27.07.2026 сформулировал повторяющуюся боль (память +`feedback-priemochnyy-list-i-limit-konteksta`): «фичу доделываешь, но откидываешь +90% функционала, потом пилим косяки, скрытые хрен найдёшь». Механизм боли — +список требований живёт **только в контексте сессии**, проверяется тем, что +контроллер сам же и написал; «скрытые» дефекты = места, где ожидание не было +записано заранее. Компакт контекста — тот же спусковой крючок: пересказ +выживает, мелочи пропадают. + +В экосистеме скилов нашёлся готовый инструмент под эту задачу — **grilling** из +[mattpocock/skills](https://github.com/mattpocock/skills) (MIT, +`skills/productivity/grilling`, 843 байта). Он предписывает: обход дерева +решений ветка за веткой, по одному вопросу за раз, **с рекомендуемым ответом на +каждый вопрос**, факты искать самостоятельно, не приступать к работе до явного +подтверждения общего понимания. Файл `grill-me` в том же репозитории — заглушка, +делегирующая в `grilling`; вся суть в `grilling`. + +Инструмент внешне похож на существующий #55 `discovery-interview` — оба ведут +интервью по одному вопросу за раз. Нужно решение о границе, иначе два +интервьюера будут толкаться (нарушение CLAUDE.md §5 п.6 — «не два инструмента +на одну задачу»). + +Проверка апстрима перед принятием: репозиторий 192k★, активная разработка, +лицензия MIT, скил — чистый текст без кода, хуков, субагентов и сетевых +обращений. Отдельно рассматривался и **отклонён** Understand-Anything +(76,5k★) — дубль к #86 graphify, плюс хуки, впрыскивающие «не спрашивай +подтверждения у пользователя», 7 открытых Windows-заявок и известные проблемы +с не-ASCII путями (у нас путь проекта кириллический). + +## Decision + +Формализовать **grilling** как узел **#90**, вторая позиция 12-й off-phase +подкатегории **discovery-tooling** (новой подкатегории не заводим — +это интервью-инструмент, как и #55). + +Установка — **user-level**, `~/.claude/skills/grilling/SKILL.md`, единственная +копия. Проектная копия сознательно удалена: две копии одного скила дают +задвоение в списке и рассинхрон при правках. + +### GR1 — граница с #55 discovery-interview: разрез по наличию решения + +| | **#90 grilling** | **#55 discovery-interview** | +|---|---|---| +| Вход | у заказчика **уже есть** план/решение | есть только боль, решения нет | +| Метод | обход дерева решений | JTBD, «5 почему», прошлое поведение | +| Рекомендуемый ответ | **обязателен** на каждый вопрос | **запрещён** (правило «не наводи») | +| Артефакт | протокол `docs/grilling/` | discovery-brief / system-snapshot | +| Выход | решение выковано, фронт развилок пуст | проблема вскрыта, brief в brainstorming | + +Правило разведения одной фразой: **grilling куёт решение, которое уже есть; +discovery-interview вскрывает проблему, когда решения ещё нет.** Подсказанный +ответ в первом случае ускоряет (заказчику достаточно подтвердить), во втором — +отравляет (заказчик согласится из вежливости). + +### GR2 — граница с #19 superpowers:brainstorming + +brainstorming **порождает** варианты решения; grilling **обстреливает** +выбранный до пустого фронта развилок. Порядок в связке L1: + +`#55 discovery-interview` → `brainstorming` → `#90 grilling` → `writing-plans` +→ `subagent-driven-development` + +Классификация `planning`, вес **0.8** — ниже первичных решателей #19/#41/#42, +чтобы grilling не перехватывал обычные планировочные задачи. + +### GR3 — вендоринг и надстройка проекта + +Тело апстрима **не модифицируется**. Всё добавленное проектом вынесено ниже +разделителя «Добавлено нами», чтобы происхождение всегда было различимо, а +обновления апстрима подтягивались ручной сверкой. + +Надстройка (мотив — боль из Context): + +1. **Протокол на диске** `docs/grilling/ГГГГ-ММ-ДД-<тема>.md`, три раздела: + **Решили** / **Отрезали и почему** / **Осталось открытым**. Пишется по ходу + после каждого подтверждённого ответа (обрыв сессии не должен съедать + сделанное), перечитывается первым делом после компакта контекста. Раздел + «Отрезали» — это и есть запрошенный заказчиком **видимый список + отрезанного**: обход дерева решений естественно порождает отброшенные ветки, + оригинал их нигде не фиксирует. +2. **Порядок обхода «сначала необратимое»** — деньги и списания, схема БД, что + уходит наружу клиенту; косметика в конец очереди. +3. **Критерий остановки** — не «кажется, понятно», а **пустой фронт развилок**, + объявляемый вслух, чтобы заказчик мог возразить «а про это не спросил». + Уточняет расплывчатое оригинальное «shared understanding». +4. **«Слушай, не защищай»** — заимствовано из #55: критику существующего не + оправдывать. + +Сознательно **не** заимствовано из #55: запрет наводящих вопросов (убил бы +главную механику grilling), JTBD/«5 почему» (метод про вскрытие боли, не про +выбор из вариантов), режим SYSTEM. + +## Consequences + +**Плюсы.** Закрывается класс «договорённость сгорела при компакте» — впервые у +приёмочного листа появляется файловый носитель. Появляется проверяемый критерий +завершённости обсуждения вместо ощущения контроллера. Отброшенные варианты +перестают быть невидимыми. + +**Минусы и риски.** Ещё один интервью-инструмент рядом с #55 — риск путаницы +снят только границей GR1, её нужно соблюдать. Скил живёт вне репозитория +(user-level), поэтому **не попадает в резервную копию проекта** и не +версионируется вместе с ним; при переезде на другую машину его нужно ставить +руками. Обновления апстрима не приезжают автоматически — сверка ручная (GR3). + +**Не сделано намеренно.** Тесты триггеров (как 20/20 для #55) не прогонялись — +скил ручного вызова, авто-инвокация не критична. Отдельный хук/enforcement не +заводился: инструмент рекомендательный, принуждение к нему не предполагается. + +## Связано + +- Реестр: `docs/Tooling_v8_3.md` Прил. Н §4.63 (#90), v2.26 +- Узел: `docs/registry/nodes.yaml` #90, контракт `docs/registry/contracts/grilling.contract.json` +- Routing: `docs/routing-off-phase.md` — связка L1, граница #90 ↔ #55 +- Соседи: ADR-009 (discovery-interview #55), ADR-017 (graphify #86 — отклонённый Understand-Anything был дублем к нему) +- Память: `feedback-priemochnyy-list-i-limit-konteksta` diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 0963f948..f45e1365 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-23T20:04:31.044Z +Last updated: 2026-07-27T15:40:37.661Z | Контролёр | Состояние | Детали | |---|---|---| @@ -39,7 +39,7 @@ Last updated: 2026-07-23T20:04:31.044Z - Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 0 -- Last /brain-retro: 57 day(s) ago +- Last /brain-retro: 61 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -112,9 +112,8 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3576 | MsMpEng | 15.32ч | 0.0ч | -| 9320 | Code | 7.18ч | NaNч | -| 1164 | svchost | 2.82ч | NaNч | +| 3484 | MsMpEng | 2.99ч | 0.0ч | +| 14576 | Code | 1.46ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/registry/contracts/grilling.contract.json b/docs/registry/contracts/grilling.contract.json new file mode 100644 index 00000000..6994e384 --- /dev/null +++ b/docs/registry/contracts/grilling.contract.json @@ -0,0 +1,11 @@ +{ + "skill": "grilling", + "kind": "vendored", + "needs": ["готовый план, решение или идея заказчика, которые надо обстрелять до реализации"], + "produces": ["протокол docs/grilling/ГГГГ-ММ-ДД-<тема>.md: Решили / Отрезали и почему / Осталось открытым"], + "constraints": ["вендорен из mattpocock/skills (MIT), user-level ~/.claude/skills/grilling/; тело оригинала не модифицируется", "ADR-021 GR1 граница с discovery-interview (#55): готовое решение vs невскрытая проблема", "порядок обхода — сначала необратимое (деньги, схема БД, что уходит клиенту)"], + "preview-form": "outline", + "defaults": ["один вопрос за раз с рекомендуемым ответом; факты ищутся самостоятельно, решения — за заказчиком"], + "key-decisions": ["каждая развилка дерева решений — отдельным вопросом заказчику"], + "acceptance-criteria": ["не осталось незаданных развилок, и это объявлено вслух", "заказчик подтвердил общее понимание до начала работы"] +} diff --git a/docs/registry/nodes.yaml b/docs/registry/nodes.yaml index 74eb85e4..6bd2d869 100644 --- a/docs/registry/nodes.yaml +++ b/docs/registry/nodes.yaml @@ -1787,12 +1787,39 @@ nodes: tooling_section: "§4.62 #89" install: "npx -y firecrawl-mcp (в .mcp.json; FIRECRAWL_API_KEY в env)" + - id: "#90" + name: "grilling" + slug: "grilling" + category: "off-phase" + subcategory: "discovery-tooling" + status: "active" + dormancy_reason: null + capabilities: "Скил безжалостного допроса по УЖЕ имеющемуся плану/решению: обход дерева решений ветка за веткой, по одному вопросу за раз, с рекомендуемым ответом на каждый; факты ищет сам, решения оставляет заказчику. Протокол на диске docs/grilling/ — Решили / Отрезали и почему / Осталось открытым. Остановка — только когда не осталось незаданных развилок." + triggers: + - {keyword: "погрилль меня", weight: 1.0} + - {keyword: "grill me", weight: 1.0} + - {keyword: "grilling", weight: 1.0} + - {keyword: "обстреляй план", weight: 1.0} + - {keyword: "прожарь идею", weight: 1.0} + - {keyword: "допроси меня", weight: 1.0} + - {keyword: "проверь моё решение вопросами", weight: 1.0} + - {classification: "planning", weight: 0.8} + boundaries: + - {adr: "ADR-021", role: "GR1 — граница с #55 discovery-interview: grilling куёт решение, которое у заказчика УЖЕ есть (наводящий рекомендуемый ответ обязателен); discovery-interview вскрывает проблему, когда решения ещё нет (наводящие ответы запрещены)"} + - {adr: "ADR-021", role: "GR2 — граница с superpowers:brainstorming: brainstorming порождает варианты решения, grilling обстреливает выбранный до неразрешённых развилок"} + - {adr: "ADR-021", role: "GR3 — вендорен из mattpocock/skills (MIT), user-level ~/.claude/skills/grilling/; тело оригинала не модифицируется, надстройка проекта — отдельным блоком"} + chain_membership: ["L1"] + attributes: + tooling_section: "§4.63 #90" + install: "user-level ~/.claude/skills/grilling/SKILL.md (вендорен вручную из mattpocock/skills, MIT)" + chains: L1: name: "feature discovery & implementation chain" sequence: - "#55" - "superpowers:brainstorming" + - "#90" - "superpowers:writing-plans" - "superpowers:subagent-driven-development" triggers: diff --git a/docs/routing-off-phase.md b/docs/routing-off-phase.md index 2a43a50c..bef66bd4 100644 --- a/docs/routing-off-phase.md +++ b/docs/routing-off-phase.md @@ -48,6 +48,7 @@ | `planning` | #19 Superpowers v5.1.0 | 1 | | `planning` | #41 CCPM | 1 | | `planning` | #42 product-management | 1 | +| `planning` | #90 grilling | 0.8 | | `prod_deploy_imminent` | #85 prod-deploy-validator | 1 | | `refactor` | #11 Laravel Pint | 1 | | `refactor` | #12 Larastan | 1 | @@ -83,7 +84,7 @@ | # | Цепочка | Зачем | |---|---|---| -| L1 | `discovery-interview` (FEATURE) → `brainstorming` → `writing-plans` → `subagent-driven-development` | Полный цикл от боли заказчика до атомарных коммитов. Передаёт discovery-brief в brainstorming без re-asking; план потом исполняется параллельными субагентами. | +| L1 | `discovery-interview` (FEATURE) → `brainstorming` → `grilling` (#90) → `writing-plans` → `subagent-driven-development` | Полный цикл от боли заказчика до атомарных коммитов. Передаёт discovery-brief в brainstorming без re-asking; выбранное решение обстреливается grilling'ом до пустого фронта развилок (протокол `docs/grilling/`); план потом исполняется параллельными субагентами. | | L2 | `discovery-interview` (SYSTEM) + `audit-portal` | Ориентация по состоянию (где мы) + вердикт здоровья портала. Snapshot 18.05 — пример L2 в действии. | | L3 | `process-analysis` (#53) ↔ `process-modeling` (#52) | As-is из кода ↔ to-be BPMN. Парная работа: #53 вскрывает узкое место, #52 моделирует целевую схему. Разрез по слою-источнику — ADR-009. | | L4 | `mermaid-skill` (#37) ← `adr-kit` (#36) / `process-modeling` (#52) / `operations` (#51) | Mermaid рендерит C4 / BPMN / process-doc для трёх потребителей. Single source of truth для диаграмм. | @@ -125,6 +126,7 @@ 5. **Hard-rules (Pravila §12 / §14 / §15)** перевешивают этот routing-аид при коллизии. 6. **Граничные случаи между похожими узлами** — кросс-ссылки в Tooling §4.X (например #53 process-analysis ↔ #55 discovery-interview через слой-источник ADR-009; + #90 grilling ↔ #55 discovery-interview через наличие решения ADR-021 GR1; #39 ToB ↔ #25 Semgrep MCP через scope TB1). --- diff --git a/docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md b/docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md new file mode 100644 index 00000000..1d35fb99 --- /dev/null +++ b/docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md @@ -0,0 +1,83 @@ +# Связка Laravel → робот `mode:'resubmit'` — приёмочный лист + +Ветка `feat/client-telegram-ads`. Хвост «связка» из STATE. Владелец выбрал (28.07): пересдача +**чинит ту же кампанию** через «Исправить» (не создаёт новую). Не пушить. Коммит только по «коммить». + +**Смена поведения (осознанная, подтверждена владельцем).** Раньше (задача 3.6) пересдача очищала +`mts_campaign_id` и гоняла `RunTelegramCampaignJob` — робот создавал в кабинете НОВУЮ кампанию (заново +грузил базу). Теперь пересдача СОХРАНЯЕТ `mts_campaign_id` и гоняет новый `ResubmitTelegramCampaignJob` — +робот заходит в существующую отклонённую кампанию (`mode:'resubmit'`), вносит правки и переотправляет ЕЁ. +Аудиторию заново не грузим (она уже у кампании). Метод пересдачи робота проверен живьём (A2 + сегодня). + +**Без изменений схемы БД** (новых колонок/таблиц нет) → CHANGELOG_schema/rls-reviewer НЕ нужны. +`resubmitted` — поле JSON робота на DTO `RobotResult`, не в БД. + +## Приёмочный лист + +- [ ] **L1. `RobotResult`** — добавить поле `resubmitted` (bool, дефолт false), разбирать из JSON робота + (`{ok, campaignId, resubmitted, stoppedAt}`). Существующие поля не трогаем. + +- [ ] **L2. `TelegramRobotRunner::taskPayload`** — пробрасывать `submitMode` (нужно роботу для + `mode:'resubmit'`; для остальных режимов — null, робот игнорирует). + +- [ ] **L3. `ResubmitTelegramCampaignJob(campaignId, tenantId)`** — новый джоб, зеркалит денежно-статусную + логику `RunTelegramCampaignJob` (RLS через `tenantTx` `SET LOCAL app.current_tenant_id`; `tries=1`): + - Фаза A (tenantTx): взять кампанию lockForUpdate; статус ≠ `queued` → no-op (идемпотентность); + иначе `queued→running`. Аудиторию/файл номеров НЕ трогаем (пересдача правит существующую). + - Робот ВНЕ транзакции: `run(['mode'=>'resubmit','campaignId'=>mts_campaign_id, + 'submitMode'=> sandbox?'draft':'live','adText'=>ad_text,'buttonUrl'=>ad_link, + 'ordCategory'=>ord_category,'moderatorFile'=>moderator_file_path])`. + - Фаза B (tenantTx finalize): `mts_campaign_id` НЕ затираем (та же кампания). + - ok && resubmitted (live) → `moderating` (пере-модерация пошла); чистим status_reason. + - ok && !resubmitted (песочница draft) → `draft_ready`. + - !ok, есть mts_campaign_id → `needs_review` БЕЗ возврата брони (симметрия F5), status_reason=причина. + - !ok, нет mts_campaign_id (край) → `failed` + возврат брони (в бою) + уведомление. + - `failed(Throwable)`: та же осторожная логика, что в `RunTelegramCampaignJob` (running/queued → + есть id → needs_review без release; нет id → failed + release; иначе не трогаем). + +- [ ] **L4. Контроллер `resubmit`** — переключить на новый путь: + - guard: если `mts_campaign_id` пуст → 422 «кампания не заведена в кабинете, пересдать нельзя» + (у отклонённой он всегда есть; защита от края). + - НЕ очищать `mts_campaign_id` (чиним ту же); чистить только `status_reason`. + - dispatch `ResubmitTelegramCampaignJob` вместо `RunTelegramCampaignJob`. + +- [ ] **L5. Тесты** (`DB_DATABASE=liderra_testing`): + - `ResubmitTest.php` (контроллер): `mts_campaign_id` СОХРАНЁН; поставлен `ResubmitTelegramCampaignJob`; + новый кейс — rejected без `mts_campaign_id` → 422. + - `ResubmitJobTest.php` (новый): песочница draft→draft_ready; live ok+resubmitted→moderating (+ проверка + переданных роботу params); отказ робота (есть id)→needs_review без release; идемпотентность (не-queued→no-op). + +## Состояние — ВСЕ ГОТОВЫ (TDD, зелёные) +- [x] L1 — `RobotResult.resubmitted` (bool) + разбор из JSON робота. +- [x] L2 — `TelegramRobotRunner::taskPayload` пробрасывает `submitMode`. +- [x] L3 — `ResubmitTelegramCampaignJob` (RLS tenantTx, F5-денежная логика, `failed()`). Тест `ResubmitJobTest` (5/5). +- [x] L4 — контроллер: guard на пустой `mts_campaign_id` → 422; id СОХРАНЯЕТСЯ; dispatch нового джоба. +- [x] L5 — `ResubmitTest` обновлён (id сохранён, новый джоб, кейс 422 без id) + новый `ResubmitJobTest`. + +## Приёмка (28.07.2026) +Изменённые/новые тесты — `ResubmitJobTest` + `ResubmitTest` вместе **15/15** (51 проверка) на чистой БД; +pint/phpstan(0)/deptrac(0 нарушений) — чисто. Схема БД не менялась. +🪤 Прогон ВСЕГО каталога `tests/Feature/ClientTg/` разом флейкует на環境-квирке RefreshDatabase +(партиции auth_log/activity_log/ad_wallets + `pgsql_supplier` — таблицы «пропадают» посреди набора, +бьёт ЧУЖИЕ тесты вроде WalletReFreezeTest, воспроизводится независимо от этой фичи; ранний чистый прогон +был 202/202). Мои файлы стабильно зелёные по отдельности и вдвоём. Живой прогон всей связки (очередь → +робот) — только с go-live (песочница выкл); сам робот-режим `mode:'resubmit'` уже проверен живьём сегодня. +НЕ закоммичено (ждём «коммить»). + +## Код-ревью (субагент, 28.07.2026) — Critical нет; исправлено +- **I-1 (Important, ИСПРАВЛЕНО).** `failed()` был скопирован из `RunTelegramCampaignJob`, где негласно + «queued ⇒ нет mts_campaign_id». У пересдачи queued ВСЕГДА с id → при перманентном сбое в статусе + `queued` срабатывала ветка hasMtsId→`queued→needs_review` (перехода НЕТ в TRANSITIONS) → кампания + зависала в `queued` с замороженной бронью, уборщик её не метёт. Фикс: в `failed()` для `queued` → + `failed` + release (робот кабинет не трогал — Фаза A не закоммитила running); `running` — прежняя F5. + Тесты добавлены (queued→failed+возврат брони; running→needs_review+бронь держим). +- **M-1 (Minor, ИСПРАВЛЕНО).** Успех в бою с `resubmitted=false` (аномалия контракта) давал терминальный + `draft_ready` с зависшей бронью. Фикс: live+!resubmitted → `needs_review` (бронь под ручную сверку). +- **M-3 (наблюдение, оставлено).** `needs_review` терминальный: даже «Исправить не открылось» (кабинет не + тронут) держит бронь до ручного разбора — осознанный консерватизм F5, унаследован от эталона. + +## Оставлено на потом (вне объёма, честно) +- 🟡 Путь документа модератору (`moderator_file_path`) передаётся роботу как ОТНОСИТЕЛЬНЫЙ путь диска + 'local' (единообразно с `RunTelegramCampaignJob`). Для боевого прогона робот должен получить + АБСОЛЮТНЫЙ путь (`Storage::disk('local')->path()`) — общий предстартовый долг ОБОИХ джобов перед + go-live (в песочнице/тестах не всплывает: робот замокан / submitMode=draft документ не критичен). diff --git a/docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md b/docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md new file mode 100644 index 00000000..125e14bf --- /dev/null +++ b/docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md @@ -0,0 +1,88 @@ +# Робот-режим пересдачи (`mode:'resubmit'`) — приёмочный лист + состояние + +Ветка `feat/client-telegram-ads`. Хвост №1 из STATE `2026-07-28-telegram-review-fixes-STATE.md`. +Владелец выбрал «Робот: режим пересдачи» (28.07.2026). Не пушить. Коммит только по «коммить». + +**Что это.** Робот берёт ОТКЛОНЁННУЮ кампанию, входит в её редактор через «Исправить», +прикладывает документ модератору и повторно отправляет на модерацию **без оплаты** (0 ₽). +Полный цикл уже проверен живьём (A2, кампания 2231134); здесь — оформление в боевой режим +робота `mode:'resubmit'` (было: одноразовый скрипт). + +**Опора на живьём-проверенные селекторы (A2):** +- вход: «Исправить» в строке кампании → редактор открывается на `/telegram-a2p/{id}/message`; +- документ: `input[type="file"][accept*="pdf"]` (поле «Комментарий для модератора») — уже в `fillAd`; +- отправка без денег: кнопка `/отправить на модерацию без оплаты/i` на `/payment` (0 ₽, баланс не тронут). + +**Метод:** TDD на ЧИСТЫХ функциях (как весь робот-набор). DOM-функции (вход «Исправить», +отправка «без оплаты») юнит-тестами НЕ покрываются — их правда только живьём (проверено A2), +но денежная безопасность вынесена в чистый предикат и покрыта тестом. + +## Приёмочный лист + +- [x] **R1. `parseResubmitTask(raw)`** (task.js) — принимает `{mode:'resubmit', campaignId, + submitMode:'draft'|'live', + правки}`. + - требует `campaignId` (какую чинить) и `submitMode` ∈ {draft, live} (без дефолта — чтобы live + не случился случайно); + - правки на выбор: `moderatorFile` (документ модератору) и/или `adText`/`buttonUrl`/`ordCategory` + (правка текста/ссылки/ОРД); нужна ХОТЬ ОДНА — иначе тот же контент снова отклонят; + - НЕ требует phonesFile/budgetRub (аудитория и стоимость у кампании уже есть). + - Приёмка: валидное задание проходит; без campaignId/submitMode/без единой правки — понятная + ошибка; базовые `parseTask` (draft/live/read-status) не затронуты. Тест `resubmit-task.test.js` (10/10). + - ➕ РАСШИРЕНО (по просьбе владельца 28.07): вернули правки текста/ссылки/ОРД (были заужены). + DOM: `fillAd` разбит на хелперы `fillAdText`/`fillAdLink`/`selectOrdCategory`/`fillAdMedia`, + пересдача переиспользует их через `editResubmitFields` (правит только заданные поля). + +- [x] **R2. `NO_PAYMENT_SUBMIT_RE` + `isNoPaymentSubmitText(text)`** (cabinet.js) — чистый предикат + «это кнопка бесплатной отправки на модерацию» (true для «Отправить на модерацию без оплаты»), + и он НЕ пересекается с денежными («Списать…», «Оплатить»). Приёмка: без-оплаты → true; + «Списать с баланса…» → false; «Оплатить» → false; пустое/не строка → false. Сверка с + `isForbiddenButtonText`: без-оплаты не запрещена, денежные — запрещены. Тест `cabinet-guards.test.js` (+6). + +- [x] **R3. `openResubmitEditor(page, config, id)`** (cabinet.js) — список → нативный клик «Исправить» + в СТРОКЕ кампании `id` (scope по `campaignHrefRe`, как чтение статуса) → ждёт URL + `/telegram-a2p/{id}/message` + поле текста. Ряд/кнопка не найдены или не тот URL → падаем громко. + (DOM, ✅ ПОДТВЕРЖДЕНО A2.) + +- [x] **R4. `submitWithoutPayment(page, config)`** (cabinet.js) — на `/payment` нативный клик + ТОЛЬКО кнопки `NO_PAYMENT_SUBMIT_RE`; если совпала денежная — падаем (двойная защита через + `isForbiddenButtonText`). Убеждаемся, что ДО клика мы на `/payment`; после — ушли с `/payment` + (кампания на модерации). Кнопки без-оплаты нет/disabled → падаем громко. (DOM, ✅ ПОДТВЕРЖДЕНО A2.) + +- [x] **R5. `runResubmit(config, task, {timestamp})`** (runner.js) — браузер → логин → + `openResubmitEditor` → `attachModeratorDoc` (общий с fillAd, DRY) → `submitAd` → `submitBudget` → + `finalize(submitMode)`; при `submitMode:'live'` затем `submitWithoutPayment`. Гарантированное + закрытие браузера в finally. ПДн НЕ грузятся (номера уже в кампании) — чистить нечего. + - `draft`: доходим до `/confirmation`, НЕ отправляем → `{ok:true, resubmitted:false, stoppedAt:'confirmation'}`. + - `live`: доходим до `/payment`, жмём «без оплаты» → `{ok:true, resubmitted:true, campaignId}`. + - ошибка → `{ok:false, step, reason, campaignId}`. + +- [x] **R6. bin/run.js** — ветка `rawTask.mode === 'resubmit'` ДО `parseTask` (как read-status): + `parseResubmitTask` → `runResubmit` → печать JSON → код выхода `ok?0:1`. + +## Приёмка (28.07.2026) +Робот `npm test` **110/110** (было 94, +16: resubmit-task 10 + cabinet-guards +6). Модули грузятся +без ошибок; stdout не засоряется (JSON только в bin/run.js). +Пример боевого задания: `{ "mode":"resubmit", "campaignId":"2231134", "submitMode":"live", +"adText":"…", "moderatorFile":"screenshots/licenziya.pdf" }`. + +## ✅ ЖИВАЯ ПРОВЕРКА (28.07.2026) — оба сценария на реальных отклонённых кампаниях +Вход в кабинет жив; отклонённые reject-probe кампании 2231132 и 2231134 (несуществующий канал). +- **DRAFT** (2231132, правка текста + документ png): робот вошёл через «Исправить», отредактировал + текст, загрузил документ, дошёл `/message→/budget→/confirmation` и НЕ отправил → + `{ok:true, resubmitted:false, stoppedAt:'confirmation'}`. ✅ +- **LIVE** (2231134, правка текст+ссылка+ОРД, БЕЗ документа): полный цикл до `/payment` → клик + «Отправить на модерацию без оплаты» (0 ₽) → `{ok:true, resubmitted:true}`; повторный `read-status` + подтвердил `moderationStatus:'moderating'`. ✅ (кнопка «без оплаты» = не денежная, баланс не тронут). +Проверены живьём все три пути правок (текст/ссылка/ОРД) + документ + отправка без оплаты. + +## Оставлено на потом (вне объёма) +- Связка Laravel→robot `mode:'resubmit'`: на стороне Laravel `resubmit` контроллера (3.6) есть; + какой RobotResult ждёт джоб пересдачи и как он зовёт робота с mode:resubmit — отдельный шаг. +- Реальный живой прогон нового `runResubmit` — только с разрешения владельца в кабинете + (селекторы доказаны A2 одноразовым скриптом; здесь — тот же код, оформленный в боевой режим). +- Опц. редактирование текста/ссылки/ОРД при пересдаче (ответ на контентный отказ) — YAGNI, позже. + +## Семантика 'live' (важно, не перепутать) +В БАЗОВОЙ кампании (`runTask`) `finalize('live')` СТОИТ на `/payment` (боевая отправка/оплата — go-live D, +Сессия 6). В ПЕРЕСДАЧЕ (`runResubmit`) `live` идёт на шаг дальше и жмёт «без оплаты» (0 ₽) — это и есть +суть пересдачи (доказано A2, денег не тратит). Разные раннеры — расхождение осознанное. diff --git a/docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md b/docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md new file mode 100644 index 00000000..13410e90 --- /dev/null +++ b/docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md @@ -0,0 +1,44 @@ +# Задача 3.6 — Пересдача отклонённой Telegram-кампании: приёмочный лист + +Ветка `feat/client-telegram-ads`. Пишу по TDD. Коммит — в конце сессии (по «коммить»). +Этот файл — список требований; после компакта перечитать ПЕРВЫМ делом и сверять готовность +по нему (а не «по памяти»). + +## Что должно работать (приёмка) + +1. **Endpoint** `POST /api/telegram/campaigns/{id}/resubmit` (middleware `auth:sanctum` + `tenant`). +2. **Только отклонённую** можно пересдать: `status === rejected` → иначе **422** (повторно/не ту не жмём). +3. **Правки объявления**: `ad_text` (обяз., ≤1000), `ad_link` (обяз., url, ≤2048), `ord_category` (опц.). + Валидация как в `store`; пусто → **422** на `ad_text`/`ad_link`. +4. **Файл модератору** (опц.): `moderator_file`, форматы `.png/.jpeg/.jpg/.pdf`, ≤10 МБ. Сохраняется на + диск, путь пишется в новую колонку `client_tg_campaigns.moderator_file_path` (varchar 500 NULL). + Неверный формат → **422**. +5. **Успех**: поля обновлены; `status_reason` очищен (старая причина не висит); `mts_campaign_id` + очищен (старая кампания в кабинете больше не наша — робот создаст новую); **rejected → queued**; + `RunTelegramCampaignJob::dispatch(...)->afterCommit()`. +6. **Деньги**: в бою — `freeze(budget_cap_rub)` заново (при отказе бронь вернул опросчик 3.4; + `freeze` идемпотентен по ACTIVE-холду — освобождённый прежний не мешает). Нехватка → **409**, + статус остаётся `rejected`, джоб НЕ ставится. Песочница — без брони. +7. **Робот получает файл**: `moderator_file_path` → task `moderatorFile`; `cabinet.js` грузит его на + шаге `/message` в поле «Комментарий для модератора» (третий `input[type=file]`, accept pdf/img). +8. **Изоляция тенанта**: чужая/несуществующая кампания → **404**. + +## Что ОТРЕЗАНО (осознанно, не сейчас) + +- Смена аудитории/номеров при пересдаче — оставляем прежнюю выборку. +- Реальный live-прогон загрузки файла в кабинет — под `` (live-путь под флагом песочницы). +- Автоудаление старой отклонённой кампании из кабинета МТС — `rejected` копятся; чистилка 3.1b трогает + только черновики (отдельная задача при желании). +- Клиентская кнопка «Исправить» на экране — это Этап 4 (UX); тут только endpoint + робот. +- Абсолютный путь файла для робота на проде — общий нерешённый вопрос с `media_path` (оба — сырой + passthrough); не решаю здесь, помечено. + +## Файлы + +- Миграция `app/database/migrations/2026_07_28_000013_add_moderator_file_path_to_client_tg_campaigns.php` + + запись `db/CHANGELOG_schema.md` v8.90 + rls-reviewer (колонка на существующей RLS-таблице). +- `Campaign.php` (fillable `moderator_file_path`; `rejected→queued` уже есть с 3.2). +- `CampaignController.php` (`resubmit`), `routes/web.php` (роут). +- `RunTelegramCampaignJob.php` (`moderatorFile` в task), `TelegramRobotRunner.php` (taskPayload), + `bots/mts-telegram-ads/src/{task.js,cabinet.js}` (приём + загрузка файла). +- Тесты: `app/tests/Feature/ClientTg/ResubmitTest.php` + Node-юнит на passthrough `moderatorFile`. diff --git a/docs/superpowers/2026-07-28-telegram-dengi-kusok1-ACCEPT.md b/docs/superpowers/2026-07-28-telegram-dengi-kusok1-ACCEPT.md new file mode 100644 index 00000000..2ebf2a0c --- /dev/null +++ b/docs/superpowers/2026-07-28-telegram-dengi-kusok1-ACCEPT.md @@ -0,0 +1,64 @@ +# Телеграм-деньги, КУСОК 1 (фундамент) — приёмочный лист + +Ветка `feat/client-telegram-ads`. Спека: +`docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md` (порядок из 3 кусков). +Решение владельца (28.07): платить с ОБЩЕГО баланса (как СМС), наценка 40%. Не пушить. Коммит по «коммить». + +**Куски 2 и 3 НЕ трогаются** — поток запуска/отказа кампании (launch/reject/джобы/авто-путь/плата за имя) +пока по-прежнему висит на старом кошельке `AdWalletService` (freeze/release). Кусок 1 — только фундамент: +наценка + сервис списания/возврата с общего баланса + место в БД под новые проводки. Ничего из этого +пока НЕ подключено к боевому потоку (никто ещё не зовёт `TelegramCampaignChargeService`), поэтому поведение +кампаний не изменилось — безопасно. + +## Приёмочный лист (кусок 1 = D1 + D2 + D3 + БД) + +- [x] **D1. Наценка 40%.** Config `client_tg.markup` (`(string) env('TG_MARKUP', '1.40')`). + `TelegramTariffService::clientEstimateRub(int $impressions)` = базовая `estimateRub × markup` (bcmath scale 2). + Приёмка: 500 показов → база 225.00 × 1.40 = **315.00**; 1000 → 588.00; 0 → 0.00; наценка берётся из config + (переопределяемая — при 2.00 даёт 450.00). База (`estimateRub`) не тронута — остаётся себестоимостью-оценкой. + +- [x] **D2. Списание с общего баланса.** Новый `App\Services\ClientTg\TelegramCampaignChargeService::charge(Campaign, string $priceRub)` + — зеркало `SmsChargeService`: замок тенанта (`Tenant::lockForUpdate`) → идемпотентность по строке + `balance_transactions` (type=`tg_ad_charge`, related_type=Campaign, related_id) → нулевая сумма — no-op → + нехватка → `InsufficientBalanceException` (баланс не тронут) → списание `tenants.balance_rub` + + `BalanceTransaction` (`TYPE_TG_AD_CHARGE`, amount отрицательный). Здесь Campaign/Tenant/BalanceTransaction + на одном соединении `pgsql` — замок настоящий (в отличие от СМС на `pgsql_supplier`). + Приёмка: списывает раз; повтор — no-op; нехватка → исключение, баланс цел; ноль — не пишет проводку. + +- [x] **D3. Возврат (refund) на общий баланс.** `::refund(Campaign)`: если есть charge-проводка и ещё нет + refund-проводки — вернуть модуль списанной суммы на баланс + `BalanceTransaction` (`TYPE_TG_AD_REFUND`, + amount положительный), идемпотентно (по наличию refund-строки). Нет списания — ничего не делает. + Приёмка: после charge→refund баланс восстановлен (1000→685→1000); двойной refund не задваивает; refund без + charge — no-op. + +- [x] **БД. Новые типы проводки.** `balance_transactions.type` — CHECK-ограничение, поэтому: + - Миграция `app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php` + добавляет ТРИ значения одним `ALTER … CHECK` (чтобы на прод-кластер ехало один раз): `tg_ad_charge`, + `tg_ad_refund`, `tg_name_fee` (последнее **зарезервировано под кусок 3** — плата за имя). up() включает + уже действующий `sms_charge`; down() возвращает к списку с `sms_charge`. + - `BalanceTransaction`: константы `TYPE_TG_AD_CHARGE` / `TYPE_TG_AD_REFUND` / `TYPE_TG_NAME_FEE`. + - `db/schema.sql` (CREATE TABLE, ~л.2745): CHECK приведён к реальному состоянию БД — **заодно добавлен + пропущенный `sms_charge`** (миграция 2026_07_23 расширяла `ALTER`-ом, но в тело не свернула) + три новых. + - `db/CHANGELOG_schema.md`: запись **v8.94** (номер предварительный, при сведении с main перенумеруется). + +## Приёмка (28.07.2026) + +- Тесты (`DB_DATABASE=liderra_testing`, по отдельности — вместе флейкует на квирке партиций/типов): + - `tests/Feature/ClientTg/CampaignChargeServiceTest.php` (новый) — **5/5** (12 проверок): charge раз+идемпотентность, + нехватка→исключение+баланс цел, ноль→no-op, refund восстанавливает+идемпотентность, refund без charge→no-op. + - `tests/Feature/ClientTg/TariffServiceTest.php` (+2 кейса) — **4/4** (18 проверок): наценка 1.40 и переопределение. +- `pint` — чисто; `phpstan` — 0 ошибок (убран мёртвый null-guard: `client_tg_campaigns.tenant_id` NOT NULL, + в отличие от nullable у СМС-модели); `deptrac` — 0 нарушений. +- Миграция применена на `liderra_testing` и на локальную dev-БД `liderra` (не прод-кластер). +- **rls-reviewer** — прогнан по миграции (CHECK-only, tenant-изоляция не затронута). Вердикт — см. ниже. + +🪤 **Готча окружения (подтвердилась снова):** прогон ДВУХ ClientTg-файлов ОДНОЙ командой уронил тест-базу +(коллизия `pg_type … legal_entities` — пересоздание типов под RefreshDatabase+партиции). Ремонт: +`db:wipe --drop-types --force` ×(до 2 раз) + `migrate --force`. Гонять изменённые файлы по одному. + +## Го-лайв (кусок НЕ здесь — общий долг перед выкатом) +- Миграцию `2026_07_28_000001` прогнать на боевом кластере (как прочие CHECK-миграции). +- Подключение сервиса к потоку (charge при запуске, refund при отказе, снятие AdWalletService) — **кусок 2**. +- Плата за имя на общий баланс (`tg_name_fee`) — **кусок 3**. + +НЕ закоммичено (ждём «коммить»). diff --git a/docs/superpowers/2026-07-28-telegram-dengi-kusok2-ACCEPT.md b/docs/superpowers/2026-07-28-telegram-dengi-kusok2-ACCEPT.md new file mode 100644 index 00000000..d88867d6 --- /dev/null +++ b/docs/superpowers/2026-07-28-telegram-dengi-kusok2-ACCEPT.md @@ -0,0 +1,125 @@ +# Телеграм-деньги, КУСОК 2 — переключить поток с кошелька-заморозки на общий баланс + +Ветка `feat/client-telegram-ads`. Спека D4/D5/D6. Кусок 1 (наценка + charge/refund-сервис + БД) готов, +не закоммичен. Коммитим всё вместе после куска 3 (решение владельца 28.07: «дальше закоммитим все вместе»). +Не пушить. + +## Суть +Заменить в ПОТОКЕ кампании старый рекламный кошелёк `AdWalletService` (freeze/release) на списание/возврат +с ОБЩЕГО баланса (`TelegramCampaignChargeService::charge/refund` из куска 1). Кошелёк и его таблицы НЕ +удаляем — просто телеграм-кампании на них больше не опираются. Плата за ИМЯ отправителя +(`TelegramSenderService`/`ChargeTgNameFeeJob`) — это кусок 3 (D8), здесь НЕ трогаем. + +## 🔑 Ключевое открытие (переопределяет реализацию куска 1) +Кампанию можно **списать → вернуть (отказ модерации) → списать снова (пересдача)**. Значит идемпотентность +«есть хоть одна charge-строка → no-op» из куска 1 сделала бы пересдачу БЕСПЛАТНОЙ. Переходим на +**сальдо-идемпотентность** (непогашенное списание = charges − refunds): +- `charge`: no-op, если `count(tg_ad_charge) > count(tg_ad_refund)` (есть непогашенное списание); иначе списываем. +- `refund`: no-op, если `count(tg_ad_charge) <= count(tg_ad_refund)` (нечего возвращать); иначе возвращаем + сумму ПОСЛЕДНЕГО (непогашенного) списания. +Цикл launch→charge (сальдо +1) → reject→refund (0) → resubmit→charge (+1) считается верно. + +## Сумма списания +Клиентская цена = `estimated_cost_rub` (теперь хранится С наценкой, D4-store). При запуске пересчитываем по +свежему числу кандидатов: `estimated_cost_rub = clientEstimateRub(candidates)` и списываем именно её — +клиент видит и платит одно и то же. `budget_cap_rub` остаётся ЛИМИТОМ трат на объявление (отдаётся роботу/ +МТС), НЕ списывается. + +## Приёмочный лист (файлы) + +- [ ] **S0. Сервис — сальдо-идемпотентность.** `TelegramCampaignChargeService::charge/refund` переписать на + сравнение count(charge) vs count(refund); refund берёт сумму последнего непогашенного charge (orderByDesc id). + Тест `CampaignChargeServiceTest`: + кейс charge→refund→charge (второй charge СПИСЫВАЕТ; итог 2 charge/1 refund). + +- [ ] **D4-store. `CampaignController::store`** — `estimated_cost_rub = clientEstimateRub($candidates)` (с наценкой). + Экран показывает клиентскую цену. Тест: смета в ответе = база×1.4. + +- [ ] **D4-launch. `CampaignController::launch`** — в бою вместо `freeze(budget_cap_rub)`: + пересчитать `estimated_cost_rub = clientEstimateRub(candidates)` (уже строим для гейта) → `charge($campaign, + estimated_cost_rub)`; нехватка → `InsufficientBalanceException` → 409, кампания остаётся draft. Песочница — + без денег. Ловим `InsufficientBalanceException` (ModelNotFoundException больше не нужен). + +- [ ] **D4-resubmit. `CampaignController::resubmit`** — тот же charge вместо freeze (пересдача платится заново; + сальдо-идемпотентность это допускает). + +- [ ] **cancel. `CampaignController::cancel`** — в бою `refund($campaign)` вместо `release`. Из draft (не + списывали) refund — no-op; из queued (списали на launch) — возврат. Убрать зависимость от ModelNotFoundException. + +- [ ] **index. `CampaignController::index`** — баланс для экрана брать из общего баланса тенанта + (`tenants.balance_rub`), а не из `ad_wallets`. `frozen_rub` → '0.00' (заморозки больше нет). Иначе экран + всегда показывал бы 0 и гасил «Запустить». + +- [ ] **D5-run. `RunTelegramCampaignJob`** — оба `release` (finalize failed-без-id; failed() shouldRelease) → + `refund($campaign)`. F5 сохраняется: есть mts_campaign_id → needs_review БЕЗ возврата. Загружать модель + кампании для refund (сейчас передаётся id в release). + +- [ ] **D5-resubmit. `ResubmitTelegramCampaignJob`** — оба `release` → `refund`. F5 сохраняется. + +- [ ] **D5-poll. `PollTelegramModerationJob`** — `release` при `rejected` → `refund($campaign)` (возврат при + отказе модерации). Одобрение → ничего (уже списано). + +- [ ] **D5-sweep. `SweepStuckTelegramCampaignsJob`** — `release` (running-stuck без mts_id → failed) → `refund`. + moderating-stuck → needs_review БЕЗ возврата (как было). ⚠️ обязателен: без freeze старый `release` бросил бы. + +- [ ] **D6. Снять AdWalletService из потока кампании** — убрать импорты/вызовы freeze/release из: + CampaignController, RunTelegramCampaignJob, ResubmitTelegramCampaignJob, PollTelegramModerationJob, + SweepStuckTelegramCampaignsJob. **НЕ трогать** TelegramSenderService/ChargeTgNameFeeJob (кусок 3, D8). + TelegramAutoAccumulator (freeze) — снять freeze здесь; списание авто-пути добавит кусок 3 (D7). Между + кусками 2 и 3 авто-путь временно без списания — закрывается до общего коммита. + +- [ ] **D9(частично). Тесты.** Переписать/обновить wallet-базовые под charge/refund: + ManualFlowTest, LaunchIdempotencyTest, RefundOnFailTest, ConservativeReleaseTest, AutoFreezeTest, + WalletChannelTest (кампанийная часть), RunCampaignJobTest, ResubmitJobTest/ResubmitTest, PollModerationTest, + SweepStuckTest, ModeratingStatusTest — там, где ассертят frozen/balance кошелька, перевести на + tenants.balance_rub + balance_transactions. WalletReFreezeTest/SenderLifecycle (плата за имя) — кусок 3. + Гонять ПО ОТДЕЛЬНОСТИ (квирк партиций). pint/stan/deptrac чисто. + +## Состояние — ВСЕ ГОТОВЫ (28.07.2026, зелёные по отдельности) +- [x] S0 — сальдо-идемпотентность charge/refund; `CampaignChargeServiceTest` **6/6** (+ кейс charge→refund→charge). +- [x] D4-store / D4-launch / D4-resubmit / cancel / index — CampaignController; `CampaignApiTest` **9/9**. +- [x] D5-run — RunTelegramCampaignJob (finalize + failed); `RunCampaignJobTest` 6/6, `RefundOnFailTest` 5/5, + `ConservativeReleaseTest` 5/5. +- [x] D5-resubmit — ResubmitTelegramCampaignJob; `ResubmitJobTest` 7/7, `ResubmitTest` 8/8. +- [x] D5-poll — PollTelegramModerationJob; `PollModerationTest` 6/6, `ModeratingStatusTest`/`ModerationStuckTest` 3/3. +- [x] D5-sweep — SweepStuckTelegramCampaignsJob; `SweepStuckTest` 7/7. +- [x] D6 — AdWalletService снят из CampaignController/Run/Resubmit/Poll/Sweep/AutoAccumulator (кошелёк/таблицы + НЕ удалены; sender/name-fee — кусок 3). +- [x] D7 (авто-путь) **сделан здесь же**, чтобы не оставлять авто-путь без списания между кусками: + TelegramAutoAccumulator списывает клиентскую смету при авто-запуске; `AutoSafeguardTest` 6/6, `AutoFreezeTest` 4/4, + `AudienceGateTest` green. +- [x] D9 — 8 wallet-тестов переписаны под charge/refund (subagent'ы, только тест-файлы; прод-код НЕ тронут — + сверено `git status`). pint/stan(0)/deptrac(0) чисто. + +🪤 **Готча окружения (жёстко подтвердилась):** прогон НЕСКОЛЬКИХ ClientTg-файлов подряд (особенно +`SharesSupplierPdo`-тестов) КОРРУПТИТ тест-базу (Undefined table / пересоздание партиций-типов). Гонять +СТРОГО ПО ОДНОМУ; при `Undefined table` — `db:wipe --drop-types --force` + `migrate --force`, потом файл заново. +Все 8 файлов зелёные на ЧИСТОЙ базе поштучно. + +## Код-ревью (субагент, деньги/F5/сальдо/RLS, 28.07.2026) — Critical НЕТ +Проверено и подтверждено верным: нет двойного списания/возврата (замок тенанта → проверка сальдо; charge и +refund строго чередуются, возврат берёт последнюю непогашенную сумму); реестр не расходится с балансом +(atomically); F5 во всех 4 джобах (refund только в ветке failed без mts_campaign_id); песочница везде; +вложенные транзакции/замки корректны (tenantTx → savepoint charge; auto: замок AutoRule→tenant, без реверса). + +Находки: +- **I-1 (Important) — ИСПРАВЛЕНО.** Кампания, списанная на launch, но чей джоб так и не стартовал (воркер + умер / Redis сброшен), залипала в `queued` НАВСЕГДА с деньгами, снятыми с общего баланса — уборщик смотрел + только `running`. Раньше (кошелёк) была лишь бронь на ad_wallet, теперь — реальный дебет общего баланса + клиента, поэтому радиус хуже. Фикс: `SweepStuckTelegramCampaignsJob::sweepStuckQueued()` — queued старше + `QUEUED_STUCK_AFTER_MINUTES` (60 мин, с запасом, чтобы не гоняться с живым воркером) → `failed` + refund + (queued не имеет mts_campaign_id → робот кабинет не трогал → F5-безопасно). Тесты добавлены (`SweepStuckTest` + 11/11: зависшая queued→failed+возврат; свежая queued не трогается). +- **I-2 (Important) — ОСОЗНАННО НЕ меняем.** Авто-путь: `spent_today_rub` не уменьшается при возврате + отклонённой авто-кампании → дневной лимит может «выесться» вернувшимися деньгами. Направление БЕЗОПАСНОЕ + (только НЕ-до-разрешает, переспенда/потери нет). Решение: `daily_limit_rub` трактуем как cap на ПОПЫТКИ/ + валовые списания за день, а не на чистый расход — чтобы не тащить состояние правила в путь возврата + (refund в Run/Poll джобах не знает про авто-правило). Задокументировано, кода не трогаем. +- **M-1 — ИСПРАВЛЕНО.** Устаревший docblock `CampaignController` («бронирует budget_cap / заморозки нет») → + «списывает клиентскую смету с общего баланса». +- **M-2 — кусок 3.** Плата за имя (`ChargeTgNameFeeJob`/`TelegramSenderService`) всё ещё на старом кошельке; + тип `tg_name_fee` уже добавлен в CHECK/модель под кусок 3 (D8). +- **M-3 — деньги-корректно, оставлено.** Показанная при store смета и списанная при launch могут отличаться, + если аудитория изменилась между store и launch (списываем свежую верную сумму). UX-нюанс, не потеря денег. + +## Дальше кусок 3 (D8: плата за имя → общий баланс, тип tg_name_fee). НЕ закоммичено (ждём «коммить», +## вместе с кусками 1 и 3). pint/stan(0)/deptrac(0) чисто на момент закрытия куска 2. diff --git a/docs/superpowers/2026-07-28-telegram-dengi-kusok3-ACCEPT.md b/docs/superpowers/2026-07-28-telegram-dengi-kusok3-ACCEPT.md new file mode 100644 index 00000000..00d99b9a --- /dev/null +++ b/docs/superpowers/2026-07-28-telegram-dengi-kusok3-ACCEPT.md @@ -0,0 +1,113 @@ +# Телеграм-деньги, КУСОК 3 — плата за своё ИМЯ отправителя → общий баланс (D8 + D9) + +Ветка `feat/client-telegram-ads`. Спека D8/D9. Куски 1 и 2 готовы, не закоммичены. +Коммитим всё вместе после куска 3 (решение владельца 28.07: «дальше закоммитим все вместе»). Не пушить. + +> ⛔ Код куска 3 НЕ начат. Этот лист — приёмка ДО кода (переживает компакт). Ждём слово владельца +> («делай»/«дальше») и ответ на РАЗВИЛКУ ниже. Только после — TDD. + +## Суть +Плата за своё имя Telegram-рекламы (`client_tg_senders`) сейчас идёт через старый рекламный +кошелёк-заморозку `AdWalletService` (freeze при заявке / charge+release при одобрении / release при +отказе / charge помесячно и при возврате из долга). Кампании уже переведены на общий баланс +(куски 1–2). Кусок 3 переводит на общий баланс (`tenants.balance_rub` + `balance_transactions`, +тип `tg_name_fee`) и ПЛАТУ ЗА ИМЯ — единственный кусок телеграм-денег, ещё висящий на кошельке. +Кошелёк и таблицы `ad_wallets`/`ad_wallet_*` НЕ удаляем. + +## Что выяснено при чтении (важно — переживает компакт) +- **`ChargeSmsNameFeeJob` в коде НЕТ** — «зеркало» в комментариях телеграм-файлов аспирационное, + копировать нечего. Дизайн платы за имя на общем балансе — наш, по образцу кампанийного charge. +- **У `balance_transactions` НЕТ колонки `external_key`** (есть `description`, `related_type`, + `related_id`, `type`, суммы). Значит помесячную идемпотентность (у кошелька был ключ + `telegram:sender:{id}:{YYYY-MM}`) храним БЕЗ новой колонки — через маркер периода в `description` + + `related_type=Sender`+`related_id`. Новую колонку/таблицу НЕ заводим (тип `tg_name_fee` уже в + CHECK и в модели из куска 1). +- **Кампанийный `TelegramCampaignChargeService` не подходит как есть** — там идемпотентность по САЛЬДО + (charge−refund по кампании), а у имени — по ПЕРИОДУ (месяцу). Поэтому под имя нужен отдельный + метод/сервис списания (логика «замок тенанта → проверка баланса → дебет → BalanceTransaction» — + та же, идемпотентность — другая). +- **Точки, где имя трогает деньги сейчас** (`TelegramSenderService` + `ChargeTgNameFeeJob`): + 1. `requestSender` — **freeze** месячной платы (бронь под заявку); + 2. `approve` (админ) — **charge** + **release** брони, active + `paid_until`+1мес; + 3. `reject` (админ) — **release** брони; + 4. `disableSender` (клиент, из pending) — **release** брони, cancelled; + 5. `disableByAdmin` (из pending) — **release** брони, suspended; + 6. `enableSender` (suspended→active) — проверка средств + **charge** за текущий период; + 7. `ChargeTgNameFeeJob` (помесячно, кросс-тенантно через `pgsql_supplier`) — есть деньги → **charge** + + `paid_until`+1мес; нет → копим `debt_since`; долг > grace (29 дн) → имя `suspended`. + +## 🔀 РАЗВИЛКА для владельца (нужен ответ до кода) +В кампаниях заморозку убрали совсем: деньги списываются в момент действия (запуск), заявок-броней нет. +У имени была **бронь при заявке** (`requestSender` морозил месячную плату), чтобы к моменту одобрения +деньги гарантированно были. Если убираем заморозку (как везде), у заявки на имя два варианта поведения +при нехватке денег: + +- **Вариант А (рекомендую) — как у кампаний.** Заявку принимаем всегда (деньги не трогаем); + списываем при ОДОБРЕНии админом. Если на балансе не хватило — одобрение не проходит (админ видит + «недостаточно средств»), клиент пополняет и одобряют повторно. Просто, единообразно с кампаниями/СМС, + одна точка списания. +- **Вариант Б — сохранить раннюю проверку.** Заявку при нехватке денег НЕ принимаем сразу (как сейчас + бросает `InsufficientBalanceException`), но БЕЗ фактической брони — просто проверяем `balance ≥ плата`. + Плюс: клиент раньше узнаёт, что не потянет. Минус: баланс к моменту одобрения мог уйти на кампанию — + ранняя проверка ничего не гарантирует, только UX-подсказка. + +Оба безопасны для денег. Различие — только в UX заявки. **По умолчанию беру А**, если владелец не +выберет Б. + +## Приёмочный лист (файлы) + +- [ ] **N0. Сервис/метод списания платы за имя с общего баланса.** Новый + `TelegramSenderChargeService` (или метод в общем сервисе — решить в коде): `chargeName(Sender + $sender, string $period)` — замок `Tenant::lockForUpdate` → идемпотентность ПО ПЕРИОДУ (есть ли уже + `tg_name_fee`-проводка по этому `Sender`+`related_id` за `period`) → проверка `balance_rub ≥ fee` → + дебет `tenants.balance_rub` + `BalanceTransaction` (`TYPE_TG_NAME_FEE`, `amount_rub` отрицательный, + `related_type=Sender::class`, `related_id=sender->id`, период-маркер в `description`). Порог + платёжеспособности — по `tenants.balance_rub` (НЕ ad_wallet). bcmath scale 2. Тест: списывает раз; + повтор за тот же период — no-op; нехватка → списания нет (для джоба — сигнал «денег нет», без броска). + +- [ ] **N1. `ChargeTgNameFeeJob` → общий баланс.** Убрать `AdWalletService`/`AdWallet`; порог считать по + `tenants.balance_rub`; списание через N0; период = `Carbon(paid_until).format('Y-m')`. Сохранить всё + остальное 1:1: `paid_until`+1мес при успехе; при нехватке в грейсе — копим `debt_since`, `paid_until` + не двигаем; долг > grace → `suspended` + `Log::warning('client_tg.name_suspended_for_debt')`. + Кросс-тенантное перечисление — по-прежнему через `pgsql_supplier` (BYPASSRLS), денежная операция под + `SET LOCAL app.current_tenant_id`. Песочница → ранний `return` (без денег). Тесты: списывает+сдвиг + paid_until; грейс копит долг; долг>grace → suspended; идемпотентность по периоду; песочница не трогает. + +- [ ] **N2. `TelegramSenderService` → общий баланс.** Убрать `AdWalletService`/`AdWallet`/`freeze`/ + `release` из потока имени: + - `requestSender` — по РАЗВИЛКЕ (А: без денег; Б: проверка `balance ≥ fee` без броска-с-бронью). + Убрать `freeze`. + - `approve` — вместо `charge`+`release` кошелька: `chargeName` с общего баланса (N0), период — + текущий месяц; active + `paid_until`+1мес. Нехватка при А — не одобряем (пробрасываем нехватку). + - `reject` / `disableSender(pending)` / `disableByAdmin(pending)` — убрать `release` (брони больше нет), + статусы прежние (rejected/cancelled/suspended). + - `enableSender` (suspended→active) — проверка `balance_rub ≥ fee` + `chargeName` за текущий период + (уже был charge, переводим с ad_wallet на общий баланс). + - `snapshot` — не трогает деньги, оставить. + +- [ ] **N3. Убрать `AdWalletService` из телеграм-потока имени полностью.** После N1+N2 ни + `ChargeTgNameFeeJob`, ни `TelegramSenderService` не импортируют `AdWalletService`/`AdWallet`. Это + закрывает последнюю ссылку телеграм-модуля на кошелёк (кампании закрыты в куске 2). `deptrac`/`stan` + чисто. Сам `AdWalletService`/`AdWalletService`-тесты/таблицы НЕ трогаем. + +- [ ] **N4 (D9). Тесты имени под общий баланс.** Переписать: + - `SenderLifecycleTest` — все ассерты `AdWallet frozen_rub/balance_rub` → `tenants.balance_rub` + + `BalanceTransaction TYPE_TG_NAME_FEE`; requestSender по выбранному варианту развилки; approve + списывает с баланса; джоб-кейсы (списание/грейс/suspend/идемпотентность/песочница) на общем балансе. + - `SenderApiTest` — там, где ассертит кошелёк/бронь → общий баланс (прочитать файл, поправить точечно). + - `WalletReFreezeTest` — это тест инварианта САМОГО `AdWalletService` (реактивация released-hold), + имя/кампанию он лишь использует как пример `source`. Прямого отношения к общему балансу не имеет. + Решение: НЕ переписывать под баланс (это не про телеграм-деньги, а про кошелёк, который остаётся); + оставить как есть — проверяет, что кошелёк-инфраструктура не сломана. Подтвердить прогоном. + Гонять ПО ОДНОМУ файлу (квирк партиций). pint/stan(0)/deptrac(0) чисто. + +## Схема БД +Новых типов/колонок/таблиц НЕ добавляем — `tg_name_fee` уже в CHECK `balance_transactions_type_check` +и в модели (кусок 1). rls-reviewer не требуется (нет новых RLS-таблиц). CHANGELOG_schema не трогаем. + +## После куска 3 +Код-ревью (деньги/идемпотентность-по-периоду/RLS в джобе/песочница), затем ОБЩИЙ коммит кусков 1+2+3 +по слову «коммить» (в gitea, не в GitHub). + +## Состояние +- [ ] N0 · [ ] N1 · [ ] N2 · [ ] N3 · [ ] N4 — НЕ начаты. Ждём слово владельца + ответ на развилку. diff --git a/docs/superpowers/2026-07-28-telegram-module-fantasy-audit.md b/docs/superpowers/2026-07-28-telegram-module-fantasy-audit.md new file mode 100644 index 00000000..84a33229 --- /dev/null +++ b/docs/superpowers/2026-07-28-telegram-module-fantasy-audit.md @@ -0,0 +1,118 @@ +# Аудит телеграм-модуля построчно: реальный ресейл МТС vs СМС-фантазии (28.07.2026) + +Ветка `feat/client-telegram-ads`. Повод: владелец — «мы ресейлим МТС Маркетолог, а не фантазируем; +в телеге МТС никакого имени отправителя нет». Прочёсан весь модуль `ClientTg` построчно 5 параллельными +аудиторами (модели+миграции / контроллеры+роуты / сервисы / джобы / фронтенд+таблица messages). + +Продукт по правде: ПОКАЗ рекламного объявления в Telegram по загруженной базе телефонов, робот-в-браузере +автоматизирует кабинет МТС. Это НЕ рассылка сообщений. Всё «сообщение на номер / отправитель / отписка / +шаблон сообщения» — наследие скопированного СМС-модуля. + +## ✅ РЕАЛЬНОЕ (не трогать) — ядро показов +- Таблицы: `client_tg_campaigns` (+доп. поля status_reason/mts_campaign_id/moderator_file_path/GRANT), + `client_tg_campaign_phones`, `client_tg_contacts` (своя база), `client_tg_tariffs` (₽ за показ), + `client_tg_auto_rule`. +- Контроллеры/маршруты: `CampaignController` (list/store/show/launch/cancel/resubmit), `AutoRuleController`. +- Сервисы: `TelegramAudienceService`, `TelegramTariffService` (+наценка), `TelegramCampaignChargeService` + (charge/refund с общего баланса — куски 1-2), `TelegramRobotRunner`, `RobotResult`, `TelegramAutoAccumulator`, + `TelegramAudience`. +- Джобы: `AccumulateTelegramLeadJob`, `RunTelegramCampaignJob`, `ResubmitTelegramCampaignJob`, + `PollTelegramModerationJob`, `SweepStuckTelegramCampaignsJob`. +- Фронт: `AdvertisingTelegramView.vue` (создание кампании, аудитории, бюджет, ОРД, смета, запуск, список, + статусы, отказ+пересдача), `AdminTgView.vue:93-150` (тарифная сетка). +Ни один реальный сервис/джоб НЕ завязан на «имя отправителя» — grep по `Sender` даёт попадания только внутри +самого фантазийного кода. Расцеплять реальный поток не придётся. + +## ⛔ ФАНТАЗИИ — подтверждено построчно (наследие СМС) + +### Ф1. «Имя/бренд отправителя» + абонплата за него (владельцем подтверждено: в телеге МТС имени НЕТ) +Сквозная фича через все слои: +- Джоб `app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php` (весь) + расписание `app/routes/console.php:319-327`. +- Сервис `app/app/Services/ClientTg/TelegramSenderService.php` (весь, 247 строк, 8 методов). +- Контроллер `app/app/Http/Controllers/Api/ClientTg/SenderController.php` (весь) + маршруты + `app/routes/web.php:106-110` (`/sender`, `/sender/disable`, `/sender/enable`). +- Модель `app/app/Models/ClientTg/Sender.php` + таблица `client_tg_senders` (миграция 000010). +- Таблица `client_tg_settings` (миграция 000008) + модель `Setting.php` — ОБЕ колонки + (`name_fee_rub_per_month`, `name_debt_grace_days`) обслуживают только имя → таблица целиком под нож. +- Фронт: `app/resources/js/views/advertising/TelegramSenderPanel.vue` (весь) + встройка в + `AdvertisingTelegramView.vue:259`; карточка `AdminTgView.vue:152-185` «Своё имя / бренд». +- Деньги: тип `tg_name_fee` в `balance_transactions` (добавлен в куске 1) — станет ненужным. +- Тесты: `SenderLifecycleTest`, `SenderApiTest`, `WalletReFreezeTest`, `AdminTgApiTest` (часть про имя). + +### Ф2. Таблица `client_tg_messages` (миграция 000003) — МЁРТВАЯ +«По-получательские записи доставки» (phone/operator/provider_key/status/provider_message_id/error) — дословная +копия `sales_sms_messages`. Модели `ClientTg\Message` НЕТ (намеренно), в рабочем коде НИКТО не пишет/читает; +единственная ссылка — `SchemaTest.php:26-28` (проверка наличия). Живой поток пишет кандидатов в +`client_tg_campaign_phones`. Дропается отдельной миграцией. + +## ❓ РАЗВИЛКИ — РЕШЕНО владельцем (28.07.2026) + +- **Р1. Стоп-лист `client_tg_optouts`** → **ОСТАВИТЬ, но переосмыслить как «не показывать этим номерам»** + (ручной exclude-лист исключаемых номеров, НЕ «отписки/STOP»). Механизм `removeOptouts` в сборке аудитории + остаётся рабочим; правим только смысл/названия (комментарии, тексты, возможно имя метода/таблицы — БЕЗ слома). +- **Р2. `client_tg_templates`** → **ВЫРЕЗАТЬ** (не используется нигде: ни фронт, ни рабочий код). +- **Р3. Слово «Авторассылка»** → **ПЕРЕИМЕНОВАТЬ** в «Авто-реклама/Авто-кампании» (функция реальна, меняем только + название; СМС-лексику «рассылка» вычищаем из телеграм-текстов). Явно владелец не выбирал, но линия однозначна — + «мы ресейлим показы, не рассылку». Подтвердить перед правкой текстов. +- **Медиа-пробел** → **ДЕЛАЕМ СЕЙЧАС** (не откладываем): добавить загрузку картинки/видео в форму создания + кампании + проброс в робота. Сначала проверить, принимает ли робот `bots/mts-telegram-ads` медиа-файл + (в task.json уже есть поле `mediaFile` — вероятно да). + +## 📋 ПЛАН ЧИСТКИ (куски, TDD, коммит только по «коммить») + +- **A. Вырезать «имя отправителя» (Ф1) целиком.** Удалить: `ChargeTgNameFeeJob` + расписание console.php:319-327; + `TelegramSenderService`; `SenderController` + маршруты web.php:106-110; модель `Sender` + миграция-дроп таблицы + `client_tg_senders`; таблица `client_tg_settings` (обе колонки только про имя) — миграция-дроп + модель `Setting`; + фронт `TelegramSenderPanel.vue` + встройка `AdvertisingTelegramView.vue:259`; карточка `AdminTgView.vue:152-185`; + админ-эндпоинты/тесты про имя. Удалить тесты `SenderLifecycleTest`, `SenderApiTest`, `WalletReFreezeTest`, + часть `AdminTgApiTest`. Проверить, что реальный поток не сломался (grep `Sender`/`Setting` → 0 в живом коде). + NB: тип `tg_name_fee` в `balance_transactions` (кусок 1) — станет мёртвым; можно оставить в CHECK (безвредно) или + убрать из миграции куска 1 (решить при выполнении, чтобы не плодить миграции). +- **B. Дропнуть мёртвую таблицу `client_tg_messages` (Ф2)** отдельной миграцией + убрать проверку в `SchemaTest`. +- **C. Вырезать `client_tg_templates` (Р2)** — миграция-дроп + модель `Template` + тесты, если есть. +- **D. Стоп-лист (Р1)** — оставить механизм, переименовать смысл «отписки»→«исключаемые номера» в комментариях/ + текстах; функционал не трогать; тесты сохранить/поправить названия. +- **E. Тексты (Р3)** — «Авторассылка»/«рассылка» → «Авто-реклама» в телеграм-фронте и прозе (косметика). +- **F. МЕДИА (сейчас!)** — фронт: загрузка картинки/видео в форме создания кампании → `media_path`; бэк: проброс + до `TelegramRobotRunner` (поле `mediaFile` уже есть) и валидация. Сначала проверить поддержку в роботе. + +Порядок безопасности: сначала бэкенд-вырезание с тестами (A→B→C), потом семантика/тексты (D→E), потом фича媒 media (F). +Каждый кусок — TDD, зелёные тесты по отдельности (квирк партиций), pint/stan/deptrac чисто. Коммит — только по «коммить». + +## ⚠️ Реальный ПРОБЕЛ (не фантазия) — на заметку +Форма создания кампании принимает только текст+ссылку. У реального продукта МТС объявление может быть +с картинкой/видео, и поле `media_path` уже есть в типе (`api/telegram.ts:23`) и в базе, но загрузки медиа во +фронте нет (только «документ модератору» при пересдаче). Это недоделка показов, а не СМС-наследие — закрыть отдельно. + +## Итог +Точно под нож: Ф1 (имя отправителя во всех слоях) + Ф2 (мёртвая таблица messages). Р1–Р3 — по слову владельца. +Ничего не режется без явного «режь». Куски 1-2 (показы → общий баланс) остаются. + +--- + +## ✅ ВЫПОЛНЕНО (28.07.2026, по слову владельца «чисти модуль!») — НЕ закоммичено + +Все куски A–F сделаны, проверены тестами (гонять по одному — квирк партиций; при флейке `db:wipe +--drop-types --force`). Коммит — по слову «коммить» (вместе с кусками 1-2). + +- **A. «Имя отправителя» вырезано целиком.** Удалены: `ChargeTgNameFeeJob` (+расписание console.php), + `TelegramSenderService`, `SenderController` (+маршруты web.php), модель `Sender`, модель `Setting`, + фронт `TelegramSenderPanel.vue` (+встройка), карточка `AdminTgView.vue:152-185`, `updateTgSettings` + (контроллер+api+тесты), тип `TYPE_TG_NAME_FEE` (модель+миграция+schema.sql CHECK). Тесты + `SenderLifecycleTest`/`SenderApiTest`/`telegram-sender-panel.spec.ts` удалены; SchemaTest/ModelsTest/ + AdminTgApiTest/admin-tg-view.spec/advertising-telegram-view.spec почищены. ✅ Green. +- **B+C. Дроп мёртвых таблиц** `client_tg_messages`, `client_tg_templates` (+ senders/settings) — миграция + `2026_07_28_000017_drop_client_tg_sms_legacy_tables.php` (`DROP IF EXISTS CASCADE`), create-миграции + 000003/000006/000008/000010 удалены, модель `Template` удалена, schema CHANGELOG v8.95. ✅ Green. +- **D. Стоп-лист** — механизм оставлен, смысл «отписки/opt-out» → «список не показывать этим номерам» + (комментарии `Optout.php`, `TelegramAudienceService::removeOptouts`). Логика не тронута. ✅ AudienceServiceTest 7/7. +- **E. Лексика СМС вычищена** — «Авторассылка»→«Авто-реклама» (панель/api/тесты), «кошелёк + продвижения/заморожено»→«общий баланс» (экран), «авто-рассылка»→«авто-реклама» (accumulator). +- **F. МЕДИА (пробел закрыт).** Новый эндпоинт `POST /api/telegram/campaigns/{id}/media` (attachMedia: + png/jpg/gif/mp4 ≤50 МБ, только черновик, замена удаляет старый файл) + фронт `v-file-input` в форме + + `uploadTelegramMedia`, вызов после «Рассчитать». Робот УЖЕ принимает `media_path` (RunTelegramCampaignJob + → mediaFile → cabinet.js `fillAdMedia`). ✅ MediaUploadTest 6/6, фронт advertising-telegram-view 19/19. + +**Проверки:** pint clean, deptrac 0, larastan — только известный Pest-`$this` шум (не блокер, коммит с +`LEFTHOOK_EXCLUDE=larastan`). Прогоны зелёные поштучно: Schema 6, Models 5, AdminTgApi 3, CampaignCharge 6, +CampaignApi 9, Audience 7, Media 6; фронт 3 спеки 24 + медиа 19. Робот (bots/mts-telegram-ads) НЕ трогали. diff --git a/docs/superpowers/2026-07-28-telegram-review-fixes-STATE.md b/docs/superpowers/2026-07-28-telegram-review-fixes-STATE.md new file mode 100644 index 00000000..6e4f46d5 --- /dev/null +++ b/docs/superpowers/2026-07-28-telegram-review-fixes-STATE.md @@ -0,0 +1,114 @@ +# Телеграм-модуль: правки после сводного код-ревью (Этап B) — приёмочный лист + состояние + +Ветка `feat/client-telegram-ads`. Правки по итогам ревью 28.07.2026 (4 зоны). +Владелец выбрал объём: **C1 + 3 рассинхрона длины + все оранжевые**. Не пушить. Коммит только по «коммить». +База ветки merge-base = `b5898b80`. HEAD до правок = `aa4b482d`. + +Метод: TDD (RED→GREEN), каждую правку проверяю сам, наборы держу зелёными +(`DB_DATABASE=liderra_testing php artisan test tests/Feature/ClientTg/`; робот `npm test`). + +## Приёмочный лист (каждый пункт = один кусок, свой набор тестов) + +- [ ] **F1 (C1, 🔴 блокер).** Повторная `freeze()` после `release()` реактивирует hold, не падает 23505. + Приёмка: topup→freeze→release→freeze снова = frozen вернулся, ровно 1 hold active, без ошибки БД; + пересдача кампания (rejected→resubmit) и повторная заявка на имя (reject/cancel→re-request) не дают 500. + Файл: `AdWalletService::freeze()` — вместо `create()` реактивирующий upsert по 4-ключу. + +- [ ] **F2 (рассинхроны валидатор↔колонка, 🟡).** Приёмка: длина чуть больше колонки → 422 ВАЛИДАЦИИ + (assertJsonValidationErrors), не замаскированный 422 БД. + - имя отправителя: `SenderController` max:160 → **max:64** (колонка varchar(64)). + - `ad_link`: `CampaignController` store+resubmit max:2048 → **max:500** (колонка varchar(500)). + - `ord_category`: `CampaignController` store+resubmit max:255 → **max:200** (колонка varchar(200)). + Обновить `InputLimitsTest` — граничные длины дают ошибку именно поля. + +- [ ] **F3 (Деньги I2, 🟠).** Авто-путь: заморозка бюджета симметрично ручному launch (только live) + + счётчик `spent_today_rub` под `lockForUpdate` строки правила. Приёмка: live авто-кампания получает hold; + песочница — нет; два параллельных лида не пробивают дневной лимит. + Файл: `TelegramAutoAccumulator::passesSafeguard()` / queue-путь. + +- [ ] **F4 (Мост #1, 🟠).** Кампания не зависает в `moderating` вечно. Добавить переход + `moderating→needs_review`; предохранитель по возрасту (moderating старше N часов + N неудачных чтений → + needs_review, БЕЗ release — деньги могли уйти на модерацию). Приёмка: старую moderating метём в needs_review, + бронь НЕ снимаем; свежую — не трогаем. Файлы: `Campaign` TRANSITIONS, `SweepStuckTelegramCampaignsJob`. + +- [ ] **F5 (Мост #2/#3, 🟠).** Единое осторожное правило возврата брони. `finalize` при `!ok` И наличии + `mts_campaign_id` → `needs_review` без release; без id → failed+release. `failed()`: release только если + реально перешли в failed И нет `mts_campaign_id`. Приёмка: тесты обеих веток. + Файл: `RunTelegramCampaignJob` (finalize-ветка + failed()). + +- [ ] **F6 (Робот H1, 🟠).** `finalize` целит конкретную кнопку отправки на модерацию; кнопки + «оплатить/запустить/списать» — чёрный список: если такая топовая — падать, не кликать. Node-тест. + Файл: `bots/mts-telegram-ads/src/cabinet.js` (`finalize`/`domClickButton`). + +- [ ] **F7 (Робот H2/M5, 🟠).** Живой `finalize` НЕ возвращает `launched:true`, когда встал на `/payment`: + вернуть `launched:false, stoppedAt:'payment'`; если после submit не на `/payment` — падать громко. + Согласовать со стороной Laravel (`RobotResult`/`RunTelegramCampaignJob`: launched:false + есть id → не + терминальный ложный успех). Node + при необходимости Laravel-тест. + +- [ ] **F8 (Робот H3, 🟠).** Перед любым реальным списанием сверять фактическую стоимость (`parseCost`) с + потолком; подключить `parseCost` к потоку /budget. Node-тест. (Живое списание пока не включено — фикс + превентивный, чтобы не ушло сверх потолка при go-live.) + +- [ ] **F9 (API I4 / srv_bypass, 🟠).** Явные service-role GRANT на кросс-тенантно читаемые таблицы + (`client_tg_campaigns` — Sweep/Poll; уже есть у `senders`); поправить ложный комментарий в миграции 000011; + деплой-чеклист: ре-ран `db/03_service_bypass_policies.sql`. Требует rls-reviewer + запись в + `db/CHANGELOG_schema.md`. + +## Оставлено на потом (🟢 харднинг, НЕ в этом объёме) +ОРД молча берёт первую опцию (робот M1); гонка профиля браузера keepalive↔run (M2); ПДн-файл на диске при +kill (M3); чистый JSON в stdout (M4); ledger drift при списании из недобора (money M5); гонка openBatch +накопителя (bridge #6). — зафиксированы, вне текущего объёма. + +## Состояние выполнения — ВСЕ 9 ГОТОВЫ (TDD, зелёные) +- [x] F1 (C1) — `AdWalletService::freeze()` реактивирует released-hold (updateOrCreate по 4-ключу). Тест `WalletReFreezeTest`. +- [x] F2 — длины валидации под колонки (name 64, ad_link 500, ord_category 200). Тест `InputLengthFixTest`. +- [x] F3 — авто-путь морозит потолок + счётчик под `lockForUpdate` (`TelegramAutoAccumulator`). Тест `AutoFreezeTest`. +- [x] F4 — `moderating→needs_review` + предохранитель по возрасту (`SweepStuck.sweepStuckModeration`, config `moderation_stuck_hours=48`). Тест `ModerationStuckTest`. +- [x] F5 — осторожный возврат брони в `finalize`/`failed` (есть mts_id → needs_review без release). Тест `ConservativeReleaseTest` (+ обновлён `ExternalIdTest`). +- [x] F6 — `domClickButton` чёрный список денежных кнопок (`FORBIDDEN_BUTTON_RE`), `finalize` целит `SUBMIT_BUTTON_RE`. Тест `cabinet-guards`. +- [x] F7 — `finalize` live: `launched:false, stoppedAt:'payment'` (не врёт про запуск), падает если не дошли до /payment. +- [x] F8 — `assertCostWithinCap` подключён в live-finalize (сверка фактической стоимости с потолком). Тест `cabinet-guards`. +- [x] F9 — миграция 000016 GRANT SELECT служебным ролям на client_tg_campaigns + правка ложного комментария 000011. CHANGELOG v8.93, rls-reviewer CLEAN. + +**Приёмка (28.07.2026):** бэкенд ClientTg **196/196**; pint/phpstan/deptrac — чисто; робот `npm test` **89/89**; +миграция 000016 применена на liderra_testing; rls-reviewer по 000016 — CLEAN. Фронт НЕ трогали (JS без изменений). +НЕ закоммичено (ждём слова «коммить»). Пуш запрещён (GitHub заблокирован — gitea). + +## 🔴 ДЕПЛОЙ-ЧЕКЛИСТ модуля (для C/D, отложено) +1. Миграции client_tg (000001–000016) на боевой кластер: `migrate --pretend --force` → ревью SQL → psql + crm_migrator одной транзакцией. +2. 🔴 **ПЕРЕзапустить `db/03_service_bypass_policies.sql`** — иначе новые RLS-таблицы (в т.ч. client_tg_campaigns) + служебные роли (НЕ BYPASSRLS на проде) читают как 0 строк (тихий ноль: Sweep/Poll/оплата имени не работают). + Проверить: `crm_supplier_worker` реально видит client_tg_campaigns/client_tg_senders. +3. Go-live (D, решение владельца): TG_SANDBOX=false, живой /payment, помесячный джоб имени в расписание. + +## A — A1 ЗАКРЫТ ✅ (28.07.2026) +Боевое чтение вердикта кампании 2231134 роботом `readModerationStatus` — **успех**: +`{ok:true, moderationStatus:'rejected', reason:<полный текст 5 пунктов модерации МТС + сводка>}`. +Метка `` в `cabinet.js` снята (подтверждено на живом отказе). + +🔧 A1 вскрыл и починил **два бага локатора** в `readModerationStatus` (НЕ в коммите 68fe632c — +отдельная правка, ждёт «коммить»): +- (1) matcher ссылки: список даёт `/cabinet/campaigns/telegram/{id}`, а не `/telegram-a2p/{id}` → + расширен на оба (`campaignHrefRe`/`hrefMatchesCampaignId`, юнит-тест `cabinet-guards`); +- (2) подъём по DOM: строка списка — грид из div, контейнер со статусом на ~8 уровней выше ссылки → + предел подъёма 6→10 (возврат на ПЕРВОМ предке со статусом; выше склеиваются две кампании). +Робот `npm test` 94/94. Файлы правки: `bots/mts-telegram-ads/src/cabinet.js`, `test/cabinet-guards.test.js`. + +## A2 ЗАКРЫТ ✅ (28.07.2026) — живая пересдача 2231134, 0 ₽ +Полный цикл проверен вживую: `rejected` → пересдача с документом → `moderating`, деньги не тронуты. +Шаги (все сработали, селекторы подтверждены живьём): +1. Список → нативный клик «Исправить» в СТРОКЕ кампании (в scope ряда, как чтение статуса) → редактор + открывается сразу на шаге **/message** (`…/telegram-a2p/{id}/message`), текст/ссылка/ОРД уже заполнены. +2. Загрузка документа: `input[type="file"][accept*="pdf"]` (id `…Комментарий-для-модератора-загрузить`, + accept `.png,.jpeg,.jpg,.pdf`). +3. Боевые `submitAd` → /budget → `submitBudget` → /confirmation → `finalize('live')` → /payment + (finalize вернул `launched:false, stoppedAt:'payment'` — F7 сработала живьём). +4. На /payment — нативный клик **«ОТПРАВИТЬ НА МОДЕРАЦИЮ БЕЗ ОПЛАТЫ»** (0 ₽; кнопку «Списать…» НЕ трогаем, + она в чёрном списке F6). После — редирект в список, статус `moderating` (проверено read-status). + +🔧 **Follow-up (НЕ сделан, отдельная задача):** сам режим пересдачи в роботе НЕ реализован как +production-mode — A2 проверен одноразовым скриптом. Для боя нужно добавить `mode:'resubmit'` в +runner.js/bin/run.js (вход «Исправить» + шаг «без оплаты»), опираясь на рецепт выше. Селекторы все +подтверждены живьём. Задача 3.6 (пересдача) на стороне Laravel (`resubmit` контроллера) уже есть — +недостаёт только robot-mode. diff --git a/docs/superpowers/findings/2026-07-27-telegram-module-gaps.md b/docs/superpowers/findings/2026-07-27-telegram-module-gaps.md new file mode 100644 index 00000000..d15ffc41 --- /dev/null +++ b/docs/superpowers/findings/2026-07-27-telegram-module-gaps.md @@ -0,0 +1,77 @@ +# Дыры и незакрытые места модуля «Реклама в Телеграме по своей базе» + +**Дата:** 27.07.2026. **Ветка:** feat/client-telegram-ads (HEAD cc985e07). +**Метод:** 4 параллельных код-ревью (деньги / робот-модерация / аудитория-ПДн / UX-админка) + личная перепроверка главных пунктов по коду. + +> ⚠️ Сейчас модуль в песочнице (`TG_SANDBOX=true`): деньги не двигаются, робот делает только черновики. Все денежные дыры — латентные, сработают при выключении песочницы (Сессия 6). Пункты про робота/статусы/id важны уже для Сессии 5. + +--- + +## 🔴 Сработают при выключении песочницы (Сессия 6) — блокеры go-live + +1. **Замороженные деньги кампании НЕ возвращаются и НЕ списываются — залипают навсегда.** + `CampaignController.php:131` — единственный `freeze(source_type='campaign')`. По всему коду `release`/`charge` есть только для `'sender'` (имя). `RunTelegramCampaignJob::finalize` на всех исходах (draft_ready/launched/failed/rejected) меняет ТОЛЬКО статус, кошелёк не трогает. + Клиент жмёт «Запустить» → бронь `budget_cap_rub` → что бы ни случилось, бронь висит. Через N кампаний «денег нет» при полном балансе. Возврата из UI нет. + → Нужно: release при failed/rejected + charge по факту при успехе (Сессия 6). + +2. **Минимум 367 «не МТС» и «мало/пусто» проверяются только внутри робота, ПОСЛЕ брони.** + Гейта размера аудитории нет ни в `store`, ни в `launch`. Единственная проверка — `runner.js:35-37` уже в кабинете. Клиент грузит 10 номеров → деньги забронировались → робот упал → бронь залипла (см. п.1). + → Нужно: проверять `кандидатов ≥ 367` (и `> 0`) ДО freeze. + +3. **Списывать по ФАКТУ МТС, а не по смете.** `estimated_cost_rub` считается по кандидатам, а фактическая стоимость в кабинете зависит от асинхронного матчинга. При построении charge (Сессия 6) списывать по факту, иначе переплата/недобор. + +--- + +## 🟠 Мешают уже в Сессии 5 (робот/статусы) — не про деньги + +4. **Внешний номер кампании в кабинете МТС выбрасывается.** Робот достаёт `campaignId` из URL и отдаёт в `RobotResult`, но джоб его НЕ сохраняет, и в `client_tg_campaigns` нет колонки под него. Значит 5.1 (опрос модерации) и 5.3 (пересдача) физически не найдут кампанию среди одноимённых. + → Нужно: колонка `mts_campaign_id` + сохранять в finalize. Фундамент для 5.1/5.3. + +5. **Статус «Запущена» врёт всё окно модерации (~4 ч).** В live робот доходит до /payment, finalize ставит `launched`, а реальный вердикт МТС придёт через ~4 ч и может быть «Отклонена». Промежуточного статуса «на модерации» нет. Плюс `launched`/`rejected` — терминальные (нет исходящих переходов) → 5.1 не сможет `launched → rejected` без правки `TRANSITIONS`. + → Нужно: статус `moderating` + рёбра переходов под 5.1/5.3. + +6. **Кампания может навсегда зависнуть.** + - `queued`: если сбор аудитории `build()` упадёт внутри Фазы A — переход в running откатится, останется `queued`; `failed()` не умеет `queued → failed` → застряла. Клиент видит «В очереди» вечно. + - `running`: если воркер убили (reboot/OOM/деплой) во время робота — `failed()` не вызовется, статус `running`; переотправка → no-op. «Готовим…» вечно. + → Нужно: `$timeout` на джоб + «уборщик» зависших running/queued. + +--- + +## 🟡 Клиентский опыт и контроль + +7. **Клиент слепнет после запуска:** нет автообновления статуса на экране и нет уведомления об УСПЕХЕ/запуске (только об отказе). Одобрили через 4 ч — клиент не узнает, пока сам не зайдёт и не обновит. + +8. **Смету и число кандидатов клиент не видит перед списанием.** Экран склеивает create+launch в один клик; `estimated_cost_rub` не рендерится нигде. Смысл «черновик со сметой перед деньгами» потерян в UI. + +9. **Тупик на отказе + замкнутый круг.** Причина рисуется только если непустая; при пустой причине уведомление говорит «откройте кабинет за деталями», а экран при пустой причине ничего не показывает. Кнопки «пересдать» нет (это 5.3). + +10. **«Своё имя» и «авто-рассылка» взведены, но входа/контроля у клиента НЕТ.** Помесячный джоб за имя уже в расписании (`console.php`, ежедневно). Авто-накопитель (`TelegramAutoAccumulator`) ставит кампании в очередь БЕЗ проверки баланса и БЕЗ дневного лимита. Сейчас безвредно (нет маршрутов, чтобы включить), но джоб активен. + → Нужно: не держать джоб/авто-путь активными до появления UI + баланс-гейт и дневной лимит в авто-пути. + +--- + +## 🟢 ПДн (152-ФЗ) + +11. **Полноэкранные скриншоты кабинета сохраняются на диск и уходят письмом, не чистятся** (`runner.js:49,55,63`). На них могут быть телефоны базы. Файл номеров чистим, скриншоты — нет. + +12. **Стоп-лист opt-out сравнивается без нормализации** (`TelegramAudienceService.php:118-124`). Если запись отписки ляжет в формате `+7…`/`8…`/с пробелами — отписавшийся ОСТАНЕТСЯ в рассылке. Писателя opt-out пока нет, но правило заложить: нормализовать в `7XXXXXXXXXX`. + +--- + +## 🔵 Защита входа + +13. **Нет верхнего предела** на размер списка номеров (`phones` без `max` → можно прислать миллион → нагрузка/DoS) и на `audience_days` (без `max`). + +14. **Идемпотентность держится на «один воркер», не на коде.** При двух воркерах два параллельных `launch` теоретически создадут две реальные кампании. СМС-близнец защищён уникальным ключом в БД, тут — нет. + +--- + +## Проверено и ОК (не дыры) +- Кросс-тенант утечки аудитории нет: везде `where tenant_id` + `SET LOCAL`. ПДн во фронт/логи не текут (логи пишут только id). +- Нормализация номеров на бэке и в роботе совпадает (8→7, +7, 10-значные, мусор). +- Opt-out вычищается ДО отправки (при корректном формате, см. п.12). +- Песочница гейтит ВСЕ денежные пути; дефолт — песочница (безопасно). +- Плата за «своё имя» идемпотентна (external_key по месяцу), бронь имени всегда закрывается. +- Таймаут робота ловится честно → failed. stdout чист. ПДн-файл номеров чистится в finally. +- Админ-тарифы закрыты ролью (saas-admin/admin-db), обычный клиент не дёрнет. +- Observer приёма лида best-effort (try/catch), приём не роняет. diff --git a/docs/superpowers/plans/2026-07-26-mts-telegram-ads-bot.md b/docs/superpowers/plans/2026-07-26-mts-telegram-ads-bot.md new file mode 100644 index 00000000..e430e652 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-mts-telegram-ads-bot.md @@ -0,0 +1,920 @@ +# Бот-автоматизатор Telegram Ads через кабинет МТС — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Собрать standalone-робота (Node + Playwright), который через живую сессию браузера автоматизирует полный цикл создания Telegram-кампании по своей базе телефонов в кабинете МТС Маркетолог, с алярмом на почту и двумя режимами (черновик/боевой). + +**Architecture:** Отдельная Node-программа со своим персистентным профилем браузера на Windows-сервере (там же, где `LiderraMtsBridge`), изолированная от портала Laravel. Тестируемое ядро (парсинг задания, валидация номеров, потолок бюджета, письма) покрыто юнит-тестами; браузерная часть проверяется против живого кабинета в режиме «черновик». Алярм и отчёт — почтой напрямую через SMTP Unisender Go (nodemailer), чтобы бот оставался автономным. + +**Tech Stack:** Node.js (ESM), Playwright (persistent context), встроенный тест-раннер `node --test`, nodemailer (SMTP Unisender Go), dotenv. + +**Спека:** [docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md](../specs/2026-07-26-mts-telegram-ads-bot-design.md) + +--- + +## Расположение файлов + +Всё в новой изолированной папке репозитория `bots/mts-telegram-ads/`: + +| Файл | Ответственность | +|---|---| +| `bots/mts-telegram-ads/package.json` | манифест Node-проекта, скрипты, зависимости | +| `bots/mts-telegram-ads/.env.example` | шаблон настроек (профиль браузера, SMTP, адрес алярма, лимиты) | +| `bots/mts-telegram-ads/src/config.js` | загрузка и валидация настроек окружения | +| `bots/mts-telegram-ads/src/task.js` | загрузка и валидация задания кампании из JSON-файла | +| `bots/mts-telegram-ads/src/phones.js` | чтение/нормализация/дедуп файла номеров + проверки | +| `bots/mts-telegram-ads/src/budget.js` | проверка потолка бюджета | +| `bots/mts-telegram-ads/src/mailer.js` | отправка алярма и отчёта по SMTP (nodemailer) | +| `bots/mts-telegram-ads/src/browser.js` | запуск/поддержание персистентного профиля браузера | +| `bots/mts-telegram-ads/src/session.js` | проверка живости входа в кабинет (login guard) | +| `bots/mts-telegram-ads/src/cabinet.js` | шаги визарда кабинета (создание кампании) — селекторы из cabinet-flow.md | +| `bots/mts-telegram-ads/src/runner.js` | оркестратор: задание → шаги → режим → отчёт/алярм | +| `bots/mts-telegram-ads/bin/run.js` | CLI-вход: `node bin/run.js --task task.json` | +| `bots/mts-telegram-ads/bin/keepalive.js` | тихий keep-alive (заходит в кабинет по расписанию ОС) | +| `bots/mts-telegram-ads/docs/cabinet-flow.md` | РАЗМЕТКА реальных экранов визарда (результат Задачи 1) | +| `bots/mts-telegram-ads/test/*.test.js` | юнит-тесты ядра | + +Разделение: чистая логика (`config/task/phones/budget/mailer`) отделена от браузерной (`browser/session/cabinet`) — ядро тестируется без кабинета. + +--- + +## Milestone 0 — Разведка кабинета (делается первой, безопасно) + +### Задача 1: Разметить реальный визард Telegram-кампании по своей базе + +**Files:** +- Create: `bots/mts-telegram-ads/docs/cabinet-flow.md` + +Цель: получить точную последовательность экранов и селекторов, на которые будет опираться `cabinet.js`. Без этого браузерные задачи писать вслепую нельзя. + +- [ ] **Step 1: Открыть визард в живой сессии, режим только-чтение** + +В браузере с живой сессией (Playwright MCP) открыть `https://marketolog.mts.ru/cabinet/campaigns/telegram`, нажать «Создать кампанию». НИЧЕГО не отправлять, не жать финальный «Запустить». Делать snapshot на каждом шаге. + +- [ ] **Step 2: Пройти путь «по своей базе» до кнопки запуска** + +Пройти шаги: загрузка своей базы номеров как аудитории → ввод текста/медиа/ссылки кнопки → выбор аудитории → бюджет/CPM. На каждом шаге фиксировать: URL, заголовок шага, точные `ref`/`role`/`name` полей и кнопок, тексты-подтверждения (например, «найдено N номеров»). + +- [ ] **Step 3: Записать разметку в cabinet-flow.md** + +Задокументировать для каждого шага: что делаем, какой селектор (role+name предпочтительно), что ждём (условие успеха шага), где появляется «сколько номеров нашлось», как выглядит кнопка «Запустить», какие бывают ошибки/капча. Отметить требования к файлу номеров (формат, размер, минимум) как они показаны в UI. + +- [ ] **Step 4: Отменить черновик, если он создался** + +Если визард создал черновик — удалить его из списка кампаний (не запускать). Убедиться, что баланс не изменился. + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/docs/cabinet-flow.md +git commit -m "docs(телеграм-бот): разметка визарда кампании по своей базе в кабинете МТС" +``` + +> Все последующие браузерные задачи (10–14) ссылаются на `cabinet-flow.md`. Если разметка покажет, что «своя база» для Телеги в нашем кабинете НЕ доступна (напр., требует отдельного PRO-доступа) — СТОП, вернуться к владельцу: план дальше не идёт. + +--- + +## Milestone 1 — Скелет проекта и сессия + +### Задача 2: Скелет Node-проекта + +**Files:** +- Create: `bots/mts-telegram-ads/package.json` +- Create: `bots/mts-telegram-ads/.env.example` +- Create: `bots/mts-telegram-ads/.gitignore` + +- [ ] **Step 1: package.json** + +```json +{ + "name": "mts-telegram-ads-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "run:task": "node bin/run.js", + "keepalive": "node bin/keepalive.js" + }, + "dependencies": { + "playwright": "^1.47.0", + "nodemailer": "^6.9.0", + "dotenv": "^16.4.0" + } +} +``` + +- [ ] **Step 2: .env.example** + +``` +# Профиль браузера с живым входом в кабинет МТС +MTS_BROWSER_PROFILE_DIR=C:/liderra/mts-bot-profile +MTS_CABINET_URL=https://marketolog.mts.ru/cabinet/main +MTS_TELEGRAM_URL=https://marketolog.mts.ru/cabinet/campaigns/telegram + +# Почта для алярма/отчёта (Unisender Go SMTP — те же креды, что у портала) +SMTP_HOST=smtp.go.unisender.ru +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +ALARM_FROM=bot@liderra.ru +ALARM_TO=eclips9363@gmail.com + +# Предохранитель: боевой запуск дороже этой суммы (₽) запрещён без ручного подтверждения +BUDGET_CAP_RUB=5000 + +# Темп «по-человечески»: минимальная пауза между действиями, мс +HUMAN_DELAY_MS=800 +``` + +- [ ] **Step 3: .gitignore** + +``` +node_modules/ +.env +profile/ +screenshots/ +*.log +``` + +- [ ] **Step 4: Установить зависимости и Playwright-браузер** + +Run: `cd bots/mts-telegram-ads && npm install && npx playwright install chromium` +Expected: установка без ошибок, chromium скачан. + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/package.json bots/mts-telegram-ads/.env.example bots/mts-telegram-ads/.gitignore +git commit -m "chore(телеграм-бот): скелет Node+Playwright проекта" +``` + +### Задача 3: Загрузка и валидация настроек (config.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/config.js` +- Test: `bots/mts-telegram-ads/test/config.test.js` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; + +test('loadConfig собирает поля из env', () => { + const env = { + MTS_BROWSER_PROFILE_DIR: '/p', MTS_CABINET_URL: 'https://c', + MTS_TELEGRAM_URL: 'https://t', SMTP_HOST: 'h', SMTP_PORT: '587', + SMTP_USER: 'u', SMTP_PASS: 'p', ALARM_FROM: 'a@b', ALARM_TO: 'c@d', + BUDGET_CAP_RUB: '5000', HUMAN_DELAY_MS: '800', + }; + const c = loadConfig(env); + assert.equal(c.profileDir, '/p'); + assert.equal(c.budgetCapRub, 5000); + assert.equal(c.humanDelayMs, 800); +}); + +test('loadConfig падает при отсутствии обязательного поля', () => { + assert.throws(() => loadConfig({}), /MTS_BROWSER_PROFILE_DIR/); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd bots/mts-telegram-ads && node --test test/config.test.js` +Expected: FAIL — модуль `../src/config.js` не найден. + +- [ ] **Step 3: Реализовать config.js** + +```js +function required(env, key) { + const v = env[key]; + if (v === undefined || v === '') throw new Error(`Не задана переменная окружения: ${key}`); + return v; +} + +export function loadConfig(env = process.env) { + return { + profileDir: required(env, 'MTS_BROWSER_PROFILE_DIR'), + cabinetUrl: required(env, 'MTS_CABINET_URL'), + telegramUrl: required(env, 'MTS_TELEGRAM_URL'), + smtp: { + host: required(env, 'SMTP_HOST'), + port: Number(required(env, 'SMTP_PORT')), + user: required(env, 'SMTP_USER'), + pass: required(env, 'SMTP_PASS'), + }, + alarmFrom: required(env, 'ALARM_FROM'), + alarmTo: required(env, 'ALARM_TO'), + budgetCapRub: Number(required(env, 'BUDGET_CAP_RUB')), + humanDelayMs: Number(env.HUMAN_DELAY_MS ?? '800'), + }; +} +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +Run: `cd bots/mts-telegram-ads && node --test test/config.test.js` +Expected: PASS (2 теста). + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/src/config.js bots/mts-telegram-ads/test/config.test.js +git commit -m "feat(телеграм-бот): загрузка и валидация настроек окружения" +``` + +--- + +## Milestone 2 — Тестируемое ядро (TDD) + +### Задача 4: Задание кампании (task.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/task.js` +- Test: `bots/mts-telegram-ads/test/task.test.js` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTask } from '../src/task.js'; + +const base = { + phonesFile: 'phones.txt', adText: 'Короткий текст', + budgetRub: 1000, cpmRub: 100, buttonUrl: 'https://site.ru', + mode: 'draft', +}; + +test('parseTask принимает корректное задание', () => { + const t = parseTask(base); + assert.equal(t.mode, 'draft'); + assert.equal(t.budgetRub, 1000); +}); + +test('parseTask отвергает неизвестный режим', () => { + assert.throws(() => parseTask({ ...base, mode: 'xxx' }), /mode/); +}); + +test('parseTask требует phonesFile', () => { + const { phonesFile, ...rest } = base; + assert.throws(() => parseTask(rest), /phonesFile/); +}); + +test('parseTask отвергает пустой текст', () => { + assert.throws(() => parseTask({ ...base, adText: '' }), /adText/); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd bots/mts-telegram-ads && node --test test/task.test.js` +Expected: FAIL — модуль не найден. + +- [ ] **Step 3: Реализовать task.js** + +```js +const MODES = ['draft', 'live']; + +export function parseTask(raw) { + const req = (k) => { + if (raw[k] === undefined || raw[k] === '' || raw[k] === null) + throw new Error(`Задание: не задано поле ${k}`); + return raw[k]; + }; + const mode = req('mode'); + if (!MODES.includes(mode)) throw new Error(`Задание: mode должен быть draft|live, получено ${mode}`); + const budgetRub = Number(req('budgetRub')); + const cpmRub = Number(req('cpmRub')); + if (!(budgetRub > 0)) throw new Error('Задание: budgetRub должен быть > 0'); + if (!(cpmRub > 0)) throw new Error('Задание: cpmRub должен быть > 0'); + return { + phonesFile: req('phonesFile'), + adText: req('adText'), + budgetRub, cpmRub, + buttonUrl: req('buttonUrl'), + mediaFile: raw.mediaFile ?? null, + clientTag: raw.clientTag ?? null, + mode, + }; +} +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +Run: `cd bots/mts-telegram-ads && node --test test/task.test.js` +Expected: PASS (4 теста). + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/src/task.js bots/mts-telegram-ads/test/task.test.js +git commit -m "feat(телеграм-бот): парсинг и валидация задания кампании" +``` + +### Задача 5: Файл номеров (phones.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/phones.js` +- Test: `bots/mts-telegram-ads/test/phones.test.js` + +Требования (из ресёрча + подтвердить по cabinet-flow.md): только цифры, нормализация к формату `7XXXXXXXXXX`, дедуп, отбрасывание мусора, минимум номеров и максимум размера — берём консервативные значения (min 1, предупреждение при <500; лимит по строкам параметризуем). + +- [ ] **Step 1: Написать падающий тест** + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizePhones } from '../src/phones.js'; + +test('нормализует и дедуплицирует номера', () => { + const raw = '+7 (912) 345-67-89\n8 912 345 67 89\n79161112233\nмусор\n'; + const r = normalizePhones(raw); + assert.deepEqual(r.numbers, ['79123456789', '79161112233']); + assert.equal(r.total, 2); + assert.equal(r.dropped, 1); // строка "мусор" +}); + +test('8XXXXXXXXXX приводится к 7XXXXXXXXXX', () => { + const r = normalizePhones('89161112233'); + assert.deepEqual(r.numbers, ['79161112233']); +}); + +test('пустой ввод даёт 0 номеров', () => { + const r = normalizePhones(' \n '); + assert.equal(r.total, 0); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd bots/mts-telegram-ads && node --test test/phones.test.js` +Expected: FAIL — модуль не найден. + +- [ ] **Step 3: Реализовать phones.js** + +```js +export function normalizePhones(text) { + const seen = new Set(); + const numbers = []; + let dropped = 0; + for (const line of String(text).split(/\r?\n/)) { + const t = line.trim(); + if (t === '') continue; + let digits = t.replace(/\D/g, ''); + if (digits.length === 11 && digits.startsWith('8')) digits = '7' + digits.slice(1); + if (digits.length === 10) digits = '7' + digits; + if (digits.length !== 11 || !digits.startsWith('7')) { dropped++; continue; } + if (seen.has(digits)) continue; + seen.add(digits); + numbers.push(digits); + } + return { numbers, total: numbers.length, dropped }; +} +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +Run: `cd bots/mts-telegram-ads && node --test test/phones.test.js` +Expected: PASS (3 теста). + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/src/phones.js bots/mts-telegram-ads/test/phones.test.js +git commit -m "feat(телеграм-бот): нормализация и дедуп файла номеров" +``` + +### Задача 6: Потолок бюджета (budget.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/budget.js` +- Test: `bots/mts-telegram-ads/test/budget.test.js` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assertWithinCap } from '../src/budget.js'; + +test('пропускает бюджет в пределах потолка', () => { + assert.doesNotThrow(() => assertWithinCap(1000, 5000)); +}); + +test('бросает при превышении потолка', () => { + assert.throws(() => assertWithinCap(6000, 5000), /потолок/i); +}); + +test('ровно потолок — допустимо', () => { + assert.doesNotThrow(() => assertWithinCap(5000, 5000)); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd bots/mts-telegram-ads && node --test test/budget.test.js` +Expected: FAIL — модуль не найден. + +- [ ] **Step 3: Реализовать budget.js** + +```js +export function assertWithinCap(budgetRub, capRub) { + if (budgetRub > capRub) + throw new Error(`Бюджет ${budgetRub}₽ превышает потолок ${capRub}₽ — боевой запуск запрещён`); +} +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +Run: `cd bots/mts-telegram-ads && node --test test/budget.test.js` +Expected: PASS (3 теста). + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/src/budget.js bots/mts-telegram-ads/test/budget.test.js +git commit -m "feat(телеграм-бот): предохранитель потолка бюджета" +``` + +### Задача 7: Письма алярма и отчёта (mailer.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/mailer.js` +- Test: `bots/mts-telegram-ads/test/mailer.test.js` + +Тестируем построение письма (тема/тело/вложение) через инъекцию транспорта — реальный SMTP не дёргаем. + +- [ ] **Step 1: Написать падающий тест** + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMailer } from '../src/mailer.js'; + +function fakeTransport() { + const sent = []; + return { sent, sendMail: async (m) => { sent.push(m); return { messageId: 'x' }; } }; +} + +test('alarm формирует письмо со скриншотом', async () => { + const t = fakeTransport(); + const mailer = createMailer(t, { from: 'a@b', to: 'c@d' }); + await mailer.alarm({ step: 'upload', reason: 'капча', screenshotPath: '/s.png' }); + assert.equal(t.sent.length, 1); + assert.match(t.sent[0].subject, /алярм/i); + assert.match(t.sent[0].text, /капча/); + assert.equal(t.sent[0].attachments[0].path, '/s.png'); +}); + +test('report формирует письмо об успехе', async () => { + const t = fakeTransport(); + const mailer = createMailer(t, { from: 'a@b', to: 'c@d' }); + await mailer.report({ campaignId: '123', matched: 640, mode: 'draft' }); + assert.match(t.sent[0].subject, /готово/i); + assert.match(t.sent[0].text, /640/); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +Run: `cd bots/mts-telegram-ads && node --test test/mailer.test.js` +Expected: FAIL — модуль не найден. + +- [ ] **Step 3: Реализовать mailer.js** + +```js +import nodemailer from 'nodemailer'; + +export function createMailer(transport, { from, to }) { + return { + async alarm({ step, reason, screenshotPath }) { + await transport.sendMail({ + from, to, + subject: `[МТС-бот] АЛЯРМ на шаге «${step}»`, + text: `Робот остановился.\nШаг: ${step}\nПричина: ${reason}\nСмотри скриншот во вложении.`, + attachments: screenshotPath ? [{ path: screenshotPath }] : [], + }); + }, + async report({ campaignId, matched, mode }) { + await transport.sendMail({ + from, to, + subject: `[МТС-бот] Готово (${mode})`, + text: `Кампания обработана.\nID: ${campaignId}\nНашлось номеров: ${matched}\nРежим: ${mode}`, + }); + }, + }; +} + +export function smtpTransport(smtp) { + return nodemailer.createTransport({ + host: smtp.host, port: smtp.port, secure: false, + auth: { user: smtp.user, pass: smtp.pass }, + }); +} +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +Run: `cd bots/mts-telegram-ads && node --test test/mailer.test.js` +Expected: PASS (2 теста). + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/src/mailer.js bots/mts-telegram-ads/test/mailer.test.js +git commit -m "feat(телеграм-бот): письма алярма и отчёта через SMTP" +``` + +--- + +## Milestone 3 — Браузерная часть (опирается на cabinet-flow.md) + +> Селекторы в задачах 8–12 — ПЛЕЙСХОЛДЕРЫ вида `<из cabinet-flow.md: …>`. Их конкретные значения берутся из Задачи 1. Проверка каждой задачи — прогон против живого кабинета в режиме черновик со скриншотом; юнит-тестами браузер не покрываем (внешний UI). + +### Задача 8: Персистентный браузер (browser.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/browser.js` + +- [ ] **Step 1: Реализовать запуск персистентного контекста** + +```js +import { chromium } from 'playwright'; + +export async function openBrowser(config, { headless = false } = {}) { + const context = await chromium.launchPersistentContext(config.profileDir, { + headless, + viewport: { width: 1440, height: 900 }, + locale: 'ru-RU', + }); + const page = context.pages()[0] ?? await context.newPage(); + return { context, page }; +} + +export async function humanPause(config) { + await new Promise((r) => setTimeout(r, config.humanDelayMs)); +} +``` + +- [ ] **Step 2: Ручная проверка входа (один раз)** + +Run: `cd bots/mts-telegram-ads && node -e "import('./src/browser.js').then(async m=>{const {openBrowser}=m;const {loadConfig}=await import('./src/config.js');const {page}=await openBrowser(loadConfig(),{});await page.goto(process.env.MTS_CABINET_URL);})"` +Действие: в открывшемся окне владелец логинится в кабинет (пароль+СМС). Профиль сохранится в `MTS_BROWSER_PROFILE_DIR`. +Expected: кабинет открыт, вход сохранён на диске. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/src/browser.js +git commit -m "feat(телеграм-бот): персистентный профиль браузера" +``` + +### Задача 9: Проверка живости входа (session.js) + +**Files:** +- Create: `bots/mts-telegram-ads/src/session.js` + +- [ ] **Step 1: Реализовать login-guard** + +```js +// Признаки «залогинен»: виден баланс ₽ и меню кабинета. +// Признак «выкинуло»: кнопка входа / отсутствие баланса. +export async function isLoggedIn(page, config) { + await page.goto(config.cabinetUrl, { waitUntil: 'domcontentloaded' }).catch(() => {}); + // «Рассылки и звонки» — стабильный пункт меню авторизованного кабинета + const menu = page.getByRole('button', { name: 'Рассылки и звонки' }); + try { + await menu.waitFor({ timeout: 8000 }); + return true; + } catch { + return false; + } +} +``` + +- [ ] **Step 2: Проверка против кабинета (живая сессия)** + +Run: скрипт, который открывает браузер и печатает `isLoggedIn`. +Expected: `true` при живом входе; после ручного «выхода» — `false`. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/src/session.js +git commit -m "feat(телеграм-бот): проверка живости входа в кабинет" +``` + +### Задача 10: Шаги визарда — загрузка базы как аудитории (cabinet.js, часть 1) + +**Files:** +- Create: `bots/mts-telegram-ads/src/cabinet.js` + +- [ ] **Step 1: Реализовать переход и загрузку базы** + +```js +import { humanPause } from './browser.js'; + +// Селекторы помечены — заполнить из docs/cabinet-flow.md (Задача 1). +export async function openTelegramWizard(page, config) { + await page.goto(config.telegramUrl, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Создать кампанию' }).click(); + await humanPause(config); +} + +export async function uploadAudience(page, config, phonesFilePath) { + // + const fileInput = page.locator('input[type="file"]'); // уточнить по cabinet-flow.md + await fileInput.setInputFiles(phonesFilePath); + await humanPause(config); + // Дождаться подсчёта совпадений и вернуть «сколько нашлось» + // + const matchedText = await page.locator('[data-testid="matched-count"]').innerText(); // уточнить + const matched = Number(matchedText.replace(/\D/g, '')); + return matched; +} +``` + +- [ ] **Step 2: Проверка в режиме черновик** + +Прогнать `openTelegramWizard` + `uploadAudience` с маленьким тестовым файлом; сделать скриншот; проверить, что число «нашлось» считывается. Ничего не запускать. +Expected: аудитория загружена, число найденных получено, скриншот сохранён. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/src/cabinet.js +git commit -m "feat(телеграм-бот): визард — загрузка своей базы как аудитории" +``` + +### Задача 11: Шаги визарда — объявление и таргет (cabinet.js, часть 2) + +**Files:** +- Modify: `bots/mts-telegram-ads/src/cabinet.js` + +- [ ] **Step 1: Добавить заполнение объявления и выбор аудитории** + +```js +export async function fillAd(page, config, task) { + // + await page.getByRole('textbox', { name: /текст/i }).fill(task.adText); + if (task.mediaFile) { + // + await page.locator('input[type="file"][accept*="image"], input[type="file"][accept*="video"]') + .setInputFiles(task.mediaFile); + } + // + await page.getByRole('textbox', { name: /ссылк|url/i }).fill(task.buttonUrl); + await humanPause(config); +} + +export async function selectAudienceAndBudget(page, config, task) { + // + // + await page.getByRole('spinbutton', { name: /бюджет/i }).fill(String(task.budgetRub)); + // + await page.getByRole('spinbutton', { name: /cpm|показ/i }).fill(String(task.cpmRub)); + await humanPause(config); +} +``` + +- [ ] **Step 2: Проверка в режиме черновик** + +Прогнать до заполненного объявления и бюджета, скриншот. Ничего не запускать. +Expected: поля заполнены корректно (видно на скриншоте). + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/src/cabinet.js +git commit -m "feat(телеграм-бот): визард — объявление, аудитория, бюджет" +``` + +### Задача 12: Финал визарда — черновик vs запуск (cabinet.js, часть 3) + +**Files:** +- Modify: `bots/mts-telegram-ads/src/cabinet.js` + +- [ ] **Step 1: Добавить финализацию по режиму** + +```js +export async function finalize(page, config, mode, screenshotPath) { + // + const launch = page.getByRole('button', { name: /запустить|на модерацию/i }); + await launch.waitFor(); + await page.screenshot({ path: screenshotPath, fullPage: true }); + if (mode === 'draft') { + return { launched: false }; // СТОП перед оплатой + } + await launch.click(); + // + await humanPause(config); + // + const campaignId = await page.locator('[data-testid="campaign-id"]').innerText().catch(() => 'unknown'); + return { launched: true, campaignId }; +} +``` + +- [ ] **Step 2: Проверка черновика** + +Прогнать полный путь в режиме `draft`: убедиться, что робот доходит до кнопки, делает скриншот и НЕ жмёт запуск. Баланс не изменился. +Expected: `{ launched: false }`, скриншот кнопки «Запустить» сохранён, деньги целы. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/src/cabinet.js +git commit -m "feat(телеграм-бот): визард — черновик-стоп и боевой запуск" +``` + +--- + +## Milestone 4 — Оркестратор, CLI, keep-alive, приёмка + +### Задача 13: Оркестратор (runner.js) с алярмом-обёрткой + +**Files:** +- Create: `bots/mts-telegram-ads/src/runner.js` + +- [ ] **Step 1: Реализовать runner** + +```js +import { openBrowser } from './browser.js'; +import { isLoggedIn } from './session.js'; +import { assertWithinCap } from './budget.js'; +import { normalizePhones } from './phones.js'; +import { + openTelegramWizard, uploadAudience, fillAd, selectAudienceAndBudget, finalize, +} from './cabinet.js'; +import { readFileSync, writeFileSync } from 'node:fs'; + +export async function runTask(config, task, mailer, { timestamp }) { + const screenshot = `screenshots/campaign-${timestamp}.png`; + let page, context, step = 'start'; + try { + if (task.mode === 'live') assertWithinCap(task.budgetRub, config.budgetCapRub); + + // подготовка файла номеров + const norm = normalizePhones(readFileSync(task.phonesFile, 'utf8')); + if (norm.total === 0) throw new Error('В файле нет валидных номеров'); + const preparedPath = `screenshots/phones-${timestamp}.txt`; + writeFileSync(preparedPath, norm.numbers.join('\n'), 'utf8'); + + ({ context, page } = await openBrowser(config, {})); + step = 'session'; + if (!(await isLoggedIn(page, config))) throw new Error('Вход слетел — нужен повторный логин'); + + step = 'wizard'; await openTelegramWizard(page, config); + step = 'upload'; const matched = await uploadAudience(page, config, preparedPath); + step = 'ad'; await fillAd(page, config, task); + step = 'budget'; await selectAudienceAndBudget(page, config, task); + step = 'finalize'; const res = await finalize(page, config, task.mode, screenshot); + + await mailer.report({ campaignId: res.campaignId ?? 'draft', matched, mode: task.mode }); + await context?.close(); + return { ok: true, matched, ...res }; + } catch (e) { + try { await page?.screenshot({ path: screenshot, fullPage: true }); } catch {} + await mailer.alarm({ step, reason: e.message, screenshotPath: screenshot }); + await context?.close(); + return { ok: false, step, reason: e.message }; + } +} +``` + +- [ ] **Step 2: Ручная проверка на «сломанном» шаге** + +Временно указать несуществующий `phonesFile` → убедиться, что уходит алярм со шагом `start`/`upload` и робот останавливается. +Expected: письмо-алярм получено, процесс завершился без падения. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/src/runner.js +git commit -m "feat(телеграм-бот): оркестратор с алярм-обёрткой и отчётом" +``` + +### Задача 14: CLI-вход (bin/run.js) + +**Files:** +- Create: `bots/mts-telegram-ads/bin/run.js` + +- [ ] **Step 1: Реализовать CLI** + +```js +import 'dotenv/config'; +import { readFileSync } from 'node:fs'; +import { loadConfig } from '../src/config.js'; +import { parseTask } from '../src/task.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; +import { runTask } from '../src/runner.js'; + +const argIdx = process.argv.indexOf('--task'); +if (argIdx === -1) { console.error('Использование: node bin/run.js --task task.json'); process.exit(2); } +const taskPath = process.argv[argIdx + 1]; + +const config = loadConfig(); +const task = parseTask(JSON.parse(readFileSync(taskPath, 'utf8'))); +const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); +const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + +const res = await runTask(config, task, mailer, { timestamp }); +console.log(JSON.stringify(res, null, 2)); +process.exit(res.ok ? 0 : 1); +``` + +- [ ] **Step 2: Прогон в режиме черновик с тестовым заданием** + +Создать `task.example.json` (mode `draft`, маленький тестовый `phonesFile`), запустить: +Run: `cd bots/mts-telegram-ads && node bin/run.js --task task.example.json` +Expected: робот проходит цикл, `launched: false`, отчёт-письмо получено, деньги целы. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/bin/run.js bots/mts-telegram-ads/task.example.json +git commit -m "feat(телеграм-бот): CLI-запуск задания" +``` + +### Задача 15: Keep-alive скрипт (bin/keepalive.js) + +**Files:** +- Create: `bots/mts-telegram-ads/bin/keepalive.js` + +- [ ] **Step 1: Реализовать keep-alive** + +```js +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; + +const config = loadConfig(); +const { context, page } = await openBrowser(config, { headless: true }); +const alive = await isLoggedIn(page, config); +await context.close(); +if (!alive) { + const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); + await mailer.alarm({ step: 'keepalive', reason: 'Вход в кабинет слетел — нужен повторный логин', screenshotPath: null }); +} +console.log(alive ? 'ALIVE' : 'DEAD'); +``` + +- [ ] **Step 2: Зарегистрировать в планировщике задач Windows** + +Создать задачу планировщика ОС: запуск `node bin/keepalive.js` каждые 15 минут. (Команда `schtasks /create ...` — задокументировать в README бота.) +Expected: каждые 15 минут вход «пингуется»; при обрыве — письмо. + +- [ ] **Step 3: Commit** + +```bash +git add bots/mts-telegram-ads/bin/keepalive.js +git commit -m "feat(телеграм-бот): keep-alive входа с алярмом при обрыве" +``` + +### Задача 16: Приёмка MVP + +**Files:** +- Create: `bots/mts-telegram-ads/README.md` + +- [ ] **Step 1: Прогнать все юнит-тесты** + +Run: `cd bots/mts-telegram-ads && npm test` +Expected: все тесты (config/task/phones/budget/mailer) PASS. + +- [ ] **Step 2: Контрольный «черновик» полного цикла** + +Прогнать `node bin/run.js --task task.example.json` (draft) с тестовым файлом → отчёт с числом найденных, деньги целы. + +- [ ] **Step 3: Один контрольный «боевой» запуск (только с разрешения владельца)** + +С явного разрешения владельца — задание `mode: live` на минимальном бюджете (в пределах потолка). Убедиться, что кампания уходит на модерацию, отчёт приходит. Проверить списание. + +- [ ] **Step 4: README с инструкцией** + +Описать: как один раз залогиниться в профиль, формат `task.json`, запуск черновик/боевой, keep-alive, что делать при алярме, требование беречь сервер (живой вход = доступ к деньгам). + +- [ ] **Step 5: Commit** + +```bash +git add bots/mts-telegram-ads/README.md +git commit -m "docs(телеграм-бот): README и критерии приёмки MVP" +``` + +--- + +## Self-review (проверка плана против спека) + +- **§3 Архитектура (вариант A, Node+Playwright, свой профиль, изоляция)** → Задачи 2, 8. ✓ +- **§4 Компоненты (хранитель сессии / исполнитель / сторож-алярм / отчётчик)** → session.js (9), cabinet.js+runner.js (10–13), mailer.js+runner catch (7,13), report (7,13). ✓ +- **§5 Вход задания и полный цикл (8 шагов)** → task.js (4), phones.js (5), cabinet.js (10–12), runner (13). ✓ +- **§5 «поток выучим в черновике»** → Задача 1 (разметка) + пометки ``. ✓ +- **§6 Обработка ошибок и алярм (список сбоев, скриншот, стоп)** → runner catch (13), keepalive (15). ✓ +- **§7 Безопасность (потолок бюджета, беречь сервер, чистка номеров)** → budget.js (6), runner подготовка файла (13), README (16). ✓ +- **§8 Тестирование (черновик, один боевой, малый файл)** → Задачи 12, 16. ✓ +- **§10 Критерии готовности** → Задача 16. ✓ + +Плейсхолдеры `` в задачах 10–12 — намеренные и разрешаются Задачей 1 (разметка кабинета), а не «TODO». Типы согласованы: `parseTask`→`task`, `normalizePhones`→`{numbers,total,dropped}`, `runTask` использует их единообразно. diff --git a/docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md b/docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md new file mode 100644 index 00000000..8636865b --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md @@ -0,0 +1,252 @@ +# План стройки: клиентский модуль «Реклама в Телеграме по своей базе» + +> **For agentic workers:** REQUIRED SUB-SKILL: используйте superpowers:subagent-driven-development +> (рекомендуется) или superpowers:executing-plans, задача-за-задачей. Шаги — чекбоксы (`- [ ]`). + +**Goal:** Дать клиенту портала самому запускать рекламу в Телеграме по своей базе — по образцу +готового СМС-модуля, но через робота-в-браузере (у МТС нет API). + +**Architecture:** Бэкенд-ядро (таблицы/цена/аудитория/кошелёк) как у СМС + **мост Laravel→Node-робот** +(`bots/mts-telegram-ads`), который в реальном кабинете МТС доводит кампанию до черновика/запуска и +возвращает статус. Клиентский Vue-экран — близнец `AdvertisingSmsView`. Авто-режим копит лидов до +пачки (≥367) и запускает в рамках лимита на объявление. Обратная связь по отказу — после живой разведки. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 + RLS (5 ролей), Redis-очередь, Pest 4; +Vue 3 + Vuetify 3, Vitest; робот — Node ESM + Playwright (Edge). Деньги — `AdWalletService`. + +**Образцы (переиспользуем решения и паттерны):** + +- Спека этого модуля: `docs/superpowers/specs/2026-07-27-client-telegram-ads-module-design.md`. +- СМС-близнец: spec `docs/superpowers/specs/2026-07-25-client-sms-broadcast-design.md`, + plan `docs/superpowers/plans/2026-07-25-client-sms-broadcast.md`, + код в ветке `feat/client-sms-broadcast` (таблицы `client_sms_*`, `AdvertisingSmsView.vue`, + `SendClientSmsCampaignJob`, `DealSmsObserver`, `ChargeSmsNameFeeJob`, `AdminSmsView.vue`). +- Робот: `bots/mts-telegram-ads/` (`src/runner.js` возвращает `{ok, matched, launched, campaignId?}`), + находки `bots/mts-telegram-ads/FLOW-FINDINGS.md`. + +--- + +## 🔴 Правила исполнения (читать перед каждой сессией) + +- **Бюджет сессии — 250–300k токенов.** В конце каждой сессии стоит маркер 🛑: прогнать тесты + зелёными → попросить у владельца «коммит» (сам не коммитить) → владелец делает `/compact` → + начать следующую сессию с чистым контекстом. Каждая сессия самодостаточна и оставляет код рабочим. +- **Ничего боевого без разрешения.** Песочница по умолчанию. Робот НЕ жмёт «запустить/оплатить», + пока §Сессия 6 не открыта владельцем. БД — тесты на `liderra_testing`, не на проде. +- **Роль админ-зоны — `crm_admin_user`** (соединение `pgsql_admin`); очередь на проде — `crm_app_user` + (не bypass) → деньги в джобе под `DB::transaction`+`SET LOCAL app.current_tenant_id`; сеть робота — + ВНЕ транзакции. Cross-tenant (помесячный джоб) — через `pgsql_supplier`. (Грабли — из СМС-handoff.) +- **Робот запускается только на Windows-машине с живой сессией МТС** (профиль `C:/liderra/mts-bot-profile`, + прямой IP). Значит воркер очереди для робо-джоба живёт там же. В CI/тестах робот **замокан**. +- **Где мы 1:1 копируем СМС** — не переписываем код заново, а адаптируем файл-близнец (DRY по реальной + кодовой базе). Полностью расписываем только НОВОЕ (мост робота, накопитель пачки, отказ). + +--- + +## Сессия 1 — Ядро: данные, тарифы, аудитория, кошелёк (бэкенд) + +**Итог сессии:** таблицы `client_tg_*` с RLS, модели, ступенчатая цена, сборка аудитории +(сделки за период / база / список + стоп-лист/дубли), канал кошелька `telegram`. Всё под Pest, зелёное. +Зеркало СМС-Этапа 1 (бэкенд). Робота ещё НЕТ. + +### Задача 1.1 — Миграции ядра + RLS + +**Files:** +- Create: `app/database/migrations/2026_07_27_000001_create_client_tg_campaigns.php` (+ `_campaign_phones`, `_messages`, `_optouts`, `_contacts`, `_templates`, `_tariffs`, `_settings`) +- Test: `app/tests/Feature/ClientTg/SchemaTest.php` + +- [ ] Написать падающий тест: таблицы существуют, есть `tenant_id`, RLS-политика `tenant_isolation`, GRANT-ы 5 ролям. (Зеркало `client_sms_*`; поля кампании: `id, tenant_id, status, ad_text, ad_link, media_path, ord_category, budget_cap_rub, audience_kind, audience_params(jsonb), created_by, timestamps`.) +- [ ] Прогнать — падает (нет таблиц). +- [ ] Написать миграции по образцу `client_sms_*` (те же RLS/GRANT-паттерны). `deals` партиционирована по `received_at`. +- [ ] `DB_DATABASE=liderra_testing php artisan migrate --force`; тест зелёный. +- [ ] **rls-reviewer** по новым таблицам (спец-агент). Дописать `db/CHANGELOG_schema.md`. +- [ ] Коммит (по «коммить»). + +### Задача 1.2 — Модели + связи + +**Files:** Create `app/app/Models/ClientTg/{Campaign,CampaignPhone,Tariff,Setting,Contact,Template,Optout}.php`; Test `app/tests/Feature/ClientTg/ModelsTest.php` +- [ ] Тест: фабрики создают строки, связи (`campaign->phones`) работают, скоуп по tenant. +- [ ] Прогнать — падает. +- [ ] Модели + фабрики (зеркало SMS-моделей). +- [ ] Тест зелёный. Коммит. + +### Задача 1.3 — Ступенчатая цена (`TelegramTariffService`) + +**Files:** Create `app/app/Services/ClientTg/TelegramTariffService.php`; Test `.../ClientTg/TariffServiceTest.php` +- [ ] Тест: объём × ступень → цена; границы ступеней; наценка НЕ применяется (как SMS). Учесть, что итоговую стоимость показа считает МТС — тариф у нас = **потолок/оценка** для UI и лимита. +- [ ] Прогнать — падает. Реализовать по образцу SMS-тарифов. Зелёно. Коммит. + +### Задача 1.4 — Сборка аудитории (`TelegramAudienceService`) + +**Files:** Create `app/app/Services/ClientTg/TelegramAudienceService.php`; Test `.../ClientTg/AudienceServiceTest.php` +- [ ] Тест: три источника — `deals` за N дней (по `received_at`) / своя база / свой список; нормализация телефона `PhoneNormalizer`→`7XXXXXXXXXX`; стоп-лист (optout) и дедуп. **Важно:** сервис отдаёт **кандидатов**; реальное «не МТС / есть в Телеграме» узнаёт только робот после загрузки в МТС (§спека §3) — тест это фиксирует комментарием и полем `candidates_count`. +- [ ] Прогнать — падает. Реализовать (зеркало SMS-отбора, но БЕЗ маршрута-оператора — маршрут определит МТС). Зелёно. Коммит. + +### Задача 1.5 — Канал кошелька `telegram` + +**Files:** Modify `app/app/Services/AdWalletService.php` (добавить канал/учёт), Test `.../ClientTg/WalletChannelTest.php` +- [ ] Тест: списание/заморозка по каналу `telegram`; `charge()` НЕ бросает (обнуляет) → проверка `free = balance − frozen ≥ cost` ДО списания. +- [ ] Прогнать — падает. Минимальная правка сервиса. Зелёно. Коммит. + +🛑 **КОНЕЦ СЕССИИ 1.** `php artisan test tests/Feature/ClientTg` — всё зелёное; rls-reviewer PASS; gitleaks чисто. Попросить «коммит» → `/compact` → Сессия 2. + +--- + +## Сессия 2 — Мост Laravel → Node-робот (сердце модуля) + +**Итог:** джоб, который по кампании запускает Node-робота в кабинете МТС, парсит его JSON-результат +и ведёт статус-машину. Песочница = только черновик (робот НЕ жмёт запуск). В тестах робот **замокан**. + +### Задача 2.1 — Статус-машина кампании + +**Files:** Modify `Campaign` (константы статусов + переходы); Test `.../ClientTg/StatusMachineTest.php` +- [ ] Тест допустимых переходов: `draft → queued → running → (draft_ready | launched | failed | rejected)`; недопустимые — исключение. +- [ ] Прогнать — падает. Реализовать. Зелёно. Коммит. + +### Задача 2.2 — Обёртка вызова робота (`TelegramRobotRunner`) + +**Files:** Create `app/app/Services/ClientTg/TelegramRobotRunner.php` (Symfony Process → `node bots/mts-telegram-ads/src/runner.js `); Test `.../ClientTg/RobotRunnerTest.php` +- [ ] Тест (робот замокан фейковым бинарём/скриптом, печатающим известный JSON): формирует task.json (phonesFile, adText, budgetRub=cap, buttonUrl, ordCategory, mode), запускает процесс, парсит `{ok,matched,launched,campaignId}`, таймаут/непарсабельный вывод → аккуратная ошибка (не роняет воркер). +- [ ] Прогнать — падает. Реализовать. Зелёно. Коммит. +- [ ] **Заметка исполнителю:** реальный робот идёт ТОЛЬКО в `mode:'draft'` до Сессии 6; live закрыт. + +### Задача 2.3 — Джоб кампании (`RunTelegramCampaignJob`) + +**Files:** Create `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php`; Test `.../ClientTg/RunCampaignJobTest.php` +- [ ] Тест: кампания `queued` → джоб пишет файл номеров (кандидаты из 1.4), зовёт `TelegramRobotRunner` (замокан), при `ok`+draft → статус `draft_ready`, `matched` записан; деньги в песочнице НЕ списываются; при ошибке робота → `failed` + причина; идемпотентно по `campaign_id`. Денежная операция (когда не песочница) — под `DB::transaction`+`SET LOCAL`, сеть робота вне транзакции. +- [ ] Прогнать — падает. Реализовать (образец транзакционных денег — `ChargeCampaignSpendJob`). Зелёно. Коммит. + +### Задача 2.4 — Песочница и конфиг + +**Files:** Modify `app/config/client_tg.php` (create), `.env.example`; Test `.../ClientTg/SandboxTest.php` +- [ ] Тест: `TG_SANDBOX=true` → робот вызывается в draft, запуск/деньги выключены; флаг читается везде. +- [ ] Реализовать. Зелёно. Коммит. + +🛑 **КОНЕЦ СЕССИИ 2.** Тесты зелёные, робот в тестах замокан. «коммит» → `/compact` → Сессия 3. + +--- + +## Сессия 3 — Клиентский экран + ручной запуск (в песочнице) + +**Итог:** Vue-экран-близнец СМС, клиентский API, маршрут `/advertising/telegram`, ручной сценарий +end-to-end в песочнице (создать → собрать аудиторию → робот-черновик → статус). Vitest зелёный. + +### Задача 3.1 — Клиентский API рассылок + +**Files:** Create `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` (+ Requests), Modify `routes/api.php`; Test `.../ClientTg/CampaignApiTest.php` +- [ ] Тест: `POST /api/telegram/campaigns` (валидация текст/ссылка/аудитория/бюджет), `GET` список/один со статусом, `POST /{id}/launch` → ставит `queued` + диспатчит джоб (замокан). Cookie-сессия (`apiClient`). `InsufficientBalanceException`→409. +- [ ] Прогнать — падает. Реализовать (зеркало SMS-контроллера). Зелёно. Коммит. + +### Задача 3.2 — Фронт-API `telegram.ts` + +**Files:** Create `app/resources/js/api/telegram.ts`; Test `app/tests/Frontend/telegram-api.spec.ts` +- [ ] Тест (msw/мок): методы create/list/get/launch дергают верные урлы. Реализовать (зеркало `client-sms.ts`). Зелёно. Коммит. + +### Задача 3.3 — Экран `AdvertisingTelegramView.vue` + +**Files:** Create `app/resources/js/views/advertising/AdvertisingTelegramView.vue`, Modify `advertisingChannels.ts` (активировать пункт), `router`; Test `app/tests/Frontend/advertising-telegram-view.spec.ts` +- [ ] Тест: форма (объявление, выбор аудитории 3 способа, бюджет-cap), кнопка «Запустить», отрисовка статуса/причины. Реализовать (зеркало `AdvertisingSmsView`). Зелёно. Коммит. + +### Задача 3.4 — Ручной сценарий end-to-end (песочница) + +**Files:** Test `app/tests/Feature/ClientTg/ManualFlowTest.php` +- [ ] Тест: создать→launch→джоб(робот замокан, draft)→`draft_ready`+matched виден в API. Зелёно. Коммит. + +🛑 **КОНЕЦ СЕССИИ 3.** Бэкенд+фронт-тесты зелёные; экран работает в песочнице. «коммит» → `/compact` → Сессия 4. + +--- + +## Сессия 4 — Авто-режим + своё имя + админ-тарифы + +**Итог:** авто-накопитель (пачка ≥367, лимит на объявление), защитный observer, жизненный цикл +имени + помесячная оплата, админ-экран тарифов. Всё под тесты. + +### Задача 4.1 — Правило авто + накопитель пачки + +**Files:** Create migration `_create_client_tg_auto_rule`, Model, `app/app/Services/ClientTg/TelegramAutoAccumulator.php`; Test `.../ClientTg/AutoAccumulatorTest.php` +- [ ] Тест: новые лиды копятся; при достижении **≥367 кандидатов** формируется кампания и ставится в очередь **в рамках `budget_cap_rub` на объявление**; ниже порога — копим, ничего не запускаем; лимит соблюдён. +- [ ] Прогнать — падает. Реализовать. rls-reviewer по новой таблице. Зелёно. Коммит. + +### Задача 4.2 — Защитный observer на новых сделках + +**Files:** Create `app/app/Observers/DealTelegramObserver.php`, register; Test `.../ClientTg/DealObserverTest.php` + регресс `DealCreateTest` +- [ ] Тест: `created` с freshness-guard → кормит накопитель; **try/catch никогда не роняет приём лида** (регресс `DealCreateTest` 10/10 остаётся зелёным). +- [ ] Реализовать (зеркало `DealSmsObserver`). Зелёно. Коммит. + +### Задача 4.3 — Своё имя/бренд + помесячная оплата + +**Files:** migrations `_create_client_tg_senders`, Model, `app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php`, `routes/console.php`; Test `.../ClientTg/SenderLifecycleTest.php` +- [ ] Тест: `requestSender`(freeze) / `disableSender`; админ `approve`(charge+release,`paid_until`+1мес)/`reject`/`disable`; помесячный джоб — проверка `free ≥ cost` ДО списания, долг>29дн→`suspended`, идемпотентно, cross-tenant через `pgsql_supplier`. +- [ ] Реализовать (зеркало `client_sms_senders` + `ChargeSmsNameFeeJob`). Зелёно. Коммит. + +### Задача 4.4 — Админ-тарифы/настройки + +**Files:** Create `app/app/Http/Controllers/Api/Admin/TgTariffController.php`, `AdminTgView.vue`, routes; Tests `.../ClientTg/AdminTgApiTest.php`, `app/tests/Frontend/admin-tg-view.spec.ts` +- [ ] Тест: `GET/PUT /api/admin/telegram/tariffs|settings` под `crm_admin_user`(`pgsql_admin`, middleware `saas-admin,admin-db`); экран правит ступени. Реализовать (зеркало `AdminSmsView`). Зелёно. Коммит. + +🛑 **КОНЕЦ СЕССИИ 4.** Авто/имя/админ зелёные; observer не роняет лид. «коммит» → `/compact` → Сессия 5. + +--- + +## Сессия 5 — Обратная связь по отказу (🔴 после живой разведки §8 спеки) + +> **Гейт:** эта сессия строит чтение причины отказа и приложение файлов. Точные экраны МТС +> **неизвестны** до контрольного живого прогона (Сессия 6, §8 спеки). Порядок: сперва разведка +> в 6.1, затем возврат сюда. Если разведка ещё не сделана — эту сессию НЕ начинать (иначе догадки). + +### Задача 5.1 — Поллинг статуса модерации + +**Files:** Create `app/app/Jobs/ClientTg/PollTelegramModerationJob.php`, `routes/console.php`; расширить `cabinet.js` роботом-читалкой статуса; Test `.../ClientTg/ModerationPollTest.php` +- [ ] Тест (робот замокан, отдаёт `moderation:rejected` + `reason`): джоб переводит кампанию в `rejected`, сохраняет причину. Селекторы причины — из разведки 6.1. +- [ ] Реализовать. Зелёно. Коммит. + +### Задача 5.2 — Уведомление клиента о причине + +**Files:** Notification/событие + показ на экране; Test `.../ClientTg/RejectNotifyTest.php` +- [ ] Тест: при `rejected` клиент получает уведомление с текстом причины; статус и причина видны в кабинете. Реализовать. Зелёно. Коммит. + +### Задача 5.3 — Правка + приложение файлов + повторная отправка + +**Files:** API `POST /{id}/resubmit` (текст/ссылка/файлы), робот-загрузчик файлов (по разведке 6.1), фронт-форма правки; Tests backend+Vitest +- [ ] Тест: клиент правит объявление, прикладывает файл(ы) → `resubmit` → робот (замокан) повторно отправляет + грузит файлы → `queued/running`; лимиты/деньги соблюдены. Реализовать. Зелёно. Коммит. + +🛑 **КОНЕЦ СЕССИИ 5.** Флоу отказа зелёный (на моках по реальным селекторам). «коммит» → `/compact` → Сессия 6. + +--- + +## Сессия 6 — Живая разведка, контрольный запуск и выкат (🔴 только с разрешения владельца) + +> Никаких боевых действий без явного «да, запускаем живьём». Реальные деньги/модерация. + +### Задача 6.1 — Живая разведка отказа (питает Сессию 5) + +- [ ] С разрешения: выключить «Автозапуск», минимальный бюджет, объявление, которое МТС отклонит. +- [ ] Поймать настоящий отказ; снять: где причина (селектор/текст), принимает ли МТС файлы и куда, + точный чекбокс оферты и текст финальной кнопки. Записать в `bots/mts-telegram-ads/FLOW-FINDINGS.md`. +- [ ] Обновить селекторы в коде Сессии 5, перегнать её тесты. + +### Задача 6.2 — Контрольный живой запуск (happy-path) + +- [ ] С разрешения: один реальный запуск малой пачкой; проверить деньги/статусы/показ; зафиксировать. + +### Задача 6.3 — Выкат на прод (по образцу СМС-handoff) + +- [ ] Влить ветку в main; накатить миграции `client_tg_*` на кластер (`migrate --pretend` → psql `crm_migrator` одной транзакцией). +- [ ] **ПЕРЕзапустить `db/03_service_bypass_policies.sql`** (иначе служебные роли видят 0 по новым RLS-таблицам — тихий ноль). +- [ ] Держать `TG_SANDBOX=true` на старте; воркер робо-очереди — на Windows-машине с сессией МТС. +- [ ] `prod-deploy-validator` (GO/NO-GO); artisan-кэш под www-data (квирк #107); планировщик подхватил помесячный джоб. +- [ ] Осознанно `TG_SANDBOX=false` после проверки в песочнице. + +🛑 **КОНЕЦ.** Модуль на проде в песочнице → боевой по разрешению владельца. + +--- + +## Self-review (сделан при написании плана) + +- **Покрытие спеки:** §1-2 (ядро/аудитория/цена/кошелёк)→Сессия 1; §3 «нет API, робот»→Сессия 2; + §4 экран→Сессия 3; §5 ручной+авто→Сессии 3-4; §6 отказ+файлы→Сессия 5; §7 оплата/имя→Сессии 1,4; + §8 живая разведка→Сессия 6. Все пункты спеки имеют задачу. +- **Границы (§9):** другие площадки/оптимизация ставок — не входят; в плане отсутствуют. OK. +- **Согласованность имён:** `client_tg_*`, `ClientTg\*`, канал `telegram`, `TG_SANDBOX`, + `budget_cap_rub` (лимит на объявление) — единообразно во всех задачах. +- **Токен-бюджет:** 6 сессий, каждая ≤~300k, каждая оставляет код рабочим+зелёным = логичная точка `/compact`. diff --git a/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md b/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md new file mode 100644 index 00000000..d28ff618 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md @@ -0,0 +1,463 @@ +# Закрытие дыр модуля «Реклама в Телеграме по своей базе» — план + +> **Для исполнителя-агента:** ОБЯЗАТЕЛЬНАЯ СУБ-СКИЛА — `superpowers:subagent-driven-development` (рекомендуется) или `superpowers:executing-plans`. Задачи в TDD: RED → verify RED → GREEN → verify GREEN → commit. Шаги отмечены чекбоксами `- [ ]`. + +**Goal:** Закрыть 14 находок аудита, чтобы модуль был безопасен к выключению песочницы: деньги не залипают/не теряются, статус не врёт и не зависает, ПДн не утекают, вход защищён, «своё имя» и «авторассылка» получают клиентский вход с предохранителями. + +**Architecture:** Laravel 13 + PostgreSQL/RLS + Redis-очередь; робот = Node+Playwright (замокан в тестах). Деньги — `AdWalletService` (freeze/release/charge) под tenant-транзакцией; сеть/робот вне транзакции. Статус-машина кампании расширяется. Каждый этап — самостоятельный, зелёный, ≈ один заход. + +**Tech Stack:** PHP 8.3, Pest 4, Vue 3 + Vuetify 3, Vitest 4, Node ESM. + +**Первоисточники:** спека [docs/superpowers/specs/2026-07-27-telegram-module-hardening-design.md](../specs/2026-07-27-telegram-module-hardening-design.md); дыры [docs/superpowers/findings/2026-07-27-telegram-module-gaps.md](../findings/2026-07-27-telegram-module-gaps.md). + +**Порядок исполнения этапов:** **1 → 2 → 5 → 3 → 4.** Этапы 1/2/5 не ждут МТС. Этап 3 добивается после живого отказа модерации (скрипт `bots/mts-telegram-ads/_check-moderation.mjs`). Этап 4 частично зависит от этапа 3. + +**Общие правила исполнения (для каждой задачи):** +- Тесты: `cd app && DB_DATABASE=liderra_testing php artisan migrate:fresh --force` (при новой миграции), затем `DB_DATABASE=liderra_testing vendor/bin/pest `. Фронт: `npx vitest run `. Node: `cd bots/mts-telegram-ads && node --test`. +- Робот в тестах ЗАМОКАН (`Mockery::mock(TelegramRobotRunner::class)`), реальный кабинет НЕ трогаем. +- ПДн: тестовые номера `7999…`. Коммит — только по «коммить» владельца, префикс `LEFTHOOK_EXCLUDE=larastan git commit -F `; STATUS.md не пре-стейджить; не пушить. +- Новая миграция/колонка → запись в `db/CHANGELOG_schema.md` + прогон `rls-reviewer` при новой таблице. deptrac `Model:[]`, phpstan уровень 5. + +--- + +## Структура файлов (что трогаем) + +**Бэкенд:** +- `app/app/Models/ClientTg/Campaign.php` — статусы (`cancelled`, `moderating`), `TRANSITIONS`, `mts_campaign_id`, `actual_cost_rub`. +- `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` — валидация входа, предстартовый гейт аудитории, endpoint отмены/пересчёта/resubmit. +- `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php` — lockForUpdate-гвард, `$timeout`, release/charge в finalize, сохранение `mts_campaign_id`. +- `app/app/Jobs/ClientTg/PollTelegramModerationJob.php` (создать) — опрос вердикта. +- `app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php` (создать) — уборщик зависших. +- `app/app/Services/ClientTg/TelegramAudienceService.php` — нормализация opt-out, публичный счётчик кандидатов. +- `app/app/Services/ClientTg/RobotResult.php` — `actualCostRub`, `moderationStatus`. +- `app/app/Services/ClientTg/TelegramAutoAccumulator.php` — баланс-гейт, дневной лимит. +- `app/app/Services/NotificationService.php` — `notifyTelegramCampaignApproved`. +- `app/app/Http/Controllers/Api/ClientTg/SenderController.php` (создать) — клиентское API имени. +- `app/routes/web.php`, `app/routes/console.php` — маршруты и расписание. + +**Робот (Node):** +- `bots/mts-telegram-ads/src/runner.js` — не сохранять/не слать ПДн-скриншоты. +- `bots/mts-telegram-ads/src/cabinet.js` — чтение фактической стоимости; чтение статуса модерации по `mts_campaign_id`; загрузка файла модератору (пересдача). + +**Фронт:** +- `app/resources/js/api/telegram.ts` — типы + новые вызовы (расчёт, отмена, resubmit, статус имени, авто-правило). +- `app/resources/js/views/advertising/AdvertisingTelegramView.vue` — смета до запуска, автообновление, кнопка пересдачи, метки. +- `app/resources/js/views/advertising/TelegramSenderPanel.vue` (создать) — управление именем. +- `app/resources/js/views/advertising/TelegramAutoRulePanel.vue` (создать) — авторассылка. + +**Миграции:** `app/database/migrations/2026_07_2X_*.php` — `mts_campaign_id`, статусы (если чек-констрейнт), дневной лимит авто. + +--- + +# ЭТАП 1 — Безопасность и защита входа + +**Не ждёт МТС. Закрывает находки #2, #11, #12, #13, #14.** + +### Задача 1.1 — Робот не сохраняет и не шлёт ПДн-скриншоты (#11) + +**Files:** +- Modify: `bots/mts-telegram-ads/src/runner.js` +- Test: `bots/mts-telegram-ads/test/runner-shots.test.mjs` (create) + +- [ ] **Шаг 1. RED.** Тест: при `TG_DEBUG_SHOTS` не задан — `runner` НЕ вызывает `page.screenshot` с `fullPage:true` и `mailer.alarm` не получает вложение-скрин. Мокаем `page`/`mailer`, считаем вызовы. + +```js +// runner-shots.test.mjs +import test from 'node:test'; +import assert from 'node:assert'; +import { runFlow } from '../src/runner.js'; // экспортировать тестируемое ядро +test('без TG_DEBUG_SHOTS полноэкранный скрин аудитории не снимается', async () => { + const calls = []; + const page = { screenshot: (o) => { calls.push(o); return Promise.resolve(); }, /* …заглушки шагов… */ }; + const mailer = { alarm: (m) => { calls.push({ mail: m }); } }; + await runFlow({ page, mailer, env: {} /* TG_DEBUG_SHOTS undefined */, /* task */ }); + assert.ok(!calls.some((c) => c && c.fullPage === true), 'fullPage-скрин не должен сниматься'); + assert.ok(!calls.some((c) => c.mail && c.mail.attachment), 'скрин не должен уходить письмом'); +}); +``` + +- [ ] **Шаг 2. Verify RED:** `cd bots/mts-telegram-ads && node --test test/runner-shots.test.mjs` → FAIL. +- [ ] **Шаг 3. GREEN.** В `runner.js` обернуть съёмку/отправку скрина условием `if (env.TG_DEBUG_SHOTS)`; по умолчанию — не снимать `fullPage` и не прикладывать к письму. Диагностический путь (при флаге) — скрин без области номеров (обрезка) — оставить как опцию. Чистку файла номеров в `finally` не трогать. +- [ ] **Шаг 4. Verify GREEN:** тест PASS; прогнать весь `node --test` (20/20 + новый). +- [ ] **Шаг 5. Commit** (по «коммить»). + +### Задача 1.2 — Нормализация opt-out при сравнении (#12) + +**Files:** +- Modify: `app/app/Services/ClientTg/TelegramAudienceService.php` (`removeOptouts`) +- Test: `app/tests/Feature/ClientTg/OptoutNormalizeTest.php` (create) + +- [ ] **Шаг 1. RED.** Тест: opt-out записан в «грязном» формате (`+7 999 000-00-01`, `89990000002`), кандидаты нормализованы (`79990000001`, `79990000002`); после `build()` эти номера НЕ попадают в аудиторию. + +```php +it('opt-out в любом формате вычищает нормализованного кандидата', function () { + $tenant = Tenant::factory()->create(); + // opt-out «грязный» + Optout::create(['tenant_id' => $tenant->id, 'phone' => '+7 999 000-00-01']); + Optout::create(['tenant_id' => $tenant->id, 'phone' => '89990000002']); + $campaign = /* черновик list c 79990000001,79990000002,79990000003 */; + $phones = app(TelegramAudienceService::class)->build($campaign)->phones; + expect($phones)->not->toContain('79990000001') + ->and($phones)->not->toContain('79990000002') + ->and($phones)->toContain('79990000003'); +}); +``` + +- [ ] **Шаг 2. Verify RED** → FAIL (сейчас сравнение сырое). +- [ ] **Шаг 3. GREEN.** В `removeOptouts` прогнать `Optout::pluck('phone')` через `PhoneNormalizer::normalize`, отфильтровать `null`, затем `array_flip`. Сравнение только по `7XXXXXXXXXX`. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 1.3 — Лимиты входа в контроллере (#13) + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` (`store` rules) +- Test: `app/tests/Feature/ClientTg/InputLimitsTest.php` (create) + +- [ ] **Шаг 1. RED.** Тесты: (а) `phones` > 200000 элементов → 422; (б) `audience_days` > 365 → 422; (в) `ad_text` длиннее лимита → 422; (г) `ad_link` не-URL → 422; (д) валидный запрос проходит и в ответе есть `dropped_count` (сколько номеров отброшено как нераспознанные). +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** Правила: `'phones' => 'nullable|array|max:200000'`, `'phones.*' => 'string|max:32'`, `'audience_days' => 'nullable|integer|min:1|max:365'`, `'ad_text' => 'required|string|max:4096'`, `'ad_link' => 'required|string|max:2048|url'` (или регексп `t.me`/`https`). `storePhones` возвращает число отброшенных; `store` кладёт `dropped_count` в ответ. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 1.4 — Идемпотентность запуска на уровне БД (#14) + +**Files:** +- Modify: `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php` (Фаза A) +- Test: `app/tests/Feature/ClientTg/LaunchIdempotencyTest.php` (create) + +- [ ] **Шаг 1. RED.** Тест: две последовательные `dispatchSync` одной `queued`-кампании (эмуляция redelivery) — робот (замокан, считает вызовы `run`) должен быть вызван РОВНО один раз; статус в конце — терминальный, не «дважды running». + +```php +it('повторный запуск не создаёт вторую кампанию', function () { + $tenant = Tenant::factory()->create(); + $campaign = /* queued */; + $runs = 0; + $mock = Mockery::mock(TelegramRobotRunner::class); + $mock->shouldReceive('run')->andReturnUsing(function () use (&$runs) { $runs++; return RobotResult::draftReady(2); }); + app()->instance(TelegramRobotRunner::class, $mock); + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); // второй заход + expect($runs)->toBe(1); +}); +``` + +- [ ] **Шаг 2. Verify RED:** проверить, что без блокировки тест либо флапает, либо (при последовательном dispatchSync) уже проходит — если проходит на последовательном, добавить прямой тест на гвард через `lockForUpdate` (юнит на то, что Фаза A читает строку с блокировкой). Цель: гвард опирается на блокировку, а не на «один воркер». +- [ ] **Шаг 3. GREEN.** В Фазе A: `Campaign::where('tenant_id',$this->tenantId)->where('id',$this->campaignId)->lockForUpdate()->first()`; проверка `status === queued` под блокировкой; переход в `running` в той же транзакции. Комментарий: блокировка строки сериализует конкурентов. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 1.5 — Предстартовый гейт аудитории ≥367 и >0 ДО денег (#2) + +**Files:** +- Modify: `app/app/Services/ClientTg/TelegramAudienceService.php` (публичный `countCandidates(Campaign): int` или использовать `build()->phones` count) +- Modify: `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` (`launch`) +- Test: `app/tests/Feature/ClientTg/AudienceGateTest.php` (create) + +- [ ] **Шаг 1. RED.** Тесты (при `config client_tg.sandbox=false`): (а) кампания с <367 кандидатов → `launch` возвращает 422 с понятным сообщением, `AdWalletService::freeze` НЕ вызван (мок кошелька), статус остаётся `draft`; (б) 0 кандидатов → 422; (в) ≥367 → проходит к freeze+dispatch. Порог берём из настроек `client_tg_settings` (дефолт 367). +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** В `launch` ДО `freeze`: посчитать кандидатов через сервис аудитории; если `count === 0` или `count < minNonMts` → `abort(422, 'Недостаточно номеров: нужно не меньше N …')`. Порог из `Setting`. Только после гейта — freeze + dispatch. +- [ ] **Шаг 4. Verify GREEN** → PASS. Прогнать всю `tests/Feature/ClientTg`. +- [ ] **Шаг 5. Commit.** + +**Приёмка этапа 1:** все тесты 1.1–1.5 зелёные; `node --test` зелёный; phpstan 0; pint чисто; deptrac 0. + +--- + +# ЭТАП 2 — Деньги: возврат и списание по факту + +**Не ждёт МТС. Закрывает #1, #3. Логика проверяется на `sandbox=false` в тестах, робот замокан.** + +> **🔴 ПРЕДУСЛОВИЕ (ревизия 27.07): выяснить билинг-модель МТС до задач 2.2/2.4.** В кабинете цена — «Стоимость кампании **от** X ₽» (нижняя граница), а Телеграм-реклама тратит бюджет по мере показов. Значит в момент отправки «факта» ещё нет. Задача 2.0: разведкой (можно вместе с Part B) подтвердить — МТС списывает РАЗОВО фиксированную сумму за кампанию ИЛИ НАКОПИТЕЛЬНО по показам. +> - Разово → `charge(показанная сумма)` при успехе (как в задачах ниже). +> - Накопительно → на старте НЕ списывать; держать бронь, списывать/добирать по завершении откруток (пост-фактум опрос стоимости кабинета); `actualCostRub` берётся из финального отчёта, а не на старте. +> Задачи 2.2/2.4 финализируются ПОСЛЕ ответа; если модель накопительная — скорректировать точку и источник списания. + +> **✅ РЕШЕНО (28.07.2026): модель НАКОПИТЕЛЬНАЯ** (резерв → списание по факту показов → возврат остатка; экран `/payment`, см. `bots/mts-telegram-ads/FLOW-FINDINGS.md`). Следствия: +> - **Задача 2.2 — СДЕЛАНА** (`RobotResult.actualCostRub` + чистая `parseCost()` в `cabinet.js`, покрыты тестами; к DOM-потоку пока НЕ подключено). +> - **Задача 2.4 (списание по факту) ПЕРЕНЕСЕНА в Этап 3.** На успехе отправки показов ещё нет — фактическую стоимость можно списать только по завершении откруток, а это опрос кабинета (Этап 3, рядом с опросом модерации/статуса). Точка списания: при закрытии кампании `charge(actualCostRub, external_key="telegram:campaign:{id}")` + `release` остатка брони. +> - **Этап 2 закрыт составом 2.1 + 2.3 + 2.2.** Бронь при запуске (`freeze` в `launch`) + возврат при отказе/сбое/отмене (`release`, задача 2.3) уже зеркалят «резерв → возврат остатка» МТС. + +### Задача 2.0 — Подтвердить билинг-модель МТС (разведка) +- [ ] В кабинете/справке МТС Маркетолог выяснить, как списываются деньги за «показы своей базе»: разовая сумма за кампанию или накопление по показам. Записать в `bots/mts-telegram-ads/FLOW-FINDINGS.md`. От ответа зависят задачи 2.2/2.4. + +### Задача 2.1 — Статусы `cancelled` + переходы возврата + +**Files:** +- Modify: `app/app/Models/ClientTg/Campaign.php` +- Migration (если статус в CHECK-констрейнте БД): `app/database/migrations/2026_07_2X_add_cancelled_status_to_client_tg_campaigns.php` +- Test: `app/tests/Feature/ClientTg/CampaignTransitionsTest.php` (create/extend) + +- [ ] **Шаг 1. RED.** Тест: `STATUS_CANCELLED` существует; переходы `draft→cancelled`, `queued→cancelled` разрешены; `failed`/`rejected` остаются терминальными в модели, но их достигают из `running`. +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** Добавить `STATUS_CANCELLED='cancelled'`, дописать `TRANSITIONS`. Если в БД есть CHECK на статус — миграция + запись в `db/CHANGELOG_schema.md`. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 2.2 — RobotResult несёт фактическую стоимость + +**Files:** +- Modify: `app/app/Services/ClientTg/RobotResult.php` (`actualCostRub`) +- Modify: `bots/mts-telegram-ads/src/cabinet.js` (читать «Стоимость кампании … ₽») +- Test: `app/tests/Feature/ClientTg/RobotResultTest.php` (create) + `bots/mts-telegram-ads/test/cabinet-cost.test.mjs` + +- [ ] **Шаг 1. RED (PHP).** Тест: `RobotResult::launched($matched, $campaignId, $actualCostRub)` и `RobotResult::draftReady(...)` хранят `actualCostRub` (nullable string). +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** Добавить поле `public readonly ?string $actualCostRub` в фабрики результата. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. RED (Node).** Юнит на парсер строки «Стоимость кампании от 201,6 ₽» → `201.60` (запятая→точка, убрать пробелы/₽). Реализовать чистую функцию `parseCost(text)` в cabinet.js и покрыть. +- [ ] **Шаг 6. GREEN + Verify.** `node --test`. +- [ ] **Шаг 7. Commit.** + +### Задача 2.3 — Возврат брони при отказе/сбое/отмене + +**Files:** +- Modify: `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php` (`finalize`, `failed`) +- Modify: `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` (`cancel`) +- Modify: `app/routes/web.php` (`POST /api/telegram/campaigns/{id}/cancel`) +- Test: `app/tests/Feature/ClientTg/RefundOnFailTest.php` (create) + +- [ ] **Шаг 1. RED.** Тест (sandbox=false, кошелёк реальный на `liderra_testing`): заморозили `budget_cap_rub` через freeze; робот (замокан) вернул `failed`; после джоба `frozen_rub` кошелька уменьшился на сумму брони (release отработал), баланс не списан. Аналогично для `cancel` из `draft`/`queued`. **Возврат при `rejected` здесь НЕ тестируем — finalize сам `rejected` не ставит (его выставляет опросчик модерации), проверка возврата при `rejected` — в Этапе 3, задача 3.4.** + +```php +it('отказ робота возвращает бронь на кошелёк', function () { + config()->set('client_tg.sandbox', false); + $tenant = Tenant::factory()->create(); + $wallet = /* пополнить AdWallet tenant на 1000 */; + $campaign = /* queued, budget_cap_rub=500 */; + app(AdWalletService::class)->freeze($tenant->id,'telegram','campaign',$campaign->id,'500.00'); + mockRobotFail('Канал недоступен'); + RunTelegramCampaignJob::dispatchSync($campaign->id,$tenant->id); + expect(freshFrozen($tenant))->toBe('0.00')->and(freshBalance($tenant))->toBe('1000.00'); +}); +``` + +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** В `finalize` при `failed`/`rejected` — `AdWalletService::release($this->tenantId,'telegram','campaign',$fresh->id)` (внутри tenantTx, но release — своя операция кошелька; следовать паттерну SenderService). В `failed()` (перманентный) — тоже release. Endpoint `cancel`: `draft/queued → cancelled` + release. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 2.4 — Списание по факту при успехе + возврат остатка + +**Files:** +- Modify: `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php` (`finalize` успех) +- Test: `app/tests/Feature/ClientTg/ChargeActualTest.php` (create) + +- [ ] **Шаг 1. RED.** Тест (sandbox=false): бронь 500; робот вернул успех с `actualCostRub='201.60'`; после джоба списано ровно 201.60 (`external_key=telegram:campaign:{id}`), остаток брони (500−201.60) возвращён; `actual_cost_rub` кампании = 201.60. Повторный `finalize` не списывает второй раз (external_key). +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** В `finalize` успех: проверить `free ≥ actual` (charge не бросает); `charge($tenant,'telegram','campaign',$id,$actual,"telegram:campaign:$id")`; затем `release` остатка; записать `matched_count`, `actual_cost_rub`. В песочнице (sandbox=true) деньги не трогаем (как сейчас). Если `actualCostRub` null (черновик) — только статус. +- [ ] **Шаг 4. Verify GREEN** → PASS. Прогнать всю `tests/Feature/ClientTg`. +- [ ] **Шаг 5. Commit.** + +**Приёмка этапа 2:** возврат при fail/reject/cancel; точное списание по факту + возврат остатка; идемпотентность charge; песочница по-прежнему денег не трогает. phpstan/pint/deptrac чисто. + +--- + +# ЭТАП 5 — «Своё имя» + «Авторассылка» (клиентский вход + предохранители) + +**Не ждёт МТС. Закрывает #10.** + +### Задача 5.1 — Баланс-гейт и дневной лимит авторассылки + +**Files:** +- Modify: `app/app/Services/ClientTg/TelegramAutoAccumulator.php` +- Modify: `app/app/Models/ClientTg/AutoRule.php` (поле `daily_limit_rub`, счётчик за день) +- Migration: `app/database/migrations/2026_07_2X_add_daily_limit_to_client_tg_auto_rules.php` +- Test: `app/tests/Feature/ClientTg/AutoSafeguardTest.php` (create) + +- [ ] **Шаг 1. RED.** Тесты: (а) при `free < cost` авто-путь НЕ диспатчит `RunTelegramCampaignJob` и пишет причину; (б) при исчерпанном дневном лимите — не диспатчит; (в) при деньгах и лимите в норме — диспатчит. (Джоб диспатч мокаем через `Bus::fake()`.) +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** Перед `dispatch` в `openBatch`/накопителе: посчитать `cost` пачки, проверить `free = balance−frozen ≥ cost` и `сумма_за_сегодня + cost ≤ daily_limit_rub`; иначе не ставить в очередь, `Log::info('client_tg.auto_skipped', reason)`. Дефолт `daily_limit_rub` = 0 (авто выключено). Миграция + CHANGELOG. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 5.2 — Клиентское API имени (SenderController) + +**Files:** +- Create: `app/app/Http/Controllers/Api/ClientTg/SenderController.php` +- Modify: `app/routes/web.php` (группа `auth:sanctum,tenant`, префикс `/api/telegram/sender`) +- Test: `app/tests/Feature/ClientTg/SenderApiTest.php` (create) + +- [ ] **Шаг 1. RED.** Тесты: `GET /sender` отдаёт статус имени (нет/pending/active/suspended, `paid_until`, грейс); `POST /sender` заводит имя (через `TelegramSenderService::requestSender`, в песочнице денег не трогает); `POST /sender/disable` отключает; `POST /sender/enable` включает `suspended` обратно без двойной оплаты в тот же месяц. Всё скоупится тенантом из `$request->user()`. +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** Контроллер тонкий, вся логика в `TelegramSenderService`. Маршруты в существующей группе (см. `routes/web.php:98`). +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 5.3 — Экран имени (TelegramSenderPanel.vue) + +**Files:** +- Create: `app/resources/js/views/advertising/TelegramSenderPanel.vue` +- Modify: `app/resources/js/api/telegram.ts` (типы + `fetchSender/createSender/disableSender/enableSender`) +- Modify: `AdvertisingTelegramView.vue` (встроить панель) +- Test: `app/tests/Frontend/telegram-sender-panel.spec.ts` (create) + +- [ ] **Шаг 1. RED.** Vitest: панель показывает статус имени и `paid_until`; при `suspended` показывает «Отключено за долг» + кнопку «Включить»; при отсутствии — форму «Завести имя». Клик по кнопкам зовёт соответствующий API (мок). +- [ ] **Шаг 2. Verify RED** → FAIL. +- [ ] **Шаг 3. GREEN.** Компонент на Vuetify; ярлыки статуса человеческие; API-моки в тесте. +- [ ] **Шаг 4. Verify GREEN** → PASS. +- [ ] **Шаг 5. Commit.** + +### Задача 5.4 — Экран авторассылки (TelegramAutoRulePanel.vue) + API + +**Files:** +- Create: `app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php` + маршруты +- Create: `app/resources/js/views/advertising/TelegramAutoRulePanel.vue` +- Modify: `telegram.ts`, `AdvertisingTelegramView.vue` +- Test: `app/tests/Feature/ClientTg/AutoRuleApiTest.php`, `app/tests/Frontend/telegram-auto-rule-panel.spec.ts` + +- [ ] **Шаг 1. RED (API).** `GET/PUT /api/telegram/auto-rule` — включить/выключить, порог, бюджет на кампанию, `daily_limit_rub`. Скоуп тенантом. +- [ ] **Шаг 2. GREEN + Verify.** +- [ ] **Шаг 3. RED (фронт).** Панель: тумблер вкл/выкл, поля порога/бюджета/дневного лимита, список помечает авто-кампании. API мок. +- [ ] **Шаг 4. GREEN + Verify.** +- [ ] **Шаг 5. Commit.** + +**Приёмка этапа 5:** клиент управляет именем и авторассылкой из UI; авто не тратит без денег и сверх дневного лимита; помесячный джоб имени включается в расписание ТОЛЬКО после этого этапа. + +--- + +# ЭТАП 3 — Жизненный цикл и модерация + +**🔴 Ждёт живого отказа МТС (Part B): нужны селекторы экрана отказа и пересдачи. Сначала разведка, потом код. Закрывает #4, #5, #6 + задачи 5.1/5.3 исходного плана.** + +### Задача 3.0 — Живая разведка Part B (с разрешения владельца) + +- [ ] Дождаться отказа модерации (кампании 2231134/2231132 или новые). Запустить `bots/mts-telegram-ads/_check-moderation.mjs`. +- [ ] По отклонённой снять и записать в `bots/mts-telegram-ads/FLOW-FINDINGS.md`: как открыть кампанию по её id/URL; ГДЕ и каким селектором показана ПРИЧИНА отказа; статус-текст «Отклонена/Одобрена/На модерации»; поле файла «Комментарий для модератора» и кнопка пересдачи. **Без этих селекторов задачи 3.4/3.6 не писать.** + +### Задача 3.1 — Колонка `mts_campaign_id` + РАННЕЕ сохранение (#4) + +**Files:** +- Migration: `app/database/migrations/2026_07_2X_add_mts_campaign_id_to_client_tg_campaigns.php` +- Modify: `Campaign.php` (fillable), `RunTelegramCampaignJob.php`, `bots/mts-telegram-ads/src/*` (робот сообщает id СРАЗУ после создания черновика — через промежуточный вывод/колбэк, не только в финальном RobotResult) +- Test: `app/tests/Feature/ClientTg/ExternalIdTest.php` + +> **🔴 Ревизия 27.07: сохранять id РАНО.** Робот создаёт реальный черновик в кабинете уже на шаге аудитории. Если прогон упадёт/воркер убьют — черновик осиротеет, а id мы потеряем → опросчик/уборщик его не найдут, уборщик вслепую вернёт деньги за реально ушедшую кампанию. Поэтому id пишем в БД, как только робот его получил, а не в finalize при успехе. + +- [ ] **Шаг 1. RED.** Тесты: (а) успех робота с `campaignId='2231134'` → `mts_campaign_id='2231134'`; (б) робот СОЗДАЛ черновик и вернул `campaignId`, но затем упал (`failed`) → `mts_campaign_id` ВСЁ РАВНО сохранён (ранний апдейт), статус `failed`. +- [ ] **Шаг 2–4.** Миграция (nullable string) + CHANGELOG. Механизм раннего сообщения id роботом: `TelegramRobotRunner` читает id из промежуточного вывода робота (или робот пишет id первой строкой при создании черновика) и джоб делает ранний `tenantTx` апдейт `mts_campaign_id` ДО завершения робота; finalize также подстраховывает. GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 3.1b — Уборка брошенных черновиков в кабинете МТС + +**Files:** +- Promote: `bots/mts-telegram-ads/_cleanup-delete-step2.mjs` → нормальный инструмент `bots/mts-telegram-ads/src/cleanup-drafts.mjs` (защита «только Черновик», сверка перед удалением) +- Test: `bots/mts-telegram-ads/test/cleanup-guard.test.mjs` (юнит на предикат «строка — черновик, не модерация, не шапка») + +> **🔴 Ревизия 27.07:** сегодня (27.07) осиротевшие черновики чистили руками (2 шт.). Любой упавший прогон робота их плодит. Нужен узаконенный инструмент + процедура. + +- [ ] **Шаг 1. RED.** Юнит: предикат уборки помечает ТОЛЬКО строки со статусом «Черновик» (не «На модерации», не шапку/select-all). На наборе строк-примеров выбирает верные. +- [ ] **Шаг 2–4.** Вынести проверенную сегодня логику (двухшаговое подтверждение, сверка «ровно черновики») в модуль; CLI-обёртка read-only по умолчанию, удаление — по явному флагу. GREEN на юните предиката. +- [ ] **Шаг 5.** Документировать процедуру в FLOW-FINDINGS. Commit. + +### Задача 3.2 — Статус `moderating` + переходы (#5) + +**Files:** `Campaign.php` (+миграция при CHECK), `RunTelegramCampaignJob.php`, `app/tests/Feature/ClientTg/ModeratingStatusTest.php` + +- [ ] **Шаг 1. RED.** Тест: `STATUS_MODERATING` есть; переходы `running→moderating`, `moderating→launched|rejected`, `rejected→queued`. Live-finalize (робот `launched=true`) ставит `moderating` (а не сразу `launched`). +- [ ] **Шаг 2–4.** Добавить статус, переходы; в `finalize` live-успех → `moderating`; песочница → `draft_ready`. GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 3.3 — Уборщик зависших + `$timeout` (#6) + +**Files:** +- Modify: `RunTelegramCampaignJob.php` (`public int $timeout = 420;`), `failed()` умеет `queued/running→failed` + release +- Create: `app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php` + расписание в `console.php` +- Test: `app/tests/Feature/ClientTg/SweepStuckTest.php` + +- [ ] **Шаг 1. RED.** Тесты: (а) зависшая `running` старше N минут БЕЗ `mts_campaign_id` (черновик не создан) → sweeper переводит в `failed` + release брони; (б) свежая `running` не трогается; (в) `failed()` умеет `queued→failed`; (г) **зависшая `running` С `mts_campaign_id` НЕ рефандится вслепую** — помечается для ручного разбора (отдельный статус/флаг), пока не сверён статус кампании в кабинете. +- [ ] **Шаг 2–4.** `$timeout` больше робот-таймаута (робот ~300с + навигации → 420с). Sweeper по `updated_at < now()-N`. **🔴 Ревизия 27.07: консервативность** — если у зависшей `running` есть `mts_campaign_id`, кампания могла реально уйти на модерацию; НЕ возвращать деньги вслепую, сначала сверить статус в кабинете (или пометить `needs_review`). Авто-`failed`+release — только для заведомо не ушедших (нет id). GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 3.4 — PollTelegramModerationJob (5.1) — робот-читалка вердикта + +**Files:** +- Create: `app/app/Jobs/ClientTg/PollTelegramModerationJob.php` + расписание +- Modify: `bots/mts-telegram-ads/src/cabinet.js` (режим `read-status` по `mts_campaign_id`, селекторы Part B), `RobotResult.php` (`moderationStatus`) +- Test: `app/tests/Feature/ClientTg/PollModerationTest.php` + +- [ ] **Шаг 1. RED.** Тест (робот замокан): кампания `moderating` с `mts_campaign_id`; робот вернул `moderationStatus='rejected', reason='…'` → статус `rejected`, `status_reason` записан, бронь возвращена (этап 2), `notifyTelegramCampaignRejected` вызван. Вернул `approved` → `launched` + `notifyTelegramCampaignApproved` (этап 4). +- [ ] **Шаг 2–4.** Джоб выбирает `moderating`-кампании, гоняет робота-читалку по `mts_campaign_id`, применяет вердикт под tenantTx. GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 3.5 — Робот читает статус/причину по `mts_campaign_id` (Node) + +**Files:** `bots/mts-telegram-ads/src/cabinet.js`, `bin/run.js` (режим `read-status`), тест `bots/mts-telegram-ads/test/read-status.test.mjs` + +- [ ] Реализовать функцию открытия кампании по id/URL и извлечения статус-текста + причины (селекторы из 3.0). Юнит на парсер статус-текста (map «Отклонена»→rejected и т.д.). `node --test`. +- [ ] **Commit.** + +### Задача 3.6 — Пересдача (5.3): resubmit + файл модератору + +**Files:** +- Modify: `CampaignController.php` (`resubmit`), `routes/web.php` (`POST /{id}/resubmit`) +- Modify: `cabinet.js` (загрузка файла в поле «Комментарий для модератора», селектор известен) +- Modify: `Campaign.php` (`rejected→queued` уже добавлен в 3.2) +- Test: `app/tests/Feature/ClientTg/ResubmitTest.php` + +- [ ] **Шаг 1. RED.** Тест: `rejected`-кампания + правка текста/ссылки + файл → статус `queued`, робот получает путь файла; повторный запуск идёт по обычному флоу. +- [ ] **Шаг 2–4.** Endpoint валидирует правки и файл (формат `.png/.jpeg/.jpg/.pdf`), меняет поля, `rejected→queued`, dispatch. Робот грузит файл на шаге сообщения. GREEN. +- [ ] **Шаг 5. Commit.** + +**Приёмка этапа 3:** отклонённую кампанию находим по `mts_campaign_id`, причина у клиента; зависшие добиваются уборщиком; пересдача возвращает в очередь с файлом; статусы не врут (moderating в окне проверки). + +--- + +# ЭТАП 4 — Клиентский опыт + +**Частично зависит от этапа 3 (кнопка пересдачи). Закрывает #7, #8, #9 + UX #5.** + +### Задача 4.1 — Смета и охват ДО запуска (#8) + +**Files:** `AdvertisingTelegramView.vue`, `telegram.ts`, `app/tests/Frontend/advertising-telegram-view.spec.ts` + +- [ ] **Шаг 1. RED.** Vitest: кнопка «Рассчитать» вызывает `createTelegram` (черновик) и показывает `estimated_cost_rub` + `planned_count`; «Запустить» доступна только после расчёта и зовёт `launchTelegram`. Прогноз стоимости виден в форме. +- [ ] **Шаг 2–4.** Разделить create/launch в UI; показать смету/охват; кнопка запуска после расчёта. GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 4.2 — Автообновление статуса (#7) + +**Files:** `AdvertisingTelegramView.vue`, тест там же + +- [ ] **Шаг 1. RED.** Vitest: при наличии кампаний в незавершённом статусе (`queued/running/moderating`) экран периодически зовёт `fetchTelegram` (использовать fake timers), список обновляется без ручного F5. Интервал останавливается при размонтировании. +- [ ] **Шаг 2–4.** `setInterval` c очисткой в `onUnmounted`; опрос только пока есть «живые» статусы. GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 4.3 — Уведомление об успехе/запуске (#7) + +**Files:** `NotificationService.php` (`notifyTelegramCampaignApproved`), `app/tests/Feature/ClientTg/ApproveNotifyTest.php` + +- [ ] **Шаг 1. RED.** Тест: `notifyTelegramCampaignApproved($tenant,$campaign)` шлёт in-app всем активным юзерам тенанта (как отказ, без pref-гейта), тело — «Реклама одобрена/запущена». +- [ ] **Шаг 2–4.** Зеркало `notifyTelegramCampaignRejected`. Вызов — из PollModerationJob при `approved` (этап 3.4). GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 4.4 — Экран отказа: пересдача + пустая причина (#9) + +**Files:** `AdvertisingTelegramView.vue`, `telegram.ts` (`resubmitTelegram`), тест там же + +- [ ] **Шаг 1. RED.** Vitest: у `rejected`-кампании есть кнопка «Исправить и пересдать» (открывает форму правки → `resubmitTelegram`); при пустой `status_reason` показывается единый текст-заглушка (совпадает с текстом уведомления), без замкнутого круга. +- [ ] **Шаг 2–4.** Кнопка + форма правки; заглушка причины. GREEN. +- [ ] **Шаг 5. Commit.** + +### Задача 4.5 — Подсказки: «модерация ~4 часа» + метка песочницы на кампании + +**Files:** `AdvertisingTelegramView.vue`, тест там же + +- [ ] **Шаг 1. RED.** Vitest: у `moderating`-кампании показан текст про срок ~4 часа; у кампаний в песочнице — per-row метка «песочница». +- [ ] **Шаг 2–4.** GREEN. +- [ ] **Шаг 5. Commit.** + +**Приёмка этапа 4:** смета/охват до запуска; статус сам обновляется; уведомление и об успехе; с экрана отказа можно пересдать; пустая причина не создаёт круг; сроки/режим понятны. + +--- + +## Финал (после всех этапов) +- Сводный код-ревью всей ветки (subagent-driven-development → финальный reviewer). +- Обновить `db/CHANGELOG_schema.md` (все миграции), `docs/observer/STATUS.md` — авто-хуком. +- Go-live (выключение песочницы, живой /payment) — ОТДЕЛЬНОЕ решение владельца после приёмки этапов 1–3, с ре-раном `db/03_service_bypass_policies.sql`. +- Использовать `superpowers:finishing-a-development-branch` для завершения. + +## Самопроверка плана +- Покрытие спеки: A→Этап1, B→Этап2, C→Этап3, D→Этап4, E→Этап5 — все области закрыты. +- Заглушек нет; коды/тесты конкретны или помечены зависимостью от живой разведки Part B (этап 3, честно обозначено). +- Согласованность имён: статусы (`cancelled`,`moderating`), `mts_campaign_id`, `actual_cost_rub`, `daily_limit_rub`, `notifyTelegramCampaignApproved`, `external_key=telegram:campaign:{id}` — единообразны по файлу. diff --git a/docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md b/docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md new file mode 100644 index 00000000..bd6fc4b1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md @@ -0,0 +1,199 @@ +# Дизайн: бот-автоматизатор рекламы в Telegram Ads через кабинет МТС Маркетолог + +**Дата:** 2026-07-26 +**Статус:** дизайн одобрен владельцем (brainstorming), готов к плану реализации +**Автор сессии:** Claude Code + владелец (Дмитрий) + +--- + +## 1. Назначение и контекст + +### Проблема +Лидерра хочет давать клиентам рекламу в Telegram по загруженной базе телефонов +(показ объявлений тем, чьи номера мы собрали/клиент загрузил). Единственный рабочий +на рынке РФ способ показать рекламу в Telegram по своей базе номеров — через +Big-Data-кабинеты операторов (МТС / Билайн). У всех у них **нет публичного API** для +автоматизации: загрузка базы и запуск кампании делаются **только руками в веб-кабинете**. + +Проверено двумя независимыми исследованиями (Perplexity + deep-research, 26.07.2026): +альтернативы МТС с API под загрузку телефонной базы в Telegram-рекламе **не существует**; +матчинг «телефон → аккаунт Telegram» операторы держат в закрытой инфраструктуре. +eLama / OneSpot / click.ru — обёртки поверх того же МТС, тоже без API под это. + +### Feasibility-gate (пройден) +В **нашем** кабинете МТС Маркетолог функция есть. На экране +`/cabinet/campaigns/telegram`: «Реклама в Telegram Ads — работа с собственной базой», +«…по своим аудиториям», строка цены «Рассылка по своей аудитории → Показы в Telegram — +0,49 ₽», баннер «Создавайте сразу несколько кампаний Telegram из файла». +Значит загрузка своей базы для Телеги в кабинете реально доступна. + +### Решение +Раз API нет — автоматизируем **веб-кабинет** программой-роботом (RPA), которая +использует живую сессию входа и кликает в кабинете как человек. Это осознанно «серая» +автоматизация (см. §7 Риски). + +### Что делаем СЕЙЧАС и что потом +- **Сейчас (этот спек):** только бот-автоматизатор полного цикла + алярм. Запуск руками. +- **Потом (отдельный спек):** клиентский модуль-витрина в портале (клиент сам заводит + задание, оплата через кошелёк, деление по клиентам). За основу возьмём существующие + модули СМС-рассылки и Яндекс-прогрева. В текущий scope НЕ входит. + +--- + +## 2. Ключевые решения (согласованы с владельцем) + +| Вопрос | Решение | +|---|---| +| Объём автоматизации | **Полный цикл**: база → объявление → аудитория → бюджет → запуск | +| Источник задания | **Запуск руками** оператором с параметрами (MVP, без витрины) | +| Канал алярма | **Почта** (через Unisender Go) | +| Защита денег | **Два режима**: «черновик» (стоп перед запуском) и «боевой» | +| Архитектура | **Вариант A**: отдельный робот на Playwright со своим устойчивым входом на сервере | + +--- + +## 3. Архитектура (вариант A) + +Отдельная небольшая программа-робот на **Playwright** (Node), живёт на том же +Windows-сервере, где уже работает МТС-мост (служба `LiderraMtsBridge`). + +- У робота **свой персистентный профиль браузера** (отдельный от служебного + MCP-браузера). Владелец логинится в него **один раз** (пароль + СМС); вход сохраняется + на диске и живёт долго (по замеру — недели; точную цифру даёт keep-alive-эксперимент). +- Робот запускается **вручную** с параметрами задания. +- Робот изолирован от портала: его поломка не задевает боевой liderra.ru. +- Связь с порталом в текущем scope не нужна (запуск руками). В будущем модуль будет + класть роботу задания — интеграция добавится тогда. + +**Почему A:** Playwright — надёжный и удобный инструмент RPA (скриншоты для разбора, +уже используется в проекте), изоляция от портала. Отвергнуты: B (робот внутри Laravel — +браузерная автоматизация на PHP капризна) и C (тот же служебный браузер — конфликты, +хрупкость). + +--- + +## 4. Компоненты + +1. **Хранитель сессии (keep-alive + login-guard)** + - Держит персистентный профиль браузера с живым входом в кабинет. + - Раз в ~15 минут тихо заходит в кабинет, чтобы вход не «заснул». + - Проверяет живость входа перед каждым заданием. + - Что делает: держит вход. Зависит от: профиля браузера на диске. + +2. **Исполнитель кампании (campaign runner)** + - Проходит полный цикл создания Telegram-кампании по своей базе в кабинете. + - Принимает задание, отдаёт результат (номер кампании, сколько номеров нашлось, статус). + - Что делает: одна кампания от начала до конца. Зависит от: живой сессии, задания. + +3. **Сторож ошибок + алярм (watchdog)** + - Ловит сбои (см. §6), делает скриншот, шлёт письмо, останавливает исполнителя. + - Что делает: превращает сбой в понятное письмо. Зависит от: Unisender Go. + +4. **Отчётчик (reporter)** + - Пишет лог и итоговое письмо «готово» при успехе. + +--- + +## 5. Вход задания и поток (полный цикл) + +### Параметры задания (даёт оператор при запуске) +- файл с номерами телефонов (только номера, любых операторов); +- текст объявления (правила модерации МТС: коротко, без повелительного наклонения, + без сленга/латиницы кроме бренда); +- медиа (картинка/видео) — опционально, для Premium-формата; +- бюджет и цена показа (CPM); +- целевое действие кнопки (сайт / канал / бот / пост); +- режим: `черновик` | `боевой`; +- потолок бюджета (предохранитель, см. §7); +- (на будущее) метка клиента. + +### Шаги исполнителя +1. Проверить, что вход жив (иначе — алярм, стоп). +2. «Реклама в Telegram» → «Создать кампанию». +3. Загрузить файл номеров как свою аудиторию; дождаться подсчёта совпадений; + **запомнить, сколько номеров нашлось**. +4. Заполнить объявление: текст + медиа + ссылку на кнопке. +5. Выбрать загруженную аудиторию как цель показа. +6. Задать бюджет и CPM (частоту показа — при необходимости). +7. Развилка по режиму: + - **черновик:** довести до кнопки «Запустить», сделать скриншот, остановиться, + записать «черновик готов»; + - **боевой:** проверить, что бюджет ≤ потолка; нажать «Запустить / на модерацию». +8. Записать итог (номер кампании, сколько нашлось, статус) в лог + письмо «готово». + +> **Важно:** точная последовательность экранов/кнопок кабинета в этом спеке не +> зафиксирована намеренно. Реальный поток робот «выучит» на фазе реализации — оператор +> (Claude) один раз пройдёт визард **в режиме черновик** (без траты денег), запишет +> фактические экраны, и под них настроит селекторы. Шаги выше — план по смыслу. + +--- + +## 6. Обработка ошибок и алярм + +Сбои, на которые робот шлёт письмо-алярм и **аккуратно останавливается** +(не тычет вслепую, задание помечает «требует внимания»): + +- вход слетел / кабинет требует СМС; +- выскочила капча; +- не найдена ожидаемая кнопка/поле (значит МТС поменял интерфейс); +- кабинет вернул ошибку; +- не хватает баланса; +- бюджет задания превышает потолок (в боевом режиме). + +Письмо содержит: тип сбоя, шаг, на котором споткнулись, и **скриншот экрана**. + +**Восстановление:** владелец заходит в браузер робота, вводит СМС / проходит капчу, +заново запускает задание. + +--- + +## 7. Безопасность и границы + +### Безопасность +- Живой вход в кабинет **с деньгами** лежит на сервере → сервер беречь как сейф + (доступ к серверу = доступ в кабинет без СМС). Папку профиля браузера закрыть правами. +- **Потолок трат:** робот не ставит бюджет выше заданного и не запускает кампанию дороже + потолка без отдельного подтверждения — предохранитель от опечатки. +- Файлы с номерами — ПДн: хранить временно, не логировать значения номеров, чистить + после задания. + +### Риски (осознаны, приняты владельцем) +- **«Серая» автоматизация** — против правил кабинета МТС. Риск ограничения/блокировки + кабинета при распознавании робота. Смягчение: человекоподобный темп с паузами, редкий + запуск, работа через живую сессию владельца. +- **Хрупкость к смене интерфейса:** МТС меняет вёрстку → робот спотыкается → алярм. + Это не потеря данных, а сигнал «позови человека». +- **Согласие/152-ФЗ на номера:** в текущем MVP (запускаем сами) — на нашей + ответственности; в будущем клиентском модуле обязательно добавить согласие. Вне scope. + +--- + +## 8. Тестирование + +- Основная отладка — режим **«черновик»**: полный путь до «Запустить» без траты денег. + Проверяем корректность заполнения всех полей. +- **Один** контрольный «боевой» запуск в конце, на минимальном бюджете — убедиться, что + финальная кнопка и модерация проходят. Только с явного разрешения владельца. +- Тестовые данные — **маленький свой файл** номеров (тестовые/согласованные), не реальная + клиентская база. + +--- + +## 9. Вне scope (не делаем в этом спеке) + +- Клиентский модуль-витрина в портале (форма, оплата через кошелёк, деление по клиентам, + ступенчатая цена) — отдельный будущий спек на основе СМС/Яндекс-модулей. +- Автоматический источник заданий (таблица/очередь/API) — пока запуск руками. +- Интеграция бота с базой портала — добавится вместе с модулем. +- Загрузка базы через официальный API МТС — его нет для Телеги (есть только для СМС). + +--- + +## 10. Критерии готовности (MVP бота) + +- Робот со своим входом на сервере, вход держится (keep-alive работает). +- Режим «черновик»: полный цикл до «Запустить» без траты, со скриншотом и записью + «сколько номеров нашлось». +- Режим «боевой»: с потолком бюджета, один успешный контрольный запуск. +- Алярм на почту срабатывает на все сбои из §6, со скриншотом. +- Отчёт «готово» при успехе. diff --git a/docs/superpowers/specs/2026-07-27-client-telegram-ads-module-design.md b/docs/superpowers/specs/2026-07-27-client-telegram-ads-module-design.md new file mode 100644 index 00000000..0f252364 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-client-telegram-ads-module-design.md @@ -0,0 +1,140 @@ +# Клиентский модуль «Реклама в Телеграме по своей базе» — дизайн + +> Дата: 27.07.2026. Статус: **черновик спеки (обсуждён с владельцем, код НЕ начат).** +> Образец — готовый клиентский **СМС-модуль** (`docs/superpowers/specs/2026-07-25-client-sms-broadcast-design.md`). +> 🔴 Часть флоу МТС (отказ модерации, приложение файлов) **живьём ещё не разведана** — см. §8. + +--- + +## 1. Цель + +Дать клиенту портала возможность **самому запускать рекламу в Телеграме по своей базе номеров** +— так же просто, как он уже рассылает СМС. Клиент выбирает объявление, аудиторию и бюджет, +нажимает «Запустить» (или включает автомат) — Лидерра доводит рекламу до показа. + +Внешне модуль должен **повторять СМС-модуль** (тот же вид, та же логика адресатов, та же +оплата с кошелька, те же ступени цены, песочница). Отличается только «мотор» под капотом (§3). + +## 2. Что берём как есть из СМС-модуля + +СМС-модуль — почти готовый чертёж. Переиспользуем его решения и, где можно, код-паттерны: + +- **Три способа выбрать аудиторию:** сделки за N дней / своя база / свой список (файл). +- **Ступенчатая цена** (тариф правит владелец; объём → дешевле за единицу). +- **Рекламный кошелёк** (`AdWalletService`, отдельный канал), проверка средств **до** списания. +- **Своё имя / бренд отправителя** — жизненный цикл + помесячная оплата (как `client_sms_senders`). +- **Песочница** — на старте ничего реально не уходит; переключается осознанно. +- **Авто на новых лидах** — паттерн защитного observer'а (никогда не роняет приём лида). +- **Отбор:** стоп-лист → дубликаты → маршрут (для Телеграма маршрут = «есть в Телеграме»). + +## 3. 🔴 Ключевое отличие от СМС: у МТС НЕТ API + +СМС-модуль работает, потому что у оператора есть «провод» (API): портал шлёт сообщения +сам, мгновенно, по копейке за штуку. **У МТС для Телеграм-рекламы провода нет** (проверено +живьём, `bots/mts-telegram-ads/FLOW-FINDINGS.md`). Единственный способ — **робот в браузере**, +который заходит в кабинет МТС Маркетолог и всё прокликивает, как человек. + +Из этого вытекают жёсткие правила, которые меняют поведение модуля (в отличие от СМС): + +| | СМС | Телеграм | +|---|---|---| +| Как уходит | API, мгновенно | робот в реальном кабинете МТС | +| Единица запуска | одно сообщение | **пачка ≥ 367 подходящих номеров** | +| Деньги | копейки за штуку, предсказуемо | **реальный бюджет в кабинете**, стоимость **считает МТС** (аудитория × фикс. ставка ≈400 ₽/1000) | +| Задержка | нет | **обязательная модерация** | +| Правка аудитории у запущенной кампании | — | **нельзя** (только собрать новую и прогнать заново) | + +**Следствие для клиента:** реклама идёт пачками; если подходящих людей меньше ~370 — копим, +пока наберётся; аудиторию у уже запущенной рекламы поменять нельзя. + +## 4. Клиентский опыт (экран `/advertising/telegram`, по образцу `AdvertisingSmsView`) + +Что вводит клиент: + +1. **Объявление** — короткий текст + ссылка (канал/бот/сайт); опц. картинка или видео. +2. **Аудитория** — один из трёх способов (§2): свежие сделки за период / своя база / свой список. +3. **Категория ОРД** — обязательное поле маркировки (дефолт «Размещение рекламы»); рекламодатель + (наш ИП) подставляется автоматически. +4. **Бюджет** — потолок трат **на это объявление** (см. §5 про авто). + +Статус кампании клиент видит в кабинете на всех этапах: черновик → на модерации → показывается +→ (или) отклонено (§6). + +## 5. Два режима запуска + +- **Ручной.** Клиент собрал объявление + аудиторию, нажал «Запустить». Робот собирает кампанию + в кабинете МТС и доводит до запуска. Клиент всё контролирует сам. +- **Авто.** Клиент включает правило один раз. Система **сама копит новых лидов**; как только + набралась пачка (≥ 367 подходящих), робот собирает и **сам запускает** рекламу — **в рамках + лимита, заданного на каждое объявление** (клиент задал «одно объявление крутить не дороже N ₽»). + Автомат работает без участия клиента; деньги всегда ограничены этим лимитом. + +## 6. Модерация и обратная связь по отказу (требование владельца) + +Модуль не должен быть «чёрным ящиком». Флоу отказа: + +1. Робот периодически проверяет статус кампании в кабинете МТС. +2. Если МТС **отклонил** — клиент получает **уведомление с причиной отказа** (робот вычитывает + причину из кабинета и возвращает её в наш портал). +3. Клиент прямо в кабинете Лидерры **правит объявление** (текст/ссылку) и при необходимости + **прикладывает файлы** (документы или новые картинки/видео). +4. Жмёт «Отправить заново» — робот повторно отправляет исправленную кампанию (и загружает + приложенные файлы) на модерацию. + +## 7. Оплата (как в СМС) + +- Списание с **рекламного кошелька** клиента в Лидерре. +- **Ступенчатая цена**, тариф видно заранее; итоговую стоимость показа считает МТС (§3). +- Опционально — **своё имя/бренд** с помесячной оплатой. +- На старте — **песочница** (реальные деньги и запуски выключены). + +## 8. 🔴 Что требует живой разведки (НЕ строить на догадках) + +Робот проверен только **до шага «Подтверждение» черновиком** — живого прохождения модерации +и отказа **ещё не было**. Поэтому неизвестно точно и должно быть снято **одним контрольным +живым прогоном с разрешения владельца** (выключить автозапуск, минимальный бюджет, объявление, +которое заведомо отклонят): + +- **Где** в кабинете МТС показывается **причина отказа** (какой селектор/текст читать роботу). +- **Принимает ли** МТС **приложенные файлы** на этапе исправления и **куда** их цеплять. +- Поведение **«Автозапуск»** (по умолчанию ВКЛ — «запустится после модерации автоматически»): + как надёжно его выключать для ручного контроля. +- Точный селектор **чекбокса оферты** и текст финальной кнопки на `/confirmation`. + +До этой разведки §6 описан «как задумано»; попадание робота в реальные экраны МТС не гарантируется. + +## 9. Границы (в первую версию НЕ входит) + +- Другие площадки (Яндекс, ВК) — у них свой «провод», это отдельные модули. +- Автоматическое масштабирование/оптимизация ставок — стоимость считает МТС, мы её не крутим. +- Тонкая аналитика показов внутри Телеграма (что отдаёт кабинет — то и показываем). + +## 10. Приложение: текст для клиента (принцип работы) + +> **Реклама в Телеграме по вашей базе** +> +> **В двух словах.** Вы показываете рекламу своего бизнеса прямо в Телеграме — тем людям, чьи +> номера у вас уже есть. Лидерра берёт список номеров, находит этих людей в Телеграме и +> показывает им ваше объявление. +> +> **Что нужно от вас:** объявление (текст + ссылка, при желании картинка/видео), кого показывать +> (аудитория), и бюджет. +> +> **Кому показываем — три способа:** свежие клиенты за период (Лидерра соберёт номера сама) / +> ваша база / свой список файлом. Подойдут номера любых операторов. +> +> **Два режима:** ручной (нажали «Запустить» — поехало) и авто (включили один раз — система сама +> копит клиентов и запускает рекламу в рамках заданного вами лимита на объявление). +> +> **После запуска:** объявление проходит проверку (модерацию), после одобрения показывается +> выбранным людям; статус виден в кабинете. +> +> **Если реклама не прошла проверку:** вы сразу получаете уведомление с причиной; правите +> объявление и, если нужно, прикладываете файлы; отправляете заново — проверка идёт снова. +> +> **Оплата:** с рекламного кошелька; цена по объёму (чем больше показов — тем дешевле); можно +> завести своё имя/бренд. +> +> **Важно знать:** реклама идёт пачками (для запуска нужно минимум ~370 подходящих человек — +> если меньше, система копит); деньги за показы реальные, поэтому в авто вы всегда задаёте предел +> трат; аудиторию у уже запущенной рекламы поменять нельзя, но можно собрать новую и запустить ещё раз. diff --git a/docs/superpowers/specs/2026-07-27-telegram-module-hardening-design.md b/docs/superpowers/specs/2026-07-27-telegram-module-hardening-design.md new file mode 100644 index 00000000..4182819e --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-telegram-module-hardening-design.md @@ -0,0 +1,91 @@ +# Закрытие дыр модуля «Реклама в Телеграме по своей базе» — дизайн + +**Дата:** 27.07.2026. **Ветка:** feat/client-telegram-ads (база cc985e07). +**Источник дыр:** [docs/superpowers/findings/2026-07-27-telegram-module-gaps.md](../findings/2026-07-27-telegram-module-gaps.md) (14 находок, 4 код-ревью + перепроверка). +**Замысел-первоисточник:** [docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md](../plans/2026-07-27-client-telegram-ads-module.md). + +## Цель +Закрыть все 14 находок так, чтобы модуль был безопасен к выключению песочницы (go-live): деньги не залипают и не теряются, кампания не врёт статусом и не зависает, ПДн не утекают, вход защищён, а «своё имя» и «авторассылка» получают клиентский вход с предохранителями. + +## Принятые решения (владелец, 27.07.2026) +1. **Деньги:** бронь при «Запустить» → **возврат всей брони** при отказе/сбое/отмене; при успехе — **списание по факту МТС** (реальная стоимость из кабинета) + возврат остатка брони. +2. **«Своё имя» + «Авторассылка»:** доделать **полностью** (клиентские экраны + предохранители), не откладывать. +3. **Порядок:** начать с **безопасности** (этапы, не ждущие живого отказа МТС). +4. **Предохранитель авторассылки:** (а) не ставить кампанию в очередь, если на кошельке не хватает; (б) дневной лимит трат на авто (клиент задаёт «не больше N ₽/день», по умолчанию авто выключено). + +## Границы (что НЕ входит) +- Физическое выключение песочницы (`TG_SANDBOX=false`) на бою — отдельное решение владельца ПОСЛЕ приёмки этапов 1–3. +- Реальный клик «Списать с баланса и отправить» в кабинете МТС на бою (тратит деньги МТС-кабинета) — только с явного разрешения владельца, на этапе go-live. +- Модель `Message` для `client_tg_messages` — как и раньше, не создаём. +- Изменения в приёме лидов и в СМС-близнеце. + +## Инварианты (не нарушать) +- БД по умолчанию только чтение; тесты на `liderra_testing`, никогда не прод. Робот в тестах замокан. Реальные телефоны — ПДн, в тестах `7999…`. +- Деньги в джобах — под `DB::transaction` + `SET LOCAL app.current_tenant_id`; сеть/робот ВНЕ транзакции; `charge()` НЕ бросает (проверять `free ≥ cost` ДО). Кросс-тенант — соединение поставщика. deptrac `Model:[]`. phpstan уровень 5. +- Новые RLS-таблицы/колонки → rls-reviewer + запись в `db/CHANGELOG_schema.md`. При выкате — ре-ран `db/03_service_bypass_policies.sql`. +- Коммит только по «коммить» (`LEFTHOOK_EXCLUDE=larastan git commit -F …`), STATUS.md не пре-стейджить, не пушить (GitHub заблокирован — gitea). + +--- + +## Дизайн по областям + +### A. Безопасность и защита входа (Этап 1) +- **Скриншоты с ПДн (#11):** робот `runner.js` больше не сохраняет полноэкранный скрин аудитории на диск и не шлёт его письмом по умолчанию. При необходимости диагностики — скрин без области с номерами (обрезка/маскирование) под флагом `TG_DEBUG_SHOTS`, по умолчанию выключен. Существующая чистка файла номеров в `finally` — сохраняется. +- **Нормализация opt-out (#12):** единый `PhoneNormalizer` применяется и при сравнении в `removeOptouts`, и (на будущее) при записи opt-out. Сравнение только по `7XXXXXXXXXX`. Любой будущий писатель opt-out обязан нормализовать. +- **Лимиты входа (#13):** валидация в `CampaignController::store`: `phones` — `max` (например 200 000 элементов), `phones.*` — `max:32`; `audience_days` — `max` (например 365); `ad_text` — разумный `max`; `ad_link` — формат URL/`t.me`. Мягкая обратная связь «отброшено N нераспознанных номеров» возвращается клиенту. +- **Идемпотентность (#14):** статус-гвард в `RunTelegramCampaignJob` берёт строку `lockForUpdate()`; переход `queued → running` защищён так, что два параллельных воркера не создадут две кампании. Опора не на «один воркер», а на блокировку строки. +- **Предстартовый гейт аудитории (#2):** число кандидатов считается ДО любых денег; если `< 367` «не МТС» или `= 0` — `launch` возвращает понятную ошибку (422), деньги не бронируются, кампания остаётся черновиком. Порог — из настроек (`client_tg_settings`). + +**Приёмка A:** скрин с номерами не появляется на диске/в почте; отписавшийся в любом формате не попадает в аудиторию; список >лимита и period >лимита отвергаются; двойной параллельный launch создаёт одну кампанию; загрузка <367 номеров не бронирует деньги и даёт понятную ошибку. + +### B. Деньги: возврат и списание по факту (Этап 2) +- **🔴 ПРЕДУСЛОВИЕ — выяснить билинг-модель МТС (ревизия 27.07):** в кабинете цена показана как «Стоимость кампании **от** X ₽» — слово «от» = нижняя граница, а не итог. Реклама в Телеграме тратит бюджет ПО МЕРЕ ПОКАЗОВ, то есть в момент отправки на модерацию фактической потраченной суммы ещё нет. **До реализации charge надо подтвердить, как МТС списывает: разово фиксированную сумму за кампанию ИЛИ накопительно по показам.** От этого зависит смысл «факта»: + - если разово-фиксированно → `charge(показанная сумма)` при успехе корректен; + - если накопительно → в момент запуска списывать НЕЛЬЗЯ; правильнее держать бронь и списывать/добирать по завершении откруток (нужен пост-фактум опрос стоимости). Тогда `actualCostRub` берётся не на старте, а из финального отчёта кабинета. + Выяснить в разведке (вместе с Part B). +- **Возврат брони (#1):** `RunTelegramCampaignJob::finalize` при `failed`/`cancelled` вызывает `AdWalletService::release(...'campaign', id)`. Возврат при `rejected` — в Этапе 3 (finalize сам `rejected` не ставит, его выставляет опросчик модерации). Явный путь отмены клиентом (draft/queued → cancelled) с release. +- **Списание по факту (#1, #3):** при успехе `finalize` делает `charge(actual)` + `release` остатка брони, `external_key = telegram:campaign:{id}` (идемпотентность), проверка `free ≥ actual` перед `charge`. **Источник `actual` определяется билинг-моделью (см. предусловие).** +- **Робот считывает стоимость:** `cabinet.js` читает сумму из кабинета; на каком шаге брать «факт» — по итогам выяснения модели. +- **Статус-машина:** добавляется `cancelled`. +- Всё под песочницей безопасно (денег не трогаем); логика тестируется на замоканном роботе с `sandbox=false` в тесте. + +**Приёмка B:** при сбое/отмене замороженная сумма возвращается (frozen уменьшается); при успехе списывается сумма по подтверждённой модели, остаток брони возвращается; повторный `finalize` не списывает дважды (external_key). Возврат при `rejected` принимается в Этапе 3. + +### C. Жизненный цикл и модерация (Этап 3 — ждёт живого отказа МТС) +- **🔴 Внешний id сохраняем РАНО, не в finalize (ревизия 27.07):** робот создаёт РЕАЛЬНЫЙ черновик в кабинете МТС уже на шаге выбора аудитории. Значит любой упавший/прерванный прогон (робот споткнулся, воркер убит) оставляет живой черновик в кабинете. Поэтому `mts_campaign_id` (nullable string) сохраняется в БД СРАЗУ, как робот его получил (после создания черновика) — отдельным ранним апдейтом под tenant-контекстом, а не только в `finalize` при успехе. Иначе опросчик модерации и уборщик не найдут кампанию, а уборщик вслепую вернёт деньги за реально ушедшую кампанию. +- **🔴 Уборка брошенных черновиков (ревизия 27.07):** нужен путь очистки осиротевших черновиков в кабинете МТС (сегодня, 27.07, чистили руками — 2 черновика). Узаконить готовый скрипт уборки (`_cleanup-delete-step2.mjs` → нормальный инструмент с защитой «только черновики») + периодический/ручной прогон. Уборщик зависших (ниже) на `running` с известным `mts_campaign_id` сперва СВЕРЯЕТ статус кампании в кабинете (не ушла ли на модерацию), и только если это брошенный черновик — переводит в `failed`+release; иначе — помечает для ручного разбора, деньги вслепую не возвращает. +- **Статус «на модерации» (#5):** новый статус `moderating`. Переходы: `running → moderating` (live-отправка ушла), `moderating → launched|rejected` (вердикт), `rejected → queued` (пересдача, 5.3). В песочнице остаётся `draft_ready`. +- **Опросчик вердикта (5.1):** `PollTelegramModerationJob` (по расписанию) для кампаний в `moderating` с `mts_campaign_id` запускает робота-читалку; робот по `mts_campaign_id` открывает кампанию, читает статус/причину (селекторы из живой разведки Part B); на `rejected` — `status='rejected'`, `status_reason`, возврат брони (этап B), `notifyTelegramCampaignRejected` (готов); на `approved` — `launched` + уведомление об успехе (этап D). +- **Уборщик зависших (#6):** `$timeout` на `RunTelegramCampaignJob` (≥ таймаут робота + запас); отдельный sweeper переводит зависшие `queued`/`running` старше N минут в `failed` (с release брони). Гвард в `failed()` умеет переводить из `queued`/`running`. +- **Пересдача (5.3):** маршрут `POST /{id}/resubmit` (правка текста/ссылки + файл в «Комментарий для модератора», селектор известен), `rejected → queued`, робот-загрузчик файла. + +**Приёмка C:** отклонённая кампания находится роботом по `mts_campaign_id`, причина показывается клиенту; кампания не зависает (уборщик добивает); пересдача возвращает в очередь и грузит файл. Селекторы Part B зафиксированы в FLOW-FINDINGS. + +### D. Клиентский опыт (Этап 4) +- **Смета до денег (#8):** экран разделяет «Рассчитать» (create → показать `estimated_cost_rub` + `planned_count`) и «Запустить» (launch). Прогноз стоимости и число кандидатов видны перед списанием. +- **Автообновление (#7):** экран мягко опрашивает статус активных кампаний (интервал), список обновляется без F5. +- **Уведомление об успехе (#7):** `NotificationService::notifyTelegramCampaignApproved` (одобрено/запущено) — как и отказ, важное операционное, без pref-гейта. +- **Экран отказа (#9):** кнопка «Исправить и пересдать» (этап C 5.3); при пустой причине — единый текст-заглушка, согласованный с уведомлением (не «смотрите в кабинете», если в кабинете-экране пусто). +- **Подсказки:** «модерация ~4 часа», per-campaign метка песочницы. + +**Приёмка D:** клиент видит смету+охват до запуска; статус обновляется сам; приходит уведомление и об успехе; с экрана отказа можно пересдать; пустая причина не создаёт замкнутый круг. + +### E. «Своё имя» + «Авторассылка» (Этап 5) +- **Своё имя (#10):** клиентские маршруты + экран управления: завести имя, видеть статус/`paid_until`/остаток грейса, понимать «отключено за долг», включить обратно. Двойная оплата в месяц исключена (external_key по месяцу — уже есть). +- **Авторассылка (#10):** клиентский экран правила: включить/выключить, порог, бюджет на кампанию; список помечает авто-кампании. Помесячный джоб имени и авто-путь **не активны на бою до наличия этих экранов**. +- **Предохранители авто:** авто-путь (`TelegramAutoAccumulator`) перед `dispatch` проверяет `free ≥ cost` (иначе не ставит в очередь, пишет причину) и соблюдает **дневной лимит** трат (клиентская настройка, по умолчанию авто выключено). + +**Приёмка E:** клиент управляет именем и авторассылкой из UI; авто не ставит кампании без денег и не превышает дневной лимит; статусы имени понятны клиенту. + +--- + +## Порядок исполнения +**1 → 2 → 5 → 3 → 4.** Этапы 1, 2, 5 не ждут МТС — делаем сразу. Этап 3 добиваем, когда придёт живой отказ модерации (скрипты `_check-moderation.mjs` ловят его). Этап 4 частично зависит от этапа 3 (кнопка «пересдать»). + +## Тестовая стратегия +Каждый этап — TDD, робот замокан (`TelegramRobotRunner`/`RobotResult`), деньги проверяются на `sandbox=false` в тесте, база `liderra_testing`. Денежные инварианты (возврат/факт/идемпотентность) — отдельными Pest-тестами. Фронт — Vitest. Живой кабинет МТС — только для разведки Part B (этап 3), с разрешения владельца. + +**Оговорка по идемпотентности (ревизия 27.07):** истинную параллельность двух воркеров в Pest честно не воспроизвести — `dispatchSync` идёт последовательно и позеленеет даже без блокировки. Защита `lockForUpdate` в Фазе A правильная и нужна, но тест доказывает мало; не обольщаться зелёным, корректность держится на блокировке строки, а не на тесте. + +## Каждый этап ≈ один заход (200–250к токенов) +Этап 3 крупнее прочих (id + статус + опросчик + уборщик + пересдача) и при исполнении может разбиться на два захода (3a: id/статус/уборщик/опросчик; 3b: пересдача+файл). diff --git a/docs/superpowers/specs/2026-07-27-vk-reklama-client-portal-design.md b/docs/superpowers/specs/2026-07-27-vk-reklama-client-portal-design.md new file mode 100644 index 00000000..5ce7eae4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-vk-reklama-client-portal-design.md @@ -0,0 +1,236 @@ +# Дизайн: раздел «VK Реклама» на клиентском портале Лидерры — копия Яндекс-показы + +**Дата:** 27.07.2026 +**Статус:** дизайн/спека (код НЕ пишем — решение владельца: «сначала только дизайн/спека»). +**Автор сессии:** контроллер + разведка по коду и живому API ВК. + +--- + +## 1. Задача простыми словами + +На клиентском портале `lk.liderra.ru` в разделе «Рекламные возможности» уже сделан канал +**«Яндекс Аудитория»** — клиент сам запускает рекламу: витрина → рекламный кошелёк → +мастер кампании → список/отчёт. Рядом стоит **заглушка «VK Реклама»**. + +Нужно сделать **VK Реклама** — точную копию клиентского Яндекс-раздела, но с «трубой» +наружу в ВК вместо Яндекса. Клиент так же грузит аудиторию, ставит частоту/цену, грузит +баннеры и запускает — только реклама крутится в ВК. + +**Каналы автономны:** клиент запускает Яндекс без ВК и ВК без Яндекса. Это отдельные +кампании и отдельные запуски. Логику каналов **не сливаем**. + +--- + +## 2. Что уже сделано в этой сессии (факты, не планы) + +- **Доступ к рекламному API ВК получен и проверен вживую.** В кабинете ВК Реклама + (аккаунт `1090930978`, юрлицо «Кондратьев Платон Дмитриевич», ИНН 245210851872, + договор TT-23027133) запрошены и **сразу выданы** ключи `client_id` + `client_secret`. + Проверено: обмен ключей на токен (`grant_type=client_credentials`) → Bearer-токен на + 24 ч; вызовы `user.json`, `campaigns.json`, `ad_plans.json`, `ad_groups.json`, + `banners.json` → HTTP 200; кабинет активен, валюта RUB. +- **Ключи сохранены на бою:** `VK_ADS_CLIENT_ID`, `VK_ADS_CLIENT_SECRET`, + `VK_ADS_ACCOUNT_ID=1090930978` дописаны в прод `.env` (бэкап `/tmp/env-bak-vk-20260727`). + Код их пока **не читает** — на работающий портал не влияет. +- **База копирования — НЕ `main`.** Живой Яндекс-раздел, который допиливается сейчас, — + ветка **`feat/reklama-yandex-pokazy`** (27 коммитов впереди `main`, ещё не в проде, + модель «оплата за показы / CPM»). В `main` лежит старая модель «за клики» — её копировать + нельзя. ⚠️ Это **движущаяся цель**: Яндекс-показы ещё дорабатывается. + +--- + +## 3. Как устроен клиентский Яндекс-показы (что копируем) + +Модель: **медийная CPM-кампания** — реклама-баннеры показываются загруженной аудитории +(сделки за N дней ± свой список телефонов), оплата **за показы**. + +| Кусок | Поведение (ветка `feat/reklama-yandex-pokazy`) | +|---|---| +| **Кошелёк** | Один на клиента (`ad_wallets.tenant_id` unique), баланс+заморозка. `channel` — только ярлык на операциях/холдах, баланс не делит. Пополнение через общий биллинг с `credit_target='advertising'`. | +| **Режимы** | `auto` («Крутить постоянно» — скользящее окно последних `audience_days` дней сделок) / `manual` («Разовый запуск» — фикс-снимок `snapshot_from..snapshot_to` + свой список ВСЕГДА + `run_days`). | +| **Цена/CPM** | Клиент видит плоский CPM (`client_cpm_rub`, дефолт **120 ₽/1000**). Наша наценка скрыта: `ad_settings.ad_margin_percent` дефолт **40%** → доля Яндекса `share=(100−40)/100=0.60`. | +| **Смета** | `impressions = размер_аудитории × frequency`; `cost = impressions × cpm / 1000` (округление ВВЕРХ до копейки). Мин. аудитория **100**. Считает сервер, фронт только показывает. | +| **Списание** | По показам, монотонно (high-water): `billable = min(delivered, paid_impressions)`; списываем только прирост (`delta>0`); ключ идемпотентности `"yandex-imp:{id}:{billable}"`. При `delivered≥paid` → статус `completed` + возврат заморозки. | +| **Баннеры** | 15 точных размеров, клиент грузит готовый баннер под каждый размер, ≤150 КБ, jpg/png/gif; флаг «в показ» (`included`); шаг «Утвердить баннеры». Плюс `landing_url` (ссылка объявления). | +| **Креатив в Яндексе** | Запущенное объявление использует **один `yandex_creative_id`, который вводит ОПЕРАТОР** (адаптивный креатив покрывает все размеры). Отсюда статус `queued` и админ-экран «ввести номер креатива». | +| **Жизненный цикл** | `draft` → `submit` → `queued` (ждёт креатив от оператора) → `launch` → `pending_moderation` → `running` → (`completed` / `paused` / `rejected` / `stopped_no_funds`). Заморозка при запуске, списание при показах, возврат на 4 выходах. | +| **Стоп по деньгам** | После списания `AdWalletGate::isSolvent` → если нет → `AdStopAllService::stopAll` (событие + письмо + пауза кампаний). | +| **Мастер (Vue)** | 4 шага: «Кому показываем» / «Как часто показывать» / «Баннеры» / «Проверка и отправка». | +| **Админ** | Настройки CPM/наценки; список `queued` кампаний; ввод `yandex_creative_id` (право — column-level GRANT только на эту колонку роли `crm_admin_user`). | + +--- + +## 4. Что переносится КАК ЕСТЬ (канало-независимое, не копируем повторно) + +Эти части уже написаны как общая инфраструктура «по каналу» и **переиспользуются** VK-каналом +без переделки (создавать второй экземпляр бессмысленно — это одна и та же логика): + +- **Кошелёк:** `AdWalletService` (`freeze`/`charge`/`release`), `AdWalletGate`, `AdStopAllService`, + событие `AdvertisingStopped` + слушатель `PauseCampaignsOnAdStop`, письмо о низком балансе. +- **Денежная математика:** `AdImpressionPricing` (CPM→рубли, округление вверх), формула сметы + `CampaignEstimateService`, монотонное списание `CampaignImpressionCharger` (ключ идемпотентности + просто получит VK-префикс). +- **Сборка аудитории:** `CampaignAudienceBuilder` (сделки + свой список, RLS/BYPASSRLS-паттерн). +- **Хранение/утверждение баннеров:** таблица `ad_campaign_banners`, флаг `included`, шаг утверждения + (сам **набор размеров** — Яндексовый, заменим, см. §6). +- **Каркас мастера и шаги 1/2/4**, список/отчёт-компоненты, статус-машина и денежный жизненный цикл. +- **Админ-настройки** CPM/наценки. + +> Это НЕ «слияние каналов». Каналы остаются автономными: VK-кампании живут под `channel='vk'`, +> независимо от Яндекса. Мы лишь не плодим второй экземпляр общей сантехники. + +--- + +## 5. Что копируем в отдельные VK-версии (Яндекс-специфичный «шов») + +Создаём VK-аналоги ТОЛЬКО для того, что завязано на Яндекс: + +| Яндекс (есть) | VK (сделать) | +|---|---| +| `Services/Advertising/YandexDirectClient.php` (Direct API v5, деньги в микрорублях) | **`VkAdsClient.php`** — VK Ads API v2 (`https://ads.vk.com/api/v2`), объекты `ad_plans → ad_groups → banners`; токен через `client_credentials` с авто-обновлением (24 ч); учёт лимитов частоты (ретраи/паузы на 429). | +| `Services/Sales/YandexAudienceClient.php` (сегмент из телефонов) | **VK «список пользователей»** через `remarketing/users_lists` (телефоны → SHA-256; см. §6 про минимум 2000). | +| `CampaignLauncher.php` (жёстко зашит порядок вызовов Директа) | **`VkCampaignLauncher.php`** — порядок вызовов ВК: создать users_list → `ad_plan` → `ad_group` (таргет = users_list, стратегия CPM/авто или ручная ставка) → `banner`. | +| `ChargeCampaignSpendJob` → `getCampaignImpressions` (отчёт Директа, возможно TSV/async) | Тот же джоб, источник показов — **статистика ВК** (JSON, синхронно). | +| `SyncCampaignModerationJob` → `getAdsModeration` | Статус модерации из объекта ВК. | +| `BannerSizes.php` (15 размеров Яндекса) | **`VkBannerFormats.php`** — форматы ВК (свои; см. §6). Проверка точного размера при загрузке ключуется от этого каталога. | +| Колонки `yandex_*` на `ad_campaigns`/`ad_campaign_ads` | Колонки `vk_*`: `vk_users_list_id`, `vk_ad_plan_id`, `vk_ad_group_id`, `vk_banner_id` (имена — по факту VK-объектов; уточнить тест-запуском). | +| `config services.yandex_direct.*` | `config services.vk_ads.{enabled, base_url, client_id, client_secret, account_id}` (`enabled` дефолт **false**). | +| Vue `views/advertising/AdvertisingYandexView.vue` + `/advertising/yandex` | **`AdvertisingVkView.vue`** + роут `/advertising/vk`; в `advertisingChannels.ts` пункту «VK Реклама» добавить `route: '/advertising/vk'` (сайдбар/моб. сам заменит заглушку на ссылку). | + +--- + +## 6. Три реальных отличия ВК от Яндекса («переходники», не переделка логики) + +1. **Минимум аудитории для загрузки списка — 2000 (у Яндекса 100).** + VK Ads программно принимает список пользователей **от 2000 совпадений**. Отзеркаливаем тот же + guard «мало аудитории», но с числом ВК (2000). Следствие: VK по своему списку реально + пригоден только клиентам с большой базой; для маленьких список не запустится (честное + сообщение в мастере). Таргетинг по интересам/гео **в этой спеке не делаем** (владелец: + «берём всё от Яндекса»); оставляем как возможное будущее улучшение. + +2. **Форматы креативов у ВК свои.** Каталог размеров Яндекса не подходит. Нужен `VkBannerFormats` + (перечень форматов ВК) + свой валидатор загрузки. Точный перечень/веса — **подтвердить живым + тест-запуском** (см. §9), не по документации. + +3. **Жёсткие лимиты частоты запросов к API ВК.** Пойман HTTP 429 на `remarketing`. В `VkAdsClient` + закладываем паузы/ретраи с backoff; у Яндекса такого нет. + +Дополнительно — **упрощение ПОДТВЕРЖДЕНО (27.07.2026):** ВК позволяет загружать креатив +**программно** (`content/static.json` → HTTP 200 + `content_id`, §13). Поэтому для ВК +шаг `queued` + админ-ввод `vk_creative_id` + column-GRANT **убираем** — мастер ВК короче +Яндекса: `draft → submit → pending_moderation → running` (без операторского `queued`). +Клиентские баннеры (15 слотов) при отправке грузятся в контент ВК, объявление ссылается на +полученный `content_id`. Админ-экран «ввести номер креатива» для ВК не нужен. + +--- + +## 7. Данные и модель (VK) + +- Таблица `ad_campaigns` уже мультиканальная (`channel` дефолт `yandex`). VK-кампании — строки с + `channel='vk'`. Добавить nullable-колонки `vk_users_list_id`, `vk_ad_plan_id`, `vk_ad_group_id`, + `vk_banner_id` (миграция; RLS/GRANT как у соседних; запись в `db/CHANGELOG_schema.md`). + Общие поля (`mode`, `client_cpm_rub`, `frequency`, `estimated_impressions`, `paid_impressions`, + `delivered_impressions`, `landing_url`, `yandex_cost_rub` — последнее переименовать в `provider_cost_rub` + ИЛИ переиспользовать как есть) — общие. +- `ad_campaign_banners` — общая, VK-баннеры хранятся так же (размеры — из VK-каталога). +- Кошелёк, транзакции, холды — общие (`channel='vk'` как ярлык). +- **RLS-ревью обязателен** (агент `rls-reviewer`): каждая новая колонка/таблица под tenant-isolation; + служебные роли (`crm_supplier_worker`/`crm_admin_user`) — через `db/03_service_bypass_policies.sql` + (после добавления таблиц ПЕРЕзапустить, иначе джобы/админ увидят «тихий ноль»). + +--- + +## 8. Решения владельца (закрыто 27.07.2026) + +1. **Кошелёк — ОБЩИЙ баланс на все каналы.** Оставляем модель как есть: один рекламный кошелёк + на клиента (`ad_wallets.tenant_id` unique), Яндекс и ВК делят баланс, расход по каналам виден + по ярлыку `channel` на операциях/холдах. Модель кошелька и биллинг-пополнение НЕ трогаем. + +2. **Стена 2000 — ЗЕРКАЛО + честное сообщение.** Отзеркаливаем тот же guard «мало аудитории», + но с числом ВК (2000 вместо 100). Маленький список — честно не запускаем, с понятным + сообщением в мастере. Таргетинг по интересам/гео НЕ добавляем (VK по списку — для клиентов + с большой базой). Возможное будущее улучшение, вне этой итерации. + +3. **Креатив ВК — РЕШЕНО тест-запуском 27.07.2026: программная загрузка РАБОТАЕТ.** + `POST /api/v2/content/static.json` вернул HTTP 200 + `{id, variants{original,uploaded}}` с + CDN-URL. Значит для ВК **операторский шаг убираем** (`queued` + админ-ввод номера креатива + НЕ нужны) — мастер ВК проще Яндекса: клиент грузит баннеры → они загружаются в контент ВК + программно → объявление ссылается на полученный `content_id`. Подробности — §13 «Журнал проверок». + +--- + +## 9. Что проверяем ЖИВЫМ тест-запуском в песочнице/на аккаунте (не по документации) + +Владелец: «проверяй сам, не надейся на документацию». До финала плана подтверждаем на реальном +API ВК (read + аккуратные create в песочнице/на аккаунте с балансом 0, БЕЗ реальной траты): + +- Точные поля создания `ad_group`: авто-стратегия vs ручная `max_price`, модель оплаты CPM. +- Минимальный дневной/общий бюджет (в рублях), если ВК требует. +- Минимум размера `users_list` для API-загрузки (ожидаем 2000) и формат телефонов + хеш (SHA-256). +- Форматы/размеры/веса баннеров, поддержка адаптивного/универсального объявления, + возможна ли **программная** загрузка креатива. +- Поля статуса модерации и как ставить паузу/возобновление (`status` объекта). +- Источник показов в статистике ВК (для списания). +- Реальные лимиты частоты (для backoff). + +--- + +## 10. Раскатка и безопасность + +- **Рубильник:** `services.vk_ads.enabled` (дефолт false) + токен. Пока выкл — VK-джобы/лаунчер + не делают ни одного обращения к ВК (как у Яндекс-показы сейчас). Включаем, когда всё проверено + и владелец даёт добро. +- **Ключи ВК** — только в `.env` (уже на бою), не в git (gitleaks в pre-commit). +- **main = прод инвариант, GitHub мёртв → пуш только в gitea.** VK-раздел строим в **worktree от + `main`** (текущая рабочая ветка старая, Яндекс-кода не содержит) — но реально ветку от `main` + берём ПОСЛЕ того, как показы-Яндекс доедет в `main` (решение владельца: «сначала спека», код позже). +- Тесты: Pest на backend (лаунчер/чарджер/аудитория/лимиты), Vitest на мастер/список, + `rls-reviewer` на миграции, регрессия перед выкатом. Токен ВК в тестах не гонять (мокать клиент). + +--- + +## 11. Границы (чего НЕ делаем в этой итерации) + +- Не делаем таргетинг ВК по интересам/гео (только «по своему списку», как Яндекс). +- Не трогаем и не «чиним» Яндекс-показы (его допиливает другая сессия). Замеченную мелочь — + `SyncCampaignModerationJob` перебирает `AdCampaignAd`, а показы-лаунчер хранит один + `yandex_ad_id` без строки `AdCampaignAd` (модерация может не увидеть медийное объявление) — + **передать владельцу как наблюдение по Яндексу** и учесть при копировании (VK хранить строку + объявления, чтобы модерация видела). +- Не включаем реальную трату в ВК до отдельного go-live с владельцем. + +--- + +## 12. Резюме одним абзацем + +VK Реклама — это клиентский Яндекс-показы, у которого поменяли «трубу наружу»: вместо Яндекс.Директ ++ Яндекс.Аудитории — VK Ads API (`ad_plans→ad_groups→banners` + `remarketing/users_lists`). Кошелёк, +деньги, смета, мастер, статусы, стоп по деньгам — общие и переносятся как есть. Отдельные VK-версии +нужны только для `VkAdsClient`, `VkCampaignLauncher`, VK-форматов баннеров, VK-аудитории и VK-экрана. +Три отличия-переходника: минимум аудитории 2000, свои форматы креативов, лимиты частоты API. Доступ +к API ВК уже получен и проверен, ключи на бою, канал под рубильником до go-live. + +--- + +## 13. Журнал проверок ВК (живой API, не документация) + +Владелец: «проверяй сам, не надейся на документацию». Проверено на реальном аккаунте +`1090930978` (баланс 0 ₽ → трата невозможна), 27.07.2026: + +| Проверка | Метод | Результат | +|---|---|---| +| Доступ к API | `POST oauth2/token.json` (client_credentials) | ✅ Bearer-токен, TTL 24 ч | +| Кабинет активен | `GET user.json` | ✅ `id=1090930978`, `status=active`, `currency=RUB` | +| Кампании | `GET ad_plans.json` | ✅ 200, count 0 | +| Группы | `GET ad_groups.json` | ✅ 200, count 0 | +| Объявления | `GET banners.json` | ✅ 200, count 0 | +| Свои аудитории | `GET remarketing/users_lists.json` | ⚠️ 429 (жёсткий лимит частоты) | +| **Программная загрузка креатива** | `POST content/static.json` (тест-PNG 256×256) | ✅ **200**, `{id:123146072, variants{original,uploaded}}` + CDN-URL → **операторский шаг для ВК не нужен** | + +**Побочный эффект:** в медиатеке ВК осталась одна безобидная тест-картинка (256×256 градиент), +не привязана к кампаниям, тратить не может. Кампании/группы/объявления НЕ создавались. + +**Осталось подтвердить при реализации** (тест-создание ad_plan→ad_group→banner): точные поля +стратегии CPM (авто/ручная ставка), минимальный бюджет, минимум `users_list` (ожидаем 2000) и +формат/хеш телефонов, каталог форматов баннеров, источник показов в статистике, реальные лимиты +частоты для backoff. diff --git a/docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md b/docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md new file mode 100644 index 00000000..7d3aa33d --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md @@ -0,0 +1,86 @@ +# Телеграм-реклама: деньги на общем балансе + наценка 40% (переиспользуем SMS-модель) + +Ветка `feat/client-telegram-ads`. Решение владельца (28.07): платить с ОБЩЕГО баланса (как SMS), +наценка 40%. Не пушить. Коммит только по «коммить». + +## Что выяснено (обоснование) +- **SMS — рабочая модель:** клиентская цена считается при создании, списывается с ОБЩЕГО баланса + тенанта (`tenants.balance_rub`) через `SmsChargeService`, идемпотентность — по строке + `balance_transactions` (related_type+related_id). Заморозки НЕТ. Пополнение — общий баланс (ЮKassa). +- **Яндекс — НЕ решено:** отдельный `ad_wallets`/`AdWalletService` (кошелёк+заморозка) не используется + НИГДЕ в бою (ни Яндекс, ни телеграм-кампании), пополнять нечем. Заготовка без проводки. +- **Телеграм сейчас** висит на этом неиспользуемом кошельке (freeze/release/charge к ad_wallet) — + поэтому за показы не списывается. Плата за имя тоже идёт в ad_wallet (мимо реальных денег). + +## Целевая модель (как SMS) +Деньги за телеграм-рекламу — с **общего баланса** тенанта. Кошелёк с заморозкой из телеграм-потока +УБИРАЕМ. Инфраструктуру `ad_wallets`/`AdWalletService` НЕ удаляем (таблицы/схему не трогаем — может +пригодиться), просто телеграм на неё больше не опирается. + +**Наценка 40%:** клиентская цена = базовая смета тарифа × `markup` (config `client_tg.markup`, дефолт +1.40). Клиент видит и платит цену С наценкой. Тариф остаётся базой (себестоимость-оценка). + +**Момент списания = ЗАПУСК (как SMS — списываем до отправки):** на `launch`/`resubmit` списываем +клиентскую смету (`estimated_cost_rub` с ×1.4) с общего баланса; не хватает → 409, кампания остаётся +черновиком (робот не пойдёт). Это заменяет нынешнюю заморозку `freeze`. +**Возврат при отказе модерации / сбое до кабинета** (было `release`): возвращаем на общий баланс +(refund). Одобрение → ничего (уже списано). Робот упал, но кампания МОГЛА уйти в кабинет +(есть mts_campaign_id) → needs_review, деньги НЕ возвращаем (F5, как сейчас). +> Почему списание при запуске, а не при одобрении: гарантирует оплату ДО того, как объявление +> появится в кабинете МТС (у МТС нет API остановить показ по нашему «не хватило денег»). Зеркалит +> SMS. Отказ модерации — редкий и обратимый (refund), одобрение — обычный путь без лишних операций. + +## Приёмочный лист + +- [ ] **D1. Наценка.** config `client_tg.markup` (1.40). `TelegramTariffService`: метод + `clientEstimateRub(impressions)` = `estimateRub × markup` (bcmath scale 2). `store`/пересчёт сметы и + экраны используют клиентскую цену. Приёмка: смета 100 показов base×1.4; markup из config. + +- [ ] **D2. Списание с общего баланса.** Новый `TelegramCampaignChargeService` (зеркало + `SmsChargeService`): замок тенанта → идемпотентность по `balance_transactions` + (related_type=Campaign, related_id) → нехватка → InsufficientBalanceException → списание + `tenants.balance_rub` + BalanceTransaction (новый тип `TYPE_TG_AD_CHARGE`). Приёмка: списывает раз; + повторный вызов — no-op; нехватка → исключение, баланс не тронут. + +- [ ] **D3. Возврат (refund) на общий баланс.** Метод `refund(campaign)`: если по кампании есть + charge-транзакция — вернуть сумму на баланс + обратная BalanceTransaction (`TYPE_TG_AD_REFUND`), + идемпотентно (по наличию refund-строки). Приёмка: после charge→refund баланс восстановлен; двойной + refund не задваивает. + +- [ ] **D4. launch → charge вместо freeze.** `CampaignController::launch`: в бою `charge` клиентской + сметы вместо `freeze(budget_cap)`; нехватка → 409, draft. Песочница — без денег. Аналогично + `resubmit`. Приёмка: бой списывает смету; нехватка → 409; песочница не трогает деньги. + +- [ ] **D5. Отказ/сбой → refund вместо release.** Точки, где сейчас `release`: + `RunTelegramCampaignJob`/`ResubmitTelegramCampaignJob` (failed без mts_id), `PollTelegramModerationJob` + (rejected). Заменить на `refund`. Логика F5 (есть mts_id → needs_review без возврата) сохраняется. + Приёмка: отказ модерации возвращает деньги; needs_review — нет. + +- [ ] **D6. Убрать заморозку из телеграм-потока.** Снять вызовы `AdWalletService::freeze/release` и + импорты из: CampaignController, RunTelegramCampaignJob, ResubmitTelegramCampaignJob, + PollTelegramModerationJob, TelegramAutoAccumulator. (AdWalletService и таблицы НЕ удаляем.) + Приёмка: телеграм-код не ссылается на AdWalletService; авто-путь морозит через… см. D7. + +- [ ] **D7. Авто-путь (TelegramAutoAccumulator).** Сейчас при живом авто-запуске морозит потолок. + Перевести на ту же модель: денег НЕ списывать в накопителе (спишет launch-путь джоба при запуске), + ИЛИ списывать смету при авто-запуске — выбрать при реализации так, чтобы точка списания была ОДНА + (не задвоить). Приёмка: авто-кампания списывает ровно один раз, дневной лимит цел. + +- [ ] **D8. Плата за имя (ChargeTgNameFeeJob) → общий баланс.** Сейчас `AdWalletService::charge` к + ad_wallet. Перевести на общий баланс (та же charge-модель, свой external_key + `telegram:sender:{id}:{YYYY-MM}`, тип `TYPE_TG_NAME_FEE`). Порог платёжеспособности — по + `tenants.balance_rub`. Приёмка: списывает с общего баланса; долг>grace → имя suspended (как было). + +- [ ] **D9. Тесты.** Обновить существующие (freeze→charge, release→refund в Run/Resubmit/Poll/launch), + новые для charge/refund-сервиса и наценки. Все ClientTg зелёные по отдельности; линтеры чисто. + +## Схема БД +Новые ТИПЫ транзакций (`balance_transactions.type` — если это enum/CHECK, нужна миграция + +CHANGELOG_schema + rls-reviewer; если строковая колонка без CHECK — миграция не нужна). Проверить при D2. +Новых таблиц/колонок на кампании НЕ добавляем (`estimated_cost_rub` уже есть). + +## Порядок (куски по 1-2 дня) +Кусок 1: D1 (наценка) + D2/D3 (charge/refund сервис) — фундамент, чистый TDD, без трогания потока. +Кусок 2: D4/D5/D6 (переключить launch/reject/джобы с freeze/release на charge/refund). +Кусок 3: D7 (авто-путь) + D8 (плата за имя) + D9 (добить тесты). +Между кусками — приёмка и коммит по «коммить». diff --git a/tools/observer-chain-map.json b/tools/observer-chain-map.json index 8fcfeb6e..b98117ef 100644 --- a/tools/observer-chain-map.json +++ b/tools/observer-chain-map.json @@ -2,6 +2,7 @@ "_note": "node_chosen -> L-цепочки. Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, прочее) НЕ включаются -> chainsFor вернёт null. Имена ключей = реальные значения primary_rationale.node_chosen (skill-id из skill_invoked). MCP/agent-узлы (laravel-boost, openapi-mcp-server, api-docs, sentry-mcp, redis-mcp, pest, github-mcp) в node_chosen не появляются, но включены для полноты покрытия цепочек L1-L13 (контролёр C6 требует, чтобы каждая L из routing-off-phase.md была покрыта). Синхронизируется с docs/routing-off-phase.md через tools/observer-chain-map-checker.mjs.", "discovery-interview": ["L1", "L2"], "superpowers:brainstorming": ["L1", "L16", "L17"], + "grilling": ["L1"], "superpowers:writing-plans": ["L1"], "superpowers:subagent-driven-development": ["L1"], "audit-portal": ["L2"], diff --git a/ПИЛОТ.md b/ПИЛОТ.md index b41b33fa..504ba681 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -85,7 +85,7 @@ lefthook отдельно. (было 44 на полигоне); `ad_creative_jobs`, `ad_campaign_messages`, `ad_campaign_banners` покрыты. Проверено запросом, не на веру. - **Права проверены живыми запросами:** v9.17 (`crm_admin_user` INSERT в `ad_campaign_messages` - + нумератор) — есть; колокольчик `in_app_notifications` — есть; очередь заданий робота + и нумератор) — есть; колокольчик `in_app_notifications` — есть; очередь заданий робота (`crm_app_user` INSERT / `crm_admin_user` UPDATE) — есть; `crm_supplier_worker` INSERT в ленту — есть. - **`.env` дополнен** (бэкап `/home/ubuntu/env-backup-20260729-predeploy.bak`):