Commit Graph

1313 Commits

Author SHA1 Message Date
Дмитрий 9f96ce4cb0 test(смс-клиент): починены три свои ошибки проверки типов (приёмка Этапа 2)
Проверка типов фронта (vue-tsc) в приёмке дала 11 ошибок. Разобрал по журналу
авторства: мои — три, все из Task 7, остальные восемь старше этой работы
(сборка модуля 25–26.07 и чужие экраны автоподбора).

Причина у всех трёх одна: тип ответа сервера выводился из заглушки по умолчанию
(кампания «только с номером» и пустой список сообщений), поэтому любой тест,
подсовывающий настоящий ответ с полями и сообщениями, считался ошибкой.
Лечение — объявить тип заглушки явно.

Правка только в тестах, поведение и боевой код не тронуты: файл целиком
43/43 зелёных, проверка типов 11 → 8 (остались чужие/давние).

Восемь чужих не чиню намеренно: они не Этапа 2, а правка ради красоты отчёта
размыла бы состав работы. Вписаны в итоговую таблицу приёмочного листа.
2026-07-28 13:48:34 +03:00
Дмитрий 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.
2026-07-28 11:42:37 +03:00
Дмитрий 5b7d6e1bab fix(смс-клиент): «не определён оператор» вместо неправды «номер не из МТС»
Строка приёмочного листа 2.3. Раньше номер с НЕИЗВЕСТНЫМ оператором и номер
чужого оператора давали одну причину, и клиент читал про свой номер неправду:
«не из МТС» — хотя чей он, мы не знаем. Теперь причины две, и обе правдивы.

Так приходит большинство таких номеров: вписанные руками — без оператора
всегда, номера «своей базы» — до того, как ДаДата его проставит.

Причина живёт в четырёх местах, а не в трёх, как считал план: отборщик,
читатель снимка (журнал рассылки) и ДВА словаря подписей на экране. Счётчик в
контроллере складывает любые причины — править нечего.

Два капкана, оба доказаны вырезанием, а не рассуждением:

Порядок проверок. Отсеивать номер сразу, как только оператор не распознан (так
велел план), нельзя: универсальный канал берёт и номер без оператора, и такие
номера перестали бы уходить — рассылка молча уменьшилась бы при зелёных тестах.
Сперва спрашиваем маршрутизатор, причину называем только после отказа.
Поведение не изменилось ни на волос — изменилась надпись.

Деление. Словарь операторов отдаёт пустоту и когда оператора нет, и когда имя
есть, но словарь его не знает («Тинькофф Мобайл»). Делим по сырому значению,
иначе получилось бы новое враньё в другую сторону.

Тесты: +5 (три причины врозь, сторож универсального канала, журнал рассылки —
чтобы предпросмотр и журнал не расходились в словах). ClientSms 165/165, приём
лидов 17/17, фронт 1656 зелёных, phpstan 0, pint и eslint чисто.

Живой прогон: одна сводка сразу показывает и правду, и контроль — «Уйдёт 1 СМС
— 9.00 ₽ · Не уйдёт: не определён оператор — не знаем, куда слать — 1 · номер
не из МТС (пока шлём только по МТС) — 1». В журнале рассылки та же правда.

Экран пока НЕ подсказывает, что оператор ещё выясняется — отдельная работа,
записана в «Чего эта работа НЕ делает» п.16.
2026-07-28 10:58:45 +03:00
Дмитрий 537d3e9c4d perf(смс-клиент): 20 000 номеров не подвешивают портал
Строка приёмочного листа 2.9. Две правки на пути, который ждёт человек у экрана:
предпросмотр сметы и создание рассылки.

Стоп-листы больше не читаются целиком — спрашиваем только про номера этой
рассылки, пачками по 1 000. Общий стоп-лист портала общий на всех клиентов и
растёт без предела: раньше клиент с двумя сотнями номеров тянул его в память
весь. Ответ отбора не меняется, меняется цена вопроса.

Аудитория собирается построчно и сырыми строками, без сборки 20 000 моделей —
во всех трёх источниках. Правила модели при этом сохраняются, на это поставлен
отдельный сторож: удалённая сделка в получатели не попадает (вырезанием
доказано — в обход правил она возвращается).

Третье место из плана — квадрат в джобе отправки — было вылечено ещё в задаче
про снимок получателей; своей работой не считаю.

Замеры по два прогона на сторону, на одной базе. Предпросмотр 20 000: память
сверх маленькой рассылки 37 → 18 МБ, время 0.56 → 0.25 с. Создание 20 000:
1.4 → 1.05 с — разница мала, победой не называю. Большой чужой стоп-лист
(50 000) при 200 своих номерах: +27 → +4 МБ.

Тесты: 4 новых (три на объём, один сторож). Меряют разницей «маленькая
рассылка → большая», а не абсолютом: цена самого запроса так вычитается.
ClientSms 162/162 два прогона подряд, приём лидов 17/17, phpstan 0.

Живой прогон: 20 000 контактов в локальной базе. Предпросмотр 0.8 с, создание
кнопкой 1.9 с, снимок 20 000 из 20 000. Обе рассылки очередь отправила целиком
(40 000 сообщений, ≈148 в секунду в песочнице); во время отправки портал
открывался как обычно — список рассылок 0.5 с, сделки 0.6 с.

Скорость самой отправки не улучшалась — это очередь, а не экран.
2026-07-28 09:38:02 +03:00
Дмитрий 9fc2f030cf feat(смс-клиент): срок «за последние N дней» не больше года
Строка приёмочного листа 2.8. Больше 365 дней задать нельзя, и человек читает
почему: «Больше 365 дней нельзя — возьмите срок покороче».

Правило max:365 стоит в общей проверке полей — значит и на предпросмотре, и на
создании рассылки, мимо экрана его не обойти. Заодно человеческий текст у нижней
границы: стандартный называл поле программистским именем audience_days.

На экране проверка настоящая, а не только атрибут max у поля: напечатать 400
руками браузер спокойно даёт, и атрибут при этом молчит. При запрещённом сроке
экран показывает сообщение, запрос на сервер не уходит и смета гасится — иначе
кнопка «Отправить» осталась бы живой со старым числом получателей.

Тесты: 2 новых серверных (отказ с человеческим текстом на обеих дорогах +
контрольная граница ровно 365) и 2 на экране. ClientSms 158/158, приём лидов
17/17, фронт 1654 зелёных. Проверено вырезанием четырежды.

Живой прогон: 30 дней — «Уйдёт 5 СМС — 45.00 ₽»; напечатал 400 — сообщение,
смета исчезла, кнопка заперлась, на сервер не ушло ни одного запроса; вернул
365 — смета снова на месте. Прямыми запросами мимо экрана сервер отказывает тем
же текстом и рассылку не создаёт.
2026-07-28 08:42:14 +03:00
Дмитрий 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 — пропущенная запись
о снимке получателей, дописана задним числом.
2026-07-28 08:09:04 +03:00
Дмитрий a86c5f78ec feat(смс-клиент): тестовая сделка не получает СМС ни одной дорогой
Строка приёмочного листа 2.5. Отсев поставлен в обеих точках, откуда
уходят сообщения:

- ClientSmsAudienceBuilder::fromDeals() — через него идут и предпросмотр
  сметы, и снимок получателей при запуске рассылки;
- SendAutoSmsForDealJob — авто-СМС на новый лид аудиторию не строит,
  своей проверки не имел.

Условие написано как «отмечена НЕ тестовой ИЛИ не отмечена вовсе»:
колонка is_test в базе NULLABLE, и наивное «is_test = false» молча
выбрасывало бы сделки с пустым флагом (решение В-43). Доказано
вырезанием: с наивным условием получателей 0 вместо 1.

Тесты: 4 новых (AudienceFilterTest), ClientSms 151/151, приём лидов
17/17. Живой прогон: в предпросмотре 5 → 4 получателя после отметки
одной сделки тестовой; авто-СМС на тестовую сделку не ушло, а после
снятия отметки на той же сделке ушло — ноль не молчаливый сбой.
2026-07-28 07:21:06 +03:00
Дмитрий 3a2f260334 feat(смс-клиент): кончились деньги — рассылка встаёт честно, а не молча
Перед каждым списанием джоб проверяет, что деньги есть. Без этого
AdWalletService::charge при недоборе не бросает исключение, а прижимает
баланс к нулю: часть рассылки уходила бы бесплатно, а клиент видел бы
пустой кошелёк без объяснения.

Доступное этой рассылке = баланс − заморожено + СВОЯ активная заморозка.
Наивное «баланс − заморожено» остановило бы рассылку на первом же номере
при полном кошельке — вся смета заморожена при создании (ловушка В-41,
доказана вырезанием: тест-страховка краснеет).

Кончились деньги — номер пишется в журнал как «не хватило денег», рассылка
получает статус «остановлена» с причиной no_funds, заморозка снимается.
Экран разбирает причину: «Остановлено: закончились деньги. Ушло N из M,
списано X ₽».

Живой прогон нашёл то, чего не видели тесты (В-48): статус в колонке рядом
писал «остановлена вами» и для денежной остановки. Починено, тест расширен
на всю ячейку.

Строка приёмочного листа 2.4. Тесты: ClientSms 147/147, приём лидов 17/17,
фронт 1648, phpstan 0, pint чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 06:36:11 +03:00
Дмитрий 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>
2026-07-28 05:37:16 +03:00
Дмитрий 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>
2026-07-27 20:17:16 +03:00
Дмитрий 37493b7d02 feat(смс-клиент): экраны Этапа 1 — «Не писать этим», «Остановить», ключ заказа, общий стоп-лист в админке
Вкладка «Не писать этим» отдельной панелью SmsOptoutsPanel.vue: номер руками,
пачкой из файла, удаление; непонятые строки показаны образцами, а не молча.

Кнопка «Остановить» видна, пока рассылка в очереди, идёт или ждёт утра; после
остановки строка показывает честный итог «Ушло N из M, списано X ₽» — деньги
фактические, а не смета.

Ключ заказа crypto.randomUUID() уходит с каждой отправкой и меняется после
успеха. На ответ сервера «похоже, это повтор» экран задаёт вопрос словами
сервера и повторяет только по согласию человека.

В админке раздел «Общий стоп-лист»: список, внесение с причиной, удаление.

Отказ получателя (страница по ссылке и приписка в тексте) в экранах
отсутствует — отменён владельцем, см. В-30 приёмочного листа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:45:47 +03:00
Дмитрий b29a4ca4c4 revert(смс-клиент): отказ получателя убран целиком — решение владельца
Владелец: «нет такой функции и задачи нет, забудь о ней! пришла и пришла смс».
Причина — приписка «Отказ: liderra.ru/s/…» ставила НАШ адрес в рекламное СМС, которое
клиент шлёт своим покупателям: он рекламирует себя, а не нас.

Убрано: страница отказа /s/{token}, таблица коротких ссылок, сервис токенов, приписка
в тексте рассылки, колонка with_optout_link, ограничение частоты sms-unsubscribe,
три файла тестов, три миграции (на прод не выкатывались).

Осталось нетронутым: стоп-лист самого клиента «Не писать этим» и общий стоп-лист
портала — это другое, их владелец не отменял.

Строки приёмочного листа 1.6-1.13 срезаны, записано в «Чего эта работа НЕ делает» п.14
и в журнал вопросов В-30. Возражение про 38-ФЗ высказано владельцу и им отклонено.
Всё удалённое лежит в истории: коммиты 7aa30833 и 1dece3a2.

ClientSms 140/140, приём лидов 17/17.
2026-07-27 18:58:48 +03:00
Дмитрий 456294b8ce feat(смс-клиент): двойное нажатие «Отправить» — одна рассылка, не две
Две разные защиты, и они не взаимозаменяемы.

Жёсткая: у заказа есть ключ, который экран придумывает при открытии формы. Тот же
ключ = тот же самый заказ: двойной клик, обрыв связи, повтор браузера возвращают
первую рассылку и денег не трогают. Молча — человек ничего нового не просил.
Гонку добивает уникальный индекс в базе, нарушение ловится и отдаёт первую рассылку.

Мягкая: заказ другой, но текст и источник те же, и десяти минут не прошло. Здесь
решает человек — сервер отвечает вопросом «вы уже это запускали, отправить ещё раз?»,
а с подтверждением рассылка уходит.

Строки приёмочного листа 1.18-1.20 (серверная часть). Защита проверена вырезанием:
убрать проверку ключа — 2 красных теста.

Мягкая защита закономерно задела два прежних теста, где рассылка с тем же текстом
создаётся дважды подряд намеренно, — им дописано подтверждение.

ClientSms 156/156, приём лидов 17/17, phpstan по своим файлам чисто.
2026-07-27 18:38:01 +03:00
Дмитрий f5483332e9 feat(смс-клиент): кнопка «Остановить» — после нажатия ни одного нового СМС
Клиент может остановить рассылку в очереди, на отправке и в ожидании утреннего окна.
Нажатие — это отметка «попросил остановить», а не мгновенный обрыв: сообщение, начатое
в этот момент, доводится до конца, сеть на полпути не рвём. Джоб читает отметку свежим
запросом перед каждым следующим номером.

Итог честный: статус «остановлена», причина «клиент», ушло столько, сколько в журнале,
списано ровно за это, заморозка снята полностью. Номера, до которых не дошли, в журнал
не пишутся — с ними ничего не произошло (В-27).

Строки приёмочного листа 1.14-1.17 (серверная часть; кнопка на экране — Task 10).
Защита проверена вырезанием: убрать выход из цикла — 2 красных теста.

ClientSms 151/151, приём лидов 17/17, phpstan по своим файлам чисто.
2026-07-27 18:28:58 +03:00
Дмитрий 1dece3a2b4 feat(смс-клиент): хвост «Отказ: liderra.ru/s/…» в тексте — цена считается уже с ним
Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2).
Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно
в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз,
при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит.

Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же —
число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе
число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл
текст на второй кусок.

Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием:
считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1.

ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто.
2026-07-27 18:22:26 +03:00
Дмитрий 7aa3083399 feat(смс-клиент): страница отказа по ссылке из СМС — без входа, номер маской, с лимитом
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка
liderra.ru/s/<токен> открывает простую страницу с одной кнопкой. Нажал — номер
в стоп-листе именно той компании, от которой пришло сообщение.

- таблица client_sms_unsubscribe_links (RLS + tenant_isolation), одна ссылка
  на пару «клиент + номер» навсегда — при каждой рассылке новая не плодится
- сервис токенов: 12 символов, без 0/O/o и 1/l/I (ссылку диктуют вслух)
- публичная страница: blade без Vue-сборки, номер только маской +7 *** *** ** 67
- ограничение частоты 20/мин с IP — перебор ссылок иначе = утечка номеров;
  защита проверена вырезанием, тест на 429 краснеет
- неизвестная ссылка отвечает 404 без подсказок
- служебное соединение только в этом контроллере (тенант-контекста у страницы
  нет) + GRANT для crm_supplier_worker на client_sms_optouts
- CHANGELOG схемы v9.02, с напоминанием ПЕРЕзапустить 03_service_bypass_policies.sql

Строки приёмочного листа 1.6, 1.7, 1.8, 1.9 закрыты тестами; живой прогон —
после выката, страницу надо открыть телефоном.

Ветка feat/client-sms-broadcast, песочница, в main не влито.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:58:56 +03:00
Дмитрий 3531b3a0cc feat(смс-клиент): раздел «Не писать этим» самого клиента — руками, файлом, с источником
Стоп-лист тенанта получил всё, чего ему не хватало: откуда взялся отказ
(клиент / получатель / портал), комментарий зачем, загрузку файлом и внятный
ответ про непонятые строки — образцами, а не молча.

- миграция: client_sms_optouts += source (default client), note
- модель: константы источников + fillable
- API /api/sms/optouts: список, внесение руками, загрузка Excel, удаление
- изоляция тенанта явным where поверх RLS, проверена вырезанием защиты
- CHANGELOG схемы v9.01

Строки приёмочного листа 1.1-1.3 закрыты со стороны бэкенда; экран «Не писать
этим» — Task 10, до него живого прогона по этим строкам быть не может.

Открытый вопрос В-15 владельцу: вправе ли клиент снять отказ, который пришёл
от самого получателя. Пока снимается любой — строго по строке 1.2.

Ветка feat/client-sms-broadcast, песочница, в main не влито.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:49:29 +03:00
Дмитрий a39694e11b feat(смс-клиент): общий стоп-лист портала — номер закрыт у всех клиентов сразу
Этап 1 «Нельзя обжечься», задачи 1–3 приёмочного листа
(docs/superpowers/2026-07-27-PRIEMKA-client-sms-fixes.md, строки 1.4, 1.4а, 1.5).

- новая таблица sms_global_optouts (SaaS-уровень, RLS намеренно нет: номер
  закрывается у всех тенантов сразу — защита договора с МТС при жалобе);
- ClientSmsRecipientSelector отсеивает такой номер ПЕРВЫМ, раньше тенантского
  стоп-листа: наше обязательство перед оператором сильнее настроек клиента;
- клиенту причина видна словами — «номер закрыт администрацией», а не молчаливое
  «не отправлено» (решение владельца, вопрос В-2 листа);
- админ-адреса /api/admin/sms/global-optouts: внести (номер в любом виде),
  список, убрать; непонятый номер отклоняется внятно;
- запись v9.00 в db/CHANGELOG_schema.md.

Проверено: ClientSms 122/122, приём лидов 17/17, phpstan по своим файлам 0,
gitleaks чисто. Защита доказана вырезанием — без отсева падают ровно 3 теста.

Живого прогона строк 1.4/1.5 ещё нет: админ-экран идёт задачей 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:17:20 +03:00
Дмитрий bc573a99eb feat(смс-клиент): своя база — загрузка Excel, пример файла, оператор через ДаДату, отброшенные не молча
Доработки экрана «Моя база» клиентской СМС-рассылки по просьбе владельца:

- Кривые номера больше не выбрасываются молча: uploadContacts/uploadContactsFile
  возвращают rejected + rejected_samples, экран показывает «Добавлено: N,
  не распознали: M (например: …) — проверьте формат».
- Таблица базы — только «Номер» и «Оператор» (колонку «Имя» убрал).
- Загрузка базы Excel-файлом: сервис ClientSmsPhoneFileReader (phpspreadsheet)
  читает первую колонку, пропускает заголовок, номера-как-числа не уходят в
  научную нотацию; endpoint POST /api/sms/contacts/file; на экране — выбор файла
  и кнопка «Загрузить файл».
- «Скачать пример файла»: ClientSmsBaseExampleWriter + GET /api/sms/contacts/example
  — готовый .xlsx с колонкой «Телефон» и образцами номеров.
- Оператор через ДаДату: EnrichClientSmsContactsOperatorJob обогащает добавленные
  номера (DaDataPhoneClient.provider) в фоне, под tenant-контекстом (RLS), с
  защитой дневного бюджета (DaDataBudgetGuard); сбой ДаДаты не роняет прогон,
  оператор остаётся пустым; уже известного оператора не перезапрашиваем.

Тесты: бэкенд ClientSms 130/130, фронт СМС-спеки 63/63 — зелёные; pint чист;
портал пересобран. Синтетические номера 7999… в тестах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:11:13 +03:00
Дмитрий 3ad2ffb771 feat(смс-клиент): бланк письма-разрешения — Word на все 8 случаев + выбор правообладателя
Имя отправителя Лидерра регистрирует у МТС от лица клиента, поэтому письмо-разрешение
нужно всегда. Собрал генератор бланка так, чтобы он покрывал все случаи и всегда
отдавал целый документ — заполненный, где есть данные, и с прочерками, где их нет.

- ConsentLetterBuilder — единый источник текста письма на все 8 комбинаций
  «объект (юр.наименование/название ИП/товарный знак/домен) × правообладатель
  (юрлицо/ИП/физлицо)» строго по образцам МТС. Паспорт физлица не храним — прочерк.
- ConsentDocxWriter — рендер в РЕДАКТИРУЕМЫЙ Word (.docx) вместо PDF, чтобы клиент
  дописал недостающее; акцент про паспорт («должны совпадать с документом на право»).
- consentForm(): убрана жёсткая блокировка (бланк отдаётся всегда), добавлен выбор
  правообладателя owner_type/owner_name — домен/знак могут быть на физлице даже
  у клиента-юрлица/ИП, тогда письмо от этого физлица.
- Экран: подсказки «как назвать имя» под каждый вид, общее правило (до 11 знаков,
  латиницей), радио «на кого зарегистрирован домен/знак» + поле ФИО, кнопка бланка
  активна после ввода имени, ярлык «Скачать бланк разрешения (Word)».
- consent-form.blade.php удалён (заменён docx); phpoffice/phpword ^1.4 в composer.

Тесты: бэкенд ClientSms 116, фронт SMS-спеки 44 — зелёные; pint чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 18:33:45 +03:00
Дмитрий cfc143ddd6 fix(смс-клиент): вид имени по типу лица клиента + формулировка письма строго по объекту
Устранена нестыковка: физлицу давали выбрать «Название юрлица» → письмо-бессмыслица.

- Экран показывает только допустимые виды имени по типу лица из реквизитов:
  физлицо — домен и товарный знак; ИП — плюс наименование ИП; юрлицо — плюс
  название юрлица. Радио-группа рендерится из allowedNameTypes.
- sender() отдаёт subject_type клиента.
- consentLetter: тип правообладателя в письме привязан к объекту — юр.наименование
  всегда юрлицо, наименование ИП всегда ИП, домен/товарный знак по типу клиента.

Проверено в браузере: физлицо видит только домен/ТЗ; юрлицо → письмо «ООО … в лице
гендиректора … на использование юридического наименования организации», без «ИП».
Backend SenderTest 19, фронт СМС 45, pint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 16:29:30 +03:00
Дмитрий c52fedae9d feat(смс-клиент): имя отправителя регистрируем мы — разрешение по образцу + документ-основание
Имя у оператора регистрирует Лидерра от лица клиента, поэтому от клиента два
скана: подписанное разрешение и документ-основание. Оба обязательны, галочка
согласия убрана.

- Бланк разрешения — PDF по нашему образцу «Разрешение-домен УНИВЕРСАЛЬНОЕ»:
  Правообладатель = клиент, Пользователь = наш ИП из legal_entities is_default.
  4 вида имени домен/юрлицо/ИП/товарный знак — у каждого своё основание права.
- Физлицо: домен часто на физлицо — гейт по ФИО, паспорт вписывается от руки,
  паспорт не храним 152-ФЗ.
- Две колонки client_sms_senders: doc_* документ-основание, consent_doc_*
  подписанное разрешение.
- Админка: скачивание обоих сканов, вид .../document/basis и .../document/consent.
- Гейт реквизитов requisites_ready; эндпоинт GET /api/sms/sender/consent-form.

Тесты: ClientSms backend 95, фронт СМС 43, pint чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 14:38:21 +03:00
Дмитрий c47c3fb4d5 fix(смс-клиент): недосписание при повторе, срок для рассылки по сделкам, заслонка баланса + понятный интерфейс
Корректность/деньги:
- джоб рассылки: списание ПОШТУЧНО и атомарно с записью в журнал, ключ идемпотентности на номер, факт из БД — нет недосписания при падении посреди рассылки и повторе
- валидация: срок обязателен при source=deals, иначе выборка по всей истории сделок
- «Отправить» гаснет при нехватке свободного баланса; index отдаёт баланс/заморозку
- помесячный джоб имени: устойчив к пустым настройкам, сигнал в лог при автоотключении за долг
- цена одного авто-СМС + подтверждение при включении авто-рассылки
- загрузка контактов сообщает число нераспознанных номеров

Понятность для клиента:
- «СМС» вместо «сегментов», «пробный режим» вместо «песочницы», человеческие причины пропуска, шаги 1-2-3, выгода своего имени, факт вместо оценки в таблице, имя и текст в подтверждении

Тесты: бэкенд ClientSms 83/83, фронт СМС 50/50, приём лидов 10/10; pint/stan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:14 +03:00
Дмитрий f2e6783ae8 feat(смс-клиент): фронт Этапа 2 — карточка имени, авто-СМС, админ-экран
Клиент: карточка имени отправителя (статусы pending/active/suspended/rejected,
диалог заказа с согласием и платой, отключение) + вкладка «Авто-СМС» (тумблер+текст).
Админ: экран «СМС: тарифы и имена» — правка ступеней, платы за имя/грейса, очередь
подтверждения имён (approve/reject/disable); роут /admin/sms + пункт AdminLayout.
API-обёртки в client-sms.ts и admin.ts (cookie-сессия).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 23:21:17 +03:00
Дмитрий 51b8f501ae feat(смс-клиент): бэкенд имени и авто-рассылки
Жизненный цикл имени: клиент requestSender (freeze первого месяца) / disableSender;
админ approve (charge+release, active, paid_until+1мес) / reject / disable. Помесячная
оплата ChargeSmsNameFeeJob (проверка средств ДО списания, долг>29 дней→suspended,
идемпотентно по external_key), расписание в console.php. Авто-рассылка: DealSmsObserver
(защитный, freshness-guard, НИКОГДА не роняет приём лида) → SendAutoSmsForDealJob
(best-effort, идемпотентно по deal_id, песочница/деньги/маршрут). Действующее имя
кампании = active-имя тенанта иначе liderra.ru.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 23:20:52 +03:00
Дмитрий ea97bba219 feat(смс-клиент): таблицы имени/авто-правила + модели + грант админ-кошелька
client_sms_senders (жизненный цикл имени) и client_sms_auto_rule (авто-рассылка) —
RLS tenant-таблицы; гварды грантов для crm_supplier_worker/crm_admin_user (cross-tenant
джоб и админ-подтверждение). Грант записи в ad_wallets для crm_admin_user (approve
списывает/снимает заморозку). CHANGELOG v8.97/v8.98 с deploy-note про ре-ран srv_bypass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 23:20:51 +03:00
Дмитрий 9d6c3372b6 feat(смс-клиент): экран рассылки + пункт меню + маршрут /advertising/sms
AdvertisingSmsView: кошелёк-шапка, вкладки (мои рассылки/новая/база/шаблоны),
счётчик символов·сегментов·цены, предпросмотр с задержкой и расшифровкой отсева,
диалог подтверждения, баннеры песочницы и имени отправителя (liderra.ru). Роут
advertising-sms; активирован пункт-заглушка «Рассылка СМС» в advertisingChannels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 21:25:49 +03:00
Дмитрий c47dc8ff23 feat(смс-клиент): фронт-API client-sms.ts
Обёртки к /api/sms/* (cookie-сессия, как advertising.ts): рассылки, предпросмотр,
база контактов, шаблоны. Типы кампании/сообщения/контакта/шаблона/предпросмотра.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 21:25:47 +03:00
Дмитрий d1a4c28dc1 feat(смс-клиент): API рассылок/базы/шаблонов + админ-тарифы + маршруты
ClientSmsController (index/preview/store/show, база контактов, шаблоны;
tenant по $request->user()->tenant_id + явный where; 409 при нехватке денег,
freeze при создании). AdminSmsTariffController (правка ступеней и настроек,
зона saas-admin/admin-db, crm_admin_user). Маршруты /api/sms и /api/admin/sms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 20:59:57 +03:00
Дмитрий 5d423be226 feat(смс-клиент): джоб отправки — деньги через кошелёк, песочница, retry
SendClientSmsCampaignJob: single-tenant под crm_app_user (SET LOCAL перед
каждой операцией; сеть вне транзакций). Списание/снятие заморозки через
AdWalletService (канал sms, рубли, идемпотентно по external_key). Песочница
не двигает деньги. failed() снимает заморозку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 20:59:56 +03:00
Дмитрий 3ec722416c feat(смс-клиент): сервисы — цена по ступеням, аудитория, отбор получателей
ClientSmsPricing (ступень по объёму получатели×сегменты, наценка не
применяется), ClientSmsAudienceBuilder (сделки/база/список, tenant-scope
SET LOCAL + явный where, нормализация телефона), ClientSmsRecipientSelector
(стоп-лист→дубль→маршрут), ClientSmsPlan DTO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 20:59:35 +03:00
Дмитрий c661b99097 feat(смс-клиент): таблицы ядра рассылки + тарифы + RLS + модели
8 таблиц client_sms_* (6 tenant-RLS + 2 глобальных тарифы/настройки),
политики tenant_isolation по образцу ad_wallets, гранты crm_app_user /
crm_admin_user (гварды ролей). Модели ядра + сид ступеней и настроек.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 20:59:13 +03:00
Дмитрий 1baeae7f3a Merge branch 'feat/reklamnyy-koshelek-chast-A'
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-07-25 18:19:07 +03:00
Дмитрий 498f8cfd6d fix(портал продаж): вход только по токену — чужая web-сессия не перебивает
Гвард 'sales' (Sanctum) на stateful-домене lk.liderra.ru подставлял
App\Models\User из открытого рядом обычного кабинета вместо SalesUser —
500 на всех маршрутах портала, роль слетала в «менеджера» (боевой
инцидент 24.07.2026). Новый драйвер 'sales-token' в AppServiceProvider
авторизует только по Bearer-токену, не заглядывая в web-сессию;
обычный кабинет (auth:sanctum) и impersonation не затронуты.

Регресс-тест SalesGuardTokenPriorityTest воспроизводит поломку.
larastan-хук исключён: 2 ошибки — чужой pre-existing WIP ветки
(SetTenantContext, SmscSmsProviderTest), мои файлы stan-чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 17:46:53 +03:00
Дмитрий b5898b8017 @
fix(прогрев): менеджер+этап по ИНН у непривязанных строк и защита от дубля карточки

Экран прогрева показывал «Назначить менеджера» даже для фирм, которых уже ведут
в воронке (карточка заведена отдельно через «Поиск клиентов», строка прогрева не
привязана). Из 250 фирм Яндекса так было у 51.

- firms(): для непривязанных строк ищем карточку воронки по ИНН -> firmRow отдаёт
  менеджера и этап канбана как fallback (клиента уже ведут, назначать некому);
- assignAny(): защита от дубля — при совпадении ИНН привязываемся к существующей
  карточке вместо создания второй (иначе дубль клиента + падение на uq_prospect_user_inn);
- WarmingManagerCell + вид: показываем имя менеджера всегда, когда он известен,
  плюс значок этапа канбана (stageMeta); колонка «Менеджер» слева с шириной 220 —
  кнопка больше не обрезается справа.

Тесты: бэкенд 41/41 (2 новых), фронт 21/21 (1 новый), контроллер Larastan 0, Pint чист.
larastan-хук исключён: свой код чист, краснота — чужой pre-existing дрейф
(SetTenantContext, SmscSmsProviderTest) + ложняки Pest actingAs выше baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-25 13:05:57 +03:00
Дмитрий ce511a50d9 feat(реклама-фронт): оплата рекламного кошелька картой (ЮKassa) в диалоге пополнения
Диалог AdWalletTopupDialog теперь предлагает два способа: «Оплатить картой»
(POST /api/billing/topup, credit_target=advertising — редирект на confirmation_url
при включённом шлюзе, мгновенный успех при заглушке) и прежнее «Получить счёт»
(регресс не тронут). Убран устаревший текст про «оплата картой скоро появится».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 11:55:04 +03:00
Дмитрий cc43e696f6 feat(реклама): оплата картой ЮKassa зачисляет рекламный кошелёк (credit_target в settle; основной баланс без изменений) 2026-07-25 11:44:30 +03:00
Дмитрий 1661e1bb94 feat(реклама-фронт): админ-экран «Реклама — расход и маржа» по тенантам 2026-07-25 11:12:36 +03:00
Дмитрий 566028ed9d feat(реклама): админ-API расход/маржа по тенантам (ad_wallet_transactions charge) 2026-07-25 11:02:23 +03:00
Дмитрий 23ce42c2b5 feat(реклама-фронт): пополнение рекламного кошелька — счёт с назначением «реклама» 2026-07-25 10:54:16 +03:00
Дмитрий c1f64e391f feat(реклама-фронт): кнопки Пауза/Возобновить + диалог Отчёта на карточке кампании 2026-07-25 10:42:48 +03:00
Дмитрий bdc1d09475 feat(реклама): пауза/возобновление кампании клиента (Директ suspend/resume) 2026-07-25 10:26:08 +03:00
Дмитрий b19579937e feat(реклама-фронт): правка кампании на ходу (Р30) + финальная проверка B2 2026-07-25 02:17:43 +03:00
Дмитрий ed515f516f feat(реклама-фронт): шаг «Проверка и запуск» — сводка, согласие, запуск кампании 2026-07-25 02:02:38 +03:00
Дмитрий 11bc282729 feat(реклама-фронт): шаг «Бюджет» (недельный/дневной, только правда без прогнозов)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 01:46:43 +03:00
Дмитрий 34a57d0fcf feat(реклама-фронт): шаг «Объявления» — форма креатива с серверной валидацией и загрузкой картинки 2026-07-25 01:40:15 +03:00
Дмитрий 480ac8f6be feat(реклама-фронт): мастер запуска — каркас v-stepper + шаг «Кого рекламируем» с живым счётчиком
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 01:28:15 +03:00
Дмитрий 7cd689f897 feat(реклама-фронт): вкладка «Мои кампании» — карточки со статусами
CampaignList.vue: карточки кампаний Яндекс Аудитории (B2-4) — статус-чип
по словарю (running/pending_moderation/rejected/stopped_no_funds/paused/
draft), недельный бюджет, дата запуска; «Пауза»/«Отчёт» — disabled-заглушки
(нет эндпоинтов в B1); пустое состояние с кнопкой «Новая реклама».
AdvertisingYandexView.vue: вкладка campaigns теперь рендерит CampaignList,
события new/edit переключают вкладку. TDD: 3 новых теста +
advertising-yandex-view.spec.ts обновлён под мок fetchCampaigns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 01:14:30 +03:00
Дмитрий b468d6683f feat(реклама-фронт): шапка-кошелёк (баланс/заморожено/свободно + алерт нехватки) 2026-07-25 01:05:11 +03:00
Дмитрий bbbc4b4b90 feat(реклама-фронт): роут /advertising/yandex + витрина Яндекса ведёт на реальный экран
Канал «Яндекс Аудитория» в разделе «Рекламные возможности» (сайдбар +
мобильное «Ещё») больше не заглушка: клик ведёт на новый роут
/advertising/yandex со скелетом экрана (заголовок + вкладки «Мои
кампании» / «Новая реклама»). Остальные 4 канала — без изменений
(по-прежнему открывают AdStubDialog).
2026-07-25 00:57:03 +03:00