Стадию меняют два разных места — результат разговора менеджера и автопереезд
по деньгам. Джоба не оставляла следа вообще, поэтому «что менялось за день»
было не из чего построить. Запись движения перенесена в событие модели: один
шов на всех, включая любой будущий третий источник.
- sales_prospect_moves — журнал всех движений (append-only, с GRANT'ами ролям);
- prev_stage на карточке — откуда приехала (кормит счётчик «69/1» у «Отказа»);
- stage += 'trash' — место под колонку «Корзина».
Сторож проверен вырезанием: без записи движения краснеют 4 теста, в том числе
тест джобы и тест API.
Стадии manual_testing и kp_sent в CHECK + 4 nullable-колонки под их поля.
Откат и повторный накат проверены на своей тестовой базе измерением колонок.
Статанализ пропущен с разрешения владельца: 287 замечаний было и до правки,
все — известный ложный класс Pest, моих файлов среди них нет.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Закрывает находки ревью: C1-блокер + рассинхроны длины + все оранжевые. TDD, всё зелёное.
Поведение в песочнице не меняется; правки готовят ветку к боевому включению.
F1 (блокер): AdWalletService::freeze реактивирует released-hold через updateOrCreate по
4-ключу — пересдача кампании и повторная заявка на имя больше не падают на дубле ключа
23505 в боевом режиме.
F2: длины валидации выровнены под колонки БД — имя 64, ad_link 500, ord_category 200;
длинное значение даёт ошибку поля, а не замаскированный 422 от БД.
F3: авто-рассылка морозит потолок бюджета симметрично ручному запуску только в бою и
считает дневной лимит под lockForUpdate строки правила.
F4: кампания не зависает в moderating вечно — переход moderating→needs_review плюс
предохранитель уборщика по возрасту client_tg.moderation_stuck_hours=48, бронь не трогаем.
F5: единое осторожное правило возврата брони в finalize и failed — есть mts_campaign_id
значит могла уйти на модерацию → needs_review без release; нет id → failed плюс возврат брони.
F6: робот cabinet.js — денежные кнопки оплатить/списать/запустить в чёрном списке
domClickButton, finalize целит только кнопку отправки на модерацию.
F7: finalize live не врёт launched:true на шаге /payment — launched:false, stoppedAt:payment;
не дошли до /payment → падаем громко.
F8: assertCostWithinCap подключён в live-finalize — сверка фактической стоимости с потолком.
F9: GRANT SELECT служебным ролям на client_tg_campaigns миграцией 000016 — иначе
кросс-тенантные джобы Poll/Sweep видели бы 0 строк на проде; правка ложного комментария в
000011. CHANGELOG v8.93, rls-reviewer CLEAN. ДЕПЛОЙ: ПЕРЕзапустить db/03_service_bypass_policies.sql.
Приёмка: бэкенд ClientTg 196/196; робот npm test 89/89; pint/phpstan/deptrac чисто. Фронт не трогали.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрывает Этап 5 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
Клиент управляет именем отправителя и авторассылкой сам из кабинета; авто не тратит
без денег и сверх дневного лимита. TDD, весь бэкенд+фронт зелёный.
## 5.1 — предохранители авторассылки
Накопитель в БОЮ перед постановкой пачки проверяет: смета умещается И в свободный
остаток кошелька (balance−frozen), И в дневной лимит правила за вычетом трат за
сегодня. Не прошла — держим черновиком + Log::info('client_tg.auto_skipped', reason).
Дефолт daily_limit_rub=0 → авто выключено (само денег не потратит). В песочнице гейта
нет (деньги не трогаются, как ручной launch). Новые колонки client_tg_auto_rule:
daily_limit_rub, spent_today_rub, spent_date (счётчик за день, сброс при смене даты).
## 5.2 — клиентское API имени (SenderController)
GET /sender (статус + остаток грейса), POST /sender (завести), /sender/disable,
/sender/enable (suspended→active, идемпотентно по периоду — без двойной оплаты в месяц;
проверка средств ДО списания). Логика в TelegramSenderService (enableSender+snapshot),
контроллер тонкий. Всё скоуп тенантом.
## 5.3 — экран имени (TelegramSenderPanel.vue)
Самодостаточная панель: статус имени человеческими словами, дата оплаты, остаток грейса
при долге; suspended → «Отключено за долг» + «Включить»; нет имени → форма «Завести имя».
telegram.ts: fetchSender/createSender/disableSender/enableSender.
## 5.4 — экран авторассылки + API (AutoRuleController)
GET/PUT /api/telegram/auto-rule (вкл/выкл, объявление, порог, бюджет, дневной лимит).
Порог клиентский — новая колонка client_tg_auto_rule.batch_threshold (NULL → дефолт
конфига 367; ниже 367 API не даёт — минимум МТС). TelegramAutoRulePanel.vue: тумблер +
поля порога/бюджета/лимита. В списке кампаний авто-кампании (created_by=null) помечены
чипом «авто».
Обе панели встроены в AdvertisingTelegramView.
## Схема
Две аддитивные миграции на существующую таблицу client_tg_auto_rule (000014 daily_limit
+ счётчик за день; 000015 batch_threshold). RLS/GRANT не тронуты (табличный GRANT
покрывает новые колонки). Записи db/CHANGELOG_schema.md v8.91/v8.92; обе прогнаны через
rls-reviewer — CLEAN.
## Приёмка
TDD. Бэкенд ClientTg 177/177; phpstan/pint/deptrac чисто. Фронт полный набор
212 файлов/1538 тестов зелёные; vue-tsc+ESLint по нашим файлам чисто. Проверено вживую
в браузере (клиент demo, песочница): обе панели читают и пишут — правило сохраняется
точь-в-точь (порог/бюджет/лимит), имя заводится (pending, деньги не тронуты).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрывает Этап 3 «Жизненный цикл и модерация» целиком (3.1–3.6) и бэкенд-часть 4.3.
План docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
## 3.5 — робот читает статус/причину модерации по mts_campaign_id (Node)
- `parseModerationStatus` (cabinet.js) — чистый парсер статус-текста кабинета в канон
Laravel-опросчика: «Отклонена»→rejected, «Одобрена»/«Активна»→approved,
«На модерации»→moderating, «Черновик»→draft, иначе null (тогда опросчик ждёт).
Терминальные вердикты приоритетнее слова «модераци…» в строке-блобе. Юнит-тест
read-status.test.mjs (11 кейсов: регистр/nbsp/блоб/приоритет/мусор).
- `readModerationStatus` (cabinet.js) + `runReadStatus` (runner.js) + режим `read-status`
в bin/run.js: открывает список кампаний, находит ряд по id, читает статус; при отказе —
причину из слайд-модалки «Причины» (#slide-modal-root). Отдаёт JSON
{ok, moderationStatus, reason?, campaignId} — его уже разбирает RobotResult (3.4).
🔴 Читалка кабинета помечена <FLOW-CONFIRM>: DOM-обёртки ряда/модалки собраны по
живой разведке «Сессии 6» (FLOW-FINDINGS.md), но именно этим кодом live ещё не прогнаны —
подтвердить на следующем цикле модерации с разрешения владельца. Парсер от DOM не зависит.
## 3.6 — пересдача отклонённой кампании (rejected → queued + документ модератору)
- Миграция 000013: колонка `client_tg_campaigns.moderator_file_path` (varchar 500 NULL,
после media_path) + CHANGELOG схемы v8.90; rls-reviewer прогнан — чисто (nullable-колонка
данных, не tenant-скоуп, RLS не меняется). Модель — fillable.
- Endpoint `POST /api/telegram/campaigns/{id}/resubmit`: только отклонённую (иначе 422);
правки ad_text/ad_link/ord_category (валидация как store) + опц. файл модератору
(.png/.jpeg/.jpg/.pdf ≤10 МБ, сохраняется на диск local). Успех: поля обновлены,
status_reason и mts_campaign_id очищены (робот создаст новую кампанию в кабинете),
rejected→queued, dispatch RunTelegramCampaignJob afterCommit. В бою — гейт аудитории
+ freeze budget_cap_rub заново (при отказе бронь вернул опросчик 3.4; freeze идемпотентен
по ACTIVE-холду), нехватка → 409, остаётся rejected. Песочница — без брони.
- Робот: task `moderatorFile` (task.js passthrough + TelegramRobotRunner.taskPayload),
RunTelegramCampaignJob отдаёт moderator_file_path; fillAd грузит файл в поле «Комментарий
для модератора» (третий file-input, accept pdf) — помечено <FLOW-CONFIRM> (live не прогнан).
- Тесты: ResubmitTest.php (7 кейсов: rejected→queued+очистка+джоб / файл сохранён /
не-rejected→422 / live 409 / валидация / .exe→422 / чужой→404); Node task.test.js (+2).
## 4.3 — уведомление об одобрении (бэкенд был готов в 3.4, добор покрытия)
- ApproveNotifyTest.php (4 кейса): notifyTelegramCampaignApproved шлёт in-app всем активным
юзерам тенанта без pref-гейта, тело «одобрена/показы пошли»; неактивный/чужой не получают.
TDD. Приёмка (моя область, чистый прогон): весь ClientTg 150/150, робот 78/78,
ApproveNotify 4/4; phpstan 0, deptrac 0, pint чисто.
Приёмочный лист 3.6 — docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
Осталось в Этапе 4: фронтенд 4.1/4.2/4.4/4.5 (Vue-экран + Vitest) — НЕ начато.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Этап 3 «Жизненный цикл и модерация», задача 3.1. Плюс закрыта задача 3.0
(живая разведка экрана отказа) — вердикт МТС по кампании «займ» (2231134)
пришёл: «Отклонена». Разведка read-only, деньги не тронуты.
Задача 3.1 — колонка mts_campaign_id + РАННЕЕ и надёжное сохранение:
- Миграция client_tg_campaigns.mts_campaign_id (varchar(32) NULL, после
status_reason) + запись CHANGELOG_schema v8.89 (предварит., ветка). RLS не
меняется; rls-reviewer не требуется (nullable-колонка данных).
- Робот (Node): чистый хелпер parseCampaignId(url) в cabinet.js (покрыт
тестом); runner.js захватывает id СРАЗУ после создания черновика (шаг
аудитории) и печатает маркер MTS_CAMPAIGN_ID=<id> в stderr; id теперь
идёт и в ветке ОТКАЗА (раньше терялся).
- Обёртка (PHP): TelegramRobotRunner восстанавливает id из stderr-маркера во
всех путях (таймаут/непарсабельный вывод/JSON без id); RobotResult::failed
принимает id.
- Джоб: finalize сохраняет mts_campaign_id при ЛЮБОМ исходе (успех/отказ), не
затирая ранее сохранённый id. Метод failed() не трогали — туда результат не
доходит (осознанный residual, закроют уборщик 3.1b и sweeper 3.3).
Разведка отказа (3.0) записана в bots/mts-telegram-ads/FLOW-FINDINGS.md:
причина показана текстом в слайд-модалке «Причины отклонения кампании»
(кнопка «Причины»); поля загрузки файла на экране отказа нет — документ
грузится через «Исправить» → шаг «Сообщение» → «Комментарий для модератора»;
кнопка пересдачи — «Исправить».
TDD, робот замокан, тесты на liderra_testing (номера 7999…).
Приёмка: Node 48/48 (npm test), Pest ExternalIdTest 2/2 + регрессия ClientTg
122/122, phpstan (4 боевых файла) 0, deptrac 0 нарушений, pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог).
Задача 5.2 — обратная связь по отказу:
- миграция client_tg_campaigns.status_reason (varchar 500, nullable; RLS без изменений — колонка на существующей таблице), запись v8.88 в CHANGELOG_schema
- Campaign: status_reason в fillable
- NotificationService::notifyTelegramCampaignRejected — in-app уведомление всем активным
пользователям тенанта без pref-гейта (важное операционное сообщение доходит всегда)
- RunTelegramCampaignJob: при провале робота сохраняет причину в status_reason и шлёт уведомление
(Tenant грузится внутри tenantTx для RLS, notify — после транзакции)
- фронт: telegram.ts (+status_reason), AdvertisingTelegramView показывает причину отказа/ошибки
- тесты: RejectNotifyTest (4 Pest) + 2 Vitest на экран
Закалка робота против частых глюков кабинета МТС:
- browser.js: gotoStable() — терпеливая загрузка с перезагрузкой (кабинет виснет на пустой крутилке)
- session.js: isLoggedIn через gotoStable — больше нет ложного «вход слетел»
- cabinet.js: знакомство пропускается если его нет (только для новичков); оферта по #isOfferAccepted;
осознан шаг /payment (денежная развилка «оплатить/без оплаты» — Сессия 6)
Живая разведка кабинета зафиксирована в FLOW-FINDINGS.md (селекторы, шаг /payment, поле файла модератору).
Проверки: Pest ClientTg 93/93, Vitest экрана 7/7, node-тесты робота 20/20.
Робот в тестах замокан, тесты на liderra_testing. Реальных ПДн нет (номера фейковые 7999…).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.
4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
(audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).
4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.
4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).
4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).
Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сессия 1 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Бэкенд-ядро — зеркало готового СМС-модуля. Робота ещё нет — он в Сессии 2.
- 8 таблиц client_tg_* с RLS tenant_isolation и GRANT crm_app_user; справочные
tariffs/settings без RLS с guarded-GRANT. rls-reviewer PASS 8 из 8.
- 7 моделей ClientTg + связи campaign->phones.
- Ступенчатая цена TelegramTariffService — ₽ за показ по объёму; тариф = потолок,
точную стоимость считает МТС.
- Сборка аудитории TelegramAudienceService — сделки за период / своя база / свой
список, нормализация телефона, стоп-лист и дедуп; отдаёт кандидатов, реальный
охват узнаёт робот после загрузки в МТС.
- Канал кошелька telegram: перенесён общий AdWallet со свежей версии портала
— модели, сервис, миграции; списание и заморозка по каналу telegram под тестами,
charge не бросает и не уходит в минус, нехватка средств ловится freeze до списания.
Проверки: 26 из 26 Pest зелёные, larastan 0, gitleaks чисто.
db/CHANGELOG_schema.md v8.86 — номер предварительный, ветка отстаёт от main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись:
- B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder
(open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming)
и боевых СМС, guard по источнику. rls-reviewer OK 8/8.
- B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала.
- B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов),
тип WarmingBadgeState в sales.ts.
- B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане;
осиротевший WarmingChannelIcons удалён.
Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет).
cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca).
Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный.
B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап A, Task 1. Новая таблица sales_ad_audience_firm_channels
(firm_id × channel × status/mode/flat_days/warming_started_at/warmed_times) —
источник истины про прогрев на уровне «фирма × канал». Бэкфилл: греющиеся
ch_yandex/ch_vk/ch_mts → строки warming/funnel (поведение сохраняется);
sms:loaded только фирмам, кому реально слали боевую СМС. GRANT таблица+sequence
обеим ролям admin-db. schema.sql v8.84 + CHANGELOG. ch_* не удаляются (откат).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blanket ON ALL TABLES не покрывает таблицу, созданную позже миграцией, а
ALTER DEFAULT PRIVILEGES срабатывает только для роли-создателя — без
инлайновых GRANT crm_admin_user словил бы permission denied на бою (dev-тесты
идут суперюзером и грантов не видят). Зеркалит паттерн соседней миграции
2026_07_19_100000_create_sales_ad_audience_tables.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Дельта-миграция создаёт таблицу настроек трёх площадок прогрева
(yandex/vk/mts) взамен разрозненных колонок в синглтоне
sales_ad_audience_state. Версия предварительная на ветке
feat/warming-platforms — v8.81 занят параллельным модулем «Прогрев СМС»,
сверить номер при слиянии в main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Основа для разделения экрана "Реклама на кандидатов" на три
самостоятельные площадки (Яндекс/ВК/Телеграм). Новая таблица —
строка настроек на площадку (рубильник, порог запуска, синхронизация,
статус); колонки sales_ad_audience_state не удаляются — страховка
отката, как и channels в v8.80.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и 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>
Task 5 плана v2: RecalcAdAudienceJob (routes/console.php, дневное 03:10 МСК) ночью
сверяет стадию связанной карточки sales_prospects с последней запомненной у фирмы
(sales_ad_audience_firms.stage_seen/stage_changed_at) и зовёт AdAudienceScheduler,
чтобы проставить каждому номеру состояние active/paused/stopped.
Найдена и закрыта дыра в исходном плане: ни prospect->updated_at (двигается ЛЮБОЙ
правкой карточки — заметка менеджера ложно продлевала бы рекламу), ни firm->assigned_at
(момент назначения менеджера, не смены стадии) не годились источником даты «сколько
фирма сидит в текущей стадии». Решение: фирма сама хранит эту дату — миграция
2026_07_20_110000 (+2 nullable-колонки, db/CHANGELOG_schema.md v8.78). Новый тест
«правка карточки не перезапускает отсчёт рекламы» закрывает ровно эту дыру.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 1 плана docs/superpowers/plans/2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika.md.
Две таблицы: sales_ad_audience_phones (номера, expires_at) + sales_ad_audience_state
(рубильник/срок/id сегмента). SaaS-level без RLS, как sales_prospects.
NB: LEFTHOOK_EXCLUDE=cspell,larastan — оба фейла pre-existing, не от этого коммита
(проверено git stash + повторный прогон хуков на чистом дереве: те же 20 cspell-слов
в старых строках CHANGELOG и те же 14 larastan-ошибок в tests/Feature/Admin и
tests/Feature/Billing, не тронутых этим коммитом).
feat(sales): результат разговора и его содержание — одной записью в журнале
Владелец: «объедини в одну запись: договорились… сверху, внизу текст».
Было две записи подряд — в ленте читалось как два события, хотя разговор один.
- БД v8.75: sales_prospect_notes.title VARCHAR(500) NULL — что решили.
- Одна запись kind=note: title = «Договорились на созвон 20.07.2026 17:35»,
body = краткое содержание. Есть результат без содержания — как раньше,
одна запись kind=stage без заголовка. Ручная заметка — без заголовка.
- В ленте заголовок строкой сверху, под ним текст.
- Старые парные записи задним числом не сливаем — историю не переписываем.
Спека §20.2. Гейты: Pest 240/240 sales, Vitest 64/64 воронка, vue-tsc чисто,
Larastan 0 в своих файлах. TDD RED→GREEN.
🪤 Тесты DOM для v-dialog: контент уезжает телепортом в body — искать через
document.body.querySelector, а не wrapper.find.
LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
feat(sales): журнал разговоров по кандидату + переделка обоих окон воронки
Владелец: «краткое содержание разговора писать, чтобы не потерять историю»
и «переделай обоих дизайн, очень не практично, спроси у перплексити».
ЖУРНАЛ (БД v8.74, таблица sales_prospect_notes, append-only):
- kind=note — менеджер написал руками; kind=stage — автозапись о переезде
по стадии (взял в работу, созвон, недозвон, отказ, регистрация).
Повторное открытие карточки журнал не засоряет.
- GET/POST /api/sales/prospects/{id}/notes; лента свежими сверху, грузится
при открытии карточки, а не вместе с доской.
- PATCH /prospects/{id} БОЛЬШЕ НЕ принимает notes: он молча затирал прошлую
запись — ровно та потеря истории, ради которой журнал и появился.
Нашёл rls-reviewer, закрыто тестом. Старые notes перенесены в журнал.
ДИЗАЙН (по разбору Pipedrive/HubSpot/Salesforce через Perplexity):
- Карточка 1100px, две колонки. Слева «что за фирма» + история разговоров
с полем «о чём поговорили». Справа зона действия: «Куда звонить» (номер
крупно, ссылкой tel:), «Результат разговора» своим фоном, контактные лица.
Кнопки внизу окна. Пустая история объясняет, что делать.
- Форма создания разбита на разделы: Компания / Контактные лица / Заметка;
Юрлицо и Город в одну строку; обязательных полей по-прежнему два.
Спека §19. Гейты: Pest 231/231 sales, Vitest 60/60 воронка, vue-tsc чисто,
Larastan 0 в своих файлах, rls-reviewer PASS. TDD RED→GREEN.
LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии
tests/Feature/Admin/*Balances*, свои файлы проверены отдельно и чисты.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
feat(sales): кандидат — ИНН обязателен, юрлицо отдельно от бренда, контактные лица
Замечание владельца по форме «Добавить кандидата»: телефонов бывает много, нужны
ФИО и должность человека, ИНН обязателен, бренд и юрлицо — разные вещи, юрлицо
подтягивать по ИНН через ДаData.
- БД v8.73: sales_prospects.legal_name VARCHAR(500) + contacts JSONB DEFAULT [].
phone остаётся «главным телефоном»; inn в БД по-прежнему nullable — карточки
из поиска и 24 живые строки могут быть без него.
- store(): ИНН обязателен + контрольная сумма ФНС; повтор ИНН у того же
менеджера — понятный 422 вместо 500 от уникального индекса; пустые контакты
и телефоны отсекаются; phone = первый телефон первого контакта.
- POST /api/sales/prospects/lookup-inn — юрлицо/город по ИНН через готовый шов
PartyLookup (тот же, что в «Реквизитах»). Ничего не сохраняет; ДаData молчит
или упала — заводим руками.
- Диалог создания: ИНН* с кнопкой «Найти», бренд*, юрлицо, блок контактных лиц
(+человек / +телефон). Карточка показывает юрлицо и контакты со ссылками tel:.
Спека §16, CHANGELOG_schema v8.73. Гейты: Pest 28/28, Vitest 38/38,
Larastan 0 в своих файлах, rls-reviewer PASS 4/4.
LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии
tests/Feature/Admin/*Balances*, свои файлы проверены отдельно и чисты.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
Фаза C плана 2026-07-16-external-services-online-monitoring:
- KNOWN_SERVICE_KEYS whitelist в контроллере — GET /balances и плитка скрывают
осиротевшие строки (jivosite и любые будущие).
- balancesPayload() — общий сборщик для GET /balances и POST /refresh.
- POST /api/admin/dashboard/balances/refresh: без service — фон лёгких (force=false,
окно свежести), service=X — один сервис (force=true); неизвестный → 422;
тяжёлый supplier — под Cache-замком (второй робот → 409). Кнопки/фон почту не шлют.
- Миграция удаляет осиротевшую строку jivosite.
Тесты: 9 Admin/External Feature зелёные, Larastan 0 по своим файлам.
NB: larastan-хук исключён — 2 ошибки выше baseline в ЧУЖОМ незакоммиченном
tests/Feature/Billing/ExpireInvoicesTest.php (параллельная сессия в общей папке).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Этап 1 Task 2. Дефолт stage=new в $attributes (доступен до refresh).
ide-helper мисин добавлен в локальный стаб (регенерация всех моделей).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Этап 1 Task 1. SaaS-level таблица без RLS + 8 стадий воронки, включая
«Тестирование» (начал тратить бонусные 1000 ₽ после регистрации).
Дизайн/план обновлены под 8-ю стадию. cspell исключён: сработал на
пред-существующих словах CHANGELOG_schema, мои файлы проверены чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Миграции портала продаж выдавали GRANT только роли crm_admin_user. Но на боевом
DB_ADMIN_USERNAME=crm_supplier_worker (проверено чтением живой базы 14.07.2026) —
портал открылся бы и упал на первом же запросе с permission denied.
Локальные тесты этого не ловят: dev-база под суперюзером, прав хватает всем.
Гранты теперь выдаются ОБЕИМ ролям (crm_admin_user + crm_supplier_worker) циклом
по списку, отсутствующая роль пропускается. Работает при любом способе применения:
artisan migrate (владелец таблиц crm_supplier_worker, как site_visitors на бою)
и ручной psql по рунбуку (владелец crm_migrator).
Гейты: бэкенд 2924/2928 (4 skipped, 0 падений), Sales 179/179, Pint чист,
Larastan 0, deptrac 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ветка разошлась с боевым 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>
Полный прогон вскрыл реальный баг: миграция создавала партицию 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>
Разбор докладной 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>
jivo_chat_id -> chat_id + добавлены source/user_id/ip (спека
2026-07-13-own-chat-widget-design §5). Историческая миграция создания
таблицы не тронута.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.
Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
- итоговая проверка выводила площадку из префикса имени и переставала узнавать
строку -> ложное missing 11.07 и 12.07;
- лид от такой строки приходил с project без метки -> webhook не мог определить
канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.
Что сделано:
- SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
три строки, общего префикса у них нет.
- VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
- Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.
Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.
Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.
Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна
Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».
База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.
Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).
Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».
Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
По rls-ревью: соседние SaaS-supplier-таблицы (supplier_sync_runs/deferred_sync)
создаются через pgsql_supplier + GRANT SELECT,INSERT + USAGE на sequence для
crm_supplier_worker в DO-блоке с IF EXISTS. Дефолтный Schema::create оставлял
таблицу во владении crm_migrator без грантов → VerifySupplierOrderJob упал бы
permission denied на боевом Managed PG. Приведено к паттерну + SaaS-коммент + CHECK status.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кнопка «Удалить» на карточках «На актуализацию» рядом с Добавить/Заменить.
Отклоняет ТОЛЬКО находку (новый сайт/адрес): фирма поля остаётся, находка → архив,
её новые ключи запоминаются у фирмы поля (новая колонка dismissed_actualize_keys jsonb).
Классификатор вычитает отклонённые ключи → та же находка больше не всплывает; реально
другой новый сайт даст новый ключ → покажется. Не глушим по имени, конкурента не убираем.
Бэк: миграция + модель-каст + ProposalClassifier (вычитание) + эндпоинт dismissActualize
+ роут. Фронт: api + store + кнопка «Удалить». Тесты: классификатор 8/8, ветка 185/185,
фронт 30/30. Canon-sync schema.sql (v8.62) — follow-up (миграция idempotent-guarded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Стиль-only (braces_position, ordered_imports, fully_qualified_strict_types и т.п.),
поведение не меняется. Autopodbor 476/476 зелёный после причёсывания.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный
оклад daily_salary и процент от пополнений topup_step. Убраны
percent_oborot и fix_per_client.
Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает
FK sales_users.current_tariff_id и sales_client_assignments.tariff_id,
удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов
возвращает 0 по default-ветке. Снимки привязок не трогаются.
Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics,
фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта,
CHANGELOG схемы v8.61 и спека портала.
Не на проде: ветка feat/sales-portal-demo.
Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0.
Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>