Commit Graph

684 Commits

Author SHA1 Message Date
Дмитрий 16aa00a0e4 feat(sales): Этап 2 — сервис-канал поиск→портал (managers + ingest)
Портал публикует /api/sales/integration/{managers,prospects} под сервис-токеном
(X-Sales-Token, config sales.integration_token). ingest создаёт карточки stage=new
с полным payload, дедуп по (sales_user_id, inn|phone), assigned_by=начальник.
Гейты: 8/8 Pest, Larastan 0. Финдер-сторона — следующим коммитом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:13:59 +03:00
Дмитрий 2f6e88e784 feat(sales): замечания владельца по воронке — богатая карточка, сортировка созвонов, счётчики фильтра
Три правки после демо Этапа 1 (все по TDD):
1. Карточка показывает ВСЕ данные поиска из payload (тип ProspectPayload 1:1 с
   dataclass Firm; computed infoRows рендерит юрлицо, директора+личный ИНН,
   контакты, бюджет Директа вилкой, каналы/коллтрекинг, оценку). Демо-сидер
   кладёт полный синтетический payload.
2. Начальнику отдаётся manager_counts; фильтр показывает «Имя (N)».
3. Колонка «Переговоры» сортируется по next_call_at ASC (просроченные сверху).

Гейты: бэк 12/12, фронт 19/19, Larastan 0. НЕ выкачено (ждём разрешения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:52:54 +03:00
Дмитрий d6cddf73a6 feat(sales): artisan sales:prospects-demo — демо-карточки воронки
Этап 1 Task 8. По 2 карточки на каждую из 8 стадий у указанного менеджера
(для показа досок). Baseline под artisan() Pest-паттерн.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:35:52 +03:00
Дмитрий 9d8fd8d48c feat(sales): API воронки — список (свои/все+фильтр) + результаты разговора
Этап 1 Task 3–7. GET /api/sales/prospects (менеджер видит свои; начальник —
все + ?manager_id). PATCH /prospects/{id} — переговоры (next_call_at),
недозвон (причина), отказ (причина; запрещён из stage=user). ownership 403.
8 тестов зелёные. Baseline Larastan под Pest-паттерны нового файла.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:33:29 +03:00
Дмитрий 4b7463d1d9 feat(sales): модель SalesProspect + фабрика
Этап 1 Task 2. Дефолт stage=new в $attributes (доступен до refresh).
ide-helper мисин добавлен в локальный стаб (регенерация всех моделей).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:24:53 +03:00
Дмитрий ad50861ed5 fix(sales): тариф не сохранялся — первая ступень «0—1» (from=0) отклонялась валидацией
Форма «Тарифы менеджеров» дефолтит первую ступень с 0 (с момента привязки),
и расчёт вознаграждения (tenure стартует с 1) с from=0 работает верно, но
валидатор params.periods.*.from требовал min:1 → «Сохранить» падало 422
(«Поле params.periods.0.from должно быть не менее 1»). min:1 → min:0.

TDD: тест store с первой ступенью from=0 → 201 (падал 422 до фикса).
Baseline Larastan: actingAs 20→21 (добавлен один тест, квирк Pest+Larastan п.25).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:15:10 +03:00
Дмитрий 3a07cb2059 fix(bot): сторож резал ПРАВДУ про минимальный заказ — «от 1 номера» считал выдумкой
Живая проверка на боевом сразу после выката: «Можно купить всего 5 номеров?» — бот
ответил обрубком «Платите только за то, что реально получите». Две мои же ошибки:

1. Правило про минимальный заказ было слишком грубым и резало ЧЕСТНУЮ формулировку
   «минимальный заказ — от 1 номера». Теперь режем только выдуманный минимум БОЛЬШЕ
   одного («минимальный заказ — 100 заявок», «пакет от 500 штук»).
2. Сторож собирал разрешённые числа ТОЛЬКО цифрами. В статье написано словом («продаём
   любой объём, хоть ОДНУ штуку»), бот ответил цифрой («от 1 номера») — и число 1 сочлось
   выдуманным. Теперь слово-число в материалах разрешает цифру в ответе; добавлены
   падежные формы (одну, одного, двух, трёх).

Живьём: «Можно купить всего 5 номеров?» → «Минимального заказа нет — можно купить хоть
одну заявку, хоть пять, хоть сколько угодно».

Тесты: бот 259/259, весь бэкенд 2924/2928 (0 падений).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:51:13 +03:00
Дмитрий b1b1686b0e fix(bot): прогон 250 вопросов × 2 режима — 11 находок закрыто, все проверены перепрогоном
Владелец: «проверь их обоих на тех 250 вопросах» → «правь все».
Разбор: docs/superpowers/findings/2026-07-14-bot-250-both-modes-run.md

Цены оказались вылечены полностью (0 выдуманных цен на 500 ответов), но вылез другой
пласт — бот путал гостя с вошедшим клиентом и импровизировал там, где правды нет в статьях.

ЧТО ПОЧИНЕНО (было → стало на перепрогоне):
- Гостю приписывали счёт и подарок («у вас уже есть 1000 ₽, они лежат на счёте») 2 → 0.
  У гостя нет ни счёта, ни подарка: он не зарегистрирован.
- Вошедшему называли его баланс подарком портала 2 → 0 и подсовывали сценарий новичка
  («живите на подарок», «создайте первый проект») — подарок он потратил давно.
- Вошедшему врали про состояние проектов («два работают», а работает один) 4 → 0
  и про заявки («сегодня 12», а сегодня 0) 1 → 0.
- «Сотая заявка уже дешевле» 2 → 0: ступень меняется по ОБЪЁМУ (первая — 500 заявок).
- «1000 ₽ хватит на несколько десятков заявок» 1 → 0. По решению владельца число заявок
  на подарок НЕ называем вообще: сколько придёт, зависит от источника.
- «Поднимите лимит — заявок будет больше» 1 → 0: лимит это ПОТОЛОК, а не источник.
- «Такого не бывает» про списание без заявки 1 → 0: на проде фантомные списания БЫЛИ.
- «Для Москвы — Московскую область» 1 → 0: Москва и СПб — самостоятельные субъекты.
- Обрубки фраз после чистки сторожем («Они лежат…», «Нажмите её…») 3 → 0.

ЗАМЕЧАНИЕ ВЛАДЕЛЬЦА ПО ЖИВОМУ ЛОГУ: «номера это и есть заявки, а он их делит».
Фраза «мы не продаём номера поштучно» была ВЫДУМКОЙ бота — в статьях её нет.
Теперь: номер = заявка = лид = контакт, продаём любой объём (хоть одну штуку),
минимального заказа нет; спросили цену — бот называет цифру ПЕРВОЙ фразой.
Живьём: «Сколько стоит один номер?» → «Заявка стоит от 55 ₽ до 25 ₽».

ПРАВДА ПРО ВОЗВРАТ ДЕНЕГ вынесена в статью vozvrat-deneg.md — дословно из «Политики
возврата» (/legal/refund): остаток по заявлению на почту, до 3 рабочих дней, за вычетом
комиссий; стоимость переданных заявок не возвращается. Раньше бот придумывал процедуру.

Плюс: сторож больше не считает выдумкой число, названное САМИМ клиентом («можно купить
всего 5 номеров?»); демо-стенд перестал сам себе противоречить (счётчик проекта «сегодня»
разошёлся с числом сделок); добавлен прогонщик bot:run-questions для таких проверок.

Тесты: бот 258/258, весь бэкенд 2738/2744 (0 падений). Сторож не перестарался: из 250
старых одобренных ответов режет целиком 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:32:40 +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
Дмитрий a2bd2dbc8b fix(bot): гостю больше нельзя приписывать его ступень, баланс и заявки
Проверка на боевом 14.07.2026 (сразу после выката фикса цен): гость — человек не вошёл,
никаких заявок у него нет — показал скриншот с 50 ₽, и бот ответил: «Вы сейчас на второй
ступени (50 ₽), потому что в этом месяце уже получили заявки». Выдумка про человека.

Причина: сторож сверяет личные цифры только у ВОШЕДШЕГО (карточка фактов). У гостя
карточки нет — и модель фантазировала свободно.

Теперь AnswerGuard знает, посчитаны ли личные цифры собеседника. Если нет (гость) —
режет фразы, утверждающие его нынешнее состояние: «вы сейчас на… ступени», «у вас
на балансе N», «вы уже получили заявки». Общие объяснения не трогаются: «вы платите
только за полученные заявки», «когда наберёте объём — перейдёте на следующую ступень».

Тесты бота: 242/242 (+2 новых).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:49:53 +03:00
Дмитрий 7b7718d319 fix(bot): бот врал про цены живым гостям — брал ВСЕ версии сетки тарифов разом
Живой разговор на боевом 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>
2026-07-14 15:32:15 +03:00
Дмитрий 27bf64a885 feat(bot): гостю 60 вопросов в первый час, дальше 30 + страница разборов через портал
Лимиты гостя по решению владельца: разговорился — пусть говорит (60 вопросов в
первый час), но если завис в чате на весь день — тормозим до 30 в час, иначе один
посетитель съест дневной бюджет. Потолок одного разговора поднят 40 → 150 (иначе
«60 в час» упиралось бы в обрыв разговора).

Страница разборов «Как это работает» отдаётся самим порталом по адресу
/kak-eto-rabotaet: на боевом nginx отдаёт лендинг только по «/», все остальные пути
уходят в портал — значит, конфиги сервера трогать не нужно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:46:15 +03:00
Дмитрий 9233bf9960 Merge remote-tracking branch 'gitea/main' into worktree-jivo-bot-core
# Conflicts:
#	app/routes/console.php
2026-07-14 13:27:46 +03:00
Дмитрий c0487ddaa5 fix(billing,ux): портал видит отмену платежа и перестаёт молчать в формах
Разбор живого клиента (стоматология, Красноярск, 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>
2026-07-14 13:14:49 +03:00
Дмитрий 61f35d0227 fix(supplier): перенос из main — в офлайн-режиме не дёргать поставщика при создании
Перенос 7fa811b4 из gitea/main в ветку стройки, чтобы выкат отсюда не вернул баг на
боевой (класс ошибки 08.07 — «затёрло выкатом из устаревшей ветки»).

Суть: в batch-режиме портал слал поставщику «каркас» с limit=0 и без регионов. Кабинет
отбивает такой запрос ВСЕГДА — снято живьём с боевого 14.07:
{"status":"Error","message":"Введите limit!"}. Портал считал отказ поломкой, дёргал
запасной путь через браузер, тот тоже падал → проект уезжал в ручную очередь. Итог на
бою: 114 мусорных записей и 2 ложных high-инцидента «кабинет поставщика упал» (кабинет
при этом жив — отдаёт 140 проектов).

Лиды не терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob (18:00) с
посчитанными лимитами и регионами. Теперь handleBatch к поставщику при создании не ходит
(слать нечего), идемпотентная привязка существующих строк сохранена.

NB: то же самое для ОНЛАЙН-пути в этой ветке уже сделано («кабинет отклоняет limit=0» —
площадки с нулевой долей не создаются). Правки не пересекаются: там handleOnline, тут
handleBatch.

Тесты: 254/254 (Supplier + Plan5) в этой ветке; в main полный прогон 2453/2458.

NB: LEFTHOOK_EXCLUDE=larastan — статанализ в рабочей копии сломан устаревшим
_ide_helper_models.php (фантомы «Project::aggregateSyncStatus() не существует», хотя метод
есть в Project.php:181). По изменённым файлам статанализ прогнан в чистой песочнице: 0
ошибок. Bypass согласован с владельцем. Follow-up: перегенерировать стаб (--write-mixin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:26:03 +03:00
Дмитрий d435101039 fix(supplier): в офлайн-режиме не дёргать поставщика при создании проекта
Прод-инцидент 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>
2026-07-14 10:44:44 +03:00
Дмитрий 411f45ed2e Merge remote-tracking branch 'gitea/main' into worktree-jivo-bot-core 2026-07-14 10:18:16 +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
Дмитрий 6d70664904 fix(billing): перенос из main — письма о заморозке не доходили (RLS в очереди)
Перенос fc78ee1e из gitea/main в ветку стройки, чтобы выкат отсюда не вернул
сломанные письма обратно на боевой (класс ошибки 08.07 — «затёрло выкатом из
устаревшей ветки»).

Суть: 5 писем (заморозка / напоминание / финальное / разморозка / «проект
остановлен — нет денег») уезжали в очередь с моделью Tenant; воркер грузил её
заново под crm_app_user, где RLS без app.current_tenant_id отдаёт 0 строк →
ModelNotFoundException, письмо не уходило никогда. Теперь письмо несёт снимок
данных и в БД при отправке не ходит.

Плюс дедуп persistent-инцидентов сторожа — по факту незакрытого инцидента,
а не по окну 60 мин (иначе копия инцидента каждый час, бесконечно).

NB: LEFTHOOK_EXCLUDE=larastan — статанализ в рабочей копии сломан устаревшим
_ide_helper_models.php (239 ошибок в 104 ЧУЖИХ файлах, напр. фантом
«Tenant::requiredLeadsForTomorrow() не существует», хотя метод есть в
Tenant.php:93). По изменённым файлам статанализ прогнан отдельно: 0 ошибок.
Bypass согласован с владельцем. Follow-up: перегенерировать стаб (--write-mixin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:38:43 +03:00
Дмитрий 992e62fd2c fix(visitors): карточка «Что смотрят в кабинете» падала — нет колонки t.name
Запрос активности портала просил у 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>
2026-07-14 09:14:16 +03:00
Дмитрий 5b7bbbda50 fix(billing): письма о заморозке баланса не доходили — воркер не видел клиента под RLS
Прод-инцидент 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>
2026-07-14 08:55:37 +03:00
Дмитрий a649a41ce4 feat(visitors): короткие ссылки для рассылок — liderra.ru/s, /hh, /tg
В смс дорог каждый символ: вместо длинного хвоста с метками сервер сам
подставляет канал и дату рассылки (дд-мм), чтобы разные рассылки не слипались.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:53:35 +03:00
Дмитрий ff36e0c10e fix(visitors): партиции нарезает только штатный механизм + сторож схемы на 80 таблиц
Полный прогон вскрыл реальный баг: миграция создавала партицию 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>
2026-07-14 05:22:46 +03:00
Дмитрий 12e8ba0cac feat(visitors): чистка данных старше 180 дней (152-ФЗ)
Гостей чистит visitors:prune (воскресенье 03:15 МСК), партиции событий —
существующий partitions:drop-expired по новой настройке retention=6 месяцев.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:06:27 +03:00
Дмитрий dc335cc3d8 feat(demo): демо-проект выглядит живым — портал на кадрах не выглядит пустым 2026-07-14 05:01:04 +03:00
Дмитрий f2ab97f9f6 feat(visitors): админ-API воронки, каналов, визитов и активности в кабинете
Воронка — по уникальным живым гостям (is_datacenter=false); гости с хостингов
и VPN считаются отдельным числом, в конверсию не попадают.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:00:00 +03:00
Дмитрий cd6875edbb fix(autopodbor): откат склейки возвращает проекты и источники на свою карточку
Разбор докладной 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>
2026-07-14 04:51:16 +03:00
Дмитрий 52b6a34eab fix(bot): ссылку на разбор берём у ближайшей подходящей статьи, а не только у первой 2026-07-13 20:57:46 +03:00
Дмитрий 9f1b2ccb8d fix(bot): гость получает ссылку на разбор по всем частым вопросам (канбан, баланс, заявки) 2026-07-13 20:38:12 +03:00
Дмитрий 202a49590a feat(visitors): события регистрации и входа пишет backend, гость связывается с клиентом
Три точки: обычный вход, вход через 2FA (оба пути — код и резервный),
подтверждение почты (именно там клиент реально создаётся, а не на /register).
Учёт в try/catch — ошибка учёта не может уронить вход. Регресс авторизации 114/114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:56:54 +03:00
Дмитрий 705a2d18d4 feat(demo): конкуренты-стоматологии в демо-данных для съёмки разборов 2026-07-13 19:48:39 +03:00
Дмитрий 50daf92d6d feat(visitors): маячок POST /api/track + кука гостя на .liderra.ru
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.
2026-07-13 19:46:48 +03:00
Дмитрий 4546d39554 fix(autopodbor): разные конкуренты больше не считаются дублями (пустое поле — не совпадение) 2026-07-13 19:35:36 +03:00
Дмитрий bdd81ec8d5 feat(visitors): таблицы site_visitors + site_events (партиции, GRANT'ы, маскирование ПДн) 2026-07-13 19:21:29 +03:00
Дмитрий 1bac5d9a42 feat(bot): гостю - ссылка на пошаговый разбор по слагу статьи 2026-07-13 18:12:18 +03:00
Дмитрий b3982980f6 feat(demo): демо-клиента с красивыми данными для съёмки разборов 2026-07-13 17:08:21 +03:00
Дмитрий 292a8793cd feat(bot): гостю бот показывает пример на лендинге, а не молчит 2026-07-13 14:27:13 +03:00
Дмитрий bb40340ada fix(bot): сторож больше не режет правду «ограничений по нишам нет» 2026-07-13 13:44:15 +03:00
Дмитрий aa9537d43e fix(bot): сторож ловит жёсткий отказ в замене в любой форме (живой баг 13.07) 2026-07-13 13:37:37 +03:00
Дмитрий 1804f38724 test(bot): связка сторож↔карточка фактов — формат не разъедется молча 2026-07-13 12:49:33 +03:00
Дмитрий c3b5cfce5b fix(bot): сторож проверяет число↔факт — перепутанные цифры из карточки больше не проходят
Ревьюер прогнал AnswerGuard руками: пропускал числа словами («два проекта»),
разрыв «число...единица» («20 в день»), чужие единицы (сделок/клиент/сутки/...)
и самое опасное — число, которое в карточке ЕСТЬ, но относится к ДРУГОМУ факту
(модель переставила местами). Карточку (ClientFacts) генерирует код с
предсказуемым форматом, поэтому сторож теперь разбирает её на пары
«факт → правильное число» (баланс, цена ступени, получено/до след. ступени,
кол-во проектов, заявки сегодня/вчера/7дней/месяц, списано) и режет
несовпадение по смыслу, а не по буквальной формулировке.

TDD: 9 новых тестов (196→195 итого с учётом снятого дубля), 195/195 зелёных.
Стресс-прогон 20 предложений (10 враньё + 10 честных) вручную — 0 расхождений
после фикса дизамбигуации «этот/этом месяц» vs голое «за месяц».
2026-07-13 12:40:54 +03:00
Дмитрий 550486b61c fix(bot): переписка вошедшего клиента закрыта от посторонних (404) 2026-07-13 12:06:53 +03:00
Дмитрий 26eef67131 feat(bot): задача очереди отдаёт мозгу карточку фактов вошедшего 2026-07-13 12:00:42 +03:00
Дмитрий 3e2f27d1b4 fix(bot): карточка фактов — граница ступени, пустая сетка цен, честный докблок 2026-07-13 11:54:04 +03:00
Дмитрий d5ad30b3dc feat(bot): личный вопрос вошедшего отвечается по карточке, гостю — вход в кабинет 2026-07-13 11:48:39 +03:00
Дмитрий 8da2e8c219 fix(bot): сторож режет не только выдуманные цены, но и выдуманные количества 2026-07-13 11:36:30 +03:00
Дмитрий 7cfa998e88 feat(bot): карточка фактов — проекты, заявки, списания, пополнение 2026-07-13 11:30:47 +03:00
Дмитрий 831e8c399c feat(bot): карточка фактов — ступень; починены русские склонения 2026-07-13 11:23:01 +03:00
Дмитрий 673fddbe60 feat(bot): карточка фактов клиента — деньги 2026-07-13 11:09:27 +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