Commit Graph

75 Commits

Author SHA1 Message Date
Дмитрий df70a78e17 merge: ветка Билайна и Мотива сведена с главной — 347 коммитов, один затык
Затык был ровно один — routes/web.php: главная добавила ручку суточного итога
сторожей с машины робота, ветка добавила приёмник статусов Билайна. Ручки
независимые, оставлены обе.

Попутно поправлено примечание в config/services.php: оно говорило «заявка подана,
срок до десяти рабочих дней», а имя `Liderra.ru` было ОДОБРЕНО обеими сетями
10.08.2026 в тот же день — проверено глазами в кабинете. Примечание, которое
врёт, хуже отсутствующего: следующая смена прочла бы его и не стала поднимать
выключатель.

Замер на сведённом коде:
- модуль СМС 575/575, 7151 проверка (опора ветки была 548 — главная принесла 27);
- фронтенд 2205 из 2210. Пять падений — ЧУЖИЕ и приехали из главной:
  KoshelyokZakladkiPoKanalam.spec.ts, экран AdWalletMoneyColumn.vue спотыкается
  об отсутствующее поле frozen_breakdown. Доказано отпечатками: и тест, и экран
  байт в байт совпадают с gitea/main, моя правка их не касалась;
- Pint и PHPStan — чисто.
2026-08-10 21:39:33 +03:00
Дмитрий a159bf73cb feat,смс: портал научился сети Мотива — маршрут через Билайна на выключателе
Номер абонента Мотива не доходил даже до каналов: словарь операторов о Мотиве
не знал, и номер выбывал на первой заставе «кому мы вообще шлём».

Что сделано:
· словарь знает ОБА сырых написания — торговое «Мотив» от ДаДаты и юридическое
  ООО "ЕКАТЕРИНБУРГ-2000" из реестра Россвязи, где слова «Мотив» нет ни разу
  замер по DEF-9xx.csv: 29 диапазонов, среди 89 имён реестра столкновений нет;
· Мотив добавлен к четвёрке владельца — сетей стало пять;
· маршрут через Билайна стоит на выключателе SMS_BEELINE_SERVES_MOTIV,
  умолчание ВЫКЛЮЧЕНО: до одобрения имени отправка вернёт 20230, а этот отказ
  окончательный — повтора нет, сообщение умрёт;
· цена по сети, а не одна на канал: 770 копеек за Мотива. Экран сверки тоже
  научен считать по сети — иначе занижал бы расход на 1,84 ₽ с сообщения;
· проверка ввода настроек и галки в админке пропускают Мотива: без этого
  владелец не смог бы его отметить, а заполненный список отменяет код;
· на клиентском экране «Имя отправителя» появилась пятая строка — скрыть её
  значило бы солгать умолчанием.

Тесты: модуль 548/548, было 530; 1801 проверка. Фронтенд 120/120, Sales 483/483.
Контрольные прогоны: убранное юридическое написание красит ровно четыре
зависящих от него теста, перевёрнутое умолчание выключателя — ровно один.

Не выкачено. Включать после того, как Билайн согласует имя — ориентир 24.08.2026.
2026-08-10 20:27:55 +03:00
Дмитрий 272ccbffe0 feat,смс: приёмник статусов Билайна — платформа сама сообщает судьбу
Билайн умеет звонить нам сам: POST на /api/webhook/beeline-delivery/{secret}
с полями ORDID, CNRID, RESCOUNT, STATUS, FINALTIME. Раньше судьбу сообщения
добирал только опрос — теперь она приходит сразу, как у МТС и Теле2.

Защита та же, что у двух соседних приёмников: секрет короче 32 знаков или
пустой = приёмник закрыт наглухо; сверка секрета через hash_equals; список
адресов отправителя необязательный, пустой список объявляется в журнале;
ограничитель 600 обращений в минуту; на чужой адрес и на неверный секрет
отвечаем 404, а не 403, чтобы не подсказывать чужому, что дверь тут есть.

Записи в журнал уровня warning, не notice: на бою LOG_LEVEL=warning и всё,
что ниже, не доезжает вовсе.

12 тестов написаны до кода, каждый посмотрен красным. Защита проверена
подсовыванием ошибки — краснеют ровно те тесты, что должны. Модуль целиком
530/530, pint чисто, статанализ 0.

Адрес приёмника в кабинете Билайна ещё не заявлен и секрет на бою не задан.
Пока их нет, приёмник закрыт, а канал работает по-старому через опрос.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:38:11 +03:00
Дмитрий 977c10bdba feat,смс: канал Билайна — отправка, судьба сообщения и вход двух видов 2026-08-10 12:08:51 +03:00
Дмитрий 459947f76b Р126 третья дверь: работа оборванного круга сохранена и принята, дыра переадресации найдена
Круг смены 6 оборвали средой, работа помощника лежала в дереве несохранённой и её снёс бы
первый же чужой откат. Замерил целостность, прочёл его отчёт, прогнал своей рукой, вынес
приговор. Заново ничего не переделывал.

ПРИНЯТО. Третья и последняя дверь с ключом рендера закрыта той же проверенной дверью.
Список разрешённых машин общий с рендером и это доказано работой, а не доводом. Закрытая
дверь красит СЕРЫМ, а не красным, и ключ при этом физически никуда не уходит, замерено мной
двумя случаями по отдельному запуску на каждый.

Помощник сверх задания нашёл и починил ложь, которой никто не искал: подпись плитки называла
серый словами не отвечает, то есть гнала владельца продлевать исправные прокси.

Мои ошибки называю первыми, их три.

Пятьдесят девятая. Я назвал adminDashboardHelpers.ts чужим файлом соседней смены прямо в
промте смене 7, в списке не трогать и в коммит не брать. Взял из головы, не замерив. Вся
правка в этом файле - работа моего же помощника. Исполни следующая смена мой промт дословно,
починка подписи плитки не попала бы в коммит и сгорела.

Шестидесятая. Мой снимок оборванного круга был неполон: записал шесть файлов, их семь.
Перечень собрал командой, но раньше, чем помощник закончил, и не перемерил перед тем, как
назвать его окончательным.

Шестьдесят первая. Мой датчик прогона искал не то слово и я чуть не объявил состоявшийся
прогон несостоявшимся. Спас узкий прогон, которым я прибор перепроверил.

Вырезание второго рода дало дыру. Допущение защиты: дверь проверяет адрес из настройки и
считает, что запрос уйдёт именно туда. Случай мимо: разрешённая машина отвечает иди на другой
адрес. Замерено на двух подставных машинах: тайный ключ уехал на машину, которой в списке
разрешённых НЕТ, а плитка осталась ЗЕЛЁНОЙ и показала чужой адрес как свой. Перемерил на
живой двери боевого Поиска клиентов тем же ножом, отдельным запуском - то же самое. Причина
прочитана в исходнике библиотеки: при уходе на чужой адрес снимаются только два заголовка,
и нашего среди них нет. Вторым прибором замерено, что отключения переадресации нет нигде во
всём боевом коде. Это не отменяет Р126, дверь делает обещанное на своих случаях, но обещание
шире дела и про это не сказано нигде. Отдельным кругом.

Датчики: полный прогон моей рукой на своей базе 5344 проверки, 5340 зелёных, 4 пропущено,
красных 0, арифметика сходится. Проверки интерфейса 3 из 3. Разметка 0 ошибок.
2026-08-07 09:29:23 +03:00
Дмитрий 9247281688 fix поиск клиентов: дверь к рендер-виртуалке — разрешённые машины и обязательное шифрование, Р126
Тайный ключ SELF_RENDER_KEY уходил заголовком X-Render-Key на любой адрес, какой окажется
в настройке. Проверялось ровно одно: адрес не пуст и ключ не пуст. Отсюда две молчаливые беды:
опечатка в SELF_RENDER_ENDPOINT увела бы ключ чужому человеку навсегда и без признаков,
а http:// увёз бы его открытым текстом.

Дверь повторяет проверенное устройство Р123 от голосового робота: своя причина на пустой
адрес и на пустой ключ, список разрешённых машин с пустым списком как «закрыто всё»,
обязательное https кроме своей машины, а «своя машина» — два независимых ответа: окружение
из положительного списка И двоичная проверка петли 127.0.0.0/8, ::1, localhost по RFC 6761.
Закрытая дверь = мягкая деградация: наружу не ходим, отдаём пусто, причина в журнал уровнем
warning, потому что на боевом всё ниже не доезжает.

Тумблера «можно без шифра» нет намеренно: он и был бы той самой опечаткой.

Дыр было ДВЕ, а не одна. Тот же ключ и тот же SELF_RENDER_ENDPOINT уходят из
RenderServiceYandexListSource — лента Яндекс.Карт. Починка только названного файла оставила
бы дыру открытой, поэтому дверь общая на обе.

Боевой поиск не сломается: умолчание списка НЕ пустое и содержит замеренный боевой адрес
виртуалки 51-250-1-97.sslip.io — пустое умолчание остановило бы поиск в момент выката.
Набор тот же, что владелец уже принял для OBZVON_ALLOWED_HOSTS: это одна и та же машина.
Прогнаны все формы адреса, какие могут стоять в боевой настройке, в окружении production.

Проверок 37, сторожа показаны красными пятью отдельными сломами, возврат доказан слепками.
Larastan 0, Pint чисто.

Не закрыто и ждёт слова владельца: ProxyMarketProbe шлёт тот же ключ на другую настройку
без двери; боевое значение SELF_RENDER_ENDPOINT не замерено, на боевой не ходил.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r126-dver-rendera-2026-08-07.md
2026-08-07 08:04:35 +03:00
Дмитрий dd3b6fb4b5 fix обзвон: к роботу только по шифрованной дороге, кроме своей машины — Р123
Дверь пускала http к разрешённому хосту, и тайный ключ уезжал заголовком
открытым текстом — молча, без единого признака. Одной опечатки в OBZVON_ENDPOINT
хватало. Теперь адрес без шифрования портал не принимает: дверь закрыта, звонка
нет, причина сказана словами и лежит в журнале уровнем предупреждения.

Исключение ровно одно — разработчик на своём компьютере — и устроено оно ДВУМЯ
независимыми ответами, чтобы одной перепутанной настройки на боевом для утечки
не хватило:

1. приложение само говорит, где живёт: спрашиваем Laravel, а не список слов.
   Список окружений положительный, а не «всё кроме боевого»: любое неназванное
   окружение обязано шифрование.
2. робот стоит на ЭТОЙ ЖЕ машине: адрес ведёт на петлю — вся сеть 127.0.0.0/8,
   ::1, ::ffff:127.x.x.x, имена localhost и *.localhost по RFC 6761. Определяется
   двоичным сравнением адреса, а не совпадением слов. Резолвер имён не зовём
   намеренно: он вносит в проверку безопасности неопределённость и щель между
   «проверили» и «пошли».

Именно второй ответ и закрывает то, что владелец назвал незакрытым: одной
ошибки в APP_ENV на боевом теперь мало — боевой робот петлёй не станет. На это
стоит именной сторож.

Тумблера «можно без шифра» нет намеренно: он и был бы той опечаткой, ради
которой Р123 принято.

12 новых сторожей: четыре случая ножа приёмки ж, з, и, к; сторож на само
исключение; сторож против совпадения слов вместо петли; сторож на подложное
шифрование, когда проверку чужого удостоверения снимают. Каждый показан
красным — починка ронялась четырьмя разными способами, и каждый раз краснело
именно то, что этот способ ломает. Возврат доказан слепком со снятием невидимых
знаков конца строки.

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

Найдено и НЕ тронуто второе место того же класса: services.self_render шлёт
секрет заголовком, и его дверь не проверяет ни схему, ни хост вовсе. Путь живой,
боевой. Вынесено решением владельцу отдельно.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 06:31:35 +03:00
Дмитрий 18f933550e feat обзвон: портал умеет позвать голосового робота — портальная половина шва З-0.3
Модуль «Обзвон» построен на 17 задач из 53 и при этом не мог позвонить ни разу:
между порталом и роботом не было шва. Эта половина его закрывает — робот живёт
на отдельной машине, портал теперь умеет сказать «позвони», спросить «что с
разговором» и сказать «останови».

Что сделано:
- блок services.obzvon: адрес, тайный ключ, предел одновременных обращений,
  ожидание места, ДВА предела времени, список разрешённых хостов. Всё через env,
  ни одного секрета в коде;
- ObzvonClient — три умения плюс опознавательный знак задания;
- ObzvonOtvet и IskhodObrashcheniya — пять исходов вместо «вышло / не вышло»;
- .env.example — семь настроек с пустыми значениями и пояснениями;
- 35 сторожей.

Главное решение. Ответ клиента НЕ булев. «Портал не получил ответа» — это не
«звонка не было»: робот мог принять задание и уже звонить, пока ответ терялся по
дороге. Слейся эти две правды — работник очереди повторил бы вслепую, и живому
человеку позвонили бы дважды, а с кошелька клиента списали бы вторую трубку.
Поэтому «не знаю» отделено от «точно не было», и у задания есть устойчивый
опознавательный знак, одинаковый у всех повторов одной попытки.

Решение владельца Р84: робот делит железо с боевым рендером «Поиска клиентов».
Предел одновременных взят замками общего кэша, а не числом в памяти процесса —
иначе два работника очереди дали бы двойную нагрузку. Что он ограничивает и чего
НЕ ограничивает, сказано прямо в шапке клиента.

Урок соседнего шва self_render взят дословно: успех — только по условленному
слову робота. Ответ 200 с мусором успехом не считается. В отличие от задания,
мусор записан не в «отказ», а в «не знаю» — иначе вернулась бы дыра с двойным
звонком. Клиент, в отличие от SelfRenderClient, не повторяет запрос сам никогда.

Журнал — только от «предупреждения» и выше: на бою LOG_LEVEL=warning.

Незакрытое названо в отчёте: защита от двойного звонка ЗАЯВЛЕНА, но не замкнута,
пока нет приёмника на машине робота З-0.6; настоящие пределы времени и предел
одновременных на живой нагрузке не мерены; ключ едет открытым заголовком.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-0-3-2026-08-06.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:01:50 +03:00
Дмитрий a0761a5c38 fix(реклама): отказ по деньгам говорил клиенту по-английски
Прогон Я7 приёмочного листа на боевом: черновик со сметой 15 000 руб при
свободных 9 122,76 руб. Запуск не состоялся правильно — в Директе не создано
ничего, деньги не тронуты, кампания осталась черновиком. А вот текст отказа
доезжал до клиента как есть:

    Insufficient balance: price_kopecks=1500000, balance_rub=9122.76

Причина: InsufficientBalanceException наследует RuntimeException, и ручка
запуска ловила его общим уловителем, отдавая наружу getMessage. Кнопка
«Возобновить» этой болезнью не болела — там свой уловитель с человеческим
текстом, и он же показал, что сломан именно запуск.

Починка: отдельный уловитель ВЫШЕ общего. Наружу — только клиентские рубли:
сколько нужно и сколько свободно. Ни копеек, ни английского, ни нашей доли.

Сторож PonyatnyyOtkazPoDengamTest — три проверки: нет английского и копеек,
названы оба клиентских числа, нет цены рекламной площадки. Принят красным:
до починки отдавал ровно техническую строку.

Заодно: срок мобильного прокси продлён до 03.09.2026 — поправлена устаревшая
подсказка в config/services.php. На боевом дата уже заменена, плитка стала
зелёной: «жив, IP 92.36.112.83, до 03.09.2026».

В журнал приёмки записаны прогоны Я7, Я8, Я9 — все три закрыты живьём и
бесплатно — и пересчёт цены комбинаций: деньги уходят только за настоящие
показы, поэтому семь прогонов из девяти стоят ноль. Настоящая стена не
денежная, а временная: сегмент 1653 человека даёт около показа в час, и
«вся смета» это полтора месяца.

Прогоны: 427 тестов рекламы зелёные, pint чисто, статанализ 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:09:35 +03:00
Дмитрий 593030271b feat(смс): имя отправителя выбирается по оператору получателя, а не одно на всех
Имя согласовывает ОПЕРАТОР, и у каждого оно своё. Клиентская рассылка несла одно
имя всем каналам, а умолчанием ему служило `services.sms.mts.naming` — то есть имя,
согласованное с МТС, подставлялось Теле2 и подставилось бы Мегафону и Билайну.
Живой отказ Теле2 `invalid_source_address` 04.08.2026 — ровно этот механизм. На бою
у клиентов заведено НОЛЬ своих имён, значит это касалось каждой отправки.

Появился ClientSmsSenderResolver: спрашивает имя у той сети, чей номер. Пустая
строка на выходе — не «имени нет», а «своего имени для этой сети нет, канал
подпишется собственным согласованным именем из настроек».

Переведены на него оба места отправки: рассылка (SendClientSmsCampaignJob) и
авто-СМС по сделке (SendAutoSmsForDealJob).

Универсальный СМС-центр передавал имя КАК ЕСТЬ — теперь тоже падает на своё
(SMSC_NAMING). Раньше пустое имя туда не приходило никогда, с этой правкой стало
бы приходить: ушли бы СМС без отправителя.

Заплатка внутри канала Теле2 (приведение написания к своему регистру) остаётся, но
больше не несущая: она чинила следствие в одном канале, а причина была общая.

Экранное имя (ResolvesClientSmsSenderName) НЕ трогали — это запись намерения, а не
то, чем подпишется сеть; в его шапке теперь написано почему, чтобы не «починили»
обратно.

🪤 Урок по дороге: авто-СМС ловит любую ошибку и молча выходит. Справочник имён был
передан не в ту функцию — вместо падения получилась ТИШИНА, не уходило ничего.
Поймали три чужих теста, до того зелёных.

Проверено: 4614 тестов, упал один чужой (ExampleTest — в этой папке не собран
фронтенд). Статанализ 0. Стиль правленых файлов чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 05:28:35 +03:00
Дмитрий 458f206ce9 feat(смс): канал Теле2 — отправка, судьба сообщений и приёмник отчётов
Канал «SMS-Таргет» (target.t2.ru, HTTP API v2, Basic Auth) обеими половинами:

- отправка через POST /send_message (msisdn числом, shortcode, text);
- опрос судьбы GET /send_message/message-id-{id} — по одному сообщению,
  пакетного запроса у t2 нет;
- приёмник обратных звонков GET|POST /api/webhook/t2-delivery/{secret}
  со строками ?id=&status=&parts= — форма другая, чем у МТС.

Ловушки, закрытые тестами:

- слово status в ответе встречается дважды: снаружи «запрос удался»,
  внутри судьба сообщения. Спутать = объявить доставленным всё подряд;
- отказ приезжает с HTTP 200 — судим по телу, не по коду;
- незнакомое слово статуса судьбу НЕ меняет, сохраняется в delivery_raw.
  За «не доставлено» клиенту возвращаются деньги (В-198) — выдумка двигала
  бы рубли;
- t2 наш ответ не проверяет и повторов не делает: пропущенный звонок потерян,
  поэтому опрос client-sms:poll-delivery остаётся обязательным.

Деньги приёмник не двигает — возврат живёт в команде опроса (В-146).
Защита приёмника как у МТС: секрет не короче 32 знаков (пустой = закрыт
наглухо), список адресов отправителя, счётчик обращений; чужому 404, не 403.

Канал ВЫКЛЮЧЕН и живьём не пробован: имя отправителя Liderra.ru (заявка
№ 31121) на согласовании, без него t2 не выдаёт логин с паролём.
Порядок включения — docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md

Миграций нет: графы судьбы появились ещё при МТС. Теле2 уже был в списке
разрешённых операторов и в тарифах админки — новых экранов не нужно.

Проверено: 78 тестов зелёные, Pint чист, статанализ по ВСЕМУ проекту 0 ошибок,
маршрут зарегистрирован. Против main — только вставки, удалений нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:06:01 +03:00
Дмитрий 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>
2026-08-01 19:50:41 +03:00
Дмитрий dd83f440eb feat(смс-клиент): приёмник отчётов о доставке от МТС — ускоряет опрос, не заменяет его
Строка листа 5.2. Появился адрес, на который МТС может присылать судьбу сообщения сам,
не дожидаясь нашего вопроса. В ответ отдаём код 204 — этого он требует, иначе считает
доставку неудачной и шлёт повторы.

Несущее решение здесь одно, и из него растёт всё остальное: приёмник — НАДСТРОЙКА, а не
замена. Опрос каждые десять минут остаётся на месте. Значит любой отказ приёмника
безобиден: не понял письмо, не узнал номер сообщения, вовсе выключен — всё доберёт опрос.
Поэтому везде выбран ноль вместо догадки, и это позволило честно работать при незнании.

А незнание крупное: формы письма, которое шлёт МТС, живьём не видел никто. Записано только,
что письма приходят и что отвечать надо кодом 204. Документации тут веры нет — она уже
соврала про опрос, описав одну форму вместо другой. Выдумывать я не стал (В-243): приёмник
разбирает ровно ту форму, которую видел живой ответ на опрос, а незнакомое письмо кладёт в
журнал сервера своей ФОРМОЙ — перечнем полей, без содержимого, потому что внутри телефоны, а
это персональные данные. Первый же живой отчёт покажет свою форму сам, и читатель дописается
одной правкой. Проверено живьём: телефон в журнал не утёк.

Правило «как отчёт ложится в журнал» переехало из команды опроса в общий дом на два входа.
Разъехавшись, они писали бы по-разному, а по журналу считаются деньги. Форма письма при этом
живёт в канале, приёмник про устройство МТС не знает ничего — новый оператор с кабинетом
вставляется, не трогая ни приёмника, ни журнала.

Деньги приёмник не двигает вовсе. Возврат за недоставленное остаётся в опросе, где у него
своя двойная защита от повтора; вторая дорога к кошельку означала бы вторую возможность
вернуть дважды. Отдельный тест это стережёт.

Адрес публичный, поэтому защита тройная: секрет в адресе (не задан — приёмник закрыт наглухо,
а не «пускать всех»), необязательный список адресов отправителя и счётчик обращений. Чужому —
404, существование приёмника не подтверждаем. Список адресов пока пуст: адреса МТС нам
неизвестны, и придумать их нельзя.

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

Живьём: верный отчёт правит строку и отвечает 204; чужой секрет — 404 и строка не тронута;
непонятное письмо — 204 и ни одной записи; отчёт про неизвестный номер — 204 и предупреждение;
адрес вне списка — 404. Кошелёк за весь прогон не шелохнулся. Стенд возвращён.

Включение — сторона владельца: адрес указывается в кабинете МТС. До этого всё работает опросом.
🔴 И честно: пока канал МТС на бою не отправляет ничего, проверить приёмник живым письмом
нечем — отчётам просто неоткуда взяться.
2026-08-01 03:02:47 +03:00
Дмитрий 5f26c22c33 feat телеграм-робот: переключатель канала process/poll и сервис-токен
Пока только настройки, поведение не меняется: по умолчанию старый процессный путь.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:03:16 +03:00
Дмитрий 9488075076 fix реклама: минимум площадки считается по длине периода, а созданные объявления сами уходят на модерацию
Живой запуск кампании на бою 30.07.2026 вскрыл две поломки, которых вчерашняя
починка не видела. Обе — из класса молчаливых: портал рапортует успех, а в жизни
ничего не происходит.

ПЕРВАЯ. Минимум Директа НЕ постоянный.

Вчера мы приняли живой отказ «Budget for this period cannot be less than 600 rub.»
за постоянный порог и зашили 600 ₽ в настройку. Сегодня та же кампания на неделю
получила отказ «cannot be less than 2400 rub.» — проверка её пропустила, и клиент
снова увидел английский текст.

Две точки дали правило: минимум = 300 ₽ за каждый календарный день периода,
считая оба края.
   период 30.07-31.07, 2 дня → 600 ₽  = 2 × 300
   период 30.07-06.08, 8 дней → 2400 ₽ = 8 × 300

Теперь порог считается от периода показа, а ставка за день вынесена в настройку
YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY. Период вычисляется ДО денег — иначе считать
минимум не от чего.

ВТОРАЯ. Создать объявления — не значит запустить рекламу.

Запуск проходил успешно, портал ставил статус «на модерации», а в кабинете лежали
кампания, группа и 15 объявлений в состоянии DRAFT и OFF. Яндекс кладёт всё
созданное черновиком и проверку сам не начинает. Реклама не показалась бы никогда,
и узнать об этом можно было только глазами в кабинете: наш журнал говорил, что всё
хорошо.

Добавлены два вызова, которых не было вовсе:
   ads.moderate     — отдать созданные объявления на проверку
   campaigns.resume — включить показ

Порядок обязателен. Пока кампания черновик, включить её нельзя — Директ отвечает
«кампания является черновиком и не может быть остановлена». Сначала модерация,
она переводит кампанию в MODERATION, и только потом включение.

Отбор объявлений строго по Ids. На CampaignIds Директ отвечает «отсутствует
обязательный параметр Ids» — именно так отправка молча не сработала бы.

На модерацию уходят объявления, созданные ИМЕННО этим заходом: повторная отправка
уже проверяемого — отказ по позиции, он порвал бы возобновляемый запуск. Включение
показа безобидно при любом повторе: на включённой кампании Яндекс отвечает
предупреждением, а не отказом.

Заодно клиент Директа перестал молчать про отказы внутри ответа: раньше он смотрел
только AddResults, UpdateResults и DeleteResults, теперь ещё ModerateResults и
ResumeResults. Отказ по позиции в этих двух проходил бы насквозь незамеченным.

ПРОВЕРЕНО ЖИВЬЁМ. Кампания № 6 на бою: в кабинете 713175197, группа 5778555449,
15 объявлений, аудитория подключена, статус MODERATION, показ включён. На счету
Яндекса 8775 ₽. У клиента заморожено 3333.36 ₽ за 27778 показов до 06.08.

Тесты: 346 зелёных по рекламе, 43 по клиенту Директа, статанализ ноль замечаний.
Четыре новых теста — минимум по длине периода на боевом случае, отправка на
модерацию, порядок модерация-до-включения, отсутствие повторной отправки.
Два старых теста закрепляли частный случай в два дня — им проставлен явный срок
показа, иначе они молча описывали бы неправду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:43:25 +03:00
Дмитрий 8769245ed5 feat реклама: портал сам проверяет готовность аудитории и минимальную смету до запуска
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Живой бой 30.07.2026. Сегмент дозрел за 15 часов — Яндекс опознал 1644 человека
из 1693, охват 3379. Портал нажал «Запустить» и получил отказ Директа:
   Budget for this period cannot be less than 600 rub.

Стена оказалась не одна, а две. Обе — молчаливые: портал о них не знал.

Первая — аудитория. Сегмент в Аудиториях существует и при этом НЕГОДЕН, пока
Яндекс сводит загруженные номера с людьми. Портал этого не спрашивал вообще:
ни один файл не читал can_create_dependent. Он просто шёл в Директ строить
условие ретаргетинга и получал «объект не найден» — тот же отказ, что и на
несуществующий сегмент. Судить о готовности можно ТОЛЬКО по can_create_dependent:
статус is_processed означает «обрабатывается», а не «готов».

Вторая — деньги. Директ не берёт кампанию, у которой бюджет за период ниже
шестисот рублей. Приёмочная кампания на 1693 показа давала около 146 рублей.
Проверки минимума в портале не было тоже.

В обоих случаях клиент видел сырую ошибку Яндекса на английском и не понимал,
виноват ли он и что делать дальше.

Что сделано. Обе проверки выполняются ДО первого обращения к Яндексу: в кабинете
ничего не создаётся, деньги не морозятся, кампания остаётся черновиком.
   аудитория ещё готовится → 202 и «подождите, обычно несколько часов»
   смета мала              → 422 и «нужно 6945 показов, это 833.40 рублей»

Наружу уходят ТОЛЬКО клиентские числа. Ни минимума площадки, ни нашей наценки:
по паре «минимум площадки — цена клиенту» долю Яндекса можно вычислить делением.
Минимум вынесен в настройку YANDEX_DIRECT_MIN_SPEND_RUB, Яндекс может его менять.

Тестом вперёд, в живой Яндекс из тестов не ходили. Четыре новых теста на запуск
и два на ответы портала. Реклама целиком: 342 теста зелёные.

Заодно приведён в порядок список исключений статанализа. Он был красным ЗАДОЛГО
до этой правки и не пускал ни одну правку кода: 629 замечаний до моих изменений,
638 после. Проверено в обеих папках работы — не артефакт подпапки.

Разбор 637 замечаний по составу:
   611 — статанализ не понимает устройство тестов Pest и ругается на
         обращения вида this->postJson и this->tenant. Таких записей в списке
         исключений уже было 671 — просто новые тестовые файлы туда не дописали.
    26 — придирки к типам, из них 7 в боевом коде.

Все семь в боевом коде разобраны поимённо и оказались ложной тревогой. Доказано
не рассуждением, а зелёными тестами, которые упали бы при настоящей поломке:
   shows_until, три замечания — три теста прямо проверяют закрытие кампании по
      истечении срока показа и разморозку остатка. Будь там строка вместо даты,
      первый тест упал бы, а деньги клиента остались бы заперты навсегда.
   snapshot_from и snapshot_to, два замечания — тесты ручного режима строят
      аудиторию по этим датам, на строке они бы рухнули.
   SetTenantContext строка 56 и CampaignMessageService строка 168 — лишние
      подстраховки, вреда нет.

Корень ложных срабатываний: Larastan ищет приведения типов в старом виде —
свойством casts, а у нас метод casts. Код правильный, инструмент отстал.

После обновления списка статанализ зелёный: 0 замечаний. Теперь сторож снова
ловит НОВОЕ, а не молчит красным.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:07:49 +03:00
Дмитрий 9234a9c2bc feat реклама показы: очередь заданий робота-грузчика, служебный канал и постановка при запуске
Портал ставит роботу задание, когда у баннеров ещё нет номеров креативов: вместо
ошибки клиент видит «готовим картинки», кампания остаётся черновиком, деньги не
морозятся. Робот берёт задания строго по одному — иначе слепки креативов до и
после перемешаются, и опознать их будет нельзя.

Канал робота закрыт своим сервис-токеном, внесён в исключения проверки CSRF и
отдаёт файл только того задания, которое сейчас в работе. Постановка задания
стоит внутри проверки рубильника Директа — при выключенном рубильнике портал в
Яндекс не ходит.

Права на новую таблицу выданы роли crm_admin_user: канал идёт через посредник
admin-db, подменяющий подключение. Нумератор выдан crm_app_user — он единственный
вставляет строки. Журнал схемы — запись v9.06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:13:02 +03:00
Дмитрий fbbe49dac6 feat(реклама): показы — фундамент Части 4: предохранитель бюджета + номер креатива Яндекса на кампании
- config yandex_direct.spend_limit_guard_multiplier — бэкстоп от перерасхода, не клиентская цена
- миграция ad_campaigns.yandex_creative_id nullable + точечный GRANT UPDATE админ-роли с гардом
- модель AdCampaign: yandex_creative_id в fillable и casts integer
- тесты: config-набор и миграция зелёные, реклама-набор не сломан
- CHANGELOG схемы v9.01; план Части 4 и findings контракта медийного API Директа

Часть B «мотор» ещё впереди: медийные методы клиента, переписанный запускатор, контроллер.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:48 +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
Дмитрий 635a70546d feat(реклама): конфиг yandex_direct (песочница по умолчанию, рубильник) 2026-07-24 22:21:53 +03:00
Дмитрий 2b85ba1d27 feat(смс мтс): канал МТС через Маркетолог api.mts.ru + мост, только МТС-номера
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
МТС-провайдер переписан с Exolve на «МТС Маркетолог, Рассылки по своей базе PRO»
(омни-адаптер api.mts.ru, тело submits/naming, ответ submitResults). Обслуживает
только МТС-номера. Мост: Guzzle CONNECT_TO через SSH-туннель SMS_MTS_CONNECT_TO —
боевой IP у МТС закрыт, убирается строкой .env после открытия доступа. Имя
отправителя из БД/конфига (liderra.ru). Конструктор обратно-совместим — роутинг-
тесты целы. За флагом SMS_MTS_ENABLED, песочница не тронута. Тесты СМС 82/82,
larastan по изменённым файлам 0, pint чисто. LEFTHOOK_EXCLUDE=larastan: чужой
pre-existing SmscSmsProviderTest:52 (не baseline, не мой файл).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:31:22 +03:00
Дмитрий 509c66e7f9 fix(смс): предпросмотр/оценка учитывают наличие имени канала (§3.4) + убран мёртвый конфиг
Раньше имя отправителя проверялось только в джобе на отправке — предпросмотр
и оценка цены считали такие номера оплачиваемыми. Теперь SmsRecipientSelector
принимает список каналов с активным именем и отсеивает остальные как
skipped_no_sender ещё на отборе — единый источник для preview/store/джоба.

Заодно нашёлся и починен латентный баг: SalesSmsSender::activeByProviderKey()
мержил через Eloquent Collection::merge(), которая перевязывает по первичному
ключу и array_values()-ит результат — терялись строковые ключи keyBy('provider_key').
Починено через collect()->merge() (обычный array_merge, ключи-каналы целы).

Убран мёртвый плоский блок config('services.sms.smsc') — читается только
providers.smsc.
2026-07-23 13:18:45 +03:00
Дмитрий 08ffe371b8 feat(смс): реестр каналов в конфиге + сборка роутера из реестра 2026-07-23 12:15:46 +03:00
Дмитрий 6e771636ef feat(смс): модуль «Прогрев СМС» с заделом под мультиклиентность
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл).

Что сделано:
- разъём провайдера SmsProvider: новый оператор подключается одним файлом
- заглушка FakeSmsProvider — модуль работает и проверяется ДО согласования
  имени отправителя у операторов (это недели), иначе разработку не закончить
- маршрутизация по оператору: билайновский номер уходит через Билайн за
  4,75 ₽, прочие через МТС — без ручного выбора канала
- стоп-лист: кто отписался, тому не шлём никогда, проверка перед списанием
- отбор получателей с шестью причинами пропуска, все ДО траты денег
- списание скопировано с AutopodborChargeService; пока клиента нет
  (tenant_id пуст) с баланса не берём — платим оператору напрямую
- оператор номера доезжает из «Поиска клиентов» в прогрев (был известен
  и оплачен ДаДате, но терялся при передаче)

Мультиклиентность в костях: колонка tenant_id во всех четырёх таблицах
СМС с первого дня, NULL = «Лидерра сама». Клиент добавляется строкой,
а не переделкой модуля.

Найдено и закрыто при исполнении:
- замок от двойного списания стоял не на том соединении: кампания на
  pgsql_supplier, деньги на pgsql, lockForUpdate по кампании отпускался
  сразу. На бою два запуска списали бы дважды, обрыв — оставил бы пометку
  «оплачено» при неушедших деньгах. Источник правды перенесён в
  balance_transactions под замок по тенанту. Доказано тестом: старый код
  списывал 700 вместо 850
- приём в портал требовал phones строкой по regex — словарь с оператором
  получал 422, в базу не доезжало ничего. Тесты были зелёные, потому что
  звали сервис МИМО контроллера. Проверка теперь принимает оба формата,
  тест идёт через HTTP
- телефоны директоров в contacts остаются строками (договор
  SalesProspectController), словари — только в верхнем phones

Заодно вылечена мигающая поломка 48 тестов доставки лидов: помощник
createRoutingSnapshotFromProject клал снимок на сегодня, а LeadRouter
после 21:00 МСК ищет завтрашний (вечерний переворот заливки) — вечерние
прогоны падали, дневные проходили. Помощник теперь зеркалит активную дату
роутера в любой час. Регрессия SnapshotHelperTimeOfDayTest замораживает
22:00 МСК и пинит инвариант. Боевой LeadRouter не тронут.

Тесты: 84 бэкенд + фронт по экрану + 397 поисковика, весь набор 3226
зелёный, статанализ чист. Все защиты проверены вырезанием.

План: docs/superpowers/plans/2026-07-20-sms-progrev-modul.md
Спека: docs/superpowers/specs/2026-07-20-sms-progrev-modul-design.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 06:35:43 +03:00
Дмитрий 8e687f99c1 feat: сквозной путь клиента от рекламного клика до Вебвизора
Путь человека рвался пополам: лендинг и кабинет считали разные счётчики,
а вход и регистрация не писались вообще. Почему люди бросают регистрацию,
узнать было нельзя.

Счётчик лендинга 110476275 становится счётчиком полного пути и грузится
на всех страницах кабинета. Прежний счётчик кабинета 110494416 не тронут,
его история цела, на страницах кабинета работают оба.

Что сделано:
- загрузчик Метрики поднимает счётчики по области: public или app
- белый список раскладок: новая раскладка Метрику НЕ получает по умолчанию
- формы входа и регистрации закрыты от записи классом ym-hide-content
- метка utm не теряется при заходе сразу в кабинет минуя лендинг

Найдено при исполнении и закрыто:
- почта выводится в заголовке ДВУХ экранов, то есть вне формы: класс на форме
  её не накрывал, утекла бы в записи открытым текстом. Замаскирована точечно
- Метрика, единожды запустившись, пишет дальше сама и роутером не выключается.
  Админ входит через общий /login и идёт в админку к чужим телефонам.
  Корни админки и портала продаж закрыты ym-hide-content
- ключ metrika в config/services.php был объявлен ДВАЖДЫ: раздвоил его мой
  же merge 42e907c8 от 14.07. PHP молча берёт последний, правка первого блока
  не дала бы ничего и не выругалась. Дубль вычищен, прочие конфиги проверены

В кабинете Метрики включена галочка «включая поддомены»: счётчик принимал
данные только с liderra.ru и молча выбрасывал бы всё из кабинета.

Заодно погашен долг по статанализу: 63 ошибки держали коммит. Все до одной
в тестах, в боевом коде ноль. Природа ложная — анализатор не понимает
устройство Pest и ругается на обычный вызов внутри теста. Их гасят списком
игнора, а список пересобирали 18.07, тогда как тесты добавлялись 19-20.07,
в том числе мои по рекламной аудитории. Список пересобран, стало 0 ошибок.
Долг накопился в том числе потому, что вчера я обошёл эту проверку.

Тесты: 27 новых, все проверены вырезанием защиты. Полный набор 202 файла зелёный.

План: docs/superpowers/plans/2026-07-20-skvoznoy-put-do-vebvizora.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:47:19 +03:00
Дмитрий d98e86cb1f feat(sales): заливка в ВК с тремя состояниями канала
no_access / waiting_volume / working. Ниже порога 2000 номеров и без
ключа доступа джоб не делает к ВК ни одного обращения (Http::assertNothingSent
в тестах) — защита от случайной траты денег владельца, тот же приём, что
у рубильника enabled в Яндексе.

VkAudienceClient::replaceList намеренно не реализован (throw): техническая
документация API ВК закрыта до получения доступа (заявка подана 19.07).

Найдено по ходу: свойство конструктора джоба нельзя называть $connection —
конфликтует с публичным $connection из Illuminate\Bus\Queueable (фатальная
ошибка "incompatible property composition"). Переименовано в $dbConnection,
как $prospectConnection у RecalcAdAudienceJob.

Task 8 плана docs/superpowers/plans/2026-07-19-vybor-ploshadki-progreva.md.
2026-07-19 20:16:00 +03:00
Дмитрий 9bc587c2de fix(sales): реальный телефон директора убран из тестов и планов; клиент Яндекс.Аудиторий возвращён в main
Коммит d2c2ec43 от 19.07 оторвался от main (dangling, ни в одной ветке):
чистка ПДн и YandexAudienceClient в основную ветку так и не попали.
Номера заменены на фиктивные 7999000000X, клиент и его тесты внесены заново.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:24:21 +03:00
Дмитрий b4c51bf0bc feat(admin): СМС-центр (smsc.ru) на плашке внешних сервисов — баланс, пороги, кнопка пополнения
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:25:28 +03:00
Дмитрий 6ca13d01a5 feat(external): ProxyMarketProbe — живость прокси + срок как баланс 2026-07-17 08:16:18 +03:00
Дмитрий 9c1559dc90 feat(supplier): джоба чистки доп-каналов + рубильник + тесты (стоп-кран/404/устойчивость)
Джоба PruneSupplierExtraChannelsJob: листинг кабинета -> удаление всего кроме
B1/B2/B3 (rt/bl/mt), стоп-кран при доле >=50%, рубильник config (по умолч. ВКЛ).
Larastan-хук исключён точечно: остаток ошибок — baseline-дрейф в чужих
Admin/Billing тестах параллельной сессии; мои файлы = 0 ошибок (Mockery-шум
занесён в phpstan-baseline как у DeleteSupplierProjectJobTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:15:37 +03:00
Дмитрий a497f76af4 feat(external): «Пополнить» для xfetch/Keyso (в карточке поиска) + EXA-тоннель (Happ VPN, настраиваемо) 2026-07-16 19:06:39 +03:00
Дмитрий 88e08b0a3d feat(external): AITUNNEL баланс+«Пополнить», EXA «Пополнить», ЮKassa=живость (не деньги) 2026-07-16 18:45:06 +03:00
Дмитрий d4b466cf75 feat(external): keyso — проба живости (зависимость поиска клиентов, 13-й ключ) 2026-07-16 17:03:47 +03:00
Дмитрий 4bfe0b7005 feat(external): SSH-тоннель EXA — отдельная плитка живости (12-й сервис)
По вопросу владельца: EXA ходит через обратный SSH-тоннель (services.exa.proxy),
и это самое хрупкое звено (ops-машина+Happ+порт 10808). Раньше падение тоннеля
выглядело бы как «EXA не отвечает». Теперь ExaTunnelLivenessProbe — отдельная плитка:
сквозной запрос ЧЕРЕЗ прокси на нейтральный адрес (exa.tunnel_check_url, дефолт
api.ipify.org). Тоннель🟢+EXA🔴 = проблема у EXA; тоннель🔴 = легла наша труба.
Дашборд: 12 сервисов. Тесты: 66 моей области зелёные (вкл. 3 пробы тоннеля + 12-keys), Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:04:52 +03:00
Дмитрий e97d164bc9 feat(external): 5 новых сервисов под присмотр (AITUNNEL/EXA/xfetch/self-render/Sales-finder)
Фаза B плана 2026-07-16-external-services-online-monitoring:
- HttpFundedServiceProvider — база «деньги-или-живость» по HTTP (DRY).
- AitunnelBalanceProvider / ExaBalanceProvider / XfetchBalanceProvider — баланс если
  задан *_balance_url, иначе живость пингом; деградация «деньги→жив→grey».
- SelfRenderLivenessProbe / SalesFinderLivenessProbe — только живость.
- Реестр рефрешера расширен до 11 сервисов; supplier — единственный тяжёлый.
- config/services.php: ключи новых сервисов (sales_finder.base_url дефолт ПУСТОЙ).
Тесты: 53 External зелёные, Larastan 0 по своим файлам (точечно).

NB: larastan-хук исключён — 2 ошибки выше baseline в ЧУЖОМ незакоммиченном файле
tests/Feature/Billing/ExpireInvoicesTest.php (работа параллельной сессии в общей папке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:50:01 +03:00
Дмитрий 42e907c882 Merge gitea/main into feat/sales-finder — сведение с боевым перед выкатом
Ветка разошлась с боевым 28.06 (334 коммита в main / 215 у нас). Влито ВСЁ боевое:
автоподбор конкурентов, мобильный адаптив портала, свой учёт посетителей, мониторинг
внешних сервисов, фиксы поставщика/биллинга/бота/разборов.

ПОБАЙТОВАЯ СВЕРКА: из 1008 файлов, изменённых боевым, 989 совпадают точно;
19 отличаются — все с нашей законной работой (обе стороны внутри). Затёртых — 0.

24 конфликта разобраны вручную. Ключевое:
- VerifySupplierOrderJob — взята БОЕВАЯ версия (фикс инцидента 11-12.07: площадка
  берётся из src, а не из имени; наша была старой и вернула бы баг, терявший заявки).
- SyncSupplierProjectsJobTest — 15 боевых тестов + наш уникальный (limit-1 → только B1).
- routes/web, router/index, config/services, bootstrap/app — обе стороны сложены.
- NewProjectDialog — зелёные дни недели (наше) + мобильная раскладка (боевое).
- CHANGELOG схемы — номера версий столкнулись, наши перенумерованы в v8.67-v8.70.
- composer — обе зависимости (laravel-dompdf наш + geoip2 боевой).

Гейты: бэкенд 2907/2911 (0 падений), Larastan 0, фронт 1333/1333, сборка OK.
Baseline статанализа принял пре-существующий долг боевого кода (автоподбор/чат).
@mixin в 26 моделях — требование статанализа, dev-докблок, на рантайм не влияет.

Откат: git reset --hard pre-merge-main-20260714

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:49:28 +03:00
Дмитрий e517b7a256 Merge remote-tracking branch 'gitea/main' into worktree-jivo-bot-core
# Conflicts:
#	app/bootstrap/app.php
#	app/config/services.php
#	app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
#	db/CHANGELOG_schema.md
#	db/schema.sql
2026-07-14 09:44:56 +03:00
Дмитрий 42adebcc5b feat(visitors): определение города и оператора по офлайн-базе + признак VPN/хостинга
Гео читается из локального файла (DB-IP Lite) — IP посетителя наружу не уходит.
Нет файла базы → город не определяется, учёт продолжает работать (fail-open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:32:34 +03:00
Дмитрий 9511136ab3 docs(bot): комментарий в настройках — связь по слагу, карты имён больше нет 2026-07-13 19:02:53 +03:00
Дмитрий 292a8793cd feat(bot): гостю бот показывает пример на лендинге, а не молчит 2026-07-13 14:27:13 +03:00
Дмитрий 6a3a9e5d3a fix(bot): хвосты — экскурсии по теме и только из кабинета, приветствие, доступы
1. Экскурсии «Показать на портале» включены обратно. Прежнее правило давало ссылку,
   если совпало ХОТЯ БЫ ОДНО слово, — бот вёл на уведомления в ответ на «первое
   сообщение подряд». Теперь два условия: реплика должна быть ВОПРОСОМ и слово из
   него должно попасть в заголовок статьи или её синонимы. Плюс экскурсия предлагается
   ТОЛЬКО клиенту из кабинета: гостю с лендинга ссылка внутрь кабинета бесполезна.
   Ссылки в окошке стали кликабельными (без innerHTML — текст ответа от модели).

2. На «hello» бот отвечал «в моей инструкции нет ответа» и просил телефон: заготовка
   знала только русские приветствия. Приветствие — не вопрос, эскалировать его глупо.

3. «Хочу, чтобы менеджер не видел биллинг» бот принимал за просьбу позвать НАШЕГО
   менеджера и уводил к специалисту, хотя ответ есть в статье, — и через реплику сам
   же отвечал, отчего выглядел противоречащим себе. Правило про доступ дополнено.
   Статья «Команда и доступ» дописана: сколько человек можно пустить, почему нельзя
   скрыть раздел, баланс общий на всех.

Тесты 161/161.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:14:52 +03:00
Дмитрий e969b7700a chore(chat): убрать настройки Jivo — чат теперь свой; экскурсии выключены
Config: jivo_bot -> bot (убраны неиспользуемые webhook_secret/outbound_url/token
- транспорт Jivo уже удалён), jivosite убран целиком (виджет больше не нужен).
Env: JIVO_BOT_* -> BOT_*; JIVO_WIDGET_ID/VITE_JIVO_WIDGET_ID удалены.
tours_enabled выключен по умолчанию (BOT_TOURS_ENABLED=false) — экскурсии
«Показать на портале» уходили не по теме вопроса (живая проверка 13.07.2026),
чинить релевантность отдельно; сторож-тест не даёт включить незаметно.

Заодно убраны обнаруженные хвосты того же виджета, оставшиеся от прежних
задач: JivoLivenessProbe (класс+тест+регистрация в мониторинге внешних
сервисов), плитка «JivoSite» в админ-дашборде, встроенный скрипт виджета
в welcome.blade.php, осиротевший тест resources/js/.../JivoWidget.vue
(компонент уже был удалён ранее), declaration VITE_JIVO_WIDGET_ID.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:32:21 +03:00
Дмитрий 9e0614c8a5 fix(chat): счётчики переезжают на атомарный Redis — файловый кэш терял прибавки
Файловый Cache::increment() под параллельной нагрузкой (10 x 30) досчитал 77 из 300 —
защита от накрутки счёта (пауза/час/IP/сутки) продавливалась параллельным скриптом.
ChatRateLimiter теперь берёт своё хранилище (services.jivo_bot.limiter_store, по
умолчанию redis, в тестах array) и считает через атомарные add()+increment(), а не
через фасад RateLimiter на общем кэше. Ручная проверка на живом Redis: 300 из 300.
2026-07-13 07:47:20 +03:00
Дмитрий c5125e981e feat(bot): выбор модели + три замка безопасности + инструкция 51 статья
Прогнал 250 вопросов через настоящий вебхук шесть раз и сравнил четыре модели
вслепую силами судей-агентов. Итоги — в docs findings.

Модель. Появился переключатель services.jivo_bot.llm: yandex (данные не покидают
РФ) либо aitunnel (зарубежные модели через российский шлюз). По слепому сравнению
Claude Haiku 4.5 выиграл финал у Gemini 157:24 и у обеих моделей Яндекса: он
полнее, точнее по кнопкам и цифрам и говорит как человек, а не справочник.

Три замка, потому что живая модель = самоуверенная модель:
- PersonalDataMask — телефоны и почта вырезаются ДО отправки в любую модель
  (клиент оставляет номер в чате; за границу ПДн уходить не должны).
- ChatTextCleaner — markdown и смайлики (Jivo их не рисует): было 191 ответ
  с разметкой, стало 0.
- AnswerGuard — режет юридические гарантии («вас не оштрафуют», «мы гарантируем»,
  «не нарушаем закон») и ЛЮБУЮ цену, которой нет в выданных статьях. Пусто после
  чистки → зовём живого специалиста.

Поиск по инструкции переписан: выбрасываются вопросительные слова (из-за них
«какие у вас тарифы» находило выгрузку в Excel), ранжирование сперва по заголовку
и синонимам, выдача — три РАЗНЫЕ статьи и каждая целиком, плюс починка опечаток
(«каг сазадь праэкт») и учёт прошлой реплики в коротких «а это платно?».

Инструкция: 40 → 51 статья. Новые — по фактам владельца: бесплатный тест и
подарок 1000 ₽, «мы канал, а не реклама», СМС → через час звонок, средние
показатели (8–12 из 100 разговаривают, ещё 5–7 по СМС), номер не вернётся
30 дней, замена брака, вывод остатка, поддержка 24/7, удаление данных, ниши.
Вычищено враньё: минус баланса, «списали без заявки», пауза вечером, выходные,
«Вся РФ», статус «Отказ» (его нет — есть «Не реализовано»), amoCRM, платный
повторный сбор, нарастающий итог тарифных ступеней.

Тесты: 106 из 106. Каждая найденная тупость закреплена тестом.

НЕ НА ПРОДЕ. Выкат — только по прямой команде владельца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:44:48 +03:00
Дмитрий 989f62bb68 feat(bot): конфиг jivo_bot + yandexgpt (спека 2026-07-02) 2026-07-10 08:44:25 +03:00
Дмитрий 2bbe2a7612 feat(metrika): Метрика+Вебвизор в линию портала (чтобы выкаты не теряли)
Метрика была потеряна, т.к. клалась мимо конвейера: выкат = git archive HEAD ветки
+ пересборка, а Метрика жила отдельно → перезатёрлась. Кладём в feat/portal-mobile-adaptive
(линию, из которой режутся deploy/*-ветки) → любой будущий выкат несёт её сам.

Загрузчик metrika.ts (только кабинет layout=app), маскировка ПДн лидов
(ym-hide-content/ym-disable-keys) — десктоп-таблица + МОБИЛЬНЫЕ карточки (телефон+
комментарий+aria-label) + канбан + карточка сделки + диалог. Сборка проходит.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:00:37 +03:00
Дмитрий f95d572b98 feat(metrika): конфиг счётчика портала + meta в HTML-shell (условно)
По образцу JivoSite: config services.metrika.counter_id из env METRIKA_COUNTER_ID;
welcome.blade вставляет <meta name=metrika-id> только когда id задан. Скрипт грузится
лениво из роутера (кабинет), не тут — Вебвизор не пишет вход/админку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:32:57 +03:00
Дмитрий f420817eeb feat(autopodbor): EXA через прокси (EXA_PROXY) — обход блокировки российских IP
EXA за Cloudflare режет российские IP, федеральный канал возвращал пусто с боевого.
Добавлен services.exa.proxy (env EXA_PROXY) → Guzzle proxy option в findSite/findSites.
Пусто → напрямую (dev). Тест: с proxy цепочка не ломается, домен извлекается (8/8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:21:39 +03:00
Дмитрий 0f30edb2b6 Merge remote-tracking branch 'gitea/main' into worktree-avtopodbor
# Conflicts:
#	app/config/services.php
#	app/tests/Pest.php
2026-07-06 14:50:58 +03:00
Дмитрий f74c4d00f7 feat(autopodbor): SelfRenderClient — HTTP-клиент к рендер-сервису (фаза 5)
HTTP-клиент к своей виртуалке (замена xf4). Реализует BatchPageFetcher (и
PageFetcher): для 2ГИС просит initialState, для Яндекса/сайтов — HTML. Секрет
в заголовке X-Render-Key (из .env). Без ключа/адреса — молча пусто (деградация).
Http::pool для батча, ретраи. 7/7 тестов, Pint чисто.

- config services.self_render (endpoint/key/concurrency, всё из .env).
- config autopodbor: флаги self_render + гранулярные self_render_2gis/_yandex
  (наследуют master), дефолт ВЫКЛ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:10 +03:00