diff --git a/docs/superpowers/plans/2026-08-08-telegram-knopka-ostanovit.md b/docs/superpowers/plans/2026-08-08-telegram-knopka-ostanovit.md new file mode 100644 index 00000000..9d459d8f --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-telegram-knopka-ostanovit.md @@ -0,0 +1,2167 @@ +# Телеграм: кнопка «Остановить рекламу» — план работ + +> **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:** клиент может остановить идущую телеграм-рекламу одной кнопкой; портал узнаёт конец +рекламы от кабинета по слову «Завершена» и возвращает клиенту деньги за непоказанное. + +**Architecture:** две части. Ч1 (фундамент) — робот учится словам «Завершена»/«Останавливается» +и передаёт наверх сырой текст строки; портал закрывает кампанию и возвращает переплату уже +готовым `TelegramZavershenieService`. Ч2 (кнопка) — экран → ручка `POST /stop` → задание роботу +нового вида `stop` → робот жмёт «Завершить» в строке ИМЕННО этой кампании и доказывает, что +слово сменилось. Денег в момент нажатия не трогаем: их вернёт Ч1, когда кабинет закончит. + +**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 (портал), Vue 3 + Vuetify 3 / Vitest (экран), +Node 20 + Playwright + `node --test` (робот), PostgreSQL 16 c RLS. + +**Замысел:** [2026-08-08-telegram-knopka-ostanovit-design.md](../specs/2026-08-08-telegram-knopka-ostanovit-design.md) +**Первоисточник замеров:** [2026-08-08-telegram-konec-reklamy-i-vozvrat.md](2026-08-08-telegram-konec-reklamy-i-vozvrat.md) + +--- + +## Чего НЕЛЬЗЯ делать в этом плане + +1. **Боевой прод liderra.ru — живые клиенты и деньги.** Ни одного выката, ни одной правки + боевой базы. Выкат — отдельный разговор с владельцем после зелёных сторожей. +2. **Кабинет МТС — боевой.** Ни одного живого нажатия «Завершить» без отдельного разрешения + владельца. Весь план проверяется тестами без браузера. +3. **Робот живёт в ДВУХ копиях** — `bots/mts-telegram-ads` (репозиторий) и + `C:\liderra\mts-telegram-robot` (рабочая). Правим только репозиторий; сведение — задача 11, + **сводить, а не копировать поверх**. +4. **В эту же ветку коммитит соседняя смена.** Никогда `git add -A`; только явные пути. После + каждого коммита — `git show --stat`. +5. **Файлы правим только через Edit/Write.** Запись из-под Bash в этом проекте не переживает + вызов инструмента (проверено 01.08.2026). + +## Карта файлов + +**Робот** (`bots/mts-telegram-ads/`): + +| Файл | Что делает | +|---|---| +| `src/cabinet.js` (правка) | `STATUS_SLOVA_RE` + `parseModerationStatus` учат два слова; `otvetPoRyadu` отдаёт сырой текст; новая `stopCampaign` жмёт «Завершить» | +| `src/stop-plan.js` (создать) | чистая логика остановки: белый список кнопок подтверждения, признак «остановка подтверждена» | +| `src/runner.js` (правка) | `runStop` — режим остановки целиком | +| `src/poll-plan.js` (правка) | режим `stop` → работа `stop`, номера не нужны | +| `bin/poll.js` (правка) | развилка на новую работу | +| `bin/run.js` (правка) | ручной запуск `mode: 'stop'` | +| `test/status-zavershena.test.mjs` (создать) | сторож слов, включая мину «Завершить» ≠ «Завершена» | +| `test/otvet-po-ryadu.test.mjs` (создать) | сторож сырого текста строки | +| `test/stop-plan.test.mjs` (создать) | сторож белого списка и признака подтверждения | +| `test/poll-modes.test.js` (правка) | режим `stop` номеров не требует | + +**Портал** (`app/`): + +| Файл | Что делает | +|---|---| +| `database/migrations/2026_08_08_000002_add_stop_requested_at_to_client_tg_campaigns.php` (создать) | колонка «когда попросили остановить» | +| `app/Models/ClientTg/Campaign.php` (правка) | `stop_requested_at` в `$fillable` и `casts` | +| `app/Models/ClientTg/RobotJob.php` (правка) | `MODE_STOP` | +| `app/Services/ClientTg/TelegramRobotQueue.php` (правка) | одинаковыми считаем задания ОДНОГО вида | +| `app/Services/ClientTg/RobotResult.php` (правка) | читает `rowText` и `stopped` | +| `app/Services/ClientTg/TelegramZavershenieService.php` (правка) | `zakrytPoSlovuKabineta` | +| `app/Services/ClientTg/TelegramStatsApplier.php` (правка) | пишет слово в журнал; закрывает кампанию по `completed` | +| `app/Services/ClientTg/TelegramOstanovkaApplier.php` (создать) | разбирает отчёт об остановке | +| `app/Http/Controllers/Api/ClientTg/CampaignController.php` (правка) | ручка `stop` | +| `app/Http/Controllers/Api/TgRobotController.php` (правка) | ветка `MODE_STOP` | +| `routes/web.php` (правка) | маршрут `POST /api/telegram/campaigns/{id}/stop` | +| `resources/js/api/telegram.ts` (правка) | `stopTelegram` + поле типа | +| `resources/js/views/advertising/AdvertisingTelegramView.vue` (правка) | кнопка, окно, состояние «Останавливается…» | + +**Сторожа портала** (создать в `app/tests/`): +`Feature/ClientTg/ZakrytiePoSlovuKabinetaTest.php`, `Feature/ClientTg/ZadaniyaRaznyhVidovTest.php` +(правка `TelegramRobotQueueTest.php` тоже подойдёт — см. задачу 5), +`Feature/ClientTg/OstanovkaReklamyTest.php`, `Feature/ClientTg/OstanovkaOtchyotRobotaTest.php`, +`Frontend/telegram-knopka-ostanovit.spec.ts`. + +**Документы:** `db/schema.sql`, `db/CHANGELOG_schema.md`. + +--- + +# Часть 1 — портал узнаёт конец рекламы от кабинета + +## Задача 1: робот учит два слова кабинета + +**Файлы:** +- Создать: `bots/mts-telegram-ads/test/status-zavershena.test.mjs` +- Правка: `bots/mts-telegram-ads/src/cabinet.js` (`STATUS_SLOVA_RE` ~строка 137, + `parseModerationStatus` ~строка 51) + +- [ ] **Шаг 1: написать красного сторожа** + +Создать `bots/mts-telegram-ads/test/status-zavershena.test.mjs`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseModerationStatus, STATUS_SLOVA_RE } from '../src/cabinet.js'; + +// Замер 08.08.2026: кнопку «Завершить» нажали на живой кампании 2237821 (наша №15). +// Кабинет ответил «Останавливается — это займёт до 30 минут», а закончив — «Завершена». +// Разборщик не знал НИ ОДНОГО из двух слов и три захода подряд присылал пустоту. + +// Живой ряд ИДУЩЕЙ кампании 2237821 (07.08.2026) — байт в байт из разведки. +// +// 🔴 Здесь зарыта мина ценой в живые деньги. Читалка собирает ВЕСЬ текст ряда, включая +// подписи кнопок, а у идущей рекламы в ряду стоит кнопка «Завершить». Сравнивай мы по +// корню «заверш» — каждая работающая реклама читалась бы как законченная, портал закрыл +// бы её и вернул клиенту деньги за рекламу, которая крутится прямо сейчас. +const RYAD_IDET = '07.08 Telegram по своей базе Завершить Посмотреть настройки Запущена ' + + 'Каналы Свой список 400 ₽ 182,4 ₽ 2 0 0 % 0 ₽ 0 0 % 0 ₽ 0,96 ₽ 0 0 ₽ 0 ₽ 0,96 ₽'; + +// Ряд после завершения: кнопки «Завершить» больше нет — осталась одна «Посмотреть настройки». +const RYAD_ZAVERSHENA = '06.08 Telegram по своей базе Посмотреть настройки Завершена ' + + 'Каналы Свой список 400 ₽ 182,4 ₽ 22 0 0 % 0 ₽ 0 0 % 0 ₽ 10,56 ₽ 0 0 ₽ 0 ₽ 10,56 ₽'; + +const RYAD_OSTANAVLIVAETSYA = '06.08 Telegram по своей базе Посмотреть настройки Останавливается ' + + 'Каналы Свой список 400 ₽ 182,4 ₽ 22 0 0 % 0 ₽ 0 0 % 0 ₽ 10,56 ₽ 0 0 ₽ 0 ₽ 10,56 ₽'; + +test('🔴 кнопка «Завершить» у ИДУЩЕЙ рекламы не делает её законченной', () => { + assert.equal(parseModerationStatus(RYAD_IDET), 'approved'); +}); + +test('«Завершена» — реклама закончилась', () => { + assert.equal(parseModerationStatus('Завершена'), 'completed'); + assert.equal(parseModerationStatus('Завершено'), 'completed'); + assert.equal(parseModerationStatus(RYAD_ZAVERSHENA), 'completed'); +}); + +test('«Останавливается» — ещё НЕ конец: показы идут, деньги не вернулись', () => { + assert.equal(parseModerationStatus('Останавливается'), 'stopping'); + assert.equal(parseModerationStatus(RYAD_OSTANAVLIVAETSYA), 'stopping'); +}); + +// 🪤 Вторая половина той же беды, уже случавшаяся дважды. Ряд кампании ищется подъёмом от +// ссылки вверх до предка, в тексте которого есть слово-статус. Добавь слово только в +// разбор — ряд не найдётся вовсе, разбору нечего будет читать, и починка будет выглядеть +// сделанной, оставаясь мёртвой. +test('🪤 новые слова опознают ряд в списке — иначе разбору нечего читать', () => { + assert.match(RYAD_ZAVERSHENA, STATUS_SLOVA_RE); + assert.match(RYAD_OSTANAVLIVAETSYA, STATUS_SLOVA_RE); +}); + +test('🔴 слово-опознаватель ряда тоже не ловится кнопкой «Завершить»', () => { + assert.equal(STATUS_SLOVA_RE.test('Завершить'), false); +}); + +test('прежние семь слов не сломались', () => { + assert.equal(parseModerationStatus('Отклонена'), 'rejected'); + assert.equal(parseModerationStatus('Одобрена'), 'approved'); + assert.equal(parseModerationStatus('Активна'), 'approved'); + assert.equal(parseModerationStatus('Запущена'), 'approved'); + assert.equal(parseModerationStatus('На модерации'), 'moderating'); + assert.equal(parseModerationStatus('Запланирована'), 'moderating'); + assert.equal(parseModerationStatus('Черновик'), 'draft'); + assert.equal(parseModerationStatus('Неведомое слово'), null); +}); +``` + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd bots/mts-telegram-ads && node --test test/status-zavershena.test.mjs +``` + +Ожидаем: падают тесты про «Завершена» и «Останавливается» (получено `null`, ждали `completed` +/ `stopping`) и про опознание ряда. Тест «кнопка Завершить у идущей рекламы» пока проходит — +он охраняет то, что мы вот-вот можем сломать. + +- [ ] **Шаг 3: добавить слова в опознаватель ряда** + +В `src/cabinet.js` заменить строку `export const STATUS_SLOVA_RE = ...` на: + +```js +export const STATUS_SLOVA_RE = + /(отклонен|одобрен|активн|запущен|модераци|запланирован|черновик|завершена|завершено|останавливается)/i; +``` + +🔴 Именно `завершена|завершено`, а НЕ корень `заверш`: у идущей рекламы в ряду стоит кнопка +«Завершить», и по корню опознавателем стал бы любой ряд, включая чужой. + +- [ ] **Шаг 4: научить разбор** + +В `src/cabinet.js`, в `parseModerationStatus`, сразу ПОСЛЕ строки +`if (s.includes('отклонен')) return 'rejected';` вставить: + +```js + // 🔑 Замер 08.08.2026, живая кампания 2237821: нажали «Завершить» — кабинет написал + // «Останавливается, это займёт до 30 минут», а закончив — «Завершена». Ни одного из двух + // слов разборщик не знал, и три захода подряд слово терялось. + // + // 🔴 Сравниваем ПОЛНЫМИ словами, а не корнем «заверш». Читалка собирает весь текст ряда + // вместе с подписями кнопок, а у идущей рекламы в ряду стоит кнопка «Завершить»: по корню + // каждая работающая реклама читалась бы как законченная, портал закрыл бы её и вернул + // клиенту деньги за рекламу, которая крутится прямо сейчас. + // + // Порядок: «останавливается» ДО «завершена» — это разные состояния, и пока идёт остановка + // деньги ещё не вернулись, закрывать рано. + if (s.includes('останавливается')) return 'stopping'; + if (s.includes('завершена') || s.includes('завершено')) return 'completed'; +``` + +- [ ] **Шаг 5: сторож зелёный** + +```bash +cd bots/mts-telegram-ads && node --test test/status-zavershena.test.mjs +``` + +Ожидаем: все тесты PASS. + +- [ ] **Шаг 6: прежние сторожа робота не сломались** + +```bash +cd bots/mts-telegram-ads && npm test +``` + +Ожидаем: PASS, было 239 зелёных — стало больше на число новых. + +- [ ] **Шаг 7: коммит** + +```bash +git add bots/mts-telegram-ads/src/cabinet.js bots/mts-telegram-ads/test/status-zavershena.test.mjs +git commit -m "робот учит слова кабинета «Завершена» и «Останавливается»" +git show --stat +``` + +--- + +## Задача 2: сырое слово кабинета доезжает до портала + +Сейчас `readCampaignStats` отдаёт только разобранный статус. Из-за этого запись в журнале +«слово незнакомое» приходит БЕЗ самого слова — следующее новое слово мы снова потеряем. + +**Файлы:** +- Создать: `bots/mts-telegram-ads/test/otvet-po-ryadu.test.mjs` +- Правка: `bots/mts-telegram-ads/src/cabinet.js` (`readCampaignStats` ~строка 415) +- Правка: `app/app/Services/ClientTg/RobotResult.php` +- Правка: `app/app/Services/ClientTg/TelegramStatsApplier.php` +- Правка: `app/tests/Feature/ClientTg/RobotResultTest.php` + +- [ ] **Шаг 1: красный сторож робота** + +Создать `bots/mts-telegram-ads/test/otvet-po-ryadu.test.mjs`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { otvetPoRyadu } from '../src/cabinet.js'; + +// 🔴 Дыра, видная только после 08.08.2026. Портал пишет в журнал «кабинет показал слово, +// которого разборщик не знает» — и самого слова в записи НЕТ: наверх уезжал только +// разобранный статус, а сырой текст ряда робот выбрасывал. Узнать слово можно было +// единственным способом — сходить в кабинет глазами. Так и пришлось. +const RYAD_NEZNAKOMOE = [ + '06.08 Telegram по своей базе', 'Посмотреть настройки', 'Приостановлена', + 'Каналы', 'Свой список', '400 ₽', '182,4 ₽', + '22', '0', '0 %', '0 ₽', '0', '0 %', '0 ₽', '10,56 ₽', '0', '0 ₽', '0 ₽', '10,56 ₽', +]; + +test('незнакомое слово уезжает наверх сырым текстом ряда', () => { + const o = otvetPoRyadu(RYAD_NEZNAKOMOE); + assert.equal(o.status, null); + assert.match(o.rowText, /Приостановлена/); +}); + +test('цифры и статус собираются как прежде', () => { + const o = otvetPoRyadu([ + '07.08 Telegram по своей базе', 'Завершить', 'Посмотреть настройки', 'Запущена', + 'Каналы', 'Свой список', '400 ₽', '182,4 ₽', + '12', '0', '0 %', '0 ₽', '0', '0 %', '0 ₽', '5,76 ₽', '0', '0 ₽', '0 ₽', '5,76 ₽', + ]); + assert.equal(o.status, 'approved'); + assert.equal(o.impressions, 12); + assert.equal(o.spendRub, 5.76); +}); + +test('сырой текст обрезан — в журнал не должен уехать роман', () => { + const dlinnyy = Array.from({ length: 400 }, (_, i) => `кусок${i}`); + assert.ok(otvetPoRyadu(dlinnyy).rowText.length <= 300); +}); +``` + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd bots/mts-telegram-ads && node --test test/otvet-po-ryadu.test.mjs +``` + +Ожидаем: FAIL — `otvetPoRyadu` не экспортируется (`SyntaxError` / `is not a function`). + +- [ ] **Шаг 3: вынести сборку ответа отдельной чистой функцией** + +В `src/cabinet.js` прямо ПЕРЕД `export async function readCampaignStats(` вставить: + +```js +/** + * Что отдать порталу по прочитанному ряду списка. Отдельной чистой функцией нарочно: + * сам поход в кабинет тестом не поймать, а вот сборка ответа — обычная арифметика. + * + * 🔴 `rowText` — сырой текст ряда. Без него запись портала «кабинет показал незнакомое + * слово» приходит БЕЗ самого слова, и узнать его можно только сходив в кабинет глазами + * (так и вышло 08.08.2026). Обрезаем: в журнал не должен уезжать роман. + */ +export function otvetPoRyadu(kuski) { + const tekst = kuski.join(' '); + + return { + status: parseModerationStatus(tekst), + rowText: tekst.slice(0, 300), + ...parseStatsRow(kuski), + }; +} +``` + +Затем в конце `readCampaignStats` заменить + +```js + const status = parseModerationStatus(ryad.join(' ')); + return { status, ...parseStatsRow(ryad) }; +``` + +на + +```js + return otvetPoRyadu(ryad); +``` + +- [ ] **Шаг 4: сторож робота зелёный** + +```bash +cd bots/mts-telegram-ads && node --test test/otvet-po-ryadu.test.mjs && npm test +``` + +Ожидаем: всё PASS. + +- [ ] **Шаг 5: красный сторож портала** + +Дописать в конец `app/tests/Feature/ClientTg/RobotResultTest.php`: + +```php +test('сырое слово кабинета доезжает до портала', function () { + // 🔴 Без него запись «кабинет показал незнакомое слово» приходит БЕЗ самого слова. + $r = RobotResult::fromRobotJson([ + 'ok' => true, + 'campaignId' => '2237821', + 'status' => null, + 'rowText' => '06.08 Telegram по своей базе Посмотреть настройки Приостановлена', + ]); + + expect($r->rowText)->toContain('Приостановлена'); +}); + +test('признак остановки читается', function () { + expect(RobotResult::fromRobotJson(['ok' => true, 'stopped' => true])->stopped)->toBeTrue(); + expect(RobotResult::fromRobotJson(['ok' => true])->stopped)->toBeFalse(); +}); +``` + +- [ ] **Шаг 6: убедиться, что сторож КРАСНЫЙ** + +```bash +cd app && php artisan test tests/Feature/ClientTg/RobotResultTest.php +``` + +Ожидаем: FAIL — `Undefined property RobotResult::$rowText`. + +- [ ] **Шаг 7: научить RobotResult** + +В `app/app/Services/ClientTg/RobotResult.php` в конструктор, после `?string $cabinetStatus = null,` +добавить: + +```php + /** + * Сырой текст ряда кабинета (обрезан роботом до 300 знаков). Нужен ровно для + * одного: чтобы НЕЗНАКОМОЕ слово попало в журнал вместе с самим словом. Прежде + * наверх уезжал только разобранный статус, и узнать новое слово можно было + * единственным способом — сходить в кабинет глазами (08.08.2026 так и вышло). + */ + public readonly ?string $rowText = null, + /** Робот нажал «Завершить» и кабинет это подтвердил (режим stop). */ + public readonly bool $stopped = false, +``` + +и в `fromRobotJson`, после строки с `cabinetStatus:`, добавить: + +```php + rowText: isset($json['rowText']) ? (string) $json['rowText'] : null, + stopped: (bool) ($json['stopped'] ?? false), +``` + +- [ ] **Шаг 8: положить слово в журнал** + +В `app/app/Services/ClientTg/TelegramStatsApplier.php` заменить блок +`if ($result->cabinetStatus === null) { Log::warning(...); }` на: + +```php + if ($result->cabinetStatus === null) { + Log::warning('client_tg.status_neznakomoe_slovo', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + // 🔴 Само слово — главное в этой записи. Без него она бесполезна: ровно так + // 08.08.2026 слово потерялось трижды подряд, и узнать его удалось только + // сходив в кабинет глазами. + 'stroka_kabineta' => $result->rowText, + 'message' => 'Кабинет показал статус кампании словом, которого разборщик не знает — посмотреть строку и научить parseModerationStatus', + ]); + } +``` + +- [ ] **Шаг 9: сторожа зелёные** + +```bash +cd app && php artisan test tests/Feature/ClientTg/RobotResultTest.php tests/Feature/ClientTg/StatusKabinetaNaChteniiTest.php +``` + +Ожидаем: PASS. + +- [ ] **Шаг 10: коммит** + +```bash +git add bots/mts-telegram-ads/src/cabinet.js bots/mts-telegram-ads/test/otvet-po-ryadu.test.mjs app/app/Services/ClientTg/RobotResult.php app/app/Services/ClientTg/TelegramStatsApplier.php app/tests/Feature/ClientTg/RobotResultTest.php +git commit -m "сырое слово кабинета доезжает до журнала портала" +git show --stat +``` + +--- + +## Задача 3: портал закрывает кампанию по слову «Завершена» и возвращает переплату + +**Файлы:** +- Создать: `app/tests/Feature/ClientTg/ZakrytiePoSlovuKabinetaTest.php` +- Правка: `app/app/Services/ClientTg/TelegramZavershenieService.php` +- Правка: `app/app/Services/ClientTg/TelegramStatsApplier.php` + +- [ ] **Шаг 1: красный сторож** + +Создать `app/tests/Feature/ClientTg/ZakrytiePoSlovuKabinetaTest.php`: + +```php +create(array_merge([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_LAUNCHED, + 'mts_campaign_id' => '2237821', + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '400.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 400, + 'matched_count' => 380, + 'estimated_cost_rub' => '268.80', + 'mts_cost_rub' => '182.40', + 'actual_cost_rub' => '255.36', + 'created_by' => 1, + ], $peremenit)); +} + +// 🪤 Кошелёк заводится ровно так же, как в соседнем ZavershenieIVozvratTest: тенант с нулём +// и пополнение через AdWalletService. Создавать строку AdWallet руками нельзя — служба +// кошелька ведёт свои записи, и возврат лёг бы мимо них. +beforeEach(function () { + config()->set('client_tg.markup', '1.40'); + config()->set('client_tg.sandbox', false); + + $this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + + app(AdWalletService::class)->topup($this->tenant->id, '1000.00', 'telegram', 'Пополнение для теста'); +}); + +function ostatokPosleZakrytiya(int $tenantId): string +{ + return (string) AdWallet::where('tenant_id', $tenantId)->first()->balance_rub; +} + +test('🔑 слово «Завершена» закрывает рекламу и возвращает переплату', function () { + // Живые числа кампании №15: списано 255,36 ₽, площадка израсходовала 10,56 ₽. + // Клиенту причитается 255,36 − 10,56 × 1,4 = 240,58 ₽. + $campaign = kampaniyaPodZakrytie($this->tenant->id); + $bylo = ostatokPosleZakrytiya($this->tenant->id); + + app(TelegramStatsApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, + 'status' => 'completed', + 'impressions' => 22, + 'spendRub' => '10.56', + ]), + ); + + expect($campaign->fresh()->status)->toBe(Campaign::STATUS_COMPLETED); + expect(ostatokPosleZakrytiya($this->tenant->id))->toBe(bcadd($bylo, '240.58', 2)); +}); + +test('🔴 слово «Останавливается» НЕ закрывает и денег НЕ трогает', function () { + // Остановка занимает до 30 минут, всё это время показы идут и тратят. Закрыть сейчас + // значит вернуть больше, чем следует, — из своего кармана. + $campaign = kampaniyaPodZakrytie($this->tenant->id); + $bylo = ostatokPosleZakrytiya($this->tenant->id); + + app(TelegramStatsApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, + 'status' => 'stopping', + 'impressions' => 22, + 'spendRub' => '10.56', + ]), + ); + + expect($campaign->fresh()->status)->toBe(Campaign::STATUS_LAUNCHED); + expect(ostatokPosleZakrytiya($this->tenant->id))->toBe($bylo); +}); + +test('возврат уходит РОВНО один раз, даже если кабинет скажет «Завершена» дважды', function () { + $campaign = kampaniyaPodZakrytie($this->tenant->id); + $bylo = ostatokPosleZakrytiya($this->tenant->id); + + $otchyot = RobotResult::fromRobotJson([ + 'ok' => true, 'status' => 'completed', 'impressions' => 22, 'spendRub' => '10.56', + ]); + + app(TelegramStatsApplier::class)->apply($this->tenant->id, (int) $campaign->id, $otchyot); + app(TelegramStatsApplier::class)->apply($this->tenant->id, (int) $campaign->id, $otchyot); + + expect(ostatokPosleZakrytiya($this->tenant->id))->toBe(bcadd($bylo, '240.58', 2)); +}); + +test('🔴 «Завершена» при неизвестном расходе — ручной разбор, а не тихое закрытие', function () { + // Закрыть с нулевым возвратом значило бы навсегда закрепить переплату: сравнивать не с чем. + $campaign = kampaniyaPodZakrytie($this->tenant->id); + $bylo = ostatokPosleZakrytiya($this->tenant->id); + + app(TelegramStatsApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson(['ok' => true, 'status' => 'completed', 'impressions' => 22]), + ); + + expect($campaign->fresh()->status)->toBe(Campaign::STATUS_NEEDS_REVIEW); + expect(ostatokPosleZakrytiya($this->tenant->id))->toBe($bylo); +}); + +test('возврат считается по ТОЛЬКО ЧТО прочитанному расходу, а не по вчерашнему', function () { + // 🪤 Порядок внутри применения отчёта — денежный: сперва записать свежие цифры, потом + // закрывать. Наоборот — вернём по устаревшему числу. + $campaign = kampaniyaPodZakrytie($this->tenant->id, ['stats_spend_rub' => '1.00']); + $bylo = ostatokPosleZakrytiya($this->tenant->id); + + app(TelegramStatsApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, 'status' => 'completed', 'impressions' => 22, 'spendRub' => '10.56', + ]), + ); + + expect(ostatokPosleZakrytiya($this->tenant->id))->toBe(bcadd($bylo, '240.58', 2)); +}); +``` + +В шапку файла добавить `use App\Services\Advertising\AdWalletService;`. + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd app && php artisan test tests/Feature/ClientTg/ZakrytiePoSlovuKabinetaTest.php +``` + +Ожидаем: FAIL — кампания осталась `launched`, на кошельке `0.00`. + +- [ ] **Шаг 3: новая дверь в службу завершения** + +В `app/app/Services/ClientTg/TelegramZavershenieService.php` добавить публичный метод сразу +после `rassmotret`: + +```php + /** + * Кабинет сам сказал, что реклама завершена. Закрываем и возвращаем переплату. + * + * 🔑 Слово «Завершена» узнали живьём 08.08.2026, нажав кнопку в кабинете на кампании + * 2237821. Это самый честный признак конца из всех, что у нас есть: правила А и Б — + * догадки по числам, а это слово площадки о самой себе. Они остаются подстраховкой на + * случай, если кабинет однажды сменит слово. + * + * 🔴 Слово «Останавливается» сюда НЕ ведёт: остановка занимает до получаса, всё это + * время показы идут и тратят, и возврат по текущим числам был бы завышен. + */ + public function zakrytPoSlovuKabineta(Campaign $campaign, bool $sandbox): string + { + if ($campaign->status !== Campaign::STATUS_LAUNCHED) { + return self::REZULTAT_ZHDYOM; + } + + $rashod = $campaign->stats_spend_rub; + + // Площадка говорит «завершена», а расход неизвестен — закрывать нечем: сравнивать + // не с чем, возврат вышел бы нулевым и навсегда закрепил переплату. + if ($rashod === null) { + $campaign->status_reason = 'Площадка сказала, что реклама завершена, а расход неизвестен — нужен ручной разбор'; + $campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + + Log::warning('client_tg.zavershena_bez_rashoda', [ + 'campaign_id' => (int) $campaign->id, + 'tenant_id' => (int) $campaign->tenant_id, + 'message' => 'Кабинет закрыл рекламу, но цифр расхода не дал — возвращать не за что, нужен человек', + ]); + + return self::REZULTAT_RAZBOR; + } + + return $this->zakryt($campaign, (string) $rashod, $sandbox, 'zavershena-kabinetom'); + } +``` + +- [ ] **Шаг 4: применить слово в разборе отчёта** + +В `app/app/Services/ClientTg/TelegramStatsApplier.php`: + +1. Добавить конструктор сразу после `final class TelegramStatsApplier` и `{`: + +```php + public function __construct( + private readonly TelegramZavershenieService $zavershenie, + ) {} +``` + +2. Добавить `use Illuminate\Support\Facades\Log;` уже есть; ничего нового импортировать не нужно + (`TelegramZavershenieService` — тот же namespace). + +3. В самый конец метода `apply`, ПОСЛЕ `Log::info('client_tg.stats_saved', ...)`, добавить: + +```php + // 🔑 Кабинет сказал, что реклама закончилась. Закрываем и возвращаем переплату — + // именно ЗДЕСЬ, после записи свежих цифр: возврат считается по только что + // прочитанному расходу, а не по вчерашнему. + if ($result->cabinetStatus === 'completed') { + $this->zakrytPoSlovu($tenantId, $campaignId); + } + } + + /** Закрыть кампанию по слову кабинета — под своим tenant-контекстом (RLS). */ + private function zakrytPoSlovu(int $tenantId, int $campaignId): void + { + $sandbox = (bool) config('client_tg.sandbox', true); + + DB::transaction(function () use ($tenantId, $campaignId, $sandbox): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + $campaign = Campaign::query() + ->where('tenant_id', $tenantId) + ->where('id', $campaignId) + ->first(); + + if ($campaign === null) { + return; + } + + $this->zavershenie->zakrytPoSlovuKabineta($campaign, $sandbox); + }); +``` + +(закрывающая скобка метода `apply` переезжает — следи, чтобы класс остался корректным). + +🪤 Есть край: отчёт с `status: completed`, но БЕЗ единой цифры уходит из `apply` раньше — на +ветке «площадка не дала ничего» (`$polya === []`). Тогда закрытие не позовётся вовсе, и +кампания останется `launched` до суточной задачи. Это верное поведение: закрывать нечем. +Отдельный сторож на это — в шаге 1 (тест «при неизвестном расходе — ручной разбор»); если он +покажет `launched` вместо `needs_review`, перенеси вызов `zakrytPoSlovu` ВЫШЕ раннего выхода +по пустым цифрам. + +- [ ] **Шаг 5: сторожа зелёные** + +```bash +cd app && php artisan test tests/Feature/ClientTg/ZakrytiePoSlovuKabinetaTest.php +``` + +Ожидаем: PASS все пять. + +- [ ] **Шаг 6: проверить сторожа ВЫРЕЗАНИЕМ** + +Временно закомментируй в `TelegramStatsApplier` строку `$this->zakrytPoSlovu(...)`, прогони +тест — обязан ПОКРАСНЕТЬ. Верни строку обратно. Зелёный сторож, которого не видели красным, +ничего не охраняет. + +- [ ] **Шаг 7: весь модуль цел** + +```bash +cd app && php artisan test tests/Feature/ClientTg +``` + +Ожидаем: PASS, было 438 зелёных. + +- [ ] **Шаг 8: коммит** + +```bash +git add app/app/Services/ClientTg/TelegramZavershenieService.php app/app/Services/ClientTg/TelegramStatsApplier.php app/tests/Feature/ClientTg/ZakrytiePoSlovuKabinetaTest.php +git commit -m "реклама закрывается по слову кабинета «Завершена», переплата возвращается клиенту" +git show --stat +``` + +--- + +# Часть 2 — кнопка + +## Задача 4: колонка «когда попросили остановить» + +**Файлы:** +- Создать: `app/database/migrations/2026_08_08_000002_add_stop_requested_at_to_client_tg_campaigns.php` +- Правка: `app/app/Models/ClientTg/Campaign.php` +- Правка: `db/schema.sql`, `db/CHANGELOG_schema.md` + +- [ ] **Шаг 1: миграция** + +Создать `app/database/migrations/2026_08_08_000002_add_stop_requested_at_to_client_tg_campaigns.php`: + +```php +timestampTz('stop_requested_at')->nullable()->after('spend_changed_at'); + }); + } + + public function down(): void + { + Schema::table('client_tg_campaigns', function (Blueprint $table) { + $table->dropColumn('stop_requested_at'); + }); + } +}; +``` + +- [ ] **Шаг 2: модель знает колонку** + +В `app/app/Models/ClientTg/Campaign.php`: + +в `$fillable`, сразу после `'spend_changed_at',` (если его там нет — после `'stats_read_at',`) +добавить: + +```php + // Когда клиент попросил остановить рекламу. NULL — не просили. Снимается обратно + // в NULL, если робот так и не смог нажать «Завершить» в кабинете. + 'stop_requested_at', +``` + +в `casts()`, после `'spend_changed_at' => 'datetime',` добавить: + +```php + 'stop_requested_at' => 'datetime', +``` + +- [ ] **Шаг 3: миграция применяется на тестовой базе** + +```bash +cd app && php artisan migrate --database=pgsql --force +``` + +Ожидаем: `2026_08_08_000002_add_stop_requested_at_to_client_tg_campaigns .... DONE`. + +🔴 Только тестовая/локальная база. Боевую не трогаем. + +- [ ] **Шаг 4: канон схемы** + +В `db/schema.sql` в определении таблицы `client_tg_campaigns` добавить колонку сразу после +`spend_changed_at`: + +```sql + stop_requested_at timestamptz, +``` + +В `db/CHANGELOG_schema.md` добавить новую запись НАД v9.74. Номер версии брать **следующий +свободный на момент правки** — в эту же ветку коммитит соседняя смена, и столкновение номеров +нельзя «разрешить» выбором стороны; если v9.75 уже занята, бери следующую. + +```markdown +## v9.75 (2026-08-08) — Telegram-модуль: кнопка «Остановить рекламу» (`stop_requested_at`) + +**Колонка:** `client_tg_campaigns.stop_requested_at timestamptz NULL` — когда клиент попросил +остановить рекламу. + +**Зачем.** У клиента не было способа остановить идущую рекламу: единственная честная остановка — +кнопка «Завершить» в кабинете МТС, и до 08.08.2026 жать её было запрещено. Запрет снят, кнопка +проверена живьём (кампания 2237821): кабинет отвечает «Останавливается, это займёт до 30 минут», +а закончив — «Завершена», и возвращает неоткрученный остаток целиком сам (+171,84 ₽ из 182,40). + +**Почему отметка, а не статус.** Новый статус потянул бы переходы, подписи экрана и все места, +где статусы перечислены. Кампания честно остаётся `launched`: показы правда идут ещё до получаса. + +**RLS:** не меняется — колонка на существующей таблице, политика `tenant_isolation` задана +миграцией создания, табличный GRANT покрывает новые колонки. + +**Миграция:** `2026_08_08_000002_add_stop_requested_at_to_client_tg_campaigns.php`. +``` + +- [ ] **Шаг 5: сторож схемы** + +```bash +cd app && php artisan test tests/Feature/ClientTg/SchemaTest.php tests/Feature/ClientTg/ModelsTest.php +``` + +Ожидаем: PASS. + +- [ ] **Шаг 6: коммит** + +```bash +git add app/database/migrations/2026_08_08_000002_add_stop_requested_at_to_client_tg_campaigns.php app/app/Models/ClientTg/Campaign.php db/schema.sql db/CHANGELOG_schema.md +git commit -m "колонка stop_requested_at: отметка «клиент попросил остановить рекламу»" +git show --stat +``` + +--- + +## Задача 5: очередь заданий различает виды работ + +🔴 Мина. Сейчас `enqueue` возвращает уже существующее незавершённое задание по кампании — +ЛЮБОГО вида. Нажатие «Остановить» при висящем задании на чтение цифр молча вернуло бы чужое +задание: портал сказал бы «останавливаем», а роботу никто ничего не передал бы. + +**Файлы:** +- Правка: `app/tests/Feature/ClientTg/TelegramRobotQueueTest.php` +- Правка: `app/app/Services/ClientTg/TelegramRobotQueue.php` +- Правка: `app/app/Models/ClientTg/RobotJob.php` + +- [ ] **Шаг 1: новый вид работы в модели** + +В `app/app/Models/ClientTg/RobotJob.php` после константы `MODE_RESUBMIT` добавить: + +```php + /** Нажать «Завершить» у кампании в кабинете. Номера НЕ нужны — кампания уже заведена. */ + public const MODE_STOP = 'stop'; +``` + +- [ ] **Шаг 2: красный сторож** + +Дописать в конец `app/tests/Feature/ClientTg/TelegramRobotQueueTest.php`: + +```php +test('🔴 просьба остановить не заслоняется висящим заданием на чтение цифр', function () { + // Мина: одинаковыми считались ЛЮБЫЕ два незавершённых задания по одной кампании. + // Нажатие «Остановить» при висящем чтении цифр вернуло бы чужое задание, портал сказал + // бы «останавливаем», а роботу никто ничего не передал бы. Реклама крутилась бы дальше. + $ochered = app(TelegramRobotQueue::class); + + $chtenie = $ochered->enqueue(1, 42, RobotJob::MODE_READ_STATS, ['campaignId' => '2237821']); + $ostanovka = $ochered->enqueue(1, 42, RobotJob::MODE_STOP, ['campaignId' => '2237821']); + + expect($ostanovka->id)->not->toBe($chtenie->id); + expect($ostanovka->mode)->toBe(RobotJob::MODE_STOP); +}); + +test('повторная просьба ТОГО ЖЕ вида нового задания не создаёт', function () { + $ochered = app(TelegramRobotQueue::class); + + $pervoe = $ochered->enqueue(1, 42, RobotJob::MODE_STOP, ['campaignId' => '2237821']); + $vtoroe = $ochered->enqueue(1, 42, RobotJob::MODE_STOP, ['campaignId' => '2237821']); + + expect($vtoroe->id)->toBe($pervoe->id); +}); +``` + +Если в файле нет `use App\Models\ClientTg\RobotJob;` и `use App\Services\ClientTg\TelegramRobotQueue;` — +добавить. + +- [ ] **Шаг 3: убедиться, что сторож КРАСНЫЙ** + +```bash +cd app && php artisan test tests/Feature/ClientTg/TelegramRobotQueueTest.php +``` + +Ожидаем: FAIL — первый тест, `$ostanovka->id` равен `$chtenie->id`. + +- [ ] **Шаг 4: чинить очередь** + +В `app/app/Services/ClientTg/TelegramRobotQueue.php` в методе `enqueue` заменить + +```php + $pending = RobotJob::where('campaign_id', $campaignId) + ->whereIn('status', [RobotJob::STATUS_QUEUED, RobotJob::STATUS_TAKEN]) + ->first(); +``` + +на + +```php + // 🔴 Одинаковыми считаем задания ОДНОГО вида, а не любые два по одной кампании. + // Прежде просьба остановить рекламу, пришедшая при висящем чтении цифр, молча + // возвращала чужое задание: портал отвечал клиенту «останавливаем», роботу не + // уходило ничего, и реклама продолжала крутиться за деньги клиента. + // + // Двум заданиям в очереди ничего не грозит: takeNext всё равно выдаёт строго по + // одному и не начнёт второе, пока первое в работе. + $pending = RobotJob::where('campaign_id', $campaignId) + ->where('mode', $mode) + ->whereIn('status', [RobotJob::STATUS_QUEUED, RobotJob::STATUS_TAKEN]) + ->first(); +``` + +- [ ] **Шаг 5: сторож зелёный** + +```bash +cd app && php artisan test tests/Feature/ClientTg/TelegramRobotQueueTest.php +``` + +Ожидаем: PASS. + +- [ ] **Шаг 6: соседи не сломались** + +```bash +cd app && php artisan test tests/Feature/ClientTg/TgRobotNextTest.php tests/Feature/ClientTg/LaunchIdempotencyTest.php tests/Feature/ClientTg/PollModerationTest.php tests/Feature/ClientTg/PoteryannayaKampaniyaTest.php +``` + +Ожидаем: PASS. Особенно `LaunchIdempotencyTest` — он стережёт, что повторный запуск не плодит +дублей; теперь защита работает по виду, а не по кампании. + +- [ ] **Шаг 7: коммит** + +```bash +git add app/app/Models/ClientTg/RobotJob.php app/app/Services/ClientTg/TelegramRobotQueue.php app/tests/Feature/ClientTg/TelegramRobotQueueTest.php +git commit -m "очередь заданий роботу различает виды работ — просьба остановить не теряется" +git show --stat +``` + +--- + +## Задача 6: ручка «остановить» + +**Файлы:** +- Создать: `app/tests/Feature/ClientTg/OstanovkaReklamyTest.php` +- Правка: `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` +- Правка: `app/routes/web.php:108` (рядом с `cancel`) + +- [ ] **Шаг 1: красный сторож** + +Создать `app/tests/Feature/ClientTg/OstanovkaReklamyTest.php`: + +```php +create(array_merge([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_LAUNCHED, + 'mts_campaign_id' => '2237821', + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '400.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 400, + 'estimated_cost_rub' => '268.80', + 'mts_cost_rub' => '182.40', + 'actual_cost_rub' => '255.36', + 'created_by' => 1, + ], $peremenit)); +} + +beforeEach(function () { + config()->set('client_tg.sandbox', false); + + $this->tenant = Tenant::factory()->create(); + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); +}); + +test('просьба остановить ставит отметку и кладёт роботу задание', function () { + $campaign = kampaniyaDlyaOstanovki($this->tenant->id); + + $this->actingAs($this->user) + ->postJson("/api/telegram/campaigns/{$campaign->id}/stop") + ->assertOk(); + + expect($campaign->fresh()->stop_requested_at)->not->toBeNull(); + + $zadanie = RobotJob::query()->where('campaign_id', $campaign->id)->first(); + expect($zadanie)->not->toBeNull(); + expect($zadanie->mode)->toBe(RobotJob::MODE_STOP); + expect($zadanie->payload['campaignId'])->toBe('2237821'); +}); + +test('🔴 денег ручка не трогает — статус кампании остаётся «идут показы»', function () { + $campaign = kampaniyaDlyaOstanovki($this->tenant->id); + + $this->actingAs($this->user) + ->postJson("/api/telegram/campaigns/{$campaign->id}/stop") + ->assertOk(); + + $svezhaya = $campaign->fresh(); + expect($svezhaya->status)->toBe(Campaign::STATUS_LAUNCHED); + expect((string) $svezhaya->actual_cost_rub)->toBe('255.36'); +}); + +test('остановить можно только рекламу, которая идёт', function () { + $campaign = kampaniyaDlyaOstanovki($this->tenant->id, ['status' => Campaign::STATUS_MODERATING]); + + $this->actingAs($this->user) + ->postJson("/api/telegram/campaigns/{$campaign->id}/stop") + ->assertStatus(422); + + expect(RobotJob::query()->count())->toBe(0); +}); + +test('без номера в кабинете останавливать нечего', function () { + $campaign = kampaniyaDlyaOstanovki($this->tenant->id, ['mts_campaign_id' => null]); + + $this->actingAs($this->user) + ->postJson("/api/telegram/campaigns/{$campaign->id}/stop") + ->assertStatus(422); +}); + +test('вторая просьба нового задания не создаёт', function () { + $campaign = kampaniyaDlyaOstanovki($this->tenant->id); + + $this->actingAs($this->user)->postJson("/api/telegram/campaigns/{$campaign->id}/stop")->assertOk(); + $this->actingAs($this->user)->postJson("/api/telegram/campaigns/{$campaign->id}/stop")->assertStatus(422); + + expect(RobotJob::query()->count())->toBe(1); +}); + +test('чужую рекламу остановить нельзя', function () { + $chuzhoy = Tenant::factory()->create(); + DB::statement('SET app.current_tenant_id = '.$chuzhoy->id); + $chuzhaya = kampaniyaDlyaOstanovki($chuzhoy->id); + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + + $this->actingAs($this->user) + ->postJson("/api/telegram/campaigns/{$chuzhaya->id}/stop") + ->assertStatus(404); +}); +``` + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd app && php artisan test tests/Feature/ClientTg/OstanovkaReklamyTest.php +``` + +Ожидаем: FAIL — 404, маршрута нет. + +- [ ] **Шаг 3: маршрут** + +В `app/routes/web.php` сразу после строки с `cancel` (строка 108) добавить: + +```php + Route::post('/campaigns/{id}/stop', 'App\Http\Controllers\Api\ClientTg\CampaignController@stop')->whereNumber('id'); +``` + +- [ ] **Шаг 4: ручка** + +В `app/app/Http/Controllers/Api/ClientTg/CampaignController.php` добавить метод сразу после +`cancel`: + +```php + /** + * Остановить ИДУЩУЮ рекламу (кнопка клиента, замысел 2026-08-08-telegram-knopka-ostanovit). + * + * Ставит отметку «попросили остановить» и кладёт роботу задание нажать «Завершить» в + * кабинете МТС. Честно остановить рекламу можно только там: у МТС нет API, и своей + * кнопки, которая что-то останавливает, у портала быть не может. + * + * 🔴 Денег НЕ трогаем. Кабинет предупреждает, что остановка занимает до 30 минут, и всё + * это время показы идут и тратят: вернуть сейчас — вернуть лишнее из своего кармана. + * Возврат сделает закрытие по слову кабинета «Завершена» (TelegramStatsApplier), когда + * площадка правда закончит. Замер 08.08.2026 на кампании 2237821: неоткрученный остаток + * МТС вернул нам целиком и сам, +171,84 ₽ ровно. + * + * Только `launched`: кнопку «Завершить» в кабинете мы видели глазами лишь у запущенной + * кампании. Есть ли она у той, что на проверке, — не замерено, и выдумывать не будем. + * До запуска у клиента есть `cancel`. + */ + public function stop(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if ($campaign->status !== Campaign::STATUS_LAUNCHED) { + return response()->json(['message' => 'Остановить можно только рекламу, у которой идут показы'], 422); + } + + if ($campaign->mts_campaign_id === null || $campaign->mts_campaign_id === '') { + return response()->json(['message' => 'Эту рекламу нельзя остановить: она не заведена в кабинете площадки'], 422); + } + + if ($campaign->stop_requested_at !== null) { + return response()->json(['message' => 'Эта реклама уже останавливается'], 422); + } + + DB::transaction(function () use ($campaign, $tenantId): void { + $campaign->stop_requested_at = now(); + $campaign->save(); + + app(TelegramRobotQueue::class)->enqueue( + $tenantId, + (int) $campaign->id, + RobotJob::MODE_STOP, + ['campaignId' => (string) $campaign->mts_campaign_id], + ); + }); + + return response()->json($this->dlyaKlienta($campaign->fresh())); + } +``` + +Проверить, что в шапке файла есть `use App\Models\ClientTg\RobotJob;` и +`use App\Services\ClientTg\TelegramRobotQueue;` — если нет, добавить. + +- [ ] **Шаг 5: сторож зелёный** + +```bash +cd app && php artisan test tests/Feature/ClientTg/OstanovkaReklamyTest.php +``` + +Ожидаем: PASS все шесть. + +- [ ] **Шаг 6: коммит** + +```bash +git add app/routes/web.php app/app/Http/Controllers/Api/ClientTg/CampaignController.php app/tests/Feature/ClientTg/OstanovkaReklamyTest.php +git commit -m "ручка «остановить рекламу»: отметка + задание роботу, денег не трогает" +git show --stat +``` + +--- + +## Задача 7: портал разбирает отчёт робота об остановке + +**Файлы:** +- Создать: `app/app/Services/ClientTg/TelegramOstanovkaApplier.php` +- Создать: `app/tests/Feature/ClientTg/OstanovkaOtchyotRobotaTest.php` +- Правка: `app/app/Http/Controllers/Api/TgRobotController.php:152-161` + +- [ ] **Шаг 1: красный сторож** + +Создать `app/tests/Feature/ClientTg/OstanovkaOtchyotRobotaTest.php`: + +```php +create(array_merge([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_LAUNCHED, + 'mts_campaign_id' => '2237821', + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '400.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 400, + 'estimated_cost_rub' => '268.80', + 'mts_cost_rub' => '182.40', + 'actual_cost_rub' => '255.36', + 'stop_requested_at' => now(), + 'created_by' => 1, + ], $peremenit)); +} + +beforeEach(function () { + config()->set('client_tg.markup', '1.40'); + config()->set('client_tg.sandbox', false); + + $this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + + // Кошелёк нужен: случай «кабинет успел написать Завершена» доходит до возврата денег. + app(AdWalletService::class)->topup($this->tenant->id, '1000.00', 'telegram', 'Пополнение для теста'); +}); + +test('нажали, кабинет пишет «Останавливается» — отметка остаётся, цифры записаны', function () { + $campaign = ostanavlivaemayaKampaniya($this->tenant->id); + + app(TelegramOstanovkaApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, 'stopped' => true, 'status' => 'stopping', + 'impressions' => 22, 'spendRub' => '10.56', + ]), + ); + + $svezhaya = $campaign->fresh(); + expect($svezhaya->stop_requested_at)->not->toBeNull(); + expect($svezhaya->status)->toBe(Campaign::STATUS_LAUNCHED); + expect((string) $svezhaya->stats_spend_rub)->toBe('10.56'); +}); + +test('🔑 кабинет успел написать «Завершена» — закрываем и возвращаем прямо сейчас', function () { + $campaign = ostanavlivaemayaKampaniya($this->tenant->id); + + app(TelegramOstanovkaApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, 'stopped' => true, 'status' => 'completed', + 'impressions' => 22, 'spendRub' => '10.56', + ]), + ); + + expect($campaign->fresh()->status)->toBe(Campaign::STATUS_COMPLETED); +}); + +test('🔴 робот не смог нажать — отметка снимается, кнопка возвращается клиенту', function () { + $campaign = ostanavlivaemayaKampaniya($this->tenant->id); + + app(TelegramOstanovkaApplier::class)->apply( + $this->tenant->id, + (int) $campaign->id, + RobotResult::fromRobotJson([ + 'ok' => false, 'step' => 'stop', 'reason' => 'Вход в кабинет слетел', + ]), + ); + + $svezhaya = $campaign->fresh(); + expect($svezhaya->stop_requested_at)->toBeNull(); + expect($svezhaya->status)->toBe(Campaign::STATUS_LAUNCHED); + expect($svezhaya->status_reason)->not->toBeNull(); +}); +``` + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd app && php artisan test tests/Feature/ClientTg/OstanovkaOtchyotRobotaTest.php +``` + +Ожидаем: FAIL — класса `TelegramOstanovkaApplier` нет. + +- [ ] **Шаг 3: написать разборщик** + +Создать `app/app/Services/ClientTg/TelegramOstanovkaApplier.php`: + +```php +ok) { + $this->snyatOtmetku($tenantId, $campaignId); + + // 🪤 Именно warning: на боевом LOG_LEVEL=warning, ниже не доезжает никогда. + Log::warning('client_tg.ostanovka_ne_udalas', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'reason' => $result->reason, + 'message' => 'Робот не смог нажать «Завершить» в кабинете — отметка снята, клиент попросит ещё раз', + ]); + + return; + } + + $this->stats->apply($tenantId, $campaignId, $result); + + Log::warning('client_tg.ostanovka_nazhata', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'slovo_kabineta' => $result->cabinetStatus, + ]); + } + + /** Снять «останавливается» и сказать клиенту человеческую правду. */ + private function snyatOtmetku(int $tenantId, int $campaignId): void + { + DB::transaction(function () use ($tenantId, $campaignId): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + Campaign::query() + ->where('tenant_id', $tenantId) + ->where('id', $campaignId) + ->update([ + 'stop_requested_at' => null, + 'status_reason' => 'Остановить рекламу не получилось — попробуйте ещё раз', + ]); + }); + } +} +``` + +- [ ] **Шаг 4: подключить ветку в приёмке отчётов** + +В `app/app/Http/Controllers/Api/TgRobotController.php` в выражении `match ($job->mode)` добавить +строку после ветки `MODE_RESUBMIT`: + +```php + RobotJob::MODE_STOP => app(TelegramOstanovkaApplier::class) + ->apply($job->tenant_id, $job->campaign_id, $result), +``` + +и добавить в шапку `use App\Services\ClientTg\TelegramOstanovkaApplier;`. + +- [ ] **Шаг 5: сторож зелёный** + +```bash +cd app && php artisan test tests/Feature/ClientTg/OstanovkaOtchyotRobotaTest.php tests/Feature/ClientTg/TgRobotDoneModesTest.php +``` + +Ожидаем: PASS. + +- [ ] **Шаг 6: проверить сторожа ВЫРЕЗАНИЕМ** + +Временно закомментируй `$this->snyatOtmetku(...)` в ветке отказа — тест «робот не смог нажать» +обязан ПОКРАСНЕТЬ. Верни обратно. + +- [ ] **Шаг 7: коммит** + +```bash +git add app/app/Services/ClientTg/TelegramOstanovkaApplier.php app/app/Http/Controllers/Api/TgRobotController.php app/tests/Feature/ClientTg/OstanovkaOtchyotRobotaTest.php +git commit -m "портал разбирает отчёт об остановке: успех оставляет отметку, отказ снимает" +git show --stat +``` + +--- + +## Задача 8: робот жмёт «Завершить» — чистая логика + +**Файлы:** +- Создать: `bots/mts-telegram-ads/src/stop-plan.js` +- Создать: `bots/mts-telegram-ads/test/stop-plan.test.mjs` + +- [ ] **Шаг 1: красный сторож** + +Создать `bots/mts-telegram-ads/test/stop-plan.test.mjs`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { vybratPodtverzhdenie, ostanovkaPodtverzhdena, NADPIS_ZAVERSHIT } from '../src/stop-plan.js'; + +// 🔴 Нажатие необратимо и делается в БОЕВОМ кабинете за живые деньги. Рядом в кабинете +// живут кнопки удаления — ничего «похожего» вслепую не жмём. + +test('в окне подтверждения жмём только кнопку из белого списка', () => { + assert.equal(vybratPodtverzhdenie(['Отмена', 'Завершить']), 'Завершить'); + assert.equal(vybratPodtverzhdenie(['Нет', 'Да']), 'Да'); +}); + +test('🔴 похожей кнопки мало — незнакомое окно не трогаем', () => { + assert.equal(vybratPodtverzhdenie(['Удалить кампанию', 'Отмена']), null); + assert.equal(vybratPodtverzhdenie([]), null); + assert.equal(vybratPodtverzhdenie(null), null); +}); + +test('подпись кнопки завершения — полное слово, а не «похожее»', () => { + assert.match('Завершить', NADPIS_ZAVERSHIT); + assert.equal(NADPIS_ZAVERSHIT.test('Завершить кампанию досрочно'), false); + assert.equal(NADPIS_ZAVERSHIT.test('Посмотреть настройки'), false); +}); + +test('🔴 доказательством остановки считаются ровно два слова кабинета', () => { + assert.equal(ostanovkaPodtverzhdena('stopping'), true); + assert.equal(ostanovkaPodtverzhdena('completed'), true); + // Слово не сменилось — значит не нажалось. «Наверное получилось» не считается. + assert.equal(ostanovkaPodtverzhdena('approved'), false); + assert.equal(ostanovkaPodtverzhdena(null), false); +}); +``` + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd bots/mts-telegram-ads && node --test test/stop-plan.test.mjs +``` + +Ожидаем: FAIL — файла `src/stop-plan.js` нет. + +- [ ] **Шаг 3: написать чистую логику** + +Создать `bots/mts-telegram-ads/src/stop-plan.js`: + +```js +// Решения остановки, которые можно проверить без браузера. Отдельным файлом нарочно: сам +// поход в кабинет тестом не поймать, а вот «что жать» и «нажалось ли» — обычная логика, +// и именно в ней живут опасные случаи. +// +// 🔴 Нажатие необратимо и делается в БОЕВОМ кабинете за живые деньги: у завершённой +// кампании кнопок запуска больше нет (замер 08.08.2026). + +/** + * Подпись кнопки завершения в ряду кампании. Полное совпадение, без «похожих»: + * «Завершить кампанию досрочно» — это уже другая кнопка, и жать её вслепую нельзя. + */ +export const NADPIS_ZAVERSHIT = /^завершить$/i; + +/** Белый список кнопок окна подтверждения. Рядом в кабинете живут кнопки удаления. */ +const PODTVERZHDENIE = [/^завершить$/i, /^да$/i, /^подтвердить$/i, /^ок$/i]; + +/** + * Какую кнопку окна подтверждения жать. null — окно незнакомое, не трогаем ничего: + * лучше отчитаться отказом, чем удалить кампанию клиента. + */ +export function vybratPodtverzhdenie(nadpisi) { + if (!Array.isArray(nadpisi)) return null; + + for (const obrazec of PODTVERZHDENIE) { + const nayden = nadpisi.find((t) => obrazec.test(String(t ?? '').trim())); + if (nayden !== undefined) return nayden; + } + + return null; +} + +/** + * Кабинет подтвердил остановку? Доказательство — только смена слова в ряду: + * «Останавливается» (идёт остановка) или «Завершена» (уже закончилась). + * + * 🔴 «Наверное получилось» не считается. Отчитаться успехом, не проверив, значит + * сказать клиенту «остановлено» о рекламе, которая продолжает крутиться за его деньги. + */ +export function ostanovkaPodtverzhdena(status) { + return status === 'stopping' || status === 'completed'; +} +``` + +- [ ] **Шаг 4: сторож зелёный** + +```bash +cd bots/mts-telegram-ads && node --test test/stop-plan.test.mjs +``` + +Ожидаем: PASS. + +- [ ] **Шаг 5: коммит** + +```bash +git add bots/mts-telegram-ads/src/stop-plan.js bots/mts-telegram-ads/test/stop-plan.test.mjs +git commit -m "робот: чистая логика остановки — белый список кнопок и доказательство нажатия" +git show --stat +``` + +--- + +## Задача 9: робот жмёт «Завершить» — работа в кабинете + +**Файлы:** +- Правка: `bots/mts-telegram-ads/src/cabinet.js` (новая `stopCampaign`) +- Правка: `bots/mts-telegram-ads/src/runner.js` (новая `runStop`) +- Правка: `bots/mts-telegram-ads/src/poll-plan.js` +- Правка: `bots/mts-telegram-ads/test/poll-modes.test.js` +- Правка: `bots/mts-telegram-ads/bin/poll.js`, `bots/mts-telegram-ads/bin/run.js` + +- [ ] **Шаг 1: красный сторож на развилку заданий** + +Дописать в `bots/mts-telegram-ads/test/poll-modes.test.js`: + +```js +test('poll: остановке номера не нужны — кампания в кабинете уже есть', () => { + assert.deepEqual(planForJob({ mode: 'stop' }), { needsPhones: false, kind: 'stop' }); +}); +``` + +- [ ] **Шаг 2: убедиться, что сторож КРАСНЫЙ** + +```bash +cd bots/mts-telegram-ads && node --test test/poll-modes.test.js +``` + +Ожидаем: FAIL — получено `{ needsPhones: true, kind: 'run' }`. + +- [ ] **Шаг 3: научить развилку** + +В `bots/mts-telegram-ads/src/poll-plan.js` перед строкой с комментарием про `draft и live` +добавить: + +```js + // Остановка (08.08.2026): нажать «Завершить» у кампании. Номера не нужны — аудитория + // в кабинете уже загружена, трогаем только кнопку. + if (job.mode === 'stop') return { needsPhones: false, kind: 'stop' }; +``` + +- [ ] **Шаг 4: сторож зелёный** + +```bash +cd bots/mts-telegram-ads && node --test test/poll-modes.test.js +``` + +Ожидаем: PASS. + +- [ ] **Шаг 5: нажатие в кабинете** + +В `bots/mts-telegram-ads/src/cabinet.js` добавить в начало файла к существующим импортам: + +```js +import { NADPIS_ZAVERSHIT, vybratPodtverzhdenie, ostanovkaPodtverzhdena } from './stop-plan.js'; +``` + +и добавить функцию сразу ПОСЛЕ `readCampaignStats`: + +```js +/** + * Нажать «Завершить» у кампании в кабинете и ДОКАЗАТЬ, что это сработало. + * + * 🔴 Ищем кнопку ВНУТРИ ряда своей кампании, а не по всей странице. В разведке 08.08.2026 + * считали по всей странице и требовали ровно одну — это было верно только потому, что + * запущенная кампания была одна на весь кабинет. У двух клиентов сразу так завершили бы + * чужую рекламу за чужие деньги. + * + * 🔴 Успехом считаем только смену слова в ряду («Останавливается» / «Завершена»). Отчитаться + * успехом, не проверив, значит сказать клиенту «остановлено» о рекламе, которая крутится. + */ +export async function stopCampaign(page, config, mtsCampaignId) { + const id = String(mtsCampaignId); + const METKA = 'data-liderra-ryad'; + + const alive = await gotoStable(page, config.telegramUrl, config); + if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)'); + await humanPause(config); + + // Помечаем ряд ИМЕННО этой кампании тем же подъёмом, что и при чтении цифр: вверх, пока + // в поддереве ровно один номер кампании. Пометка нужна, чтобы дальше жать локатором + // Playwright, физически не выходя за пределы своего ряда. + const pometili = await page.evaluate(({ cid, metka }) => { + const idRe = /telegram(?:-a2p)?\/(\d+)(?:\D|$)/; + for (const el of document.querySelectorAll(`[${metka}]`)) el.removeAttribute(metka); + + const anchor = [...document.querySelectorAll('a[href]')].find((a) => { + const m = idRe.exec(a.getAttribute('href') || ''); + return m && m[1] === cid; + }); + if (!anchor) return false; + + const skolko = (el) => { + const s = new Set(); + for (const a of el.querySelectorAll('a[href]')) { + const m = idRe.exec(a.getAttribute('href') || ''); + if (m) s.add(m[1]); + } + return s.size; + }; + + let row = anchor; + for (let el = anchor.parentElement, up = 0; el && up < 20; el = el.parentElement, up++) { + if (skolko(el) !== 1) break; + row = el; + } + + row.setAttribute(metka, '1'); + return true; + }, { cid: id, metka: METKA }); + + if (!pometili) throw new Error(`Кампания ${id} в списке кабинета не найдена`); + + const ryad = page.locator(`[${METKA}="1"]`); + if (await ryad.count() !== 1) throw new Error(`Ряд кампании ${id} не опознан однозначно — вслепую не жму`); + + const knopka = ryad.getByText(NADPIS_ZAVERSHIT); + const skolkoKnopok = await knopka.count(); + if (skolkoKnopok !== 1) { + throw new Error(`В ряду кампании ${id} кнопок «Завершить» ${skolkoKnopok}, а не одна — вслепую не жму`); + } + + await knopka.first().click({ timeout: 30000 }); + await humanPause(config); + + // Окно подтверждения. Жмём ТОЛЬКО кнопку из белого списка и печатаем её подпись: рядом в + // кабинете живут кнопки удаления, и «похожую» вслепую не трогаем. + const nadpisi = await page.evaluate(() => + [...document.querySelectorAll('button, [role=button]')] + .filter((b) => b.offsetParent !== null) + .map((b) => (b.innerText || '').replace(/\s+/g, ' ').trim()) + .filter((t) => t.length > 0 && t.length < 40)); + + const vybor = vybratPodtverzhdenie(nadpisi); + if (vybor !== null) { + console.log(`Подтверждение: жму «${vybor}»`); + await page.getByRole('button', { name: vybor, exact: true }).first().click({ timeout: 30000 }); + } + + // Доказательство. Кабинет обновляет ряд не мгновенно — перечитываем несколько раз. + let itog = null; + for (let popytka = 0; popytka < 4; popytka++) { + await page.waitForTimeout(5000); + itog = await readCampaignStats(page, config, id); + if (ostanovkaPodtverzhdena(itog.status)) return itog; + } + + throw new Error( + `Нажали «Завершить», но кабинет остановку не подтвердил: слово ряда «${itog?.rowText ?? 'ряд не прочитан'}»`, + ); +} +``` + +- [ ] **Шаг 6: режим остановки целиком** + +В `bots/mts-telegram-ads/src/runner.js` добавить `stopCampaign` в список импортов из +`./cabinet.js` и добавить функцию сразу после `runReadStats`: + +```js +// Режим stop (08.08.2026): нажимает «Завершить» у кампании в кабинете и проверяет, что +// кабинет это подтвердил. Ни номеров, ни почты. Возвращает robot-JSON +// { ok, campaignId, stopped, status, rowText, impressions, … } — портал применит его тем +// же кодом, что и обычное чтение цифр, поэтому свежие числа и слово кабинета доедут разом. +export async function runStop(config, campaignId) { + const id = String(campaignId); + let page, context; + try { + ({ context, page } = await openBrowser(config, {})); + await ensureLoggedIn(page, config); + const itog = await stopCampaign(page, config, id); + return { ok: true, campaignId: id, stopped: true, ...itog }; + } catch (e) { + return { ok: false, step: 'stop', reason: e.message, campaignId: id, retryable: e.retryable === true }; + } finally { + try { await context?.close(); } catch {} + } +} +``` + +- [ ] **Шаг 7: развилка в службе и ручном запуске** + +В `bots/mts-telegram-ads/bin/poll.js` добавить `runStop` к импортам из `../src/runner.js` и +вставить ветку после `read-stats`: + +```js + } else if (plan.kind === 'stop') { + result = await runStop(config, job.task.campaignId); +``` + +В `bots/mts-telegram-ads/bin/run.js` после блока `if (rawTask.mode === 'read-status') {...}` +добавить: + +```js +// Режим stop (08.08.2026): нажать «Завершить» у кампании. Как read-status, идёт ДО parseTask +// (у остановки нет ни phonesFile, ни budgetRub). 🔴 Действие необратимое и в боевом кабинете. +if (rawTask.mode === 'stop') { + if (!rawTask.campaignId) { + console.error('stop: не задан campaignId'); + process.exit(2); + } + const itog = await runStop(loadConfig(), rawTask.campaignId); + console.log(JSON.stringify(itog)); + process.exit(itog.ok ? 0 : 1); +} +``` + +(импорт `runStop` в шапке файла добавить рядом с `runReadStatus`.) + +- [ ] **Шаг 8: все сторожа робота** + +```bash +cd bots/mts-telegram-ads && npm test +``` + +Ожидаем: PASS. Число зелёных выросло относительно 239. + +- [ ] **Шаг 9: коммит** + +```bash +git add bots/mts-telegram-ads/src/cabinet.js bots/mts-telegram-ads/src/runner.js bots/mts-telegram-ads/src/poll-plan.js bots/mts-telegram-ads/bin/poll.js bots/mts-telegram-ads/bin/run.js bots/mts-telegram-ads/test/poll-modes.test.js +git commit -m "робот умеет останавливать рекламу: жмёт «Завершить» в СВОЁМ ряду и доказывает результат" +git show --stat +``` + +--- + +## Задача 10: кнопка на экране клиента + +**Файлы:** +- Создать: `app/tests/Frontend/telegram-knopka-ostanovit.spec.ts` +- Правка: `app/resources/js/api/telegram.ts` +- Правка: `app/resources/js/views/advertising/AdvertisingTelegramView.vue` + +- [ ] **Шаг 1: сперва починить чужие моки** + +🪤 Существующие тесты экрана подменяют весь модуль `resources/js/api/telegram` объектом. Добавь +в компонент новый импорт — и они упадут с `stopTelegram is not a function`. Найди их и добавь +заглушку ДО правки компонента: + +```bash +cd app && grep -rln "api/telegram'" tests/Frontend/ +``` + +В каждом найденном файле, где есть `vi.mock('../../resources/js/api/telegram'`, добавить в +объект мока `stopTelegram: vi.fn(),` (и соответствующую строку в `vi.hoisted`, если файл +пользуется этим приёмом). + +- [ ] **Шаг 2: красный сторож экрана** + +Создать `app/tests/Frontend/telegram-knopka-ostanovit.spec.ts`. Блок моков возьми ЦЕЛИКОМ из +`telegram-upravlenie-kampaniey.spec.ts` (строки 25–65: моки `vue-router`, `api/billing`, +`api/telegram` со всем перечнем функций и констант `MEDIA_*`) и добавь в мок `api/telegram` +строку `stopTelegram: stopTelegramMock,`, а в `vi.hoisted` — `stopTelegramMock: vi.fn(),`. + +Дальше — своя обвязка и проверки: + +```ts +import AdvertisingTelegramView from '../../resources/js/views/advertising/AdvertisingTelegramView.vue'; + +const vuetify = createVuetify(); + +const PRAVILO = { + enabled: false, + ad_text: '', + ad_link: '', + ad_headline: '', + media_path: null, + ord_category: 'Размещение рекламы', + budget_cap_rub: '0.00', + daily_limit_rub: '0.00', + batch_threshold: null, +}; + +/** Идущая реклама в том виде, в каком её отдаёт сервер. Поля правим точечно. */ +function kampaniya(over: Record = {}): Record { + return { + id: 1, + status: 'launched', + status_reason: null, + ad_text: 'Лидерра — поток заявок под контролем', + ad_link: 'https://liderra.ru', + ad_headline: 'Лидерра', + media_path: null, + media_kind: 'none', + ord_category: 'Размещение рекламы', + budget_cap_rub: '400.00', + audience_kind: 'list', + audience_params: null, + planned_count: 400, + matched_count: 380, + estimated_cost_rub: '268.80', + actual_cost_rub: '255.36', + mts_campaign_id: '2237821', + stats_impressions: 22, + stats_clicks: 0, + stats_subscribers: 0, + stop_requested_at: null, + created_at: '2026-08-06T10:00:00.000000Z', + ...over, + }; +} + +async function smontirovat(over: Record = {}) { + fetchTelegramMock.mockResolvedValue({ + campaigns: [kampaniya(over)], + sandbox: false, + balance_rub: '6869.40', + frozen_rub: '0.00', + }); + fetchAutoRuleMock.mockResolvedValue(PRAVILO); + + const w = mount(AdvertisingTelegramView, { global: { plugins: [vuetify] } }); + await flushPromises(); + + return w; +} + +beforeEach(() => { + vi.clearAllMocks(); + stopTelegramMock.mockResolvedValue(kampaniya({ stop_requested_at: '2026-08-08T13:53:00Z' })); +}); + +it('у идущей рекламы есть кнопка «Остановить»', async () => { + const w = await smontirovat(); + expect(w.find('[data-testid="tg-ostanovit-1"]').exists()).toBe(true); +}); + +it('🔴 у рекламы на проверке кнопки «Остановить» НЕТ', async () => { + // Кнопку «Завершить» в кабинете мы видели глазами только у запущенной кампании. + // Есть ли она у той, что на проверке, — не замерено, и выдумывать нельзя. + const w = await smontirovat({ status: 'moderating' }); + expect(w.find('[data-testid="tg-ostanovit-1"]').exists()).toBe(false); +}); + +it('🔴 кнопка не останавливает молча — сперва окно с предупреждением', async () => { + // Действие необратимо: у завершённой кампании кабинет не даёт кнопки запуска обратно. + const w = await smontirovat(); + await w.find('[data-testid="tg-ostanovit-1"]').trigger('click'); + await flushPromises(); + + expect(stopTelegramMock).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain('запустить эту же обратно нельзя'); +}); + +it('подтверждение зовёт ручку сервера', async () => { + const w = await smontirovat(); + await w.find('[data-testid="tg-ostanovit-1"]').trigger('click'); + await flushPromises(); + + // 🪤 Окно Vuetify живёт в конце body, а не внутри обёртки компонента — ищем в document. + const da = document.querySelector('[data-testid="tg-ostanovit-da"]') as HTMLElement; + expect(da).not.toBeNull(); + da.click(); + await flushPromises(); + + expect(stopTelegramMock).toHaveBeenCalledWith(1); +}); + +it('пока останавливается — кнопки нет, есть надпись про полчаса', async () => { + const w = await smontirovat({ stop_requested_at: '2026-08-08T13:53:00Z' }); + + expect(w.find('[data-testid="tg-ostanovit-1"]').exists()).toBe(false); + expect(w.find('[data-testid="tg-ostanavlivaetsya-1"]').text()).toContain('до 30 минут'); +}); +``` + +🪤 Если окно Vuetify в тестовой среде не появляется в `document.body`, смонтируй с +`attachTo: document.body` — так сделано в соседних тестах модуля, где проверяются диалоги. + +- [ ] **Шаг 3: убедиться, что сторож КРАСНЫЙ** + +```bash +cd app && npx vitest run tests/Frontend/telegram-knopka-ostanovit.spec.ts +``` + +Ожидаем: FAIL — кнопки нет. + +- [ ] **Шаг 4: функция обращения к серверу** + +В `app/resources/js/api/telegram.ts` в `interface TelegramCampaign` добавить поле: + +```ts + /** + * Когда клиент попросил остановить рекламу. null — не просили. Пока отметка стоит, + * кабинет ещё останавливает показы (до 30 минут), и деньги за непоказанное вернутся + * не сейчас, а когда площадка закончит. + */ + stop_requested_at?: string | null; +``` + +и функцию рядом с `cancelTelegram`: + +```ts +/** + * POST /api/telegram/campaigns/{id}/stop — остановить ИДУЩУЮ рекламу. + * + * 🔴 Необратимо: у завершённой кампании кабинет МТС не даёт кнопки запуска обратно (замер + * 08.08.2026). Деньги за непоказанное вернутся не сразу — остановка занимает до 30 минут, + * и всё это время показы ещё идут. + */ +export async function stopTelegram(id: number): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post(`/api/telegram/campaigns/${id}/stop`); + return data; +} +``` + +- [ ] **Шаг 5: кнопка и окно на экране** + +В `app/resources/js/views/advertising/AdvertisingTelegramView.vue`: + +1. В импорт из `api/telegram` добавить `stopTelegram`. + +2. В `