b6a15c5bbf7bbdcad9b25682a45f7784b0bbbd81
236 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b6a15c5bbf |
merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.
Девять столкновений, каждое разобрано по существу.
Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.
Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.
Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.
Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.
Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.
СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.
Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
- 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
перенесены из рабочей ветки, где владелец их уже принял;
- остальные 42 - свои, в новом коде ветки, и починены по существу:
задвоенный ключ массива в трёх тестах (след копирования - комментарий
оторвался от своей строки), врущие описания двух помощников (PHP сам
делает из ключа-номера число), сужение типа возврата, прятавшее от
анализатора свойства подставного отправителя, лишний знак вопроса и
четыре бесполезных перенумерования списка.
- Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
поимённо и покраснел; убрал - ноль.
Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.
Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.
Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.
Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.
NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9ef688fa8d |
merge: подтянул main после выката починок модерации Яндекса
Мой коммит уехал в main отдельно — пересадкой на актуальную вершину и оттуда на боевой. Возвращаю main к себе, чтобы ветка не разъезжалась. Пришло 9 коммитов, из них главное — работа соседней сессии по воронке продаж, уже влитая в main и запушенная. Конфликта два, оба в общих машинных файлах: - cspell-words.txt — сторону не выбирал, объединил. Все слова обеих сторон на месте; убрана ровно одна строка — мой же дубль «админский», который прошлый коммит добавил дважды. Проверено сравнением с версией до слияния: другого отличия нет; - docs/observer/STATUS.md — машинный файл наблюдателя со столбиком часов процессов, взята своя версия, его всё равно переписывает хук. Замечание на будущее: патч «минимум площадки считается по длине периода» живёт в двух коммитах — |
||
|
|
2fc844756c |
fix деньги: заморозка под боевой ролью больше не зависит от памяти человека
Находка приёмки Этапа 5. В Этапе 4 уже находили, что рабочей роли не выдано право на счётчик номеров таблицы заморозок ad_wallet_holds_id_seq, из-за чего заморозка денег под боевой ролью не проходила ВОВСЕ. Тогда стенд починили руками и переписали памятку выката. Приёмка проверила это на базе, собранной ТОЛЬКО миграциями — то есть на точной модели того, что получит боевой после выката. Права там снова нет: починка жила лишь в памятке. Ни одна миграция её не несла. Цена промаха денежная и широкая: заморозка делается при КАЖДОМ заказе рассылки, при заказе имени отправителя и при запуске рекламной кампании Яндекса. Выкат, на котором забудут перезапустить db/02_grants.sql, ломает всё это разом и молча. Что сделано: - миграция 2026_08_01_101400 выдаёт USAGE, SELECT на три счётчика кошелька рабочей и админской ролям. Роли не на глаз, а спрошены у базы: INSERT на сами таблицы кошелька есть ровно у этих двух, служебному работнику кошелёк не выдавался вовсе; - сторож tests/Feature/Advertising/AdWalletGrantsTest.php спрашивает у базы, а не читает текст миграции: опечатка в имени роли внутри миграции ошибки не даёт, права просто молча не выдаются; - запись v9.32 в журнале схемы. Структуру она не меняет — только права. Порядок работы соблюдён: сторож написан ДО миграции и покраснел ровно на том, на чём должен — «не выдано право на счётчик ad_wallet_holds_id_seq». После миграции зелёный. Живой прогон под ролью crm_app_user на базе из одних миграций: до — «нет доступа к последовательности», после — заморозка проходит и возвращает номер строки. Номер записи журнала взят v9.32, а не следующий свободный v9.26: номера v9.26-v9.31 уже заняты в других, ещё не сведённых ветках, и v9.26 повторил бы столкновение номеров, которое разбирали 01.08. Разрыв закроется при сведении с main. Памятку выката это НЕ отменяет: на уже накатанных базах миграция доносит право, но перезапуск db/02_grants.sql остаётся правильным шагом для всего остального. Статанализ добавил одно замечание — ложный класс Pest: анализатор не понимает $this внутри тестов Pest и не видит markTestSkipped. Тот же класс сидит в давно закоммиченном MigrationGrantsTest. Доказательство ложности прямое: тест проходит, не будь метода — упал бы. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1acbaf9383 |
Merge remote-tracking branch 'gitea/main' into feat/prospects-manual-testing-kp
# Conflicts: # docs/observer/STATUS.md |
||
|
|
dc56c95e13 |
fix тесты: тестовая база перестала рваться посреди прогона
Починка не моя и к Этапу 5 отношения не имеет — она взята из ветки fix/robot-yandex-zamok, где написана и доказана 31.07. Сюда перенесена потому, что без неё прогон СМС-модуля в этой ветке недостоверен. Корень в самом Laravel: в конце каждого теста он проверяет, осталось ли соединение в своей черновой транзакции, и если тест её закрыл сам — сбрасывает признак «база собрана». Следующий тест пересобирает базу целиком прямо посреди прогона, снося её под всеми остальными. В проекте полно кода со своими транзакциями, поэтому срабатывало через раз. Что сделано: - пересборка базы теперь один раз и в самом начале прогона, а не лениво в середине по первому файлу, который её закажет; - признак «собрано» держится взведённым — пересборка посреди прогона стала невозможна; - отказ заливки схемы стал громким: раньше отказ мог вернуться без ошибки, и прогон ехал дальше по неполной схеме; - добавлена сверка полноты сборки: сколько шагов миграции записано против того, сколько их лежит файлами. Не сошлось — прогон умирает сразу и внятно, а не через сотни «таблицы X не существует»; - тест-ловушка TestDbRebuildGuardTest: первый тест выходит из транзакции и ставит метку, второй метку проверяет. Без защиты второй падает. Замер в этой ветке, СМС-модуль, 44 файла: - общая тестовая база и без починки — 267 зелёных, 4 красных пачки из 15 даже с шестью попытками на пачку; - своя база liderra_testing_sms и с починкой — 383 из 383 одним прогоном. Заодно вскрылась вторая, более грубая причина: общая база liderra_testing держала 143 записи о применённых миграциях при 137 файлах в этой ветке. Записей больше файлов — доказательство, что в базу пишет чужая рабочая папка. Датчик простой: сравнить число записей с числом файлов; любое неравенство значит «база не твоя, верить прогону нельзя». Лечение — своя база на рабочую папку, как уже сделано у соседних веток. Прогон всех 4000 тестов ветки с этой починкой я НЕ делал — мерил только область СМС-модуля. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7aa54773c1 |
merge: свёл заголовок объявления с веткой робота — воронка продаж и опрос в одной ветке
Влил fix/robot-yandex-zamok (26 коммитов: сведение с основной, стадии воронки «Тестирование ручное» и «Выслано КП», чтение вердикта и пересдача по опросу) в ветку заголовка объявления. Конфликт был один — docs/observer/STATUS.md, машинный файл наблюдателя со столбиком часов процессов. Взята своя, более свежая версия; файл всё равно перезаписывается хуком. Замер после слияния (своя тестовая база liderra_testing_zag, прогон в тишине): портал 4069 тестов, 4029 прошло, 16 упало; робот 130/130. До слияния было 4063/4018/17. Тестов больше, падений меньше — стык чистый. Падают шесть давних классов, не связанных с этой работой: CreativeRobotEndpoint (11 — лезет в живой Директ и получает «недействительный ключ»), CreativeJobService, InAppNotification, PhoneRegionSmoke, ProjectExtensions, SalesOverview. Проверено отдельно: таблица заданий роботу не ограничивает список режимов (обычная строка), поэтому новые «read-status» и «resubmit» миграции не требуют. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
53ebcd9fe0 |
feat(воронка продаж): журнал движений карточки + место под корзину
Стадию меняют два разных места — результат разговора менеджера и автопереезд по деньгам. Джоба не оставляла следа вообще, поэтому «что менялось за день» было не из чего построить. Запись движения перенесена в событие модели: один шов на всех, включая любой будущий третий источник. - sales_prospect_moves — журнал всех движений (append-only, с GRANT'ами ролям); - prev_stage на карточке — откуда приехала (кормит счётчик «69/1» у «Отказа»); - stage += 'trash' — место под колонку «Корзина». Сторож проверен вырезанием: без записи движения краснеют 4 теста, в том числе тест джобы и тест API. |
||
|
|
32df332901 |
fix(телеграм-реклама): заголовок объявления для рекламы сайта — кампания больше не встаёт
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков), когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал, «Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал — дошла до подтверждения) и 2234490 (сайт с заголовком — дошла). Портал спрашивает заголовок заранее, на создании черновика: обязателен только для не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40), поле на экране появляется по той же развилке. Робот заполняет его в кабинете. Три ловушки, добытые живыми прогонами (описаны в коде): - поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать; - под описание подходит несколько элементов — нужен .first(); - серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи. Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же 13 падающих классов до и после правки (ни одного в телеграм-части). В baseline статанализа добавлен известный ложный класс Pest для нового файла тестов. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
72db586a12 |
merge: свёл ветку телеграм-робота с основной, разрулил столкновение номеров журнала схемы
Основная ветка ушла вперёд на 23 коммита - приехала чужая работа по воронке продаж, вебхуку поставщика и сверке CSV. Конфликтов было два. 1. Журнал схемы db/CHANGELOG_schema.md. В обеих ветках лежала запись v9.28 от 31.07 про разное: у нас очередь заданий роботу, у них стадии воронки "Тестирование ручное" и "Выслано КП". Обе записи настоящие, поэтому ни одна не выброшена: боевая v9.28 осталась на своём номере, наши две подвинуты на v9.29 очередь заданий и v9.30 грант админ-роли. Поправлены ссылки на номер в шапках двух миграций, перекрёстные ссылки внутри самих записей и врезка "Перенумерация" вверху журнала - там теперь описан и этот случай. 2. docs/observer/STATUS.md - файл авто-генерируемый, взята версия основной ветки, хук перепишет его сам. Заголовок db/schema.sql не трогали: там своя нумерация v8.85, ни одна из веток её не двигала. Плюс одна настоящая находка статанализа, приехавшая с чужой работой: у метода SupplierPortalClient::fetchDeliveredLeads в описании возвращаемого набора не было поля tag, хотя код его уже возвращает и чужой же тест его ждёт. Дописал одно поле в описание, логику не трогал. Список исключений статанализа пересобран - разъехались счётчики ложного класса Pest от новых строк в чужих тестах. Проверено на ОТДЕЛЬНОЙ тестовой базе liderra_testing_tgmerge: телеграм на портале 244 из 244 робот 124 из 124 статанализ 0 замечаний код-стиль чисто полный прогон 4063 теста, 4018 прошло, 17 упало До сведения было 4039 тестов и 20 падений - тестов стало больше, падений меньше. Ни одно падение не касается телеграма или журнала схемы: 13 из 17 в рекламном модуле Яндекса, из них 11 - живой отказ авторизации Яндекс Директа, остальные счётные, от накопленных за прогон данных. Отдельно вскрылось при проверке: общая тестовая база liderra_testing испорчена - в ней 180 записей о применённых миграциях при 146 файлах, то есть в неё пишет не только эта рабочая папка. Из-за этого сборка базы срывалась на первом же шаге. Отдельная база всё вылечила. Это хвост Х2б, лечение в репозиторий не вносил - решение владельца. На бой ничего не выкачено, переключатель TG_ROBOT_TRANSPORT остаётся в process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ceb88ab102 |
fix тесты: тестовая база перестала рваться посреди прогона
Корень оказался в Laravel: в конце каждого теста он проверяет, осталось ли соединение в своей черновой транзакции, и если тест её закрыл сам - сбрасывает признак "база собрана". Следующий тест пересобирает базу целиком прямо посреди прогона, снося её под всеми остальными. В проекте полно кода со своими транзакциями, поэтому срабатывало примерно раз на восемь прогонов. Что сделано: - пересборка базы теперь один раз и в самом начале прогона, а не лениво в середине по первому файлу, который её закажет; - признак "собрано" держится взведённым - пересборка посреди прогона стала невозможна; - отказ заливки схемы стал громким: раньше PDO мог вернуть отказ без ошибки, и прогон ехал дальше по неполной схеме; - добавлена сверка полноты сборки: сколько шагов записано против того, сколько их лежит. Приёмка вырезанием: новый тест-сторож зелёный с защитой и падает без неё, прогон без защиты дольше на 12 секунд - это и есть лишняя пересборка. Замер до и после, полный набор портала: без правки 3175 прошло, 837 упало с правкой, прогон 1 3971 прошло, 19 упало с правкой, прогон 2 3971 прошло, 19 упало, списки совпали дословно Оставшиеся 19 - давние и не от базы: 11 падают на неверном токене Яндекса, одна на типе исключения, семь счётных от накопления данных между тестами. Плюс план перевода чтения вердикта и пересдачи на опрос - отдельным файлом, код по нему ещё не писался. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a57a70268d |
merge: воронка продаж — стадии «Тестирование ручное» и «Выслано КП» в main
Конфликты только в документах: журнал «мозга» взят из main, запись v9.28 переставлена поверх боевого журнала схемы. Код не пересекался. Сторож слоёв проверен отдельно в рабочей папке: 0 нарушений. Сторож секретов gitleaks отработал по-настоящему. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c65856ec73 |
feat(смс-клиент): досыл не дошедшим — отдельной рассылкой «Досыл к рассылке №N»
Строка листа 5.6. В журнале рассылки появилась кнопка «Дослать не дошедшим (N)».
Нажатие спрашивает отдельным окном: сколько человек получат сообщение ещё раз и что
это отдельная рассылка с отдельной оплатой. По подтверждению заводится рассылка
«Досыл к рассылке №N» — у неё свои деньги и свой итог, а первая остаётся целой.
Почему отдельной рассылкой, а не второй попыткой в старой (решение владельца В-200):
переписав старые строки, мы стёрли бы «не доставлено», и человек больше не увидел бы,
что первая попытка не дошла. Да и база второй попытки не даст: на журнале сообщений
уникальный ключ по клиенту, рассылке и номеру.
Досылаем только по ОКОНЧАТЕЛЬНОМУ итогу (моё решение В-216): пока кто-то «ещё в пути»
или пока сама рассылка идёт — кнопки нет, а сервер отказывает человеческими словами.
Правило живёт в одном месте на сервере, экран получает готовый признак и условие не
пересчитывает: разойдись они — человек нажимал бы кнопку и получал отказ.
Деньги. Досыл списывается как обычная отправка (решение В-198) и своей денежной ветки
не имеет — считает, замораживает и списывает тот же общий код заказа. Повторное
нажатие второй рассылки НЕ заводит: ключ заказа постоянный, «resend:{номер}». Второй
клик означал бы вторую оплаченную рассылку тем же людям.
Живой прогон нашёл то, чего не видели тесты (В-227): досыл забывал оператора, которого
портал уже знал. Номер уходил как «вписанный руками», получал пометку «оператора не
знаем», а такую строку обогащение регионом уже не чинит — на бою, где универсального
канала может не быть, досыл не ушёл бы никому при созданной и оплаченной рассылке.
Теперь досыл вспоминает оператора из журнала сообщений первой рассылки: журнал хранится
без срока, в отличие от снимка получателей, который чистится через 90 дней.
Ещё две дыры, которых не было в плане. Первая: «в пути» считается только по
отправленному, поэтому у рассылки в работе он равен нулю — досыл разрешался бы посреди
работы (В-229). Вторая: защита от случайного повтора отбивала бы досыл молча, ведь текст
у него тот же, что у первой рассылки (В-228).
Схема v9.25: колонка resend_of_campaign_id и индекс у client_sms_campaigns. Прав не
требует — это колонка существующей таблицы, а не новая таблица.
Тесты: 8 на сервере, 4 на экране. Все проверены вырезом — двенадцать вырезов, каждый
покрасил именно свои тесты. Живой прогон в браузере: кнопка с числом, окно про отдельную
оплату, рассылка на два нужных номера с сохранённым оператором, первая рассылка цела,
второе нажатие ничего не завело. Стенд возвращён.
Движение денег живьём НЕ проверялось: на стенде включена песочница, а выключить её —
значит слать настоящие СМС за настоящие деньги.
|
||
|
|
977d54bc53 |
feat(воронка продаж): база под стадии Тестирование ручное и Выслано КП
Стадии manual_testing и kp_sent в CHECK + 4 nullable-колонки под их поля. Откат и повторный накат проверены на своей тестовой базе измерением колонок. Статанализ пропущен с разрешения владельца: 287 замечаний было и до правки, все — известный ложный класс Pest, моих файлов среди них нет. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
81d6de7588 |
feat телеграм-робот: приём отчёта роботом и общее применение итога
Применение итога вынуто из джоба в сервис без изменения логики — теперь его зовут оба пути, процессный и опросный. Отчёт принимается только по заданию в работе. Сторож принят вырезанием: без проверки статуса повторный отчёт проходит с 200. Отдельно закрыта мина, найденная ревью защиты и отсутствовавшая в плане: роль crm_admin_user имела на client_tg_campaigns только чтение, а канал робота пишет туда итог — на бою приём отчёта упал бы по правам. Миграция v9.29 даёт UPDATE. Полный набор тестов телеграма: 217 из 217 зелёные, старые в том числе. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
541f38d452 |
fix(поставщик): отсрочка добора + живой журнал вебхука — конец ложным «вебхук потерял N»
Повод — письмо тревоги 31.07.2026 05:30 МСК: сверка №1417 отрапортовала «потеряно 7». Разбор на бою показал, что вебхук по тем же семи пришёл через 2,5 минуты (05:32:32 ×2 + 05:33:03 ×5) и получил «уже есть». Потери не было: поставщик кладёт строку в журнал отданного РАНЬШЕ, чем шлёт вебхук, а наша сверка (каждые 30 мин) попадала в эту щель. Цена ошибки не только ложная тревога: добранная карточка беднее живой (в журнале нет tag/time/phones) — у 4 из 7 не определился регион, у сделок пустые регион и город. 1. Отсрочка добора (CsvReconcileJob::GRACE_MINUTES = 15). Недостача, увиденная впервые, уходит в карантин (Redis, карта vid => время первого обнаружения) и добирается только следующим прогоном, если провисела дольше отсрочки. Реальная потеря доезжает максимум через полчаса. Потеря карантина безопасна: в худшем случае добор на прогон позже. drift и «потеряно» в письме считаются ТОЛЬКО по просроченному; «в пути» — отдельно (новая колонка supplier_csv_reconcile_log.pending_count). 2. Журнал вебхука поставщика (новая таблица supplier_webhook_log). Прежний logSupplierWebhook писал в webhook_log, снесённую 24.05 вместе с legacy-каналом, и молча выходил по Schema::hasTable — журнала не было ВООБЩЕ. Из-за этого 70 отказов 404 за 10 дней никто не видел; нашлись случайно в логе nginx. Пишем статус, адрес отправителя, запрошенный хост и отпечаток присланного ключа (первые 8 символов md5 — отвечает «наш ключ или чужой», секретом не является). Отказы дополнительно уровнем warning: на бою LOG_LEVEL=warning, info в журнал не попадает. 3. Текст письма-тревоги переписан: «потеря» = только то, что не дошло даже за отсрочку; «в пути» показывается отдельной строкой и потерей не считается. 4. В catch сверки — Log::error ПЕРВЫМ действием, до обращений к БД: при испорченной транзакции следующий запрос бросал своё исключение и настоящая причина терялась. Проверено: тесты поставщика и вебхука 230/230 (в т.ч. 5 новых на карантин, поздний вебхук, отчёт «в пути» и смоук шаблона письма), Larastan 0, Pint чисто. Прогон всей базы 3566/3575; 5 падений — чужие и до этих правок (замерено откатом файлов): биллинг на стыке месяцев и маршруты телеграм-ветки. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
32c8fa2881 |
feat телеграм-робот: таблица заданий роботу с построчной защитой
Номера телефонов в задание не кладём — только текст, ссылка и смета. После выката на бой перезапустить db/03_service_bypass_policies.sql. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
36c8ced41e |
feat(смс-клиент): судьба сообщения отдельной графой + право служебной роли править журнал
Этап 5, Task 2. Схема v9.23 и v9.24. Графа судьбы (миграция 101100): семь колонок у client_sms_messages — delivery_status, delivered_at, delivery_checked_at, delivery_raw, provider_cost, provider_parts, refunded_at, плюс индекс «кого спрашивать дальше». Почему графа, а не переписывание status: по status считаются ДЕНЬГИ и месячный объём клиента (ClientSmsVolumeCounter берёт ровно sent), его же складывает сторож зависших и разбирают два словаря подписей на экране. Заменив sent на delivered, мы обнулили бы клиенту накопление за месяц и удешевили бы цену задним числом. Значит status остаётся фактом ПЕРЕДАЧИ, а судьба живёт рядом (В-202). Цена оператора (provider_cost) хранится КАК ЕСТЬ: единицы поля cost у МТС неизвестны (В-211), в рубли не переводится и ни во что не подставляется. Право служебной роли (миграция 101200): GRANT UPDATE на client_sms_messages. Команда опроса судьбы кросс-клиентская, ходит служебным соединением и ПРАВИТ существующую строку — новых не пишет намеренно (В-204: новые строки делали бы зависшую рассылку «живой» для сторожа, и деньги остались бы замороженными). Без права на бою команда падала бы каждые десять минут; локально не видно никогда — dev и тесты ходят суперпользователем (В-126/В-127). Проверено: - сторож MigrationGrantsTest расширен и проверен ВЫРЕЗОМ: гашение GRANT в миграции красит его; - изоляция цела — запросом к базе после наката: RLS true/true, политика tenant_isolation на месте, права ролей ровно ожидаемые, все семь колонок есть; - весь модуль 327/327 (было 320, +7 читателя судьбы), 13 пачек, все с первой попытки; phpstan ровно 2 чужие давние, pint чисто. |
||
|
|
97a93cb763 |
feat(смс-клиент): кнопка «Продолжить рассылку» — доводим до конца тех, на кого не хватило денег
Строка листа 4.13, решение владельца В-49 «надо обязательно». Рассылка встала из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт дальше с того места, где остановилась. 🔴 Главная мина (В-151, подтверждена чтением кода): джоб отправки считает обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, без разбора статуса. А номер, на котором кончились деньги, записан как «не хватило денег». Значит простой повторный запуск молча пропустил бы его: человек заплатил бы за продолжение, а получатель не получил бы ничего. 🔴 И вторая находка, которой в плане не было (В-184): «оставить строку и просто не считать номер обработанным» НЕВОЗМОЖНО физически — на журнале сообщений уникальный ключ по (клиент, рассылка, номер), вторая запись не вставится. Поэтому продолжение эти строки УДАЛЯЕТ, и по смыслу это верно: «не отправили из-за денег» фиксирует НЕслучившееся и после пополнения перестаёт быть правдой. Заодно замерен масштаб: при остановке пишется РОВНО ОДНА такая строка, у остальных номеров записей нет вовсе. Что сделано: · POST /api/sms/campaigns/{id}/resume — убирает строки «не хватило денег», ставит рассылку в очередь, гасит причину остановки (иначе экран продолжал бы объяснять человеку прошлую остановку у работающей рассылки); · продолжаем ТОЛЬКО остановленную ИЗ-ЗА ДЕНЕГ (В-187). Остановленную человеком продолжать — значит отменить его решение; сорванную сторожем — наступить на ту же поломку. У каждой причины свой человеческий отказ, и текст под тестом; · правило живёт в ОДНОМ месте на сервере (как enableDecision в Task 4): список рассылок отдаёт готовый признак can_resume и число «сколько ещё уйдёт». Счёт остатка — в читателе снимка, где уже живёт «кто ждёт» (одно место, В-96); · кнопка «Продолжить» в таблице рассылок + подтверждение: сколько осталось отправить, что повторно никому не пойдёт, что цена прежняя; · цену НЕ пересчитываем, снимок НЕ пересобираем, заново НЕ замораживаем (В-39, В-188): списание идёт поштучно с проверкой перед каждым номером, в минус рассылка не уйдёт — кончатся деньги, встанет снова с той же причиной. 🔴 Мой же тест поймал расхождение с моим же решением (В-189): проверку денег я впихнул в правило «можно ли продолжить» — и кнопка исчезла бы именно у того, кому она нужна. Разделил: состояние — признак для экрана, деньги — проверка по нажатию с числами («свободно 0.00 ₽, одно сообщение 8.50 ₽»). Право DELETE на журнал сообщений рабочей роли — миграция 2026_08_01_101000, схема v9.22 (В-186, вторая половина мины В-152): без него продолжение на бою падало бы «нет доступа». Сторож MigrationGrantsTest расширен. Проверено: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов 17/17, фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Шесть вырезов, каждый покраснел ровно там, где вырезан. 🔴 Живой прогон ДЕНЕЖНЫЙ и парный под боевой ролью crm_app_user: рассылка на 5 номеров при деньгах на 2 встала (ушло 2, списано 17.00 ₽, одна строка «не хватило денег»); продолжение без пополнения — отказ с числами; БЕЗ пометки клиента рассылка не видна вовсе (изоляция держит); после пополнения с пометкой — ушло 5 из 5, списано 42.50 ₽, в журнале ровно 5 записей по одной на номер, «не хватило денег» не осталось. В браузере: кнопка видна глазами, после нажатия и прогона очереди рассылка «завершена, 5 из 5», кнопка исчезла, у нетронутой рассылки осталась. Заодно поправлена речь: было «отправим ещё 3 сообщений». Стенд возвращён и сверен; намеренное изменение одно — миграция накатана на локальную dev-базу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e29acb7af |
feat(смс-клиент): снимок получателей живёт 90 дней и чистится сам
Строка листа 4.12, решение владельца В-45. Снимок получателей — копия ПЕРСОНАЛЬНЫХ данных: список номеров рассылки с пометками, кому уйдёт и кому нет. Делается один раз при создании рассылки и больше не пересматривается (В-39), то есть после отправки лежит мёртвым грузом. Хранить его вечно нельзя. Что сделано: · команда `client-sms:purge-snapshots` в расписании раз в сутки в 03:30 (проверено schedule:list) уносит строки снимка старше 90 дней. Ходит служебным соединением — она кросс-клиентская и пометку клиента не ставит; · пачками по 5 000: снимок бывает на 20 000 строк, одним DELETE по дате это долгая блокировка. Прибор стоит именно на цикл — 5 001 строка обязана уйти целиком, иначе одна строка ПДн осталась бы жить вечно; · рассылка и её итоги НЕ трогаются: `client_sms_campaigns` и журнал сообщений остаются, как велел владелец. Цена этого названа вслух (В-178): через 90 дней уже нельзя ответить, кто из получателей ждал своего утра; · срок живёт в коде, в админке не правится (В-177): срок зависания рассылки — рабочая настройка, а 90 дней — обязательство про персональные данные, одно для всего портала. При ручном запуске срок передать можно, нулевой отклоняется человеческими словами — он снёс бы снимки живых рассылок; · про снимок НЕзакончившейся рассылки команда говорит вслух и в журнал сервера (В-176): в норме такого не бывает, и молчать об этом нельзя. Право `DELETE` служебной роли — миграция 2026_08_01_100900, схема v9.21 (В-152). Номер и версию взял по каталогу: названные планом были заняты Task 5 — третий раз этот класс (В-175). Сторож `MigrationGrantsTest` расширен и спрашивает саму базу, а не текст миграции. 🔴 Живой прогон под боевой ролью crm_supplier_worker УТОЧНИЛ мину В-152 (В-181). На стенде разрешающих политик srv_bypass нет вовсе (В-179), поэтому бой воспроизведён: политика поставлена тем же текстом, что в db/03_service_bypass_policies.sql, и после прогона убрана. Тройка: (1) политика есть, права нет — команда УПАЛА «нет доступа к таблице», строки целы (план предсказывал «удалит ноль и отрапортует успехом» — в жизни падение); (2) право есть, политики нет — «Удалено строк снимка: 0» с кодом УСПЕХА, вот настоящий тихий ноль; (3) обе опоры — удалено 3 из 4, свежая строка на месте, рассылка и её сообщение на месте, предупреждение о незаконченной рассылке прозвучало. Вывод для выката: без права беда ВИДНА (падение), без политики НЕ видна (успешный ноль). Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов 17/17, phpstan 2 чужие давние, pint чисто. Фронт не трогался вовсе — vitest и vue-tsc не гонялись, правок в экранах нет ни одной. Вырезов восемь, каждый покраснел ровно там, где вырезан; девятый — подмена служебного соединения на обычное — НЕ покраснел вообще (6/6 зелёных), и это честный результат: чем ходит команда, ловится только живым прогоном. Стенд возвращён и сверен со снимком ДО; намеренное изменение одно — миграция накатана на dev-базу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d292082ab |
feat(смс-клиент): потолок загрузки базы и пачечная запись вместо построчной
Строка листа 4.6 состояла из двух половин, и вторая («десятки тысяч
проходят и не падают») не выполнялась вовсе: каждый номер писался
отдельным запросом — 20 000 номеров стоили 25 278 запросов и 41 секунду.
Теперь пишем пачками по 1000 одним upsert: 23 запроса и 3.5 секунды.
Оплаченный ДаДатой оператор при повторной загрузке не стирается, дубли
внутри одной загрузки не роняют её, база не задваивается.
Потолок: колонка client_sms_settings.max_upload_phones (миграция
2026_08_01_100800, схема v9.20), по умолчанию 50 000, правится владельцем
в админке в границах 1 000…100 000. Сверх потолка загрузка отклоняется
целиком — частично загруженная база хуже незагруженной — и человек видит
оба числа. Экран говорит потолок ДО загрузки, числом с сервера.
Потолок спрашивается ПЕРЕД построчной проверкой номеров: иначе отказ на
50 001 номере занимал 20 секунд (замерено живым прогоном), а при верхней
границе запрос успел бы умереть по сроку жизни.
Ответ GET /api/sms/contacts стал объектом {items, max_upload_phones};
мёртвое поле contacts из ответа загрузки убрано.
Проверено: 14 серверных тестов, 2 фронтовых, 8 вырезов (каждый покраснел
там, где вырезан), живой прогон под боевой ролью crm_app_user и в браузере.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
77f61fb1d1 |
merge: сведение ветки «Реклама Телеграм» с боевым main
Слияние feat/client-telegram-ads с main
|
||
|
|
65e270583c |
feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или виртуального оператора в рассылку не берётся, и человек видит честную причину, а не молчаливую пропажу. Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём. Список — настройкой, а не в коде: client_sms_settings.allowed_operators (схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию. Правило живёт в одном месте — AllowedSmsOperators. Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок и своей базы оператор известен в момент заказа, у номеров, вписанных руками, его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали». Плата за имя не тронута (В-150): в коде два похожих списка операторов, и связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей. Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём только по МТС)» — универсальный канал возит всех. Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка 24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн». ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f25c2f550d |
feat(смс-клиент): имя, отключённое за долг, возвращается само — за один месяц вперёд
Строка листа 4.4, Этап 4 Task 3. Раньше ночной работник смотрел только на работающие имена, поэтому имя, отключённое за долг, не возвращалось само никогда и ни при каких деньгах. Теперь у него есть второй проход: как только у клиента хватило СВОБОДНЫХ денег (с учётом замороженных под рассылки), имя включается, а плата берётся за один месяц вперёд от сегодняшнего дня. Старый долг прощается — решение владельца В-133: за месяцы, когда имя не работало, брать не за что. Документы заново не запрашиваются: заявка не пересоздаётся, меняются только состояние и срок оплаты. Сумма нигде не зашита — берётся из карточки имени. Она уже считается как «цена за оператора × число операторов», и когда подключим остальных операторов, вырастет сама. Плана оказалось мало (журнал В-140). Он предлагал возвращать всё, что «отключено и с долгом», но имя отключает и владелец руками из админки — и такое имя погашено у МТС, оно не работает. Портал включал бы его обратно и списывал за это деньги клиента, отменяя решение владельца. Отличать по тексту записки нельзя: текст — не признак. Поэтому у имени появилась графа «почему отключено» (за долг / рукой владельца); сам портал возвращает только первое. Схема v9.18, миграция 2026_08_01_100600, прав не требует. Второй правкой плана (В-141) переписан его тест на двойное списание: он проходил вхолостую, потому что после первого прогона имя уже работает и второй проход его не видит. Настоящая опасность — кнопка клиента (строка 4.5) и работник спишут за один месяц дважды, если ключ у них разный. Тест теперь про это и краснеет от порчи ключа. Проверено вырезанием, четыре выреза, все вернуты: — убрал проверку денег → покраснели четыре теста; — убрал отбор по причине отключения → покраснел тест про имя владельца; — испортил ключ месяца → списание прошло дважды, тест покраснел; — убрал пометку причины из админки → покраснел тест админки. Живой прогон под боевой ролью crm_app_user, пара «не произошло / произошло»: на счету 50 ₽ при плате 100 — имя осталось отключённым, деньги не тронуты; пополнил до 550 ₽ — имя работает, списано ровно 100 ₽, оплачено до 29.08, на счету 450 ₽. В том же прогоне имя, выключенное владельцем, при 5000 ₽ на счету осталось выключенным. Вторая пара — на пометку клиента: без неё имя не включается вовсе, и работник в обоих случаях молчит. Прогон вскрыл чужую мину (В-142): у боевой роли не было права на счётчик номеров таблицы движений рекламного кошелька — под этой ролью молча не проходило ни одно списание. Это расхождение локального стенда с эталоном db/02_grants.sql, кода не касается; в памятку на выкат вписана читающая проверка этого права на бою. Прогоны: клиентские СМС 262/262 (11 пачек, все с первой попытки), приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался. |
||
|
|
c0a8da37d2 |
feat(смс-клиент): зависшая рассылка сама срывается и возвращает замороженные деньги
Строки листа 4.1 и 4.2, Этап 4 Task 1. Беда, от которой сторожим: работник очереди умирает посреди отправки (перезапуск сервера, обрыв связи). Снятие заморозки денег стоит ПОСЛЕДНИМ шагом джоба отправки — до него он в этом случае не доходит. Итог на бою: рассылка вечно «идёт», деньги клиента заморожены навсегда, в журнале тишина. Команда client-sms:watch-stuck, каждые 15 минут. Движение меряется временем последней записи в журнале рассылки: sent_count для этого не годится, он проставляется только в самом конце. Порядок — решение владельца В-132: сперва ОДНА попытка дожать (это безопасно, джоб пропускает уже отправленные номера по ключу на номер), и только если и после неё не сдвинулась — срываем, размораживаем остаток, ставим причину «сторож». Итог считаем из журнала. Честно ждущая утра рассылка не трогается вовсе (строка 4.2): отличаем по состоянию — ждущая waiting_window, зависшая sending. Ждущих дожимает client-sms:resume-waiting. Миграция 2026_08_01_100500 — две колонки: срок «зависла» в настройках (правит владелец, строка 4.3) и отметка попытки дожать у рассылки. Прав не требуют, наследуют привилегии таблиц; повторный накат переживают. Схема v9.17. Проверено вырезанием, три выреза, все вернуты: — убрал условие про состояние → покраснел тест 4.2, ждущую положили в очередь; — убрал ветку «сперва дожать» → покраснел тест «кладётся в очередь ещё раз»; — убрал пометку клиента → живой прогон под боевой ролью показал молчаливый сбой. Живой прогон ПОД БОЕВОЙ РОЛЬЮ crm_app_user, парно: с пометкой клиента — рассылка сорвана, заморозка 17.00 → 0.00; без пометки — осталась «идёт», 17.00 зависли, и в ОБОИХ случаях команда сказала «Сорвано: 1» и вернула успех. Прогоны: ClientSms 249/249 (было 241, +8; 11 пачек, все с первой попытки), приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался. |
||
|
|
d242819e27 |
fix(смс-клиент): три блокера выката — снимок читался без пометки клиента, правка снимка и базы была без прав
Продолжение приёмки Этапа 3 по указанию владельца: «проверь замечание — так это или нет — и посмотри всё окружение на этот класс ошибок». Замечание оказалось верным, и рядом с ним нашлось ещё два блокера того же семейства. Ни один из трёх не виден ни одному тесту. 🔴 В-125. Джоб отправки читал снимок получателей ВНЕ пометки клиента: строки 115 и 139 стояли голыми в handle(), а обёртка открывалась только внутри цикла. Чтение снимка ленивое — запрос уходит не тогда, когда читателя позвали, а когда забирают очередную пачку, то есть уже за пределами чужой транзакции, а SET LOCAL живёт только до её конца. Замер у самой базы на ОДНОЙ строке снимка: суперпользователь (так идут все наши тесты) — 1 строка, боевая роль crm_app_user без пометки — 0, она же с пометкой — 1. Без ошибки, молча. На бою: рассылка закрывается «готово, отправлено 0»; а если в снимке есть ждущие своего утра — статус «ждёт утра», заморозка НЕ снимается, команда добора будит рассылку каждые 15 минут, та снова читает ноль, и деньги висят замороженными без конца. Починка стоит в самом читателе, а не у зовущего: он оборачивает КАЖДЫЙ свой запрос сам. Полагаться на внимательность каждого, кто его позовёт, оказалось нельзя — ровно на этом дыра и выросла. Зовущим не мешает: под запросом человека пометка уже стоит, вложенная транзакция ставит то же значение. 🔴 В-126 и В-127. Этап 3 научил двух помощников ПРАВИТЬ снимок и клиентскую базу — проставлять найденный у ДаДаты регион, пояс и оператора. А обе таблицы заводились под путь «строки только вставляют и удаляют»: прав на правку им не выдавали. Замер: UPDATE под боевой ролью — «нет доступа к таблице», SELECT той же ролью работает. На бою: номер, у которого пояс не был известен сразу, не получил бы его НИКОГДА, а номер без пояса не отправляется вовсе (решение владельца В-85). Для канала «своя база» пояс не известен ни у одного номера, пока помощник его не проставит, — то есть этот канал не отправил бы ни одного сообщения. Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись схемы v9.16. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, и обещание опять оказалось неполным — как в В-122 неделей раньше. ⚠️ В-128, не чиню, называю. В трёх докблоках записано, будто служебная роль обходит изоляцию. ПИЛОТ.md от 07.07: на боевом кластере её не обходит НИ ОДНА роль. Значит служебное соединение живо не обходом, а политиками srv_bypass, и перезапуск db/03_service_bypass_policies.sql при выкате — не подстраховка, а несущая опора. Правка текстов — уровень всего приложения, не этапа. Доказательства. · Тест В-125 проверяет не результат (его подделать нельзя — суперпользователь всё видит), а ПОРЯДОК: каждый запрос к снимку обязан идти внутри той же открытой транзакции, где уже выставлена пометка. Уровень вложенности отличает «пометка здесь и сейчас» от «стояла раньше, в другой, уже закрытой». Красный до починки показал 5 чтений, все без пометки. · Живой прогон НАСТОЯЩЕГО джоба под боевой ролью (SET ROLE crm_app_user), парно: с починкой — «отправлено 2 из 2, статус готово»; без починки — «отправлено 0 из 2, статус готово, джоб не упал». Тот самый молчаливый сбой, вживую. · Права: живой UPDATE под боевой ролью — до миграции «нет доступа» по обеим таблицам, после миграции обе правки проходят. Данные пробы откатаны. · Вырезами трижды: убрал обёртку у чтения снимка — тест краснеет; опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; выдал права только одной таблице из двух — краснеет на второй. Всё возвращено. Обход всего окружения на этот же класс: 35 фоновых помощников и 36 команд классифицированы по тому, ставят ли они пометку клиента и каким соединением ходят. Те, что работают без пометки, трогают только таблицы БЕЗ изоляции (портал продаж, бот, внешние балансы). Отдельно искал именно ловушку «ленивое чтение уезжает из чужой обёртки» — в рекламном модуле и сборщике аудитории всё внутри. Права на автономера у всех новых таблиц выданы обеим ролям, по кошельку перекосов нет. Не проверял вглубь маршруты портала (их закрывает общая прослойка) и модули вне рекламы/СМС. Прогоны: СМС 241/241 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных + 3 пропущенных (не трогал), phpstan 2 чужие давние, pint чисто. Стенд возвращён: dev-база — те же 12 клиентов и нули по модулю, пробные строки в тестовой базе откатаны. 🔴 При выкате ветки порядок прежний и обязателен: миграции → db/03_service_bypass_policies.sql → контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Права и изоляция — разные механизмы, эта миграция того шага не заменяет. |
||
|
|
6704054a80 |
fix(смс-клиент): приёмка Этапа 3 — служебная роль получила права, экран перестал врать
Этап 3 «Время и цена», Task 8 — приёмка. Строка листа Н.2 (проверка разграничения доступа), итоговая таблица этапа заполнена. 🔴 Блокер выката, найден проверяющим по доступу и подтверждён лично. Команда добора `client-sms:resume-waiting` (появилась в этом этапе, стоит в расписании каждые 15 минут) ходит в базу под служебной ролью `crm_supplier_worker`: она обходит рассылки ВСЕХ клиентов и потому не может работать под клиентской ролью. Прав этой роли на три таблицы, которые она читает и правит, выдано не было — миграции модуля выдавали права рабочей роли, а служебной только на имя отправителя и правило авто-СМС. Локально этого не видно: dev и тесты ходят суперпользователем. На бою команда падала бы с «нет доступа» каждые 15 минут, и камчатская часть рассылок не дошлалась бы НИКОГДА. Тот же класс, что блокер прав на счётчики 27.07. Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись схемы v9.15. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, а обещание было неполным — теперь спрашивает и про эти три таблицы. Две неправды на экране, найденные живым прогоном на НЕудобных данных. · Текст в 2325 символов: сервер отказал (потолок 1000), сметы нет, а строчка под текстом пишет «Умещается в 1 СМС за номер». В коде стояло `preview?.segments ?? 1` — на месте «не знаю» подставлялась единица и утверждалась как факт. Теперь без ответа сервера число СМС не называется вовсе: молчание честнее. Дыра не этого этапа (код от 26.07). · Сам отказ был написан по-программистски: «Количество символов в поле body не может превышать 1000». Стало: «Текст длиннее 1000 символов — сократите его.» Вырезами проверено четыре раза: опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; опечатка в цели GRANT — краснеет; убрал человеческий текст отказа — краснеет; вернул подстановку единицы — краснеет. Всё возвращено. Живой прогон строк 3.1–3.15 подряд (29.07, 06:45–07:00 МСК, песочница, локальная база): камчатский номер ушёл сразу, московский стал ждать 10:00, без региона — ждёт уточнения; экран сказал «Отправлено 1 из 3, 1 ждут утра в своих регионах, ещё 1 — уточняем регион»; команда добора до утра дала 0, после наступления срока 1 и номер ушёл, повторный запуск снова 0; ДаДата (ЗАГЛУШКА, боевой ключ не трогали) спрошена ровно один раз — про тот номер, у которого пояса не было; авто-СМС на московский лид отложена ровно на 10:00, при открытом окне три запуска дали одно сообщение; цена прошла 9.00 → 8.50 → 8.00 по накоплению, у отправленной рассылки осталась 9.00; счётчик сложил 500 из рассылки и 500 из авто-СМС; песочные отправки в счётчик не пошли. Стенд возвращён ровно в исходное: окно 10–20, 0 контактов, 0 сообщений, 0 рассылок, 0 снимков, 5 демо-сделок, кошелёк 1000.00 / заморожено 0.00. Прогоны: СМС 240/240 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных + 3 пропущенных, phpstan 2 чужие давние, vue-tsc 8 чужих давних (проверено git blame), pint чисто. 🪤 Урок приёмки: мой собственный скрипт прогона отрапортовал «красных пачек нет» и НОЛЬ зелёных — разбирал ответ не в том формате. Ноль почти всегда сбой, а не правда о мире. Теперь скрипт считает зелёным только ответ, где прошедших БОЛЬШЕ НУЛЯ. 🔴 При выкате ветки порядок обязателен: миграции → db/03_service_bypass_policies.sql → контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Эта миграция его НЕ заменяет: без srv_bypass команда добора увидит тихий ноль даже с выданными правами. |
||
|
|
6f469fb13d |
feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления
Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере)
оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске,
возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund).
Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены).
Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён
(сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом.
Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы,
а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС:
- «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет):
ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting,
фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee.
Таблицы client_tg_senders/_settings дропнуты.
- мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны
сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены).
- client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»).
- лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс».
Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media
(картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле
загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile
→ cabinet.js fillAdMedia) — не хватало только приёма файла от клиента.
Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп
СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному):
Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view
19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот
bots/mts-telegram-ads НЕ трогали.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
026e71a283 |
feat(смс-клиент): цена по накоплению за месяц, а не по объёму одного заказа
Этап 3 «Время и цена», Task 6. Строки листа 3.8, 3.9, 3.10, 3.14. Клиент, разбивший месяц на десять рассылок по сто номеров, платил по самой дорогой ступени, хотя отправил тысячу. Авто-СМС и вовсе всегда считалась по самой дорогой: в ступень уезжало число сегментов одного сообщения. Теперь ступень берётся для «сколько отправлено в этом месяце плюс объём самого заказа», и спрашивают об этом все три места сразу — предпросмотр, создание рассылки и авто-СМС на новый лид. Счётчик живёт в одном месте (ClientSmsVolumeCounter): его зовёт цена, а в Task 7 позовёт экран. Отдельного накопительного счётчика в базе не завожу намеренно — счётчик, разъехавшийся с журналом, опаснее лишнего запроса; журнал правдив, потому что деньги списываются той же записью. Считаем в СМС, а не в сообщениях (В-108). Длинное письмо — это два СМС, и платит клиент за два; считая строки журнала, мы держали бы его на дорогой ступени дольше обещанного, а цифра на экране «в этом месяце отправлено N СМС» разошлась бы со списанными деньгами. Ради этого в журнале появилась колонка segments (схема v9.14) и частичный индекс под единственный запрос счётчика. Пусто у старых записей = одно СМС (В-109). Граница месяца — по Москве, а не по Гринвичу (В-110): 31 июля 21:30 UTC это уже 1 августа в Москве. Не путать с окном 10–20 — там время местное у получателя, здесь московское у клиента. Цена по-прежнему фиксируется в момент создания и джобом не пересчитывается (3.14). 🔴 Мина, найденная самопроверкой (В-114): авто-СМС считала бы объём месяца ВНЕ изоляции по клиенту. В запросе экрана контекст ставит middleware, а в очереди — никто, и на бою счётчик вернул бы честный ноль: клиента молча посчитали бы по самой дорогой ступени, без единой ошибки в журнале. Тестами не ловится — на стенде изоляция не применяется. Счёт переехал внутрь tenant-транзакции, как и деньги в том же джобе. 🧹 Убран прежний estimateRub (В-113): он считал смету по ступени для объёма одного заказа, без накопленного, и больше не звался. Оставленный «на всякий случай» второй расчёт цены — это место, которое однажды позовут, и цифры разъедутся. Прогоны: СМС 229/229 (пачками по 3–4 файла — целиком локальная база уже не тянет, В-112), приём лидов 17/17, фронт 1663 зелёных, phpstan 0 своих, pint чисто. Вырезанием проверено четырежды: вернул старый расчёт в контроллер — покраснел тест через настоящий запрос экрана; убрал запись числа СМС в журнал — покраснел тест отправки; засчитал песочные — счётчик дал 12 вместо 1; перенёс границу месяца на Гринвич — покраснел тест границы. Живьём на локальной базе (20:17 МСК, песочница, ДаДата заглушена): предпросмотр до накопления 9.00 ₽, после 5 000 отправленных — 8.00 ₽; песочная рассылка ушла, в журнале «СМС=1», счётчик месяца остался нулём; у прежней рассылки цена так и осталась 9.00 ₽, а новый заказ уже шёл бы по 8.00 ₽. Стенд возвращён как был. Реальное списание по накопленной ступени доказано тестом, а не живьём: в песочнице деньги не двигаются (В-81). 🪤 Урок В-111: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку. |
||
|
|
3fdcd88ad3 |
feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса» не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета. Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов. Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт. Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице «Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов, их клиент разбирает сам. Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены. 1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам, 500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17: право на запись плюс нумератор. В плане про это было написано прямым текстом, я прошёл мимо. Тесты поймать не могли: ходят суперпользователем. 2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» — это норма, так и есть живьём. 3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит теперь сам джоб: показываем один раз, пока сказать нечего. 4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите. В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её больше не берёт. Сценарий существует только при частичном отказе — тест переписан на два объявления и теперь вырезание защиты его роняет. Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74, мест снятия заморозки денег по-прежнему четыре. На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ba7890bf9 |
feat реклама за показы: робот-разведчик приносит клиенту причину отказа с экрана кабинета
Задача 15. Программный интерфейс Яндекса причину отказа не отдаёт — на отклонённое объявление приходит «Отклонено на модерации.» и всё. Причина висит только на экране кабинета, и добыть её может лишь тот, у кого есть глаза. Как теперь работает: опрос модерации видит отказ и ставит роботу задание разведки по этому объявлению; робот открывает список объявлений, находит ячейку своего объявления, кликает по статусу, раскрывает строку причины, читает текст и снимает одно окно; доклад уезжает порталу формой вместе со снимком; портал кладёт его в ленту от имени Яндекса слово в слово, клиенту письмо и колокольчик. Робот не понял, что видит — задание сбойное, владельцу письмо, в ленту клиенту ничего не сочиняем. В админке появилась ручка «ждёт разбора». Четыре ловушки, пойманные по дороге и проверенные вырезанием: 1. Дедуп разведки нельзя вешать на кампанию. Отказ никуда не девается, а обход бежит по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы в кабинет по кругу. Ключ — номер объявления, журнал схемы v9.16. 2. Рубильник держал не выдачу задания, а построение клиента Директа. Разведке слепок креативов не нужен, значит при выключенном рубильнике она получила бы задание, и робот пошёл бы в живой кабинет. 3. Дедуп ленты сравнивал только с последним сообщением Яндекса. После доклада робота обход снова клал бы «причину выясняем» поверх настоящей причины. 4. Постановка разведки шла без tenant-контекста — на бою она не сработала бы ВООБЩЕ и молча: поиск дубля давал бы ноль, запись падала бы на политике доступа, всё это в предупреждение журнала при зелёных тестах. Поймал rls-reviewer. Лечение — своя транзакция с выставлением клиента, рецепт ChargeCampaignSpendJob. Сторож поставлен на сам механизм: обычным тестом это не ловится, они ходят суперпользователем. Заодно: разведке больше не снимается слепок креативов — лишний поход в живой Яндекс внутри открытой транзакции. Портал 382/382, робот 75/75, мест снятия заморозки денег по-прежнему четыре. На боевой не выкатывалось, рубильник Директа выключен. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d8723f285 |
feat(смс-клиент): Калининград и Камчатка получают СМС каждый в своё утро
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.
У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.
Три состояния строки, и их важно не путать:
— пояс известен, ждать нечего → отдаём прямо сейчас;
— пояс известен, время не пришло → ждёт своего утра;
— пояса нет → ждёт уточнения региона и НЕ уходит вовсе (В-85).
Пустой пояс больше не означает «шлём по Москве». Он означает «мы не знаем, где человек
живёт», и такому номеру СМС не уходит. Про него при этом НЕ пишется в журнал рассылки
«нет маршрута»: мы его даже не пробовали отправить, и врать про него нельзя.
Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).
Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).
Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.
В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.
Запись схемы v9.13.
|
||
|
|
86d974330b |
feat(смс-клиент): правило «с 10 до 20 по местному» живёт в одном месте, границы правит владелец
Этап 3 «Время и цена», Task 1. Закрыты строки листа Н.3 и 3.7. Поведение рассылки ещё НЕ меняется — этим займётся Task 2. Сейчас заведено то, на чём оно будет стоять, и заведено так, чтобы правило нельзя было размножить. 1. Справочник часовых поясов (RegionTimezoneMap). 89 субъектов РФ в том же порядке, что и справочник имён; сторож-тест сверяет составы, чтобы справочники не разъехались. Отдельный тест на ловушку: код субъекта у нас НЕ автомобильный — 77 это Тюменская область, а Москва 82. Неизвестный код и непонятная строка от ДаДаты дают «не знаю», а не ноль: ноль означал бы Гринвич, то есть тихую подмену Камчатки Лондоном. 2. Единственный дом правила 10–20 (SmsQuietHours): можно ли отдавать сейчас, когда откроется окно, осмысленно ли такое окно. Границы читаются из настроек один раз на объект — на 20 000 номеров иначе был бы 20 000-й запрос к базе. 3. Границы окна в общих настройках: миграция добавляет две колонки со значениями 10 и 20. Защита от повторного запуска пошаговая — прерванная ручная подача SQL на бою не должна оставить вторую колонку несозданной (урок В-80). Прав не требует: колонки наследуют привилегии таблицы. Запись схемы v9.12. 4. Админка «СМС»: два поля «Отправляем с / по» и объяснение, что часы — по местному времени получателя и клиент их не настраивает. Окно наизнанку «с 20 до 10» это отправка всю ночь, поэтому сервер его не принимает и говорит человеку почему. Проверяется ПОЛУЧИВШЕЕСЯ окно, а не присланные поля: правка одной границы тоже могла его вывернуть — журнал В-91. Прогоны: СМС 186/186 (было 171, 15 новых тестов), приём лидов 17/17, фронт 1658 зелёных и 3 намеренно пропущенных, phpstan по своим файлам 0, pint чисто, проверка типов без новых ошибок. Вырезанием проверено дважды: убрал проверку окна — покраснели два теста; убрал чтение границ с сервера на экране — покраснел фронтовый тест. Живьём: окно 11–19 сохранилось и пережило перезагрузку, «с 20 до 10» отклонено с человеческим текстом, вернул 10–20. Попутный урок В-92: два моих же новых теста сперва зеленели по неверной причине — ругань приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо и проверяют, за какое поле ругаются. |
||
|
|
68fe632cca |
fix(телеграм-реклама): правки по сводному код-ревью ветки — деньги, статус-машина, робот, RLS
Закрывает находки ревью: C1-блокер + рассинхроны длины + все оранжевые. TDD, всё зелёное. Поведение в песочнице не меняется; правки готовят ветку к боевому включению. F1 (блокер): AdWalletService::freeze реактивирует released-hold через updateOrCreate по 4-ключу — пересдача кампании и повторная заявка на имя больше не падают на дубле ключа 23505 в боевом режиме. F2: длины валидации выровнены под колонки БД — имя 64, ad_link 500, ord_category 200; длинное значение даёт ошибку поля, а не замаскированный 422 от БД. F3: авто-рассылка морозит потолок бюджета симметрично ручному запуску только в бою и считает дневной лимит под lockForUpdate строки правила. F4: кампания не зависает в moderating вечно — переход moderating→needs_review плюс предохранитель уборщика по возрасту client_tg.moderation_stuck_hours=48, бронь не трогаем. F5: единое осторожное правило возврата брони в finalize и failed — есть mts_campaign_id значит могла уйти на модерацию → needs_review без release; нет id → failed плюс возврат брони. F6: робот cabinet.js — денежные кнопки оплатить/списать/запустить в чёрном списке domClickButton, finalize целит только кнопку отправки на модерацию. F7: finalize live не врёт launched:true на шаге /payment — launched:false, stoppedAt:payment; не дошли до /payment → падаем громко. F8: assertCostWithinCap подключён в live-finalize — сверка фактической стоимости с потолком. F9: GRANT SELECT служебным ролям на client_tg_campaigns миграцией 000016 — иначе кросс-тенантные джобы Poll/Sweep видели бы 0 строк на проде; правка ложного комментария в 000011. CHANGELOG v8.93, rls-reviewer CLEAN. ДЕПЛОЙ: ПЕРЕзапустить db/03_service_bypass_policies.sql. Приёмка: бэкенд ClientTg 196/196; робот npm test 89/89; pint/phpstan/deptrac чисто. Фронт не трогали. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
03ef89e06e |
fix реклама за показы: чужой документ теперь отказывает сама база, а не дисциплина в коде
Проверка прав доступа по прошлой миграции вскрыла утечку: внешний ключ на сообщение не защищал от чужого клиента, потому что проверки целостности в PostgreSQL идут в обход RLS, а робот ходит под ролью с кросс-тенантным доступом. Он молча увёз бы документ одного клиента в модерацию кампании другого. Обе дыры воспроизведены вживую до правок. v9.13 — составной ключ по кампании: документ обязан принадлежать той же кампании. v9.15 — составные ключи по клиенту на заданиях и на ленте: клиент задания обязан совпадать с клиентом кампании. Понадобилась потому, что моя запись про v9.13 оказалась сильнее самой защиты — поймано повторной проверкой. v9.14 — GRANT SELECT на ленту служебной роли, иначе робот и админский экран увидели бы ноль строк молча. Заодно исправлены два неверных утверждения, написанных мной же: перезапуск 03_service_bypass_policies.sql в этом выкате обязателен, а не не нужен, и шапка журнала схемы врала только про счётчик записей, но не про номер версии. Три записи выкатываются только вместе. Проверка в коде задачи 16 остаётся вторым рубежом. Портал 350/350 в том числе на пересозданной с нуля базе, робот 60/60, все миграции проверены вверх-вниз-вверх, мест снятия заморозки денег по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a35b28cb15 |
feat реклама за показы: вид задания у робота — отвезти картинки, посмотреть, отвезти документ
Робот умел ровно одно — отвезти картинки в кабинет, и очередь молчаливо означала именно это. Теперь у задания есть вид: upload, inspect, deliver, плюс ссылка на сообщение ленты, документ из которого везём. Умолчание upload обязательно — задания, лежащие в очереди на момент выката, вида не имеют. Внешний ключ на сообщение НЕ защищает от чужого клиента: проверки целостности в PostgreSQL идут в обход RLS, а робот ходит под crm_admin_user с кросс-тенантным доступом. Дыра пока спящая — message_id в бою никто не пишет. Требование проверять принадлежность в коде записано в докблоке миграции, в журнале схемы v9.12 и в приёмочных строках задачи 16. Журнал схемы v9.12, а не v9.11 из плана: тот занят отметкой revived_at. Портал 345/345, робот 60/60, мест снятия заморозки денег по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa4b482d45 |
feat(телеграм-реклама): Этап 5 — «Своё имя» + «Авторассылка» из кабинета + предохранители трат
Закрывает Этап 5 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
Клиент управляет именем отправителя и авторассылкой сам из кабинета; авто не тратит
без денег и сверх дневного лимита. TDD, весь бэкенд+фронт зелёный.
## 5.1 — предохранители авторассылки
Накопитель в БОЮ перед постановкой пачки проверяет: смета умещается И в свободный
остаток кошелька (balance−frozen), И в дневной лимит правила за вычетом трат за
сегодня. Не прошла — держим черновиком + Log::info('client_tg.auto_skipped', reason).
Дефолт daily_limit_rub=0 → авто выключено (само денег не потратит). В песочнице гейта
нет (деньги не трогаются, как ручной launch). Новые колонки client_tg_auto_rule:
daily_limit_rub, spent_today_rub, spent_date (счётчик за день, сброс при смене даты).
## 5.2 — клиентское API имени (SenderController)
GET /sender (статус + остаток грейса), POST /sender (завести), /sender/disable,
/sender/enable (suspended→active, идемпотентно по периоду — без двойной оплаты в месяц;
проверка средств ДО списания). Логика в TelegramSenderService (enableSender+snapshot),
контроллер тонкий. Всё скоуп тенантом.
## 5.3 — экран имени (TelegramSenderPanel.vue)
Самодостаточная панель: статус имени человеческими словами, дата оплаты, остаток грейса
при долге; suspended → «Отключено за долг» + «Включить»; нет имени → форма «Завести имя».
telegram.ts: fetchSender/createSender/disableSender/enableSender.
## 5.4 — экран авторассылки + API (AutoRuleController)
GET/PUT /api/telegram/auto-rule (вкл/выкл, объявление, порог, бюджет, дневной лимит).
Порог клиентский — новая колонка client_tg_auto_rule.batch_threshold (NULL → дефолт
конфига 367; ниже 367 API не даёт — минимум МТС). TelegramAutoRulePanel.vue: тумблер +
поля порога/бюджета/лимита. В списке кампаний авто-кампании (created_by=null) помечены
чипом «авто».
Обе панели встроены в AdvertisingTelegramView.
## Схема
Две аддитивные миграции на существующую таблицу client_tg_auto_rule (000014 daily_limit
+ счётчик за день; 000015 batch_threshold). RLS/GRANT не тронуты (табличный GRANT
покрывает новые колонки). Записи db/CHANGELOG_schema.md v8.91/v8.92; обе прогнаны через
rls-reviewer — CLEAN.
## Приёмка
TDD. Бэкенд ClientTg 177/177; phpstan/pint/deptrac чисто. Фронт полный набор
212 файлов/1538 тестов зелёные; vue-tsc+ESLint по нашим файлам чисто. Проверено вживую
в браузере (клиент demo, песочница): обе панели читают и пишут — правило сохраняется
точь-в-точь (порог/бюджет/лимит), имя заводится (pending, деньги не тронуты).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
51b9c20d0b |
fix(смс-клиент): повторный накат доделывает индекс ключа заказа + сторож прав по трём ролям
Две находки обязательного проверяющего доступа (приёмка Этапа 2, журнал В-80). Обе — про молчаливые поломки: ошибок нет, тесты зелёные, защиты нет. 1. Индекс ключа заказа мог тихо не создаться. Миграция делает два шага — колонку и уникальный индекс, — а защита от повторного запуска стояла общим выходом в начале: «колонка есть, значит всё сделано». На бою SQL подаётся руками; прервалась подача между шагами — колонка легла, индекс нет, повторный накат прошёл мимо. Дальше два одновременных запроса с одним ключом создали бы две рассылки и списали деньги дважды. Проверка стала пошаговой. Доказано вырезанием: до правки новый тест краснеет («защита от двойного заказа потеряна молча»), после — зелёный. Живьём на локальной dev-базе: индекс уронен руками, повторный накат его вернул. 2. Сторож прав спрашивал базу только про рабочую роль. А гардов с именами служебных ролей в миграциях семь, и опечатка внутри такого гарда ошибки НЕ даёт — права просто молча не выдаются. Ровно тот блокер выката, ради которого сторож и заводился (В-36). Теперь спрашиваем и crm_admin_user, и crm_supplier_worker — по матрице, сверенной со всеми GRANT'ами миграций, — и счётчики для всех трёх ролей. Доказано вырезанием: опечатка в имени роли внутри гарда → сторож краснеет с именем роли, таблицы и права. Прогоны: СМС 171/171 (было 169, два новых теста), приём лидов 17/17, pint чисто. Структура таблиц не менялась — запись в журнале схемы v9.11. |
||
|
|
6d34d663c7 | fix реклама за показы: пометка отдана на починку держит правку открытой после возврата в черновик | ||
|
|
efcb8034a0 |
chore(смс-клиент): убрана врущая графа «кто внёс» + миграции модуля переживают повторный запуск
Хвосты Этапа 1 (журнал В-37). Строк приёмочного листа не закрывают — уборка. 1. Графа «кто внёс» в общем стоп-листе портала снесена. Она была не пустой, а врущей: при открытом в том же браузере обычном кабинете туда записывался id КЛИЕНТСКОГО пользователя — число, неотличимое от id администратора. Проверено пробой: пользователь 1 → в графе 1. Админ-зона закрыта паролем nginx, своего входа Laravel у неё нет, а сессия кабинета видна и там — то же эхо коллизии 24.07. Заполнить правдой нечем: настоящий вход админа ждёт Б-1, соседние экраны пишут id служебной заглушки, то есть одно число во всех строках. Остались номер, причина словами и дата — этого хватает и для разбора жалобы, и для договора с МТС. Возврат — down() миграции. 2. Все 16 миграций модуля начинаются с «уже сделано — выходим», уникальный индекс ключа заказа создаётся с IF NOT EXISTS. Причина не теоретическая: на бою SQL миграций подаётся в базу руками, памяти «этот файл уже применён» там нет. У тарифов и настроек это особенно важно — они засевают строки, и повторный запуск завёл бы ВТОРУЮ строку настроек молча, без ошибки. 3. Восемь GRANT … TO crm_app_user стояли без проверки существования роли — на чистой базе migrate падал бы целиком. Теперь все в гарде, как в v9.00 и v9.07. Своя ловушка гарда: опечатка в имени роли внутри IF EXISTS ошибки НЕ даёт, права просто не выдаются — а это ровно блокер выката В-36. Поэтому заведён сторож: тест спрашивает у самой базы has_table_privilege / has_sequence_privilege по каждой таблице и счётчику модуля. Заодно закрыта дыра — у фикса В-36 теста не было вовсе. Хвост «7 замечаний squawk» проверить его же инструментом нельзя: squawk читает SQL, а миграции у нас PHP. Чужой отчёт не пересказываю — проверка своя и воспроизводимая: тест прогоняет up() каждой миграции второй раз. Тесты: +3 (повторный запуск, права ролей, «чужого следа не остаётся»), один переписан. ClientSms 169/169, приём лидов 17/17, phpstan 0, pint чисто. Три выреза, все покраснели: испорченное имя роли, снятая защита от повторного запуска, возвращённая графа. Живой прогон: вошёл в обычный кабинет (та самая опасная обстановка), внёс номер через админ-раздел — в базе ровно три поля, чужого следа нет; убрал кнопкой. Пять миграций запущены по второму разу прямо на dev-базе — прошли, тарифов 5, строка настроек одна. Контроль: голый CREATE TABLE та же база отвергает. Запись схемы — v9.10. |
||
|
|
18135b47b4 |
feat(телеграм-реклама): Этап 3.5/3.6 + 4.3 — чтение вердикта, пересдача, уведомление об одобрении
Закрывает Этап 3 «Жизненный цикл и модерация» целиком (3.1–3.6) и бэкенд-часть 4.3.
План docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
## 3.5 — робот читает статус/причину модерации по mts_campaign_id (Node)
- `parseModerationStatus` (cabinet.js) — чистый парсер статус-текста кабинета в канон
Laravel-опросчика: «Отклонена»→rejected, «Одобрена»/«Активна»→approved,
«На модерации»→moderating, «Черновик»→draft, иначе null (тогда опросчик ждёт).
Терминальные вердикты приоритетнее слова «модераци…» в строке-блобе. Юнит-тест
read-status.test.mjs (11 кейсов: регистр/nbsp/блоб/приоритет/мусор).
- `readModerationStatus` (cabinet.js) + `runReadStatus` (runner.js) + режим `read-status`
в bin/run.js: открывает список кампаний, находит ряд по id, читает статус; при отказе —
причину из слайд-модалки «Причины» (#slide-modal-root). Отдаёт JSON
{ok, moderationStatus, reason?, campaignId} — его уже разбирает RobotResult (3.4).
🔴 Читалка кабинета помечена <FLOW-CONFIRM>: DOM-обёртки ряда/модалки собраны по
живой разведке «Сессии 6» (FLOW-FINDINGS.md), но именно этим кодом live ещё не прогнаны —
подтвердить на следующем цикле модерации с разрешения владельца. Парсер от DOM не зависит.
## 3.6 — пересдача отклонённой кампании (rejected → queued + документ модератору)
- Миграция 000013: колонка `client_tg_campaigns.moderator_file_path` (varchar 500 NULL,
после media_path) + CHANGELOG схемы v8.90; rls-reviewer прогнан — чисто (nullable-колонка
данных, не tenant-скоуп, RLS не меняется). Модель — fillable.
- Endpoint `POST /api/telegram/campaigns/{id}/resubmit`: только отклонённую (иначе 422);
правки ad_text/ad_link/ord_category (валидация как store) + опц. файл модератору
(.png/.jpeg/.jpg/.pdf ≤10 МБ, сохраняется на диск local). Успех: поля обновлены,
status_reason и mts_campaign_id очищены (робот создаст новую кампанию в кабинете),
rejected→queued, dispatch RunTelegramCampaignJob afterCommit. В бою — гейт аудитории
+ freeze budget_cap_rub заново (при отказе бронь вернул опросчик 3.4; freeze идемпотентен
по ACTIVE-холду), нехватка → 409, остаётся rejected. Песочница — без брони.
- Робот: task `moderatorFile` (task.js passthrough + TelegramRobotRunner.taskPayload),
RunTelegramCampaignJob отдаёт moderator_file_path; fillAd грузит файл в поле «Комментарий
для модератора» (третий file-input, accept pdf) — помечено <FLOW-CONFIRM> (live не прогнан).
- Тесты: ResubmitTest.php (7 кейсов: rejected→queued+очистка+джоб / файл сохранён /
не-rejected→422 / live 409 / валидация / .exe→422 / чужой→404); Node task.test.js (+2).
## 4.3 — уведомление об одобрении (бэкенд был готов в 3.4, добор покрытия)
- ApproveNotifyTest.php (4 кейса): notifyTelegramCampaignApproved шлёт in-app всем активным
юзерам тенанта без pref-гейта, тело «одобрена/показы пошли»; неактивный/чужой не получают.
TDD. Приёмка (моя область, чистый прогон): весь ClientTg 150/150, робот 78/78,
ApproveNotify 4/4; phpstan 0, deptrac 0, pint чисто.
Приёмочный лист 3.6 — docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
Осталось в Этапе 4: фронтенд 4.1/4.2/4.4/4.5 (Vue-экран + Vitest) — НЕ начато.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
9c332cc26e | feat реклама за показы: лента сообщений по кампании — таблица и модель | ||
|
|
73bd36b6ce |
feat(телеграм-реклама): Этап 3.1 — id кампании МТС хранится рано и при отказе (+ разведка экрана отказа)
Этап 3 «Жизненный цикл и модерация», задача 3.1. Плюс закрыта задача 3.0 (живая разведка экрана отказа) — вердикт МТС по кампании «займ» (2231134) пришёл: «Отклонена». Разведка read-only, деньги не тронуты. Задача 3.1 — колонка mts_campaign_id + РАННЕЕ и надёжное сохранение: - Миграция client_tg_campaigns.mts_campaign_id (varchar(32) NULL, после status_reason) + запись CHANGELOG_schema v8.89 (предварит., ветка). RLS не меняется; rls-reviewer не требуется (nullable-колонка данных). - Робот (Node): чистый хелпер parseCampaignId(url) в cabinet.js (покрыт тестом); runner.js захватывает id СРАЗУ после создания черновика (шаг аудитории) и печатает маркер MTS_CAMPAIGN_ID=<id> в stderr; id теперь идёт и в ветке ОТКАЗА (раньше терялся). - Обёртка (PHP): TelegramRobotRunner восстанавливает id из stderr-маркера во всех путях (таймаут/непарсабельный вывод/JSON без id); RobotResult::failed принимает id. - Джоб: finalize сохраняет mts_campaign_id при ЛЮБОМ исходе (успех/отказ), не затирая ранее сохранённый id. Метод failed() не трогали — туда результат не доходит (осознанный residual, закроют уборщик 3.1b и sweeper 3.3). Разведка отказа (3.0) записана в bots/mts-telegram-ads/FLOW-FINDINGS.md: причина показана текстом в слайд-модалке «Причины отклонения кампании» (кнопка «Причины»); поля загрузки файла на экране отказа нет — документ грузится через «Исправить» → шаг «Сообщение» → «Комментарий для модератора»; кнопка пересдачи — «Исправить». TDD, робот замокан, тесты на liderra_testing (номера 7999…). Приёмка: Node 48/48 (npm test), Pest ExternalIdTest 2/2 + регрессия ClientTg 122/122, phpstan (4 боевых файла) 0, deptrac 0 нарушений, pint чисто. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a5451bef76 |
feat(смс-клиент): галочки статусов воронки в рассылке по сделкам
Строки приёмочного листа 2.6 и 2.7. Клиент выбирает, каким сделкам слать: пять галочек воронки (Новая сделка, Просмотрено, В работе, Сделка, Не реализовано), по умолчанию отмечены все. Отбор применяется в ClientSmsAudienceBuilder::fromDeals() — через него идут и смета предпросмотра, и снимок получателей при запуске, поэтому число на экране и факт отправки остаются одним списком. Пустой список = «все статусы», отдельного значения «никому» нет (решение В-42): экран не даёт снять последнюю галочку и объясняет почему. Новая колонка audience_statuses (jsonb, NULL = «все») — уже созданные рассылки поведения не меняют. Права не нужны: колонка наследует права таблицы. Живой прогон поймал дефект, которого не видели тесты: запрет снять последнюю галочку не работал вообще — галочка Vuetify правит список на месте, и обычное наблюдение этого не видело, а тест присваивал новый список. Лечение: наблюдение вглубь + возврат после отрисовки; тест переписан на правку списка на месте. Тесты: 5 новых серверных + 4 на экране, ClientSms 156/156, приём лидов 17/17, фронт 1652 зелёных. Живой прогон: 5 → 4 получателя после снятия «Не реализовано»; рассылка только со статусом «Сделка» ушла ровно на номер этой сделки. Журнал схемы: v9.09 (эта работа) и v9.08 — пропущенная запись о снимке получателей, дописана задним числом. |
||
|
|
0548fc0d8f |
fix реклама за показы: слепок креативов при выдаче, сверка размера перед объявлением, один баннер на слот
Хвосты портала П1-П8 из приёмочного листа v12. П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина находки не подтвердилась. П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список сопоставляется при отчёте. Раньше стороны расходились и опознание падало на безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи креативов: слепок обрывался на первой странице. П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же транзакции, что и перевод в работу. Два задания в очереди получали одинаковый слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса. П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним запросом. Не сошлось или креатива нет — запуск не идёт. П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы, rls-reviewer GO. П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании. Смежная находка: перезаливка картинки теперь обнуляет номер креатива. П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от номера баннера; робот берёт расширение из ответа портала. Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c5943ebb4e |
feat(смс-клиент): снимок получателей — считаем смету и шлём по одному списку
Аудитория собиралась дважды: в контроллере для сметы и заново в джобе для отправки. Между двумя сборками приходят новые лиды, и «посчитали 900, отправили 917» было физически возможно. Теперь контроллер, посчитав смету, кладёт получателей в client_sms_campaign_phones (две новые колонки: operator, skip_reason), а джоб читает этот снимок пачками по 500 и ничего не пересобирает. Решение владельца В-39: после запуска список не пересматривается ничем, включая стоп-листы — смета и факт сходятся копейка в копейку. Человек, внесённый в «Не писать этим» уже после запуска, эту рассылку получит; следующую — нет. Следствие названо и владельцем принято. Попутно убран квадрат: проверка «номер уже отправлен» шла через in_array по массиву — на 20 000 номеров это 400 млн сравнений. Строки приёмочного листа 2.1 и 2.2. Проверено: ClientSms 144/144, приём лидов 17/17, phpstan по своим файлам 0, вырезание записи снимка красит все 4 новых теста, живой прогон — контакт, добавленный между созданием и отправкой, СМС не получил. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
49c75d17f4 |
fix(смс-клиент): права на счётчики всего семейства client_sms_* — блокер выката
Ни одна из 14 миграций модуля не выдала GRANT USAGE, SELECT на sequence. На боевом кластере роль crm_app_user не владеет таблицами и не имеет BYPASSRLS, поэтому первый же INSERT упал бы с «permission denied for sequence». Тесты и локальная база этот класс поломки не видят: там суперпользователь. Тот же случай уже был на бою — v8.84/v8.85. Починено аддитивной миграцией: старые миграции не переписываем, на кластере они не перезапускаются. Проверено вырезанием — откат снимает право, накат возвращает. Там же: три GRANT в create_sms_global_optouts обёрнуты в проверку существования роли — без неё migrate падал на чистой базе. Заодно приведены к правде два теста меню: «Рассылка СМС» давно не заглушка, а настоящий раздел, тесты этого не знали и были красные. Нашёл rls-reviewer при приёмке Этапа 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cc985e07de |
feat(телеграм-реклама): экран «реклама отклонена» + закалка робота против глюков МТС
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог). Задача 5.2 — обратная связь по отказу: - миграция client_tg_campaigns.status_reason (varchar 500, nullable; RLS без изменений — колонка на существующей таблице), запись v8.88 в CHANGELOG_schema - Campaign: status_reason в fillable - NotificationService::notifyTelegramCampaignRejected — in-app уведомление всем активным пользователям тенанта без pref-гейта (важное операционное сообщение доходит всегда) - RunTelegramCampaignJob: при провале робота сохраняет причину в status_reason и шлёт уведомление (Tenant грузится внутри tenantTx для RLS, notify — после транзакции) - фронт: telegram.ts (+status_reason), AdvertisingTelegramView показывает причину отказа/ошибки - тесты: RejectNotifyTest (4 Pest) + 2 Vitest на экран Закалка робота против частых глюков кабинета МТС: - browser.js: gotoStable() — терпеливая загрузка с перезагрузкой (кабинет виснет на пустой крутилке) - session.js: isLoggedIn через gotoStable — больше нет ложного «вход слетел» - cabinet.js: знакомство пропускается если его нет (только для новичков); оферта по #isOfferAccepted; осознан шаг /payment (денежная развилка «оплатить/без оплаты» — Сессия 6) Живая разведка кабинета зафиксирована в FLOW-FINDINGS.md (селекторы, шаг /payment, поле файла модератору). Проверки: Pest ClientTg 93/93, Vitest экрана 7/7, node-тесты робота 20/20. Робот в тестах замокан, тесты на liderra_testing. Реальных ПДн нет (номера фейковые 7999…). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d26716ed90 |
fix реклама за показы: срок показа закрывает кампанию, отчёт робота только по заданию в работе, гонка выдачи заданий
Р1. Кампания, не добравшая смету показов, оставалась running навсегда, а заморозка денег клиента - ACTIVE навсегда: единственным переходом в completed было delivered >= paid_impressions, а задачи, закрывающей кампанию по истечении срока показа, не существовало вовсе. Для медийки по списку телефонов недокрут сметы - типовой исход, а не редкий случай. Новая колонка ad_campaigns.shows_until хранит последний день показа - ровно тот, что уходит в Директ параметром EndDate. Пишется вместе с yandex_campaign_id, то есть в момент, когда дату начинает держать Яндекс; возобновляемый запуск переиспользует уже записанную дату, чтобы портал и Директ считали срок одинаково. У выхода 1 в CampaignImpressionCharger появилось второе условие - новых мест вызова AdWalletService::release не добавилось, их по-прежнему ровно четыре. Запись v9.07 в журнале схемы, rls-reviewer GO. Р2. Отчёт робота принимался по любому заданию в любом состоянии: номер брался из адреса как есть. Готово по чужому ещё не выданному заданию разложило бы номера креативов чужой кампании по её баннерам - картинка одного клиента уехала бы в объявление другого; сбой по уже закрытому заданию переписал бы правильный результат на failed. Теперь done принимает отчёт только по заданию в статусе taken - 409 в остальных случаях, та же проверка продублирована в сервисе. Р3, первая половина. Проверка «в работе никого» в takeNext не блокировала строку: две одновременные выдачи обе её проходили и уносили разные задания. Слепки creatives.get перемешивались, а размеры блоков у всех клиентов одинаковые, поэтому итог - тихая привязка чужого номера креатива. Гарантию даёт частичный уникальный индекс uq_creative_job_single_taken, плюс advisory-замок первой строкой транзакции, чтобы штатный путь спокойно отвечал роботу «работы нет». Осталось по Р3: привязать выдачу файла к номеру задания, rls-reviewer по индексу, запись v9.08 в журнал схемы. Ход работы - в файле PROGRESS рядом с промтом v12. Тесты, прогнаны в одиночку: портал 256/256, робот 35/35. Все девять новых тестов были красными до правок, каждая защита проверена вырезанием. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b29a4ca4c4 |
revert(смс-клиент): отказ получателя убран целиком — решение владельца
Владелец: «нет такой функции и задачи нет, забудь о ней! пришла и пришла смс».
Причина — приписка «Отказ: liderra.ru/s/…» ставила НАШ адрес в рекламное СМС, которое
клиент шлёт своим покупателям: он рекламирует себя, а не нас.
Убрано: страница отказа /s/{token}, таблица коротких ссылок, сервис токенов, приписка
в тексте рассылки, колонка with_optout_link, ограничение частоты sms-unsubscribe,
три файла тестов, три миграции (на прод не выкатывались).
Осталось нетронутым: стоп-лист самого клиента «Не писать этим» и общий стоп-лист
портала — это другое, их владелец не отменял.
Строки приёмочного листа 1.6-1.13 срезаны, записано в «Чего эта работа НЕ делает» п.14
и в журнал вопросов В-30. Возражение про 38-ФЗ высказано владельцу и им отклонено.
Всё удалённое лежит в истории: коммиты
|
||
|
|
5b04d751c0 |
feat(телеграм-модуль): авто-режим, своё имя + помесячная оплата, админ-тарифы
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md. Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан. 4.1 Авто-режим (пачка): - Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS). - TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик (audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold, по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе. Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе). 4.2 Защитный observer: - DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob. 🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный). - Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob. 4.3 Своё имя/бренд + помесячная оплата: - Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U). - TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) / disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin. - ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended, кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются. Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл). 4.4 Админ-тарифы/настройки: - Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db. - Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён). Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный (+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений. rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |