Перекладка, а не правка: поведение прежнее, тесты не тронуты ни в одной строке.
Был один файл на 914 строк — в голову и в контекст он уже не помещался. Стало:
- ClientSmsController (333) — сами рассылки: смета, запуск, остановка, журнал;
- ClientSmsBaseController (280) — база номеров, стоп-лист «Не писать этим», шаблоны;
- ClientSmsSenderController (354) — своё имя отправителя, бланк согласия, авто-СМС.
Эффективное имя отправителя нужно двум файлам сразу (рассылке и разделу имени),
поэтому вынесено в трейт ResolvesClientSmsSenderName. Разойтись эти два места
права не имеют: расхождение означало бы, что клиент видит на экране одно имя,
а МТС получает другое.
Адреса маршрутов не изменились ни одним символом — в routes/web.php заменено
только имя класса, иначе сломались бы фронт и старые тесты.
Сверх таблицы плана (журнал В-74): приватные помощники addContacts/addOptouts
уехали вместе со своими публичными методами, сервис цен внедрён в два
контроллера — рассылке для сметы, имени отправителя для цены авто-СМС.
Доказательства (В-75; вырезание защиты здесь неприменимо — новой защиты нет):
ClientSms 169/169 — ровно столько же, сколько до разреза, без правок тестов;
приём лидов 17/17; phpstan 1 ошибка, и та чужая давняя (nullsafe в consentForm),
просто переехала вместе с кодом; pint чисто.
Контрольный вырез: нарочно вернул два маршрута (/optouts и /sender) на старый
класс, где этих методов уже нет — покраснели 5 тестов в двух файлах. Значит
прогон действительно ходит через маршруты и заметил бы ошибку разреза.
Живой прогон: все три области экрана ответили (рассылки, шаблоны, имя
отправителя, база, стоп-лист, авто-СМС); предпросмотр по сделкам — «Уйдёт 5 СМС
— 45.00 ₽»; шаблон заведён кнопкой и убран кнопкой. Стенд возвращён как был.
Схему не трогали — записи в журнал схемы не требуется.
Владелец: «нет такой функции и задачи нет, забудь о ней! пришла и пришла смс».
Причина — приписка «Отказ: liderra.ru/s/…» ставила НАШ адрес в рекламное СМС, которое
клиент шлёт своим покупателям: он рекламирует себя, а не нас.
Убрано: страница отказа /s/{token}, таблица коротких ссылок, сервис токенов, приписка
в тексте рассылки, колонка with_optout_link, ограничение частоты sms-unsubscribe,
три файла тестов, три миграции (на прод не выкатывались).
Осталось нетронутым: стоп-лист самого клиента «Не писать этим» и общий стоп-лист
портала — это другое, их владелец не отменял.
Строки приёмочного листа 1.6-1.13 срезаны, записано в «Чего эта работа НЕ делает» п.14
и в журнал вопросов В-30. Возражение про 38-ФЗ высказано владельцу и им отклонено.
Всё удалённое лежит в истории: коммиты 7aa30833 и 1dece3a2.
ClientSms 140/140, приём лидов 17/17.
Клиент может остановить рассылку в очереди, на отправке и в ожидании утреннего окна.
Нажатие — это отметка «попросил остановить», а не мгновенный обрыв: сообщение, начатое
в этот момент, доводится до конца, сеть на полпути не рвём. Джоб читает отметку свежим
запросом перед каждым следующим номером.
Итог честный: статус «остановлена», причина «клиент», ушло столько, сколько в журнале,
списано ровно за это, заморозка снята полностью. Номера, до которых не дошли, в журнал
не пишутся — с ними ничего не произошло (В-27).
Строки приёмочного листа 1.14-1.17 (серверная часть; кнопка на экране — Task 10).
Защита проверена вырезанием: убрать выход из цикла — 2 красных теста.
ClientSms 151/151, приём лидов 17/17, phpstan по своим файлам чисто.
Человек, получивший СМС, теперь может отказаться сам: короткая ссылка
liderra.ru/s/<токен> открывает простую страницу с одной кнопкой. Нажал — номер
в стоп-листе именно той компании, от которой пришло сообщение.
- таблица client_sms_unsubscribe_links (RLS + tenant_isolation), одна ссылка
на пару «клиент + номер» навсегда — при каждой рассылке новая не плодится
- сервис токенов: 12 символов, без 0/O/o и 1/l/I (ссылку диктуют вслух)
- публичная страница: blade без Vue-сборки, номер только маской +7 *** *** ** 67
- ограничение частоты 20/мин с IP — перебор ссылок иначе = утечка номеров;
защита проверена вырезанием, тест на 429 краснеет
- неизвестная ссылка отвечает 404 без подсказок
- служебное соединение только в этом контроллере (тенант-контекста у страницы
нет) + GRANT для crm_supplier_worker на client_sms_optouts
- CHANGELOG схемы v9.02, с напоминанием ПЕРЕзапустить 03_service_bypass_policies.sql
Строки приёмочного листа 1.6, 1.7, 1.8, 1.9 закрыты тестами; живой прогон —
после выката, страницу надо открыть телефоном.
Ветка feat/client-sms-broadcast, песочница, в main не влито.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Стоп-лист тенанта получил всё, чего ему не хватало: откуда взялся отказ
(клиент / получатель / портал), комментарий зачем, загрузку файлом и внятный
ответ про непонятые строки — образцами, а не молча.
- миграция: client_sms_optouts += source (default client), note
- модель: константы источников + fillable
- API /api/sms/optouts: список, внесение руками, загрузка Excel, удаление
- изоляция тенанта явным where поверх RLS, проверена вырезанием защиты
- CHANGELOG схемы v9.01
Строки приёмочного листа 1.1-1.3 закрыты со стороны бэкенда; экран «Не писать
этим» — Task 10, до него живого прогона по этим строкам быть не может.
Открытый вопрос В-15 владельцу: вправе ли клиент снять отказ, который пришёл
от самого получателя. Пока снимается любой — строго по строке 1.2.
Ветка feat/client-sms-broadcast, песочница, в main не влито.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Этап 1 «Нельзя обжечься», задачи 1–3 приёмочного листа
(docs/superpowers/2026-07-27-PRIEMKA-client-sms-fixes.md, строки 1.4, 1.4а, 1.5).
- новая таблица sms_global_optouts (SaaS-уровень, RLS намеренно нет: номер
закрывается у всех тенантов сразу — защита договора с МТС при жалобе);
- ClientSmsRecipientSelector отсеивает такой номер ПЕРВЫМ, раньше тенантского
стоп-листа: наше обязательство перед оператором сильнее настроек клиента;
- клиенту причина видна словами — «номер закрыт администрацией», а не молчаливое
«не отправлено» (решение владельца, вопрос В-2 листа);
- админ-адреса /api/admin/sms/global-optouts: внести (номер в любом виде),
список, убрать; непонятый номер отклоняется внятно;
- запись v9.00 в db/CHANGELOG_schema.md.
Проверено: ClientSms 122/122, приём лидов 17/17, phpstan по своим файлам 0,
gitleaks чисто. Защита доказана вырезанием — без отсева падают ровно 3 теста.
Живого прогона строк 1.4/1.5 ещё нет: админ-экран идёт задачей 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Доработки экрана «Моя база» клиентской СМС-рассылки по просьбе владельца:
- Кривые номера больше не выбрасываются молча: uploadContacts/uploadContactsFile
возвращают rejected + rejected_samples, экран показывает «Добавлено: N,
не распознали: M (например: …) — проверьте формат».
- Таблица базы — только «Номер» и «Оператор» (колонку «Имя» убрал).
- Загрузка базы Excel-файлом: сервис ClientSmsPhoneFileReader (phpspreadsheet)
читает первую колонку, пропускает заголовок, номера-как-числа не уходят в
научную нотацию; endpoint POST /api/sms/contacts/file; на экране — выбор файла
и кнопка «Загрузить файл».
- «Скачать пример файла»: ClientSmsBaseExampleWriter + GET /api/sms/contacts/example
— готовый .xlsx с колонкой «Телефон» и образцами номеров.
- Оператор через ДаДату: EnrichClientSmsContactsOperatorJob обогащает добавленные
номера (DaDataPhoneClient.provider) в фоне, под tenant-контекстом (RLS), с
защитой дневного бюджета (DaDataBudgetGuard); сбой ДаДаты не роняет прогон,
оператор остаётся пустым; уже известного оператора не перезапрашиваем.
Тесты: бэкенд ClientSms 130/130, фронт СМС-спеки 63/63 — зелёные; pint чист;
портал пересобран. Синтетические номера 7999… в тестах.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Имя у оператора регистрирует Лидерра от лица клиента, поэтому от клиента два
скана: подписанное разрешение и документ-основание. Оба обязательны, галочка
согласия убрана.
- Бланк разрешения — 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>
Жизненный цикл имени: клиент requestSender (freeze первого месяца) / disableSender;
админ approve (charge+release, active, paid_until+1мес) / reject / disable. Помесячная
оплата ChargeSmsNameFeeJob (проверка средств ДО списания, долг>29 дней→suspended,
идемпотентно по external_key), расписание в console.php. Авто-рассылка: DealSmsObserver
(защитный, freshness-guard, НИКОГДА не роняет приём лида) → SendAutoSmsForDealJob
(best-effort, идемпотентно по deal_id, песочница/деньги/маршрут). Действующее имя
кампании = active-имя тенанта иначе liderra.ru.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ClientSmsController (index/preview/store/show, база контактов, шаблоны;
tenant по $request->user()->tenant_id + явный where; 409 при нехватке денег,
freeze при создании). AdminSmsTariffController (правка ступеней и настроек,
зона saas-admin/admin-db, crm_admin_user). Маршруты /api/sms и /api/admin/sms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/sales/integration/warming-state (сервис-токен) — по списку ИНН
отдаёт состояние прогрева {active, channels:{code:{live,count}}} из летописи
эпизодов (та же форма значков, что в воронке; матч фирм по firm_inn).
Неизвестный ИНН → пустой прогрев. Витрина «Греются/Прогреты» на экране «Поиск
клиентов» — в Python-службе finder (вне репо), сюда только окошко выдачи.
Кусок B закрыт со стороны портала (B1–B6).
Проверено: B6 4/4, Sales+Sms 476/476, свой код stan-чист.
larastan-хук исключён осознанно: 3 ошибки — в незакоммиченных тест-файлах
параллельной СМС-сессии (Mts/SmscSmsProviderTest), не в моём коде.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап D, Task D1. Новый эндпоинт GET /api/sales/ad-audience/sms-firms
(только head) отдаёт фирмы со строкой firm_channels(channel='sms'). Хелпер
smsSentCounts считает по каждой фирме число боевых СМС (sales_sms_messages
status='sent') по её номерам; fake_sent (песочница) не считается. firmRow получил
поле sms_sent_count (0 если не слали), проброшено и в firms/allFirms. СМС строкой
всегда loaded, сроками не греется — только чтение sales_sms_messages для счётчика,
тарификация/провайдер/запись кампаний не тронуты. Bump baseline (actingAs-ложняк).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап C, Task C3. POST /warming/{platform}/unassign {firm_ids:[]} удаляет
строки firm_channels(channel) у выбранных фирм — фирма уходит из списка этого
канала, другие каналы не тронуты. Только head, неизвестная площадка 404,
пустой/некорректный firm_ids 422. Аддитивно — warm/toggle/firms/заливки не тронуты.
Bump baseline (actingAs-ложняки).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Фаза 2 Этап C, Task C2. POST /warming/{platform}/warm {firm_ids:[], days?:1..365}
переводит выбранные фирмы в warming на канале: без days — воронка (mode=funnel);
с days — простой срок (mode=flat, flat_days=days). warming_started_at=сейчас (запуск
прогрева стартует отсчёт с текущего момента), warmed_times++ двухшагово. Только head,
неизвестная площадка 404, невалидные days/firm_ids 422. Аддитивно — toggle/firms/
firmRow/заливки не тронуты. Bump baseline (actingAs-ложняки).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Разъезд «Рекламы на кандидатов» на площадки (yandex|vk|mts) снёс общий
маршрут /ad-audience/firms — фирмо-пикер SalesSmsView.vue его звал
и получал 404. Восстановлен как платформо-независимый allFirms():
та же выборка firms(), но без where(channel, true).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET/PATCH /api/sales/warming/{platform}, GET .../firms, POST .../toggle
и .../assign заменяют общую /api/sales/ad-audience*; настройки и статус
площадки читаются из sales_ad_audience_platforms, членство фирмы —
по ch_yandex/ch_vk/ch_mts. Метод channels() (массовый мультивыбор)
убран — заменён точечным toggle. Неизвестная площадка → 404 через
platformOr404() в контроллере (не через route whereIn — иначе
непойманный сегмент проваливается в Route::fallback).
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>
У МТС Маркетолога нет программного доступа к рекламе в Telegram (только SMS
API) — портал отдаёт готовый текстовый файл с номерами, начальник грузит
руками. channels() переписан под три независимых булевых поля вместо одной
строки; firms() отдаёт три признака вместо старого 'channels'.
Выгрузка берёт только активные (state=active), неудалённые (removed_at IS
NULL) номера фирм с ch_mts=true — иначе в МТС накопятся погашенные клиенты
и придётся платить за показы им.
Task 3 плана docs/superpowers/plans/2026-07-20-tri-ploshadki-i-mts.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/sales/ad-audience/channels — начальник меняет channels
у отмеченных фирм разом (гейт denyIfNotHead, менеджеру 403).
firms() теперь отдаёт channels в списке.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 7 плана v2 (реклама на кандидатов): начальник видит список фирм
на прогреве (GET /api/sales/ad-audience/firms) и назначает менеджера
(POST .../assign) — из снимка фирмы рождается карточка «Потенциальные
клиенты» стадии new, реклама дальше следует за стадией карточки.
PATCH /api/sales/ad-audience теперь принимает все 11 сроков планировщика,
GET отдаёт их в durations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET/PATCH /api/sales/ad-audience — три числа (в рекламе сейчас, выходят на
этой неделе, ждут отправки), рубильник и срок хранения номера. Доступ только
роли head — гейт denyIfNotHead зеркалит SalesManagersController.
Экран объясняет обычными словами, что список пополняется только кнопкой
«Отправить в рекламу» в Поиске клиентов, и предупреждает, когда номеров
меньше 100 (Яндекс не запустит рекламу).
Тесты: 9 в tests/Feature/Sales/AdAudienceScreenTest.php (доступ, три числа,
смена настроек, валидация срока). SharesSupplierPdo — модели на pgsql_supplier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(sales): приём номеров в рекламную аудиторию
Сервис-канал POST /api/sales/integration/ad-audience (тот же X-Sales-Token,
что и «Отдать менеджеру»). Новый номер добавляем, известный — продлеваем срок
из настройки и просим ночной джоб дослать (synced_at=NULL, removed_at=NULL).
Формат Яндекса строгий: 11 цифр с 7, без плюса.
Task 3 плана 2026-07-19-reklamnaya-auditoriya-kabinet-nachalnika.
Моделям дописаны @property-докблоки (конвенция проекта, как у SalesProspect) —
без них Larastan не видит полей.
Тесты: 6 в tests/Feature/Sales/AdAudienceIntakeTest.php, вся Sales-пачка 264 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <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): карточка кандидата — девять полей владельца + правка контактных лиц
Владелец задал список полей карточки и попросил дать менеджеру записывать
контактных лиц прямо в карточке: поиск отдаёт 5-7 ПРЕДПОЛАГАЕМЫХ номеров
директора, живой из них выясняется обзвоном; а если директор перенаправил
к маркетологу — менеджер фиксирует и его, людей может быть 2-3.
- Карточка показывает РОВНО девять полей в его порядке: Юрлицо, Сайт, ИНН фирмы,
Адрес, Телефон общий, Каналы, Бюджет, Директор, Тел. директора (предполагаемый).
Убраны Оценка, Достоверность, Реклама, Запросы в Директе, ОГРН, Статус юрлица,
Личный ИНН директора, Почта директора — данные остаются в payload.
- PATCH /api/sales/prospects/{id}/contacts — список заменяется целиком, пустой
стирает всех; права как у update() (менеджер свои, начальник любые); главный
телефон карточки из поиска не трогается.
- ProspectContactsEditor.vue + utils/prospectContacts.ts — один редактор контактов
на диалог создания и карточку; диалог создания переведён на него.
- Закрывает хвост §16: у 24 живых карточек из поиска контактных лиц не было,
теперь дозаполняются руками.
Спека §18. Гейты: Pest 223/223 sales, Vitest 196 файлов / 1397 тестов,
vue-tsc чисто, Larastan 0 в своих файлах. 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>
@
opened: карточка из «Новые» уезжает во «Взят в работу» при первом открытии
(на прочих стадиях тихий no-op). back_to_new: вернуть in_work→new (иначе 422) —
менеджер открыл, отвлёкся, закрыл и не потерял.
POST /api/sales/prospects: менеджер заводит своего кандидата (source=manager,
stage=new, владелец всегда автор — sales_user_id из тела игнорируется).
Ingest из поиска помечает source=search; начальник фильтрует ?source=.
Гейты: 35/35 Pest, Larastan в моих файлах 0.
NB: LEFTHOOK_EXCLUDE=larastan,cspell — гейты падают на ЧУЖИХ файлах
параллельной сессии (Admin/Billing тесты балансов), мои чистые.
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>
Портал публикует /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>
Этап 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>
Ветка разошлась с боевым 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>
Лимиты гостя по решению владельца: разговорился — пусть говорит (60 вопросов в
первый час), но если завис в чате на весь день — тормозим до 30 в час, иначе один
посетитель съест дневной бюджет. Потолок одного разговора поднят 40 → 150 (иначе
«60 в час» упиралось бы в обрыв разговора).
Страница разборов «Как это работает» отдаётся самим порталом по адресу
/kak-eto-rabotaet: на боевом nginx отдаёт лендинг только по «/», все остальные пути
уходят в портал — значит, конфиги сервера трогать не нужно.
Co-Authored-By: Claude Opus 4.8 <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>
В смс дорог каждый символ: вместо длинного хвоста с метками сервер сам
подставляет канал и дату рассылки (дд-мм), чтобы разные рассылки не слипались.
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>
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.
Заменяет JivoBotController+JivoBotClient на ChatController (POST /api/chat/message)
и ProcessJivoMessageJob на ProcessChatMessageJob. Реплику клиента теперь пишет
контроллер и возвращает её номер (message_id); джоба получает этот номер и берёт
историю строго до него, не отправляя ответ во внешний Jivo — он ложится в bot_dialogs,
откуда его заберёт своё окошко (Задача 3). Мозг бота не менялся.
Спека: docs/superpowers/specs/2026-07-13-own-chat-widget-design.md §5
Кнопка «Удалить» на карточках «На актуализацию» рядом с Добавить/Заменить.
Отклоняет ТОЛЬКО находку (новый сайт/адрес): фирма поля остаётся, находка → архив,
её новые ключи запоминаются у фирмы поля (новая колонка 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>
Слияние конкурентов больше не «чёрная дыра» (баг «САПС/Метрополис»: карточка
исчезла без следа, вернуть было нечем). Теперь каждое слияние пишет событие в
autopodbor_merge_events со СНИМКОМ поглощённых карточек (имя/сайт/телефоны/
справочники), кто и когда слил.
- Таблица autopodbor_merge_events (+RLS tenant_isolation, паттерн 1:1 с sources,
rls-ревью OK; nullOnDelete на survivor_id/user_id — история не рушится).
- AutopodborCompetitorMerger::merge($tenantId,$ids,$name,$userId) пишет событие.
- GET /field/merge-events — история; POST /field/merge-events/{id}/restore —
возврат поглощённых как предложения (защита от повторного возврата).
- Фронт: панель «🕘 История слияний» + «↩ Вернуть карточку».
Бэк 428/428, фронт 12/12, Pint чистый, build ок. schema.sql канон-синк всех
таблиц автоподбора — предвыкатный TODO (вся фича migration-only в этой ветке).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Шаг 1 «Конкурентное поле» — чекпоинт:
- Финальный проход «Найти и объединить дубли» на поле и предложениях; клиент решает по каждой группе, ничего не склеиваем молча.
- Тихая склейка только по сайту и коду справочника; телефон и людный номер у более чем 4 фирм — на решение клиента.
- Колонка phones jsonb; телефон-дубли видны уже на «Предложениях».
- Sonar даёт только имя и тип, сайт всегда через EXA; вскрываем все карточки без гейта «есть сайт».
- Яндекс-карточки через локальный Playwright параллельно; рубрика из заголовка идёт в описание.
- Фикс рекламных номеров: 2ГИС-парсер разбирает телефоны пообъектно и выкидывает рекламные кнопки с platforms/caption чужих фирм.
Тесты: автоподбор 224/224 unit, 105/105 feature; фронт-спеки зелёные. Времянки app/scripts/diag-*/probe-* в гит не входят.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>