Commit Graph

732 Commits

Author SHA1 Message Date
Дмитрий c6137b910d style(sales): автоформатирование Pint в файлах рекламной аудитории
Хвосты от pre-commit хука: импорт Builder и сокращение полных имён
классов в PHPDoc. Поведение не меняется, тесты те же.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:30:33 +03:00
Дмитрий 7e8d06e1aa feat(sales): состояние канала ВК на экране начальника
Контроллер отдаёт vk_status/vk_last_synced_at/vk_last_error из GET
/api/sales/ad-audience, экран показывает человеческим текстом: «доступ
ещё не выдан» / «ждёт объёма — нужно от 2000 номеров» / «работает».

Task 9 плана docs/superpowers/plans/2026-07-19-vybor-ploshadki-progreva.md.
2026-07-19 20:28:18 +03:00
Дмитрий 7ed180b76b fix(sales): выключатель рекламы гасит и ВК, а не только Яндекс
Субагент убрал из SyncVkAudienceJob проверку общего рубильника enabled,
потому что с ней не проходили тесты. Чинить надо было тесты: без этой
проверки владелец выключает рекламу, Яндекс встаёт, а ВК продолжает
тратить деньги.

Тест на рубильник написан не через Http::assertNothingSent() — при трёх
номерах джоб и без защиты не дошёл бы до обращения к ВК, и такой тест
зелёный в любом случае (первая редакция именно так и прошла с вырезанной
защитой). Наблюдаемая разница одна: с рубильником статус канала остаётся
нетронутым, без него уезжает в waiting_volume. Проверено вырезанием.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:21:26 +03:00
Дмитрий d98e86cb1f feat(sales): заливка в ВК с тремя состояниями канала
no_access / waiting_volume / working. Ниже порога 2000 номеров и без
ключа доступа джоб не делает к ВК ни одного обращения (Http::assertNothingSent
в тестах) — защита от случайной траты денег владельца, тот же приём, что
у рубильника enabled в Яндексе.

VkAudienceClient::replaceList намеренно не реализован (throw): техническая
документация API ВК закрыта до получения доступа (заявка подана 19.07).

Найдено по ходу: свойство конструктора джоба нельзя называть $connection —
конфликтует с публичным $connection из Illuminate\Bus\Queueable (фатальная
ошибка "incompatible property composition"). Переименовано в $dbConnection,
как $prospectConnection у RecalcAdAudienceJob.

Task 8 плана docs/superpowers/plans/2026-07-19-vybor-ploshadki-progreva.md.
2026-07-19 20:16:00 +03:00
Дмитрий aaeab9c62b feat(sales): массовая смена площадки прогрева
POST /api/sales/ad-audience/channels — начальник меняет channels
у отмеченных фирм разом (гейт denyIfNotHead, менеджеру 403).
firms() теперь отдаёт channels в списке.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:43:16 +03:00
Дмитрий 7d4f973733 feat(sales): площадка прогрева приходит вместе с фирмой 2026-07-19 19:33:58 +03:00
Дмитрий 6f2bf8e119 fix(sales): в Яндекс уходят только фирмы, помеченные Яндексом 2026-07-19 19:30:10 +03:00
Дмитрий 9e1aebf423 feat(sales): отбор фирм по площадке прогрева 2026-07-19 19:26:00 +03:00
Дмитрий 2228bae51d fix(sales): ночной пересчёт рекламы читает карточки ролью с правами
На бою джоб бежит вне веб-запроса, то есть на дефолтной роли crm_app_user,
у которой нет прав на sales_prospects — пересчёт падал с permission denied
и не работал вообще. Планировщик теперь передаёт pgsql_supplier, как это
делает SalesProspectsAdvanceJob. Регресс-тест закрывает.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:22:36 +03:00
Дмитрий 49ee084b7e fix(sales): номер без хозяина не попадает в рекламу
Номера, заведённые до v8.77, остались без firm_id. Ночной пересчёт обходит
фирмы и такой номер не видит, а заливка видела — он крутился бы вечно.
Нашёл rls-reviewer при сквозной проверке.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:51:50 +03:00
Дмитрий 79d1567e7d feat(sales): список прогрева и назначение менеджера из рекламы
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>
2026-07-19 11:19:46 +03:00
Дмитрий 53d2165b25 feat(sales): ночная заливка состава аудитории в Яндекс
SyncAdAudienceJob использует готовый YandexAudienceClient (Http::fake
в тестах) и режим modify_data replace: заливает весь активный список
целиком раз в сутки (03:20 МСК, сразу после RecalcAdAudienceJob).
Рубильник sales_ad_audience_state.enabled=false — джоб не делает ни
одного обращения к Яндексу. Ошибку Яндекса кладём в last_error,
не проглатываем молча.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:07:24 +03:00
Дмитрий f6abd1764d feat(sales): фирма на прогреве помнит увиденную стадию — честный ночной пересчёт рекламы
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>
2026-07-19 10:54:21 +03:00
Дмитрий 566f016c9c feat(sales): в прогрев уходит фирма целиком, а не отдельный номер
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:35:41 +03:00
Дмитрий 9bc587c2de fix(sales): реальный телефон директора убран из тестов и планов; клиент Яндекс.Аудиторий возвращён в main
Коммит d2c2ec43 от 19.07 оторвался от main (dangling, ни в одной ветке):
чистка ПДн и YandexAudienceClient в основную ветку так и не попали.
Номера заменены на фиктивные 7999000000X, клиент и его тесты внесены заново.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:24:21 +03:00
Дмитрий e25f940f39 feat(sales): модель фирмы на прогреве и настраиваемые сроки 2026-07-19 10:11:09 +03:00
Дмитрий 3ab8b2cbff feat(sales): руками введённые телефоны контактов чистятся при сохранении (формат 79…, дубли долой)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:57:02 +03:00
Дмитрий 44b892c771 feat(sales): экран «Реклама на кандидатов» в кабинете начальника
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>
2026-07-19 08:46:36 +03:00
Дмитрий bb20a8859d @
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>
@
2026-07-19 08:16:14 +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
Дмитрий b3d758c81b @
style(sales): Pint — импорты ДаData в тесте кандидатов

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 11:40:27 +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
Дмитрий 707d4d56b2 fix(external): защитить Carbon::parse срока прокси (fat-finger в .env не роняет джобу/дашборд) + тесты границ/битой даты/алерта 2026-07-17 08:59:02 +03:00
Дмитрий 3f75baf050 feat(dashboard): строка proxy_market в плашке — срок «до …» + остаток дней + продлить 2026-07-17 08:23:09 +03:00
Дмитрий 3be76152ed style(supplier): pint-импорты в тесте чистки доп-каналов
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:15:37 +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
Дмитрий a497f76af4 feat(external): «Пополнить» для xfetch/Keyso (в карточке поиска) + EXA-тоннель (Happ VPN, настраиваемо) 2026-07-16 19:06:39 +03:00
Дмитрий 88e08b0a3d feat(external): AITUNNEL баланс+«Пополнить», EXA «Пополнить», ЮKassa=живость (не деньги) 2026-07-16 18:45:06 +03:00
Дмитрий b1814bea34 feat(external): POST /balances/refresh принимает services[] (групповое обновление) 2026-07-16 17:05:38 +03:00
Дмитрий 5a02d0698f feat(dashboard): онлайн-обновление балансов — POST /refresh + whitelist + уборка jivosite
Фаза 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>
2026-07-16 14:50:01 +03:00
Дмитрий e97d164bc9 feat(external): 5 новых сервисов под присмотр (AITUNNEL/EXA/xfetch/self-render/Sales-finder)
Фаза B плана 2026-07-16-external-services-online-monitoring:
- HttpFundedServiceProvider — база «деньги-или-живость» по HTTP (DRY).
- AitunnelBalanceProvider / ExaBalanceProvider / XfetchBalanceProvider — баланс если
  задан *_balance_url, иначе живость пингом; деградация «деньги→жив→grey».
- SelfRenderLivenessProbe / SalesFinderLivenessProbe — только живость.
- Реестр рефрешера расширен до 11 сервисов; supplier — единственный тяжёлый.
- config/services.php: ключи новых сервисов (sales_finder.base_url дефолт ПУСТОЙ).
Тесты: 53 External зелёные, Larastan 0 по своим файлам (точечно).

NB: larastan-хук исключён — 2 ошибки выше baseline в ЧУЖОМ незакоммиченном файле
tests/Feature/Billing/ExpireInvoicesTest.php (работа параллельной сессии в общей папке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:50:01 +03:00
Дмитрий c1f015d17d feat(external): ExternalBalanceRefresher — обновление набора сервисов + окно свежести + BalanceReading::alive()
Фаза A плана 2026-07-16-external-services-online-monitoring:
- BalanceReading::alive() — состояние «жив, денежного баланса нет».
- ExternalBalanceRefresher: реестр 6 сервисов, refresh(keys, force, sendAlerts),
  окно свежести 60с, edge-trigger алерта по колонке light (фон/кнопка sendAlerts=false молчат).
- RefreshExternalBalancesJob делегирует рефрешеру (поведение суточного сбора сохранено).
Тесты: 35 External зелёные, Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:50:01 +03:00
Дмитрий 908386b0da fix(billing): invoices:expire не просрочивал счета — saas_invoices читались под RLS-ролью (0 строк)
ExpireInvoicesCommand бил по SaaS-таблице saas_invoices через дефолтное
соединение (crm_app_user, RLS). Планировщик бежит без app.current_tenant_id
→ policy отдаёт 0 строк → команда НИКОГДА не помечала счёт overdue (тот же
класс бага, что SendNewLeadsDigestJob). На бою проверено: роль портала видит
0 счетов, реально 3 (кандидатов на просрочку сейчас 0 — живого вреда нет).

Лечение как у остальных cross-tenant обслуживающих команд (ScrubSoftDeletedDeals,
ReportsCleanupExpired): SaasInvoice::on('pgsql_supplier') (BYPASSRLS).

Тест: +регрессия «просрочивает счёт даже без контекста фирмы (tenant 0)»;
+SharesSupplierPdo. 2/2 зелёные, Larastan 0.

Найдено при аудите-хвосте после дайджест-фикса (01287d08).

Escape: владелец дал явное «коммить пуш и кати» + выбрал доделать в worktree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:20:47 +03:00
Дмитрий 01287d0804 fix(notifications): дайджест новых сделок не уходил на почту — список тенантов брался под RLS-ролью (0 фирм)
SendNewLeadsDigestJob брал перечень тенантов через дефолтное соединение
(crm_app_user, RLS). У очереди нет app.current_tenant_id → policy
tenants_self_isolation отдавала 0 строк, и рассылка молча превращалась
в no-op: ни одного письма о новых сделках с 19.06.2026 (проверено на бою —
0 из 58 сделок за сутки помечены отправленными).

Лечение зеркалит уже принятый фикс BalancePreflightSweepJob: перечень id
берём через pgsql_supplier (BYPASSRLS), затем per-tenant SET LOCAL внутри
digestForTenant восстанавливает контекст под RLS-ролью.

Тест: +регрессия «рассылает нескольким тенантам за прогон при системном
контексте 0»; +SharesSupplierPdo (иначе pgsql_supplier не видит
незакоммиченного тенанта). Проверено сломом выборки → 4 теста краснеют.
На бою сухим прогоном (Mail::fake): старый код 0 фирм → 0 писем,
новый 9 фирм → 1 дайджест сформирован.

Escape: владелец дал явное «коммить пуш и кати».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:45:33 +03:00
Дмитрий a29f10a25f fix(sales): «Мой доход» падал у менеджера (permission denied на sales_client_assignments)
Аутентифицированный sales-user грузится под pgsql (crm_app_user) до admin-db
(приоритет middleware Authenticate > кастомного UseAdminConnection), и модель
запоминает это подключение. SalesEarningsService лениво читал $u->assignments/
->currentTariff → запрос уходил в crm_app_user (нет прав на sales_*) → 42501.
Фикс: assignmentsOf()/tariffOf() — свежие запросы на текущем (admin) подключении,
как уже делает SalesMetricsService. Регресс-тест: bogus-подключение на модели.
Гейты: 18/18, Larastan 0. Тесты грант НЕ ловят (общий admin-PDO) — ловится живьём.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:37:47 +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
Дмитрий e9ee67e4cd harden(supplier): защита-инвариант «телефон звонившего != номер-ловушка» + робастный парсер
Defense-in-depth после 2-го инцидента в csv_recovery-канале (16.07.2026):
- RouteSupplierLeadJob: чокпоинт обоих путей (webhook+csv_recovery) — если у звонкового
  сигнала phone == identifier (номер-ловушка проекта), сделка НЕ создаётся и клиент НЕ
  списывается; лид метится processed_at+error, шлётся warning. Ловит любой будущий регресс.
- SupplierPortalClient.parseDeliveredRows: извлечение номера проекта без якоря $ —
  ловит номер-ловушку даже с хвостовыми символами.
Тесты RED->GREEN (guard 5 assertions), 45/45 route+csv, Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 07:05:27 +03:00
Дмитрий 1dc8900cec fix(supplier): CSV-сверка брала номер-ловушку B2/B3 вместо телефона звонившего
parseDeliveredRows хватал первый 7\d{10} в строке «Мои сделки», а у Билайн/МТС
проектов название = номер-ловушка (7\d{10}), стоящий раньше телефона звонившего.
Прод-инцидент 16.07.2026: 11 сделок tenant 7 легли с номером проекта вместо звонившего.
Теперь берём первый номер, не равный номеру проекта. Тест закрывает пробел — раньше
phone у B2-строки не проверялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 06:31:30 +03:00
Дмитрий 7e148474a5 feat(billing): при пополнении включать взведённый по балансу проект
releaseForTenant теперь активирует проекты, взведённые по балансу
(is_active=false + preflight_blocked_at) — те, что клиент запускал
кнопкой, но денег не хватило. Всё-или-ничего: потребность считается
с учётом их лимитов (без перерасхода). Черновики и паузы клиента не
трогаются. Покрыто 8 тестами (happy/all-or-nothing/учёт лимитов/
черновик/пауза/регресс armed-ON/идемпотентность), регресс sweep 11/11,
Larastan 0.

Спека: docs/superpowers/specs/2026-07-15-topup-auto-activate-armed-projects-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:31:27 +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