Проверка на боевом 14.07.2026 (сразу после выката фикса цен): гость — человек не вошёл,
никаких заявок у него нет — показал скриншот с 50 ₽, и бот ответил: «Вы сейчас на второй
ступени (50 ₽), потому что в этом месяце уже получили заявки». Выдумка про человека.
Причина: сторож сверяет личные цифры только у ВОШЕДШЕГО (карточка фактов). У гостя
карточки нет — и модель фантазировала свободно.
Теперь AnswerGuard знает, посчитаны ли личные цифры собеседника. Если нет (гость) —
режет фразы, утверждающие его нынешнее состояние: «вы сейчас на… ступени», «у вас
на балансе N», «вы уже получили заявки». Общие объяснения не трогаются: «вы платите
только за полученные заявки», «когда наберёте объём — перейдёте на следующую ступень».
Тесты бота: 242/242 (+2 новых).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Живой разговор на боевом 14.07.2026: гость спросил цену, бот назвал «500 ₽ за первую
заявку месяца» (такого тарифа нет — сетка 55→25 ₽), клиент поймал на вранье и ушёл.
Три поломки из одного разговора:
1. ЦЕНЫ. В pricing_tiers лежат ЧЕТЫРЕ версии сетки (старые хранятся с датой начала
действия). Портал берёт свежую через PricingTierRepository, а LivePrices читал
таблицу напрямую и склеивал все версии: «ступень 1 — 500 ₽; ступень 1 — 70 ₽;
ступень 1 — 55 ₽…». Сторож вранья был бессилен — 500 ₽ и правда лежало в поданных
модели статьях. Теперь сетку берём тем же способом, что и «Биллинг» в кабинете.
2. ССЫЛКИ МИМО ТЕМЫ. У статьи «Собрать источники — цена, очередь…» слово «цена» стоит
в заголовке, а в синонимах «50 рублей» — она перебивала «Тарифы» на любом денежном
вопросе. Теперь берём САМУЮ совпавшую статью и только при уверенном совпадении
(совпавшие слова покрывают хотя бы половину вопроса); на коротком уточнении новую
ссылку не подсовываем, если человек уже получил одну.
3. ОТВЕТ С СЕРЕДИНЫ ФРАЗЫ. «Но если вы хотите понять…» — клиент решил, что ему хамят.
Висящий союз снимался ДО того, как выбрасывалась отговорка «в инструкции этого нет».
Порядок исправлен (AnswerGuard::polishStart).
Плюс по решению владельца: на «сколько стоит заявка?» бот сразу называет вилку
«от 55 ₽ за заявку до 25 ₽ при большом объёме» (метка {{вилка}} из живой сетки).
Тесты бота: 240/240, из них 6 новых — воспроизводят тот разговор и падали на старом коде.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 файлов (11 МБ) в app/public/guides: 127 кадров, index.json, page.js.
Раньше лежали только на сервере — при развёртывании с чистого места страница
«Как это работает» осталась бы без картинок. Сверены с боевым: совпадают.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CSP на боевом (script-src self, без unsafe-inline) блокировал встроенный <script>
страницы — она грузилась пустой, без единой темы. Скрипт вынесен в отдельный файл
/guides/page.js: защиту не ослабляем, nginx не трогаем. На бою проверено: 20 тем,
кадры открываются, ошибок в консоли ноль.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Лимиты гостя по решению владельца: разговорился — пусть говорит (60 вопросов в
первый час), но если завис в чате на весь день — тормозим до 30 в час, иначе один
посетитель съест дневной бюджет. Потолок одного разговора поднят 40 → 150 (иначе
«60 в час» упиралось бы в обрыв разговора).
Страница разборов «Как это работает» отдаётся самим порталом по адресу
/kak-eto-rabotaet: на боевом nginx отдаёт лендинг только по «/», все остальные пути
уходят в портал — значит, конфиги сервера трогать не нужно.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ключевое для будущих сессий:
- клиенты доходят до страницы ЮKassa и НЕ выбирают способ оплаты — уходят в счёт
(страница исправна; 2 успешных платежа по 100₽ были — прежняя запись «успешных
оплат не было» устарела);
- отмена платежа теперь закрывается (webhook + сверка каждые 5 мин, страхует от
потери денег при недошедшем webhook);
- 2 мины: RLS в ветке отмены (тесты слепы под postgres) и LOG_LEVEL=warning
(Log::info на проде не пишется вовсе);
- грабли выката: route:cache/route:clear под www-data и CRLF от Windows-tar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Разбор живого клиента (стоматология, Красноярск, 14.07): 2 часа настраивал 26
проектов, получил 14 отказов в трёх формах и ушёл, не заплатив.
Деньги:
- отменённый шлюзом платёж больше не висит «ожидает» вечно: закрываем как failed
с причиной (PaymentSettlementService — общий путь для webhook и крона);
- billing:reconcile-payments каждые 5 минут сам спрашивает шлюз про зависшие
pending. Побочно страхует от ПОТЕРИ ДЕНЕГ: если webhook не дойдёт, оплаченный
платёж всё равно зачислится;
- кабинет говорит правду: «Оплата не завершена» + «Оплатить снова» вместо
«баланс обновится автоматически» (GET /api/billing/last-payment).
🔴 RLS-мина (поймана валидатором ДО выката): UPDATE при отмене шёл без
tenant-контекста → на проде тронул бы 0 строк, а портал рапортовал бы «отменено».
Тесты слепы (тестовая БД под postgres). Регресс-тест проверяет ПОРЯДОК:
SET LOCAL tenant ДО UPDATE. Тот же класс, что инциденты 07.07 и 12.07.
Формы (клиент бился и уходил):
- удаление проекта со сделками: причина показывается на месте + кнопка
«Поставить на паузу» (раньше 422 улетал в никуда — 4 попытки впустую);
- создание проекта: ошибка по дням недели больше не молчит (у поля не было
места для показа — 2 немых отказа);
- автоподбор «Добавить вручную»: показываем причину от сервера (был голый
catch {}), длинные ссылки 2ГИС/Яндекс.Карт принимаются — трекинг-хвост срезаем
сами. Воспроизведено тестом: именно длинная ссылка давала 3 отказа подряд.
Наблюдаемость: причины отказов пишутся в журнал (маршрут, tenant, ИМЕНА полей;
значений нет — 152-ФЗ). Уровень warning: на проде LOG_LEVEL=warning, info в
журнал не попадает вовсе. Робот-сверщик добавлен в реестр пульса.
Тесты: Pest 2475/2475, Vitest 1215/1215.
Выкачено на боевой 14.07.2026 ~13:00 МСК; сверка сразу закрыла 3 мёртвых платежа
(10 000 ₽, 5 000 ₽, 1 000 ₽).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Прод-инцидент 14.07.2026. В batch-режиме портал при создании проекта слал поставщику
«каркас» с limit=0 и без регионов. Кабинет такой запрос ОТБИВАЕТ ВСЕГДА — снято живьём
с боевого 14.07:
POST /admin/visit/rt-project-save
{"status":"Error","message":"Введите limit!"}
Дальше портал считал отказ поломкой: дёргал запасной путь через браузер, тот тоже падал,
и проект уезжал в ручную очередь. Итог на бою: 114 неразобранных записей и 2 ложных
high-инцидента «похоже, кабинет поставщика упал» (08.07 и 14.07). Кабинет при этом жив —
проверено запросом с боевого: отдаёт 140 проектов, сессия рабочая.
Лиды и деньги при этом НЕ терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob
(18:00 МСК) — уже с посчитанными лимитами и регионами. Так доехали 19/19 (07.07), 25/26
(08.07), 1/1 (10.07); «недоехавший» проект №20 у поставщика на деле есть (3 строки,
включены, лимит 1+1+1 = заказ клиента) — пусты лишь поля-ссылки в карточке.
Что сделано: handleBatch больше не ходит к поставщику при создании — слать нечего, дневной
лимит считается на cut-off, а не в момент создания. Идемпотентная привязка уже существующих
строк сохранена. Слать limit>0, чтобы кабинет «принял», НЕЛЬЗЯ: у каркаса нет регионов, и
включённая строка потянет лиды со всей страны за деньги клиента.
Тесты: batch-путь переписан под новое правило (поставщик не зовётся, ручная очередь пуста);
разбор проекта на площадки (site/call → B1+B2+B3, sms+keyword → B2+B3, sms → B3) вынесен в
прямые проверки SupplierProjectGrouping — раньше он проверялся через вызовы createProject.
Прогон: 2453/2458 (единственное падение — ExampleTest/Vite manifest, окружение свежего
worktree, к правке отношения не имеет), phpstan 0, pint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
В «Отчётах» клиенту показывали «retry-failed для owner отчёта», «3 попыток
retry» и «Готовится асинхронно». В «Конкурентном поле» подсказка звала
элементы «подкомпаниями / ЖК» (чужая ниша). Переписано по-человечески:
перезапустить до 3 раз за 7 дней, готовится в фоне, элементы — филиалы или
отдельные бренды.
Тест на баннер отчётов теперь запрещает слово «retry» в интерфейсе.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
«Объединено в Белый Зуб14.07.2026» — между названием и датой не было разделителя.
Подсказка поля «Название элемента» звала пример из чужой ниши («ЖК Нанжуль-
Солнечный») — заменена на нейтральную.
Демо-стенд: лента «Активность» в карточке заявки была пустой (событий у демо-заявок
не было вовсе), журнал кошелька копил кривую хронологию прошлых прогонов — списания
стояли раньше пополнений. Журнал денег защищён от подделки (только дописывать), поэтому
сброс демо-журнала делается ЯВНО и только на локальной машине.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Запрос активности портала просил у tenants колонку name, которой не существует:
в схеме она называется organization_name. База отвечала 42703 Undefined column,
фронт показывал красную плашку «Не удалось загрузить данные» поверх страницы
/admin/visitors. Остальные три запроса страницы работали, поэтому карточки
рисовались пустыми, а не сломанными.
Наружу поле по-прежнему отдаётся как name — контракт API и фронт не менялись.
Добавлен тест на portal-эндпоинт. Из четырёх запросов страницы тестами были
покрыты три; единственный непокрытый и оказался сломанным — тот же класс потери,
что CsvReconcileJobTest: код едет, тест нет.
Проверки: AdminVisitors 5/5, смежные Tracking 14/14, Pint чисто, Larastan 0 ошибок.
Прод не тронут — выката не было.
Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
Окошко чата: уголок для растягивания и кнопка «развернуть», размер
запоминается — длинный ответ в маленьком окне читать неудобно. Само
открывается только там, где страница просит (лендинг): на странице входа
оно распахивалось и закрывало кнопку «Войти».
Демо-стенд (demo:showcase) — цены как на боевом: 55 ₽ за заявку, сетка
55→25 ₽, сбор конкурентов 50 ₽, сбор источников 10 ₽. Кошелёк теперь
сходится: пополнено 12 400 ₽ − 25 заявок × 55 ₽ = 11 025 ₽, пополнения
датированы раньше списаний. Поле конкурентов пересобирается начисто (опыты
со склейкой ломали порядок карточек), отчёты чистятся (портал держит не
больше трёх в работе и гасит кнопку).
Журнал денег переписать нельзя — только дописать (защита от подделки),
поэтому firstOrCreate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Два бага, которых не видел ни один тест — их нашёл робот, снимавший инструкции
для гостей.
1) Карточка остановленного проекта показывала одновременно «На паузе» и зелёную
плашку «Собирает заявки»: плашка смотрела только на связь с поставщиком (там
всё «ок») и про паузу не знала. Теперь серым: «Остановлен вами — заявки не
приходят».
2) В «Канбане» сумма в шапке колонки всегда была прочерком: считалась по старому
демо-полю, которого у живых заявок нет (там ноль), хотя на карточках стояла
честная цена. Теперь считает по настоящему списанию.
Тесты: сначала падающие, потом фикс. Весь фронт 1229 зелёных.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Прод-инцидент 14.07.2026. Пять писем (заморозка, напоминание, финальное,
разморозка, «проект остановлен — нет денег») уезжали в очередь с Eloquent-моделью
Tenant. SerializesModels заменяет модель на id, а воркер грузит её заново — под
ролью crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id
отдаёт 0 строк → ModelNotFoundException. Клиент №7 заморожен с 12.07 и не получил
ни одного письма; на проде это ломало письма о заморозке для ВСЕХ клиентов.
Письма больше не ходят в БД при отправке: несут снимок данных (без SerializesModels).
Заодно: сторож incidents:watch-failures плодил копию persistent-инцидента каждый час
(строка в failed_jobs живёт вечно, а дедуп был окном в 60 мин) — 2 залипшие ошибки
дали 31 запись за сутки и красную лампу «Очереди/джобы». Дедуп persistent теперь по
факту незакрытого инцидента, а не по возрасту последней копии.
Регрессия: BalanceMailsQueueRestoreTest (6 кейсов) + 2 теста сторожа.
Прогон: 165/165 billing+incidents, phpstan 0, pint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
В смс дорог каждый символ: вместо длинного хвоста с метками сервер сам
подставляет канал и дату рассылки (дд-мм), чтобы разные рассылки не слипались.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Робот/человек по исполнению JS, кука-склейка лендинг↔кабинет, раздел
«Посетители» в админке, гео офлайн, $host в логах nginx. Отдельно записаны
грабли: перекрытие партиций (42P17), обрыв SSH на migrate, исходник лендинга
живёт в feat/sales-finder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Гостю на лендинге окошко распахивается через секунду после захода: он
пришёл смотреть, а не искать кнопку чата. Вошедшему в кабинет — нет, он
работает. Приветствие и быстрые вопросы теперь разные: гостю — с улыбкой
и про продукт («как получить телефоны тех, кто был у конкурента»),
клиенту — деловое.
Фикс: тень окошка на «дымном» фоне лендинга рисовалась сплошным ЧЁРНЫМ
прямоугольником — панели дан свой слой (translateZ), тень снова мягкая.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Полный прогон вскрыл реальный баг: миграция создавала партицию site_events_2026_07
своими границами (в местном времени), а MonthlyPartitionManager — site_events_y2026_m07
в UTC. Партиции перекрывались (42P17) и роняли 19 ЧУЖИХ тестов. Теперь партиции
нарезает только менеджер (ensureRange), как у всех остальных таблиц.
Плюс сторож схемы обновлён под +2 таблицы учёта (80 таблиц, 141 индекс) и
записаны готовые ссылки с метками для смс/hh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Гостей чистит visitors:prune (воскресенье 03:15 МСК), партиции событий —
существующий partitions:drop-expired по новой настройке retention=6 месяцев.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Воронка подсвечивает красным шаг, где теряем больше всего людей. Гости с VPN
и хостингов показаны отдельным числом, а не подмешаны в конверсию. Город при
VPN честно помечается как недостоверный.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Воронка — по уникальным живым гостям (is_datacenter=false); гости с хостингов
и VPN считаются отдельным числом, в конверсию не попадают.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Разбор докладной 13.07.2026: AutopodborController::restoreMergeEvent воскрешал
только карточку поглощённого конкурента (имя/сайт/справочники/телефоны/is_federal),
но не его autopodbor_sources — и если у источника был created_project_id (по нему
идут заявки/деньги), проект оставался приклеен к выжившей карточке под её именем.
Добавлен снимок источников поглощённых конкурентов: nullable-колонка
autopodbor_merge_events.absorbed_sources (миграция 2026_07_14_090000, идемпотентна),
заполняется AutopodborCompetitorMerger::snapshotAbsorbedSources ДО переноса/
переименования при слиянии (включая имя проекта на момент склейки). restoreMergeEvent
по этому снимку либо перевешивает исходную строку источника обратно на воскрешённую
карточку, либо пересоздаёт её (если строка была удалена при разрешении коллизии
dedup_key), и откатывает имя проекта.
Старые записи журнала (до миграции) снимка не имеют — восстанавливаются как раньше
(только карточка), ответ API помечается 'partial' => true с честным сообщением.
db/schema.sql v8.65 + db/CHANGELOG_schema.md — запись синхронизирована.
tests/Feature/Autopodbor/ + tests/Feature/Bot/ — 417/417 после migrate:fresh.
Живая проверка на локальном стенде через Playwright (создание проекта → слияние →
«Вернуть» → проект и источник вернулись на воскрешённую карточку) — подтверждено.
Ветка worktree-jivo-bot-core, НЕ на боевом проде — выкат отдельным решением владельца.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Метрику и Вебвизор не трогает — работает параллельно. Исходник лендинга взят
из feat/sales-finder (в боевую ветку он никогда не попадал); при выкате маячок
вставляется в БОЕВУЮ версию файла, чтобы не тащить на прод незавершённую стройку.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Три точки: обычный вход, вход через 2FA (оба пути — код и резервный),
подтверждение почты (именно там клиент реально создаётся, а не на /register).
Учёт в try/catch — ошибка учёта не может уронить вход. Регресс авторизации 114/114.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 4 плана 2026-07-13-visitors-analytics.md: VisitTracker (создание/обновление
гостя, запись событий, привязка к клиенту) + публичный TrackController@store,
маршрут POST /api/track (throttle:track 60/мин), лимитер в AppServiceProvider,
CSRF-исключение api/track в bootstrap/app.php.
Отклонения от плана (тест-харнесс, не прод-код):
- getCookie('lid_vid', false) в тесте убрал decrypt=false — с withCookie() это
давало двойное шифрование (withCookie сам шифрует plain-значение).
- добавлен withCredentials() перед withCookie()+postJson/getJson — Laravel
тест-клиент по умолчанию не шлёт cookie на JSON-запросы (как XHR
credentials:'omit'); реальный маячок шлёт fetch с credentials:'include',
так что прод не затронут.
Pest: 5/5 (TrackEndpointTest). Pint: app/Services/Tracking + TrackController.
Гео читается из локального файла (DB-IP Lite) — IP посетителя наружу не уходит.
Нет файла базы → город не определяется, учёт продолжает работать (fail-open).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Точное разделение робот/браузер/человек (отклик страницы, а не запрос),
сквозная склейка лендинг→портал через куку на .liderra.ru, воронка и каналы
в админке. Метрику и Вебвизор не трогаем.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ревьюер прогнал AnswerGuard руками: пропускал числа словами («два проекта»),
разрыв «число...единица» («20 в день»), чужие единицы (сделок/клиент/сутки/...)
и самое опасное — число, которое в карточке ЕСТЬ, но относится к ДРУГОМУ факту
(модель переставила местами). Карточку (ClientFacts) генерирует код с
предсказуемым форматом, поэтому сторож теперь разбирает её на пары
«факт → правильное число» (баланс, цена ступени, получено/до след. ступени,
кол-во проектов, заявки сегодня/вчера/7дней/месяц, списано) и режет
несовпадение по смыслу, а не по буквальной формулировке.
TDD: 9 новых тестов (196→195 итого с учётом снятого дубля), 195/195 зелёных.
Стресс-прогон 20 предложений (10 враньё + 10 честных) вручную — 0 расхождений
после фикса дизамбигуации «этот/этом месяц» vs голое «за месяц».
Ключ разговора в localStorage теперь привязан к тому, кто вошёл:
'liderra_chat_id_u<id>' в кабинете, 'liderra_chat_id_guest' на лендинге.
Смена вошедшего (вход/выход/вход под другим) очищает переписку на экране
и заводит новый ключ — иначе следующий человек за тем же компьютером
видел бы баланс и проекты прошлого клиента (окошко долбилось бы в чужой
разговор и получало 404 от серверной проверки владельца).