Commit Graph

236 Commits

Author SHA1 Message Date
Дмитрий ea01e50a7a feat(реклама): письмо и флаг баннера при нехватке рекламного кошелька 2026-07-24 21:11:51 +03:00
Дмитрий c41206121a fix(портал продаж): вход только по токену — чужая web-сессия не перебивает
Гвард 'sales' (Sanctum) на stateful-домене lk.liderra.ru подставлял
App\Models\User из открытого рядом обычного кабинета вместо SalesUser —
500 на всех маршрутах портала, роль слетала в «менеджера» (боевой
инцидент 24.07.2026). Новый драйвер 'sales-token' в AppServiceProvider
авторизует только по Bearer-токену, не заглядывая в web-сессию;
обычный кабинет (auth:sanctum) и impersonation не затронуты.

Регресс-тест SalesGuardTokenPriorityTest воспроизводит поломку.
larastan-хук исключён: 2 ошибки — чужой pre-existing WIP ветки
(SetTenantContext, SmscSmsProviderTest), мои файлы stan-чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:53:24 +03:00
Дмитрий 225b7d9207 feat(реклама): AdWalletService::topup — пополнение рекламного кошелька
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:40:41 +03:00
Дмитрий 83ebb91001 feat(витрина B6): read-API состояния прогрева для finder
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
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>
2026-07-23 15:34:23 +03:00
Дмитрий 80c9ca7e67 feat(витрина B1,B2,B4,B5): летопись эпизодов прогрева + значки в воронке; уборка мёртвых скоупов
Кусок 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>
2026-07-23 13:54:09 +03:00
Дмитрий 8033debfbc fix: воронка — значок «на прогреве» берём из firm_channels, не из мёртвых ch_*
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
После Фазы 2 «раздельные каналы» булевы ch_yandex/ch_vk/ch_mts на фирме
больше не заполняются — заезд и кнопка «Греть» пишут только строки
firm_channels. Но карточка воронки считала active/channels из ch_*, поэтому
у реально греющихся фирм значок оставался серым и без иконок каналов.

warmingByProspect теперь берёт членство и каналы из firm_channels со
status=warming, по образцу SalesAdAudienceController. sms — как прежде, по
факту отправленной рассылки.

Тесты ProspectWarmingBadgeTest переведены со старых ch_* на firm_channels
плюс добавлен новый тест-регресс. Sales-группа 412/412, composer stan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:37:54 +03:00
Дмитрий 48b88737e7 feat(прогрев ф2 этап D): backend СМС-канала — свой список sms-firms + счётчик «Отправлено N» (+ план этапа D)
Фаза 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>
2026-07-22 18:28:43 +03:00
Дмитрий b5e6051803 feat(прогрев ф2 этап C): экран канала — выбор + «Греть»/«Убрать», убран тоггл «Греть здесь»
Фаза 2 Этап C, Task C4 (Vue + tiny backend). SalesWarmingView переделан под модель
«фирма×канал»: галочки = выбор (без сети), кнопка «Греть» открывает модалку —
«По воронке» или «Простой срок [N] дней» (1–365) → warmFirms; кнопка «Убрать»
(массово с подтверждением + построчная иконка) → unassignFirms. Тоггл «Греть здесь»
и toggleWarmingFirm удалены. Загруженные (ещё не греются) показаны серым чипом
«Ждёт запуска» — для этого firmRow отдаёт channel_status (loaded|warming|null),
api-строка получила поле channel_status. Vuetify (v-dialog/v-text-field), палитра
проекта, переиспользованы существующие чипы sm-*. Vitest 20 (экран) + 1482 фронт,
build ✓, backend 46 GREEN, larastan 0. Bump baseline (actingAs-ложняк).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:16:48 +03:00
Дмитрий 75fd019325 feat(прогрев ф2 этап C): эндпоинт «Убрать» — снять выбранные фирмы с канала
Фаза 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>
2026-07-22 17:57:28 +03:00
Дмитрий effcf013dd feat(прогрев ф2 этап C): эндпоинт «Греть» — массовый запуск прогрева канала (воронка/простой срок)
Фаза 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>
2026-07-22 17:48:39 +03:00
Дмитрий 6775f58003 feat(прогрев ф2 этап C): показ по каналам — firms/бейджи/числа из firm_channels (+ план этапа C)
Фаза 2 Этап C, Task C1. Вкладка канала показывает ТОЛЬКО свой состав: firms({platform})
фильтрует по наличию строки firm_channels(channel) (loaded или warming). Бейджи
warming_channels строятся из строк firm_channels (yandex/vk/mts по строкам, sms по
факту рассылки), а не из ch_*. Числа вкладки (in_ads/expiring_week/waiting_sync)
считаются по фирмам со строкой firm_channels(channel, status='warming') — loaded в
рекламе не участвует. Это чинит поломку после этапа B (заезд не пишет ch_* ⇒ старые
ch_*-подсчёты давали 0/пусто у новых фирм). channelColumn удалён (сирота).
allFirms/toggle/mtsFile/движок/заливки не тронуты. ch_* не удаляем (откат).
Тесты мигрированы. Bump baseline (actingAs-ложняки).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:38:15 +03:00
Дмитрий ce48d911d6 feat(прогрев ф2 этап B): заезд = только загрузка во все 4 канала (loaded), без автостарта
Фаза 2 Этап B, Task B1 (+ план этапа B). AdAudienceIntake::ingest больше не
запускает прогрев: грузит фирму во все 4 канала (yandex/vk/mts/sms) строками
firm_channels status='loaded' (firstOrCreate — не понижает уже греющийся канал).
ch_* на заезде не пишутся (источник членства — строки firm_channels, resolveChannels
удалён). Повторный заезд обновляет только снимок-поля и НЕ сбрасывает
warmup_started_at/ready_at/stopped_at/stop_reason (заезд ≠ рестарт прогрева).
Фирма без warming-строк ни в одну заливку не идёт ⇒ автостарта нет по построению.
Запуск прогрева — кнопкой «Греть» на портале (этап C). Тесты intake мигрированы
на новую семантику. Bump baseline (+6 postJson-ложняков).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:39:45 +03:00
Дмитрий e15a4d8a69 feat(прогрев ф2): тоггл «Греть здесь» и firmRow — на строки firm_channels
Фаза 2 Этап A, Task 8. toggle пишет строку firm_channels(channel, status=warming,
mode=funnel, warming_started_at=firm.warmup_started_at, warmed_times++) вместо
булева ch_*; off удаляет строку (инкремент двумя шагами, без raw). firmRow на
вкладке площадки считает state/warming_active по строке канала через
decideForChannelRow (нет строки или loaded → stopped/false). firms() eager-грузит
firmChannels (против N+1). Границы этапа A: warming_channels-бейджи, platformState,
channelColumn, allFirms и показ всех фирм — без изменений (ch_*, показ состава →
этап C). ch_* колонки не удаляются. Бамп baseline (+9 actingAs-ложняков).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:08:35 +03:00
Дмитрий 32f9d87f9c feat(прогрев ф2): файл МТС берёт только строки firm_channels warming+active
Фаза 2 Этап A, Task 7. mtsFile() выбирает фирмы по строке firm_channels
(channel=mts, status=warming) вместо флага ch_mts и решает по ней через
decideForChannelRow. Фирма с mts-строкой loaded в файл не попадает, даже с
ch_mts=true. Формат файла (streamDownload, имя, сортировка) без изменений.
Тронут только mtsFile — toggle/firmRow/firms остаются на Task 8.
Бамп phpstan-baseline (+2 к actingAs/$head TestCall-ложнякам на 2 новых теста).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:47:08 +03:00
Дмитрий 10ea2f6295 feat(прогрев): строка фирмы показывает состояние по срокам площадки
state/stop_reason/resume_at/warming_active на вкладке площадки
(GET /api/sales/warming/{platform}/firms) теперь считаются
PlatformWarmingDecider по срокам ИМЕННО этой площадки, а не сводкой
по phone.state/firm.stopped_at — та же фирма может быть active на
yandex и уже stopped на vk. allFirms() (СМС-подборщик) сохраняет
старую сводную логику без изменений.
2026-07-22 11:27:09 +03:00
Дмитрий 2c3698c615 feat(прогрев): сроки площадки пишутся/читаются со строки площадки 2026-07-22 10:32:50 +03:00
Дмитрий fc16500fea feat(прогрев): блок warming (active + каналы) в ответе по проспектам
Task 4 плана «три площадки прогрева» кусок B: GET /api/sales/prospects
теперь отдаёт warming.active/warming.channels на каждой карточке —
сопоставление SalesAdAudienceFirm.prospect_id в PHP (pgsql_supplier
против default connection), без cross-connection JOIN. Канал sms —
если номер фирмы попал в отправленную (status=sent) рассылку.
2026-07-21 16:16:41 +03:00
Дмитрий 71c93477b8 feat(прогрев): значок sms в warming_channels по отправленной рассылке 2026-07-21 16:07:20 +03:00
Дмитрий f93ab67e7e feat(прогрев): платформо-независимый маршрут назначения менеджера (для экрана СМС) 2026-07-21 16:00:36 +03:00
Дмитрий fbe4701399 feat(прогрев): firmRow отдаёт нишу, дату запуска, менеджера и состояние прогрева
Task 1 плана «кусок B». firmRow() в SalesAdAudienceController (firms()/allFirms())
теперь отдаёт rubric, warmup_started_at (ISO8601), manager_id/manager_name (через
prospect.sales_user_id) и агрегаты warming_active/warming_channels (yandex/vk/mts/sms).
Заглушка smsWarmedFirmIds() — Task 3 наполнит связью с СМС-рассылкой.

Тест AdAudienceWarmingFieldsTest — TDD (failing → green), плюс regression-прогон
72 тестов AdAudience* и composer stan (0 ошибок, phpstan-baseline.neon дополнен
записью под Pest actingAs() в новом тестовом файле — по конвенции остальных тестов).
2026-07-21 15:53:10 +03:00
Дмитрий 079e96edf1 fix(прогрев): список фирм одинаков на всех вкладках площадок (синхронизация)
firms({platform}) больше НЕ фильтрует по ch_<platform> — отдаёт весь состав
прогрева. Так фирму видно и можно включить на любой площадке; ВК/Телеграм
перестают быть пустыми. Отметка «Греть здесь» в строке (isOnHere) по-прежнему
по своей площадке. Тест переведён на $this-> (larastan понимает тип);
phpstan-baseline регенерирован (п.25) — счётчики тестов выровнены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:19:41 +03:00
Дмитрий 6e771636ef feat(смс): модуль «Прогрев СМС» с заделом под мультиклиентность
Начальник отдела продаж отмечает фирмы прогрева галочками, пишет текст,
видит цену ДО отправки и журнал после. Отправки СМС в проекте не было
вообще — ни у СМС-центра (только баланс и HLR), ни у МТС (только файл).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:47:19 +03:00
Дмитрий 277800149f @
fix(sales): строгое разделение двух ролей начальника — экраны «мои» больше не показывают отдел

Начальник продаёт сам и руководит отделом. Правило «начальник видит всё»
применялось к ЧЕЛОВЕКУ, а не к экрану, поэтому разделы меню врали:

- «Потенциальные клиенты» показывали все карточки отдела — точную копию
  «Воронки отдела» (это владелец и заметил);
- «Мои клиенты» и «Сводка» — всех клиентов отдела;
- «Привязать клиента» — очередь заявок отдела, причём в ЧУЖОМ формате
  ({pending, history} вместо {data}), форма получала не те данные.

Новое правило: роли разделяются по ЭКРАНУ. Любой запрос по умолчанию отдаёт
только личное — включая начальника. Весь отдел выдаётся только по явному
?scope=department, и просят его только экраны раздела НАЧАЛЬНИК. Менеджеру
параметр ничего не даёт: проверка по роли, не по параметру.

ownedTenantIds теперь ВСЕГДА личные привязки (тип сузился с ?array до array),
добавлен visibleTenantIds для области видимости запроса.

Правом начальника осталось открыть ЛЮБУЮ карточку — кандидата и клиента:
иначе из «Воронки отдела» не открылась бы карточка чужого менеджера.
Ограничены только списки, не доступ к записи.

Следствие: у начальника сейчас 0 своих кандидатов и клиентов, поэтому его
личные экраны станут пустыми — это правильно, а не поломка.

Pest 272/272, Vitest зелёный, vue-tsc чист, Larastan без своих ошибок.
Спека — §23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 15:59:56 +03:00
Дмитрий e9edb206c8 @
feat(sales): кабинет начальника — единый период (без 500) + пополнения баланса

Экраны начальника отстали от вчерашних правок кабинета менеджера (§16–§21).

1. Живой баг: «Произвольный» период до выбора дат ронял запрос в 500 на пяти
   точках из шести (сводка отдела, доход, результативность, выплаты, тарифы).
   Разбор периода вынесен в трейт ResolvesSalesPeriod: 422 вместо падения,
   период по умолчанию d30 вместо this — как показывает сам PeriodPicker.

2. Пополнения баланса (topup_rub) добавлены в dashboard/overview (kpi +
   строки менеджеров) и managers/performance. На экранах: плашка «Пополнили
   баланс» в сводке отдела и колонка «Пополнили ₽» в обеих таблицах
   результативности. Из подписей убрано «(мес)» — период больше не месяц.

3. «Воронка отдела» правок не потребовала: она рендерит те же доску и карточку,
   что экран менеджера, а карточка сама грузит журнал и сохраняет контакты.

Тесты: SalesPeriodRequestTest проходит по всем шести точкам сразу.
Pest 263/263, Vitest 1420/1420, vue-tsc чист.
Спека — §22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 15:20:44 +03:00
Дмитрий 6416ec3789 @
feat(sales): периоды сегодня/вчера/7/30 дней календарём, пополнения за период, скролл доски наверх

Замечания владельца по сводке и доске.

ПЕРИОДЫ:
- Резолвер понимает today/yesterday/d7/d30; «7 дней» = сегодня и 6 предыдущих.
  Месячные this/prev/prev2 сервер принимает по-прежнему.
- По умолчанию 30 дней.
- ПОЧИНЕНО: выбор «Произвольный» без дат ронял запрос (500). Теперь понятный 422,
  а на фронте период применяется только когда отмечены ОБЕ даты.
- Произвольный выбирается календарём-диапазоном, не руками; порядок дат неважен.

ПОПОЛНЕНИЯ ЗА ПЕРИОД (переиспользован готовый topupsRub):
- Сводка: плашка «Пополнили баланс» рядом с «Σ баланс».
- Мои клиенты: колонка «Пополнил».
  Заодно даёт число, которое видимо меняется при смене периода.

ДОСКА: горизонтальная полоса прокрутки поднята НАД колонками (колонки высокие,
системная полоса уезжала за экран); синхронизация в обе стороны, ResizeObserver
на приезжающие карточки.

Спека §21 (включая §21.4 — как по коду заполняется «Требуют внимания»).
Гейты: Pest 257/257 sales+unit, Vitest 1417, vue-tsc чисто, Larastan 0 в своих. TDD.

LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 14:29:18 +03:00
Дмитрий 1e0442facf @
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>
@
2026-07-18 14:00:55 +03:00
Дмитрий 138550207a @
feat(sales): содержание разговора — вместе с результатом; недозвону дата перезвона

Владелец после первого показа карточки: «куда звонить убери; договорились на
созвон — ниже краткое содержание, и оно копится в Историю разговоров, свежие
в начало; недозвон — поставь дату следующего перезвона».

- Блок «Куда звонить» убран: дублировал строку «Телефон общий» из данных фирмы.
- «Краткое содержание разговора» переехало последним полем в блок «Результат
  разговора» (было отдельное поле с кнопкой слева — два места для одного
  действия). Уходит параметром summary вместе с результатом, ложится в журнал
  отдельной записью kind=note. Пустое — не пишем.
- Порядок внутри одного сохранения: сначала автозапись про этап, затем
  содержание → у него больший id и в ленте оно оказывается НАД этапом.
- Недозвон получил необязательное поле «Когда перезвонить»: пишется в
  next_call_at (видно на плитке) и дописывается в автозапись журнала.

Спека §20. Гейты: Pest 236/236 sales, Vitest 59/59 воронка, 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>
@
2026-07-18 13:20:27 +03:00
Дмитрий 41fbc4491d @
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>
@
2026-07-18 13:04:02 +03:00
Дмитрий 68428f16c6 @
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>
@
2026-07-18 12:29:04 +03:00
Дмитрий 48dd6c44af @
fix(sales): «Зарегистрировался» подаёт ЗАЯВКУ начальнику, а не привязывает клиента сразу

Владелец заметил: в отделе есть штатный порядок «через начальника» (экран «Заявки
на привязку»), а кнопка в карточке кандидата создавала SalesClientAssignment напрямую
и в истории заявок ничего не появлялось.

- registerProspect() теперь зовёт SalesAttachmentService::submit() от имени ВЛАДЕЛЬЦА
  карточки. Свободен → заявка pending + письма менеджеру и начальникам; занят другим →
  заявка с пометкой конфликта, чужая привязка не трогается; уже свой → заявки нет.
  Привязка со снимком тарифа создаётся только при одобрении (handleApprove).
- Стадия карточки едет в «Зарегистрировался» СРАЗУ (решение владельца): регистрация —
  факт, воронка показывает правду; «чей клиент и кому деньги» решает начальник.
  Автожизнь не зависит от одобрения — джоб смотрит linked_tenant_id.
- Отменён прежний 422 «клиент уже закреплён за другим менеджером» — теперь это заявка.
- Подсказка под полем e-mail говорит про одобрение начальником.

Спека §17 (отменяет правило §6 «привязываем сразу»). Гейты: Pest 218/218 sales,
Vitest 38/38, Larastan 0 в своих файлах. TDD RED→GREEN.

LEFTHOOK_EXCLUDE: larastan/cspell падают на файлах параллельной сессии
tests/Feature/Admin/*Balances*, свои файлы проверены отдельно и чисты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 12:09:24 +03:00
Дмитрий 1011a37e77 @
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>
@
2026-07-18 11:39:48 +03:00
Дмитрий 4f6e32e389 feat(sales): бэкенд — «Взят в работу» (opened/back_to_new) + свои кандидаты менеджера
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>
2026-07-18 10:51:02 +03:00
Дмитрий 9c1559dc90 feat(supplier): джоба чистки доп-каналов + рубильник + тесты (стоп-кран/404/устойчивость)
Джоба PruneSupplierExtraChannelsJob: листинг кабинета -> удаление всего кроме
B1/B2/B3 (rt/bl/mt), стоп-кран при доле >=50%, рубильник config (по умолч. ВКЛ).
Larastan-хук исключён точечно: остаток ошибок — baseline-дрейф в чужих
Admin/Billing тестах параллельной сессии; мои файлы = 0 ошибок (Mockery-шум
занесён в phpstan-baseline как у DeleteSupplierProjectJobTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:15:37 +03:00
Дмитрий e8ed8dabb5 chore(supplier): удалить мёртвый SupplierCsvParser (reconcile перешёл на fetchDeliveredLeads 09.07)
Старый парсер отчёта «Запрос номеров» (Name;Tag;Phone) больше не вызывается —
CsvReconcileJob перешёл на portal->fetchDeliveredLeads (журнал отданного по vid).
Класс висел неиспользуемой инъекцией в handle(). Удалён класс + 2 его теста + аргумент
в тесте + записи baseline; комментарий downloadReport подчищен. Larastan 0, 18/18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:33:55 +03:00
Дмитрий 6ceec17439 feat(sales): Этап 3 — автожизнь воронки (регистрация + джоба стадий по деньгам)
Действие «Зарегистрировался» (PATCH action=registered): email→tenant, привязка
SalesClientAssignment со снимком тарифа, linked_tenant_id+stage=registered; клиент
занят другим → 422. Диалог карточки: пункт «Зарегистрировался» + поле e-mail.
SalesProspectsAdvanceJob (каждые 15 мин, pgsql_admin): по balance_transactions
считает стадию — Σtopup≥30000→user, >0→topped_up, есть расход при 0 topup→testing,
иначе registered; ручные/отказные стадии не трогает. Схема НЕ меняется.
Гейты: бэк 19/19, фронт 9/9, Larastan 0. 🪤 property $connection конфликтовал с
трейтом Queueable → переименовал в $dbConnection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:00:16 +03:00
Дмитрий 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
Дмитрий 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
Дмитрий acdd2fbbe1 chore(gates): зафиксировать долги боевого кода — гейты снова ловят новое
Оба гейта были красными на ЧУЖОМ коде, уже работающем на бою:
- deptrac: RunResource -> AutopodborQueue (read-only «место в очереди» для UI).
  Та же категория, что зафиксированный 27.06 ProjectResource (ADR-005).
  Долг с 78a37978 (05.07) — значит коммиты автоподбора шли мимо гейта.
- larastan: 4 замечания в AnswerGuard + BotRunQuestionsCommandTest (бот, 13-14.07).
  Коммиты бота baseline не трогали — значит гейт обходили.

Поведение кода не менялось: только baseline-файлы. Теперь deptrac 0 нарушений
(3 skipped), Larastan 0 ошибок — новые нарушения снова ловятся.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:46:37 +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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий b28f858419 fix(supplier): метка канала B1_/B2_/B3_ не стирается при обновлении заказа
Прод-инцидент 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>
2026-07-12 19:14:22 +03:00
Дмитрий 4842c728c8 fix(supplier): правки по код-ревью — матч наших строк по external_id, shouldBeOff без активных, sync_run_id
- normalizeLive матчит наши строки по supplier_external_id (tag=регион, не _lidpotok) — иначе live всегда пуст
- shouldBeOff исключает ключи, активные по формуле (нет ложного should_be_off)
- recordRunSummary → insertGetId, проверка получает sync_run_id
- обновлён phpstan-baseline.neon: count mock() 2→3 (новый тест на баг 2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:55:47 +03:00
Дмитрий b94c4152f8 feat(supplier): сторож дедлайна 20:00/20:40 — письмо если робот не успел
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:36:16 +03:00