Compare commits

..

318 Commits

Author SHA1 Message Date
Дмитрий 7024a1edc7 reconcile: main = боевой прод (deploy-tariff + фича автоподбора + 3 фикса 09.07 + 2 orphan-файла)
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Старая линия main сохранена: тег main-legacy-20260709 + off-site бэкап. Дерево этого
коммита побайтово равно боевому серверу (495/495 бэкенд-файлов сверены).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:22:51 +03:00
Дмитрий 1d8a6f04ac reconcile: канон main = боевой прод (deploy-tariff + фича + 3 фикса 09.07)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:19:55 +03:00
Дмитрий 247a7b07c6 fix(autopodbor): RLS-политика межтенантного распорядителя + фронт 422-дискриминатор + price=0 гейт
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:45:12 +03:00
Дмитрий 856d85cbd4 feat(autopodbor): кнопка «Собрать источники (N)» в подвале + яркий алерт баланса 2026-07-09 09:45:12 +03:00
Дмитрий 23da9550f3 feat(autopodbor): endpoint POST /api/autopodbor/study/batch 2026-07-09 09:45:11 +03:00
Дмитрий f667a1079d feat(autopodbor): джоба зовёт распорядителя после завершения + крон autopodbor:study-tick
Задача 5: RunAutopodborStudyJob::handle() оборачивает существующее тело в
try/finally — после ЛЮБОГО завершения прогона (done/empty/failed, включая
проброшенное исключение) распорядитель AutopodborStudyScheduler::tick()
берёт следующего кандидата с освободившейся дорожки шага 2.

Задача 6: artisan autopodbor:study-tick — крон-страховка раз в минуту на
случай, если дорожка освободилась не через завершение джобы. Без
withoutOverlapping() — требует таблицу cache_locks, которой нет в схеме
(см. комментарий в routes/console.php); overlap уже защищён внутренним
Cache::lock в AutopodborStudyScheduler::tick().
2026-07-09 09:45:10 +03:00
Дмитрий 6c0ed2d300 feat(autopodbor): startStudyBatch + перевод одиночных сборов на распорядителя 2026-07-09 09:45:10 +03:00
Дмитрий aca5183a4e feat(autopodbor): AutopodborStudyScheduler — одна дорожка + round-robin 2026-07-09 09:45:09 +03:00
Дмитрий f2321f52a2 feat(autopodbor): AutopodborBudgetGate — деньги пакета с резервом проектов
Переиспользует BalancePreflightService.evaluate() для проверки, что после
списания за пакет ещё хватает баланса на дневной лимит активных проектов.
Бинарный поиск максимально доступного количества единиц пакета (maxAffordable)
и расчёт недостающей суммы (topupRub) при отказе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:45:08 +03:00
Дмитрий 35f42c22d4 feat(autopodbor): миграция batch_id + индексы очереди шага 2 2026-07-09 09:45:08 +03:00
Дмитрий 9229323af5 fix(supplier): пауза доходит до поставщика в batch — ночной джоб 18:00 гасит осиротевшие заказы
Боевой в batch-режиме: пауза проекта не доходила до поставщика — заказ висел активным
(галочка вкл, лимит>0), поставщик мог лить лиды и списывать за остановленный проект
(подтв. живой базой: omega, 57 заказов, 0 команд после паузы 08.07).

Поставщик фиксирует завтрашнюю заливку снимком в 21:00, поэтому гасить надо в 18:00-прогоне
ДО снимка (мгновенно бессмысленно — сегодня уже зафиксировано). Добавлен проход
deactivateOrphanedOrders в SyncSupplierProjectsJob: заказам источников, которых нет в
активных группах слепка за завтра, шлётся updateProject(status=paused) с ненулевым лимитом,
локально помечается inactive_since. online-режим не тронут (там пауза уже мгновенна).
Активность берётся из слепка (слепок-инвариант), не из live is_active.

Тесты (TDD RED->GREEN): batch pause + стык с delete-хвостом. 19/19 supplier+delete,
238/238 supplier+projects, pint чисто, PHPStan без новых замечаний.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:44:52 +03:00
Дмитрий 576981c1c9 feat(supplier): аварийный spike-детектор ручной очереди в incidents:watch-failures
Блок 6: если за окно (10 мин) в supplier_manual_sync_queue упала пачка
(>= threshold-manual-queue, дефолт 10) — вероятное падение кабинета поставщика.
Инцидент дедупится 60 мин, письмо шлётся каждый прогон на kdv1@bk.ru + ops@liderra.ru
(громко при реальной аварии, но одним сводным письмом). Флаг sendMail в createIncident.
phpstan-baseline: +4 TestCall::artisan() (5→9) от новых тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 74d4d9f21d)
2026-07-09 09:44:52 +03:00
Дмитрий 58fda75d52 fix(supplier): FailoverProjectChannel не шлёт per-project CRITICAL письма
Убраны письма manual_required и failover_to_form (по проекту, без троттла) —
источник потопа ~116 писем на ops@ 07.07. Строка в ручной очереди остаётся
(запись видна в админке). Зависимость Mailer удалена из канала и DI-привязки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72818f5a80)
2026-07-09 09:44:51 +03:00
Дмитрий d64aaa3e9b docs(pilot): зафиксированы 2 боевых выката 09.07 — пауза→поставщик (batch, dfa14862) + audit-chain global (ADR-021, 1495cd64)
Accessibility (Pa11y live) / a11y (push) Waiting to run
Отметка в ПИЛОТ.md обоих моих выкатов: (1) SyncSupplierProjectsJob deactivateOrphanedOrders
(пауза гасит заказ у поставщика в 18:00 до снимка 21:00; бэкап .bak-20260709-023707);
(2) AuditChainConfig global (verify-chains EXIT 0, 78 партиций; бэкап .bak-20260709-060558).
NB про параллельный CSV-reconcile money-фикс (91c5a119) — уже в gitea main, дрейфа нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:12:33 +03:00
Дмитрий 91c5a119eb fix(supplier): CSV-reconcile сверяет журнал ОТДАННОГО (по vid), не пул
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Причина фантомных сделок kdv1 (09.07.2026): CsvReconcileJob брал отчёт «Запрос
номеров» = ПУЛ собранных номеров (phones_cnt), а не ОТДАННОЕ (crms_cnt), и лепил
из пула сделки клиенту + выедал дневной лимит. Пример: пул 157, отдано 15,
у клиента 15 фантомов, а 15 реально отданных выбило лимитом.

Фикс: SupplierPortalClient::fetchDeliveredLeads() читает журнал «Мои сделки»
(index-visit, по vid) — только реально отгруженное. CsvReconcileJob сверяет по vid,
добор несёт настоящий vid (idx_supplier_leads_vid_unique -> идемпотентно с webhook).
Пул не трогается -> фантомы невозможны, лимит не забивается мусором.

Проверено: 12 новых тестов + 294 supplier зелёные, Pint/Larastan чисто. Выкачено и
проверено на проде (reconcile читает отданное, 0 фантомов за прогон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:05:52 +03:00
Дмитрий 1495cd643d fix(audit): hash-chain canonical → global-within-partition (ADR-021, supersedes ADR-018)
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Ночной audit:verify-chains падал на pd_processing_log (ложный «high»-инцидент + письма
kdv1@bk.ru). Проверка на живом кластере (read-only, хеши server-side) показала: ВСЕ 4
«per-tenant» audit-таблицы (activity_log, tenant_operations_log, balance_transactions,
pd_processing_log) записаны ГЛОБАЛЬНОЙ цепочкой (0 global-mismatch), per-tenant верификация
ложно ругается на стыках клиентов. Предпосылка ADR-018 (триггер даёт per-tenant через RLS)
эмпирически неверна — изоляция никогда не была реализована. Данные целостны.

Решение владельца (с учётом что задет и денежный balance_transactions): канон — global.
AuditChainConfig: partition='' для 4 таблиц (verify/rebuild читают конфиг → авто-global).
Данные/схему/триггер/деньги НЕ трогаем (0 mismatch под global, rebuild не нужен).

TDD: новый тест «cross-tenant rows chained by trigger (global) verify intact»; обновлены 3
теста старого per-tenant поведения (config-mirror, per-tenant regression → supersede,
rebuild per-tenant → global). Тесты: 30 audit + 23 Pd/Scheduler зелёные, pint + PHPStan(0) чисто.

Решение задокументировано в docs/adr/ADR-021; ADR-018 → Superseded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:54:10 +03:00
Дмитрий dfa14862b4 fix(supplier): пауза доходит до поставщика в batch — ночной джоб 18:00 гасит осиротевшие заказы
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Боевой в batch-режиме: пауза проекта не доходила до поставщика — заказ висел активным
(галочка вкл, лимит>0), поставщик мог лить лиды и списывать за остановленный проект
(подтв. живой базой: omega, 57 заказов, 0 команд после паузы 08.07).

Поставщик фиксирует завтрашнюю заливку снимком в 21:00, поэтому гасить надо в 18:00-прогоне
ДО снимка (мгновенно бессмысленно — сегодня уже зафиксировано). Добавлен проход
deactivateOrphanedOrders в SyncSupplierProjectsJob: заказам источников, которых нет в
активных группах слепка за завтра, шлётся updateProject(status=paused) с ненулевым лимитом,
локально помечается inactive_since. online-режим не тронут (там пауза уже мгновенна).
Активность берётся из слепка (слепок-инвариант), не из live is_active.

Тесты (TDD RED->GREEN): batch pause + стык с delete-хвостом. 19/19 supplier+delete,
238/238 supplier+projects, pint чисто, PHPStan без новых замечаний.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 05:35:19 +03:00
Дмитрий ec805957f1 chore(deploy): защищённый выкат сборки портала — не даёт затереть чужие фичи
Портал выкатывается полной заменой public/build; сборка из устаревшей ветки
молча сносила чужую работу (инцидент 08.07: Метрика снесла колонки «Сделок»).
deploy-build.sh перед подменой сверяет маркеры критичных фич (по активным
чанкам manifest.json, не по orphan-осколкам) — теряет живую фичу → ОТКАЗ.
Осознанный откат — FORCE=1, только проверка — DRY_RUN=1. Установлен на боевом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:19:17 +03:00
Дмитрий 995aae4571 fix(portal): карточки вместо таблицы на планшете-портрете (Сделки/Списания)
Широкая таблица (7 колонок) не влезала в узкую колонку планшета рядом с
rail-меню — правые колонки «Комментарий»/«Поставлен» обрезались на 600–1279px.
ResponsiveTable теперь показывает карточки на телефоне И планшете, таблицу —
только на десктопе (>=1280). Тесты табличного слоя зафиксированы на десктоп-ширину.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:57:07 +03:00
Дмитрий 8a4ee4c69b fix(deals): колонка «Конкурент» показывает только бренд до первой запятой
Имя конкурента хранится как «Бренд, описание» — в колонке «Конкурент»
показываем часть до первой запятой, полное имя — в title при наведении.
Применено к десктопной таблице и мобильной карточке. +тест на обрезку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:37:30 +03:00
Дмитрий abdb6e20c4 feat(metrika): Метрика+Вебвизор в линию портала (чтобы выкаты не теряли)
Метрика была потеряна, т.к. клалась мимо конвейера: выкат = git archive HEAD ветки
+ пересборка, а Метрика жила отдельно → перезатёрлась. Кладём в feat/portal-mobile-adaptive
(линию, из которой режутся deploy/*-ветки) → любой будущий выкат несёт её сам.

Загрузчик metrika.ts (только кабинет layout=app), маскировка ПДн лидов
(ym-hide-content/ym-disable-keys) — десктоп-таблица + МОБИЛЬНЫЕ карточки (телефон+
комментарий+aria-label) + канбан + карточка сделки + диалог. Сборка проходит.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:00:37 +03:00
Дмитрий 567e950758 fix(admin): клиентский диапазон ступеней в тарифной сетке + предпросмотр в редакторе
Cherry-pick b35d3888 поверх боевой ветки feat/portal-mobile-adaptive для
выката ТОЛЬКО правки тарифов на прод. Логика денег не менялась.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:29:40 +03:00
Дмитрий 8d9af40637 feat(billing): приветственный подарок при регистрации 300 ₽ → 1000 ₽
START_BALANCE_RUB в RegistrationService: '300.00' → '1000.00' — новый тенант
получает 1000 ₽ на баланс при подтверждении почты. Обновлён докблок
RegistrationController и тест RegistrationTest (баланс 1000, 13 тестов зелёные).
Автоподборные 300 ₽ (цена «Собрать конкурентов») НЕ тронуты — это другая сумма.
Миграций нет (константа применяется при регистрации). В UI/лендинге сумма
подарка не отображается — фронт не менялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:35:22 +03:00
Дмитрий 6d6ce28e54 fix(autopodbor): защита route.query в deep-link — чистый прогон тестов
Влитый из автоподбора deep-link «Сделки → Автоподбор» читал route.query
в onMounted без защиты. В приложении роутер всегда есть, но при монтировании
в юнит-тесте без роутера route === undefined → unhandled error (2 ошибки,
vitest exit 1). Добавил route?.query ?? {} — поведение в бою не меняется,
полный прогон снова зелёный (1184 , exit 0). Правка нужна для чистого
CI-гейта выката мобильной ветки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:44:48 +03:00
Дмитрий dead7e48c5 feat(portal-mobile): новые колонки «Конкурент» и «Источник» в карточке сделки на телефоне
После вливания переделки сделок (колонки Конкурент/Источник со ссылками
в автоподбор) эти поля были только в таблице на компьютере — в карточке
телефона их не было. Добавил в мобильную карточку:
- «Конкурент» — имя конкурента зелёной ссылкой, ведёт в автоподбор
  (/autopodbor?competitor=ID), рядом город;
- «Источник» — отдельной строкой домен/идентификатор ссылкой в автоподбор
  (+ «· +N» если источников несколько) и канал (Сайт/Звонки/СМС).
Для сделок без источника автоподбора карточка выглядит как раньше
(проект · канал · город). @click.stop — ссылки не открывают деталь сделки.

Проверено на 390px: конкурент+источник видны, вбок не едет, переход
по ссылке в автоподбор работает; тесты DealsTable/mapper/ResponsiveTable
зелёные (23).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:26:29 +03:00
Дмитрий a231fe25a7 Merge branch 'worktree-avtopodbor' into feat/portal-mobile-adaptive 2026-07-08 08:16:51 +03:00
Дмитрий 4194d86ff7 docs(сделки): спека + план «Конкурент/Источник» в реестре сделок
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:56:45 +03:00
Дмитрий a4c9200261 feat(сделки): колонки «Конкурент» и «Источник» со ссылками в автоподбор
Раздел «Сделки»: колонка «Источник» разбита на две — «Конкурент» (ссылка на
экран конкурента) и «Источник» (ссылка на «Настройки проекта»). В модалку
настроек добавлена кнопка пуск/стоп (срабатывает не закрывая окно).

- бэк DealController::index: батч-резолв конкурента+источника по
  deal.project_id → autopodbor_sources.created_project_id → competitor (без N+1)
- контракт ApiDeal/MockDeal + маппер: competitor_*/source_* поля
- DealsTable: 1 колонка → 2 (Конкурент/Источник), router-link, fallback без автоподбора
- AutopodborView: deep-link ?competitor=&project= открывает экран конкурента + модалку
- FieldCompetitorScreen: кнопка ⏸/▶ в модалке настроек + авто-открытие по ссылке

Тесты: бэк DealIndexTest +4 (31 ), фронт +новые (66 ). Дизайн не менялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:56:30 +03:00
Дмитрий 5ba484e0ed fix(portal-mobile): 4 фикса вёрстки на телефоне (обход всех экранов)
Прошёл все экраны портала на 390px, нашёл и починил переполнения:

1. NewProjectDialog: кнопки дней недели (ПН…ВС) в окне редактирования
   проекта не влезали — СБ и ВС уезжали за правый край и были не видны.
   На телефоне делим ряд поровну между 7 кнопками.

2. DaysBulkDialog (массовое «Дни сбора»): та же беда с двумя рядами дней —
   тот же приём (flex 1/1/0 на телефоне).

3. ApiTab: пары кнопок «Копировать+Перегенерировать» и
   «Сохранить+Тестовый webhook» не помещались в строку — длинная вторая
   кнопка обрезалась. Разрешаем перенос на телефоне (flex-wrap).

4. NotificationsTab: таблица «События × каналы» была шире экрана —
   колонка Email уезжала, а названия событий крошились по одному слову.
   На телефоне ужимаем колонки-галочки и уменьшаем отступы, событию
   достаётся почти вся ширина; заголовки каналов мельче.

Все правки — только в phone-media (<600px), десктоп не затронут.
Проверено замерами DOM + скриншотами на 390px; тесты затронутых
компонентов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:14:15 +03:00
Дмитрий 68cefaec09 fix(portal-mobile): кнопка «Создать проект» не жмётся к заголовку на телефоне
На узком экране заголовок «Проекты» и кнопка «Создать проект» стояли
вплотную в одну строку. На телефоне (<600px) кнопка теперь под заголовком
во всю ширину-строку, слева, с воздухом между ними; заголовок прижат влево
через align-self (перебивает utility .align-center с !important). Десктоп
не затронут — правка только в phone-media.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:27:15 +03:00
Дмитрий 62884559e9 fix(портал-мобилка): «Поле» карточки источников — кнопки не уезжают за край
Карточка источника (.ld-srccard) — flex-ряд: иконка | данные | блок кнопок
(.ld-srcctl, flex-shrink:0, max-width:320) справа. На телефоне кнопки
(Создать проект/Приостановить/Настройки проекта/В источники/Возобновить)
уезжали за край карточки. На телефоне (media <600px) карточка переносит
блок кнопок на строку ниже во всю ширину. Затрагивает обе под-вкладки
(Источники в работе + Предложения). Проверено сканом: 0 заездов. Тесты 71/71.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:04:11 +03:00
Дмитрий 53b7e600c6 fix(портал-мобилка): Счета — строка счёта не уезжает за экран на телефоне
Строка счёта была гридом из 4 колонок (дата | номер | сумма | кнопки).
На телефоне при двух кнопках («Счёт» + «Акт» после оплаты) номер крошился,
а кнопки уезжали за край. На телефоне (media <600px) строка в 3 яруса:
номер+сумма, дата+статус, ниже кнопки «Счёт»/«Акт» во всю ширину. Проверено
на засеянном оплаченном счёте с актом. Десктоп (грид 4 колонки) без изменений.
Тесты 39/39 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:51:14 +03:00
Дмитрий e8e9b31756 fix(портал-мобилка): Канбан — этапы переносятся на строки, все видны
Лента этапов на телефоне скроллилась вбок (overflow-x), из-за чего после
«Просмотрено» остальные этапы (В работе/Сделка/Не реализовано) уходили за
экран и были не видны, третий чип обрезался. Заменил горизонтальную прокрутку
на flex-wrap — все 5 чипсов-этапов переносятся на строки и видны сразу.
Тесты Канбана 23/23 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:38:56 +03:00
Дмитрий 1ac83411c6 fix(портал-мобилка): Биллинг — плитки Кошелёк/Лиды/Запас не схлопываются на телефоне
Предсуществующий баг BalanceCard: на узких экранах (<960px) ряд трёх плиток
становится столбцом, но flex:1 1 0 (flex-basis:0) схлопывал высоту плиток до
~35px, а overflow:hidden обрезал содержимое — цифры (49 050 ₽, 98 лидов, 4 дн.)
и кнопки «Пополнить» пропадали, оставались пустые заголовки. В столбце плитки
теперь flex:0 0 auto (по высоте контента). Затрагивает телефон и планшет;
десктоп-ряд (>=960px) без изменений. Тесты 52/52 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:27:41 +03:00
Дмитрий 933b273c71 fix(портал-мобилка): Биллинг «Дополнительные услуги» — плашки не крошатся на телефоне
Строка услуги (название+описание | «при успешном…» 140px | цена) на телефоне
сдавливала название до одной колонки — текст ломался по слову («Сбор конкуре
нтов»). На телефоне (media <600px) раскладываем: название на всю ширину сверху,
ниже строкой «когда списываем» + цена справа. Десктоп/планшет без изменений.
Тесты 113/113 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:12:58 +03:00
Дмитрий 86cc297ba0 fix(портал-мобилка): «Лимит оптом» — на весь экран на телефоне
Диалог массовой правки лимита теперь на телефоне открывается на весь экран
(:fullscreen), единообразно с «Дни оптом»/«Регионы оптом» из той же панели
множественного выбора. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:59:20 +03:00
Дмитрий a92c1b6f7f fix(портал-мобилка): деталь сделки на телефоне — на весь экран
Панель деталей сделки была фикс. ширины (inline 400px в «Сделках», drawer 480px
в Канбане) — на экране 390 она была шире экрана: в «Сделках» давала горизонтальный
скролл страницы и крошила карточки списка, в Канбане левый край уезжал за экран.
На телефоне:
- «Сделки» — inline-панель стала полноэкранным оверлеем (position:fixed inset:0).
- Канбан — ширина drawer = ширине экрана (useDisplay).
Десктоп/планшет — прежний master-detail 400px / drawer 480px, без изменений.
Тесты 103/103 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:56:42 +03:00
Дмитрий 25116a8abb fix(портал-мобилка): диалоги на телефоне — на весь экран (fullscreen)
Узкие центрированные модалки на телефоне резали содержимое (ряды кнопок,
вкладки, дни недели, сетку регионов — контент вылезал за края, текст обрезался
слева и справа). Диалоги с богатым содержимым теперь на телефоне открываются
на весь экран (:fullscreen по useLayoutTier + scrollable):
- Проект (создание/редактирование) — NewProjectDialog
- Новая сделка — NewDealDialog
- Пополнение баланса — TopupDialog
- Дни оптом — DaysBulkDialog (те же кнопки дней)
- Регионы оптом — RegionsBulkDialog
- Импорт: сопоставление статусов — UnknownStatusesDialog
Десктоп/планшет — прежний центрированный модал, без изменений.
Тесты диалогов 59/59 (+2 skip) зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:46:47 +03:00
Дмитрий 21505163b2 fix(портал-мобилка): «Предложения» — кнопки «На актуализацию» не вылазят за карточку
В карточках «На актуализацию» три кнопки (Добавить/Заменить/Отказаться)
были шире контента и заезжали за правый край карточки (на ~18px, «Отказаться»
упиралась в бортик). На телефоне (media <600px) кнопки теперь во всю ширину
карточки поровну (flex:1) — держатся внутри паддинга. Десктоп без изменений.
Проверено сканом всех 39 карточек: заездов за паддинг больше нет.
Тесты 71/71 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:24:31 +03:00
Дмитрий 54772e1651 fix(портал-мобилка): «Поле» — плашка предложений и строка выбора на телефоне
На телефоне (media <600px):
- Плашка «Лидерра подготовила N предложений… Разобрать предложения →»
  раскладывается в столбик: текст переставал крошиться по слову, а ссылка
  «Разобрать предложения →» больше не вылезает за край (была nowrap).
- Строка «Выбрать всех/все предложения» — flex-wrap: подсказка падает на
  новую строку во всю ширину, не жмётся в узкую колонку.
Правки в общем field-shared.css — покрывают и «В поле», и «Предложения».
Тесты «Поля» 71/71 зелёные. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:16:48 +03:00
Дмитрий 12b2947919 fix(портал-мобилка): небольшой зазор у «Поиска» в шапке на телефоне
При длинном заголовке («Конкурентное поле») кнопка «Поиск» стояла вплотную
к тексту (зазор 0). Добавлен margin-left 10px на телефоне (media <600px) —
теперь есть воздух. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:09:18 +03:00
Дмитрий 769f250af5 fix(биллинг): метка для списаний автоподбора в истории транзакций
Списания type=autopodbor_charge шли без описания и показывались как «—»
и в таблице (десктоп), и в карточках (телефон). Добавлена метка
«Списание за автоподбор» в общий словарь операций TransactionsTable.

Тесты транзакций 9/9 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:58:48 +03:00
Дмитрий fc30d4b1d1 fix(портал-мобилка): Настройки/Реквизиты — «Тип лица» в столбик на телефоне
Сегмент-контрол «Тип лица» (Физическое лицо/ИП/Юридическое лицо) на телефоне
не влезал в строку и наезжал; теперь раскладывается в столбик во всю ширину
(media <600px, скруглены верх/низ группы). Остальные поля реквизитов уже
адаптивны. Десктоп/планшет — горизонтальный переключатель, без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:54:11 +03:00
Дмитрий 2aace1ff8e fix(автоподбор): гейт «та же фирма» на разворот 2ГИС — режет иногородних чужаков
Баг AutoMoney: разворот по общему/партнёрскому/иногороднему номеру тащил чужие МФО
из Москвы/Питера (moneyman, finombudsman — фин.омбудсмен!, npmir, razvitie…) в источники
красноярского конкурента. crowdedCap ловил только массовые 8-800.

Гейт (100%-надёжный, проверен на реальных данных прода): карточку 2ГИС из разворота
выкидываем, ТОЛЬКО если город из URL карточки (2gis.ru/<city>/firm) ≠ город конкурента
И домен не наш бренд (с учётом поддоменов-филиалов). Свой город ИЛИ свой бренд — оставляем.
Яндекс НЕ трогаем (город в URL не виден, там же законные филиалы — риск срезать своих).

Проверка на проде: #16 AutoMoney/#64 Ваш Инвестор — все 2ГИС moscow/spb уходят;
#22 (автоломбард krasnoyarsk), #48 Кредит-финанс (Екб/Томск, Яндекс) — остаются.
11/11 тестов expander, вся ветка 220 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:50:38 +03:00
Дмитрий a9b40d12f9 fix(портал-мобилка): Проекты — фильтры в столбик во всю ширину на телефоне
На телефоне фильтры (Тип/Статус/Регион/День/Сортировка/Поиск) перестали
обрезаться («Р…», «Д…») — раскладываются в столбик во всю ширину
(перебит inline max-width через media <600px). Десктоп/планшет без изменений;
карточки проектов уже адаптивны.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:50:11 +03:00
Дмитрий 8341c1758e fix(портал-мобилка): Дашборд — переключатель периода во всю ширину на телефоне
На телефоне v-btn-toggle (Сегодня/7 дней/30 дней/Период) растянут на всю
ширину с равными кнопками — убрана внутренняя горизонтальная прокрутка.
Только media-запрос <600px; десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:46:55 +03:00
Дмитрий b66a6f6e7b feat(портал-мобилка): планшет — узкая колонка-иконки (rail)
На планшете (600–1279px) сайдбар сжимается в rail: только иконки пунктов,
подписи/поиск/бренд-текст скрыты, контент шире. Иконки берутся из mdi,
уже заложенных в данные пунктов; на десктопе иконки скрыты — текстовый
сайдбар остаётся прежним. Отступы топбара и контента разведены по ярусу
(десктоп 232/240px, планшет 64/72px, телефон 0). Гамбургер топбара теперь
показывается только на телефоне (d-sm-none) — на планшете/десктопе он не нужен.

Тесты каркаса 24/24 зелёные. Проверено на 820 и 1440px; десктоп не изменился.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:42:50 +03:00
Дмитрий b803c6478b feat(портал-мобилка): «Канбан» на телефоне — одна колонка + переключатель этапов
На телефоне 5 колонок больше не листаются вбок: сверху горизонтальный
переключатель этапов (чипсы со счётчиками), ниже — выбранная колонка на всю
ширину. Смена этапа сделки на телефоне — тапом по карточке через drawer
(drag вбок на узком экране неудобен). Планшет/десктоп — прежняя доска со всеми
колонками и перетаскиванием, без изменений.

Тесты Канбана 23/23 зелёные. Проверено на 390 и 1440px (+ переключение этапов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:35:58 +03:00
Дмитрий 133eb9d0f0 feat(автоподбор): текстовые номера с сайта → «на сайте, проверьте» (unverified)
Второй пробел после фикса таймаута рендера: номера, написанные на сайте простым
текстом (не кликабельные tel:/schema, без коллтрекинга), терялись. Пример Центрофинанс:
8 800 200-00-10 («Телефон:» у юр. адреса), +7 931 106-54-50 (подвал) — движок брал
только tel:-ссылки.

- HtmlPhoneScanner: тело сканируем БЕЗ <script>/<style> (меньше шума из конфигов)
- CandidateBuilder: без трекера body-номера (не в code/visible) → kind 'text' «проверьте»
- SourceAggregator + DeepStudyCollector: text-only → phone_kind 'unverified' (не теряем,
  но и за 100% настоящий не выдаём); code/справочник перебивают → 'real'
- фронт: бейдж «⚠ на сайте — проверьте» (amber), сортировка ниже настоящих, легенда
- 7 тестов (scanner/builder/aggregator/DeepStudy); вся ветка 493/493, build ок

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:23:37 +03:00
Дмитрий 9799c0eb0d feat(supplier): аварийный spike-детектор ручной очереди в incidents:watch-failures
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Блок 6: если за окно (10 мин) в supplier_manual_sync_queue упала пачка
(>= threshold-manual-queue, дефолт 10) — вероятное падение кабинета поставщика.
Инцидент дедупится 60 мин, письмо шлётся каждый прогон на kdv1@bk.ru + ops@liderra.ru
(громко при реальной аварии, но одним сводным письмом). Флаг sendMail в createIncident.
phpstan-baseline: +4 TestCall::artisan() (5→9) от новых тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 74d4d9f21d)
2026-07-07 19:19:26 +03:00
Дмитрий 316bc2d852 fix(supplier): FailoverProjectChannel не шлёт per-project CRITICAL письма
Убраны письма manual_required и failover_to_form (по проекту, без троттла) —
источник потопа ~116 писем на ops@ 07.07. Строка в ручной очереди остаётся
(запись видна в админке). Зависимость Mailer удалена из канала и DI-привязки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72818f5a80)
2026-07-07 19:19:09 +03:00
Дмитрий 6b0550ff89 feat(портал-мобилка): «Биллинг» — карточки транзакций на телефоне
История транзакций обёрнута в ResponsiveTable: на десктопе/планшете —
v-data-table-server как прежде, на телефоне — карточки (операция, сумма
цветом, дата, #ID) + компактный пейджер по 20. Серверная пагинация сохранена.

Тесты биллинга 40/40 зелёные. Проверено на 390 и 1440px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:17:17 +03:00
Дмитрий d3764cb8f2 feat(портал-мобилка): ResponsiveTable + карточки «Сделок» на телефоне
Обёртка ResponsiveTable (таблица на десктопе/планшете, карточки на телефоне
по ярусу useLayoutTier). Применена к реестру лидов «Сделки»: на телефоне —
карточки лида (чекбокс выбора, телефон, статус-плашка, источник·город,
комментарий, дата), тап открывает карточку сделки; на десктопе прежняя
v-data-table без изменений.

Тесты: ResponsiveTable.spec (2), «Сделки» 58/58 зелёные. Проверено на 390 и 1440px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:17:01 +03:00
Дмитрий aca52d130d fix(автоподбор): рендер сайта больше не теряет номера при таймауте Playwright
Шаг 2: CurlPlaywrightFetcher.site() curl'ом успешно тянул страницу с tel:-номерами,
но render() (node render-page.cjs) висел на waitUntil networkidle и падал по таймауту.
Symfony Process::run() при таймауте бросает ProcessTimedOutException — оно вылетало из
site(), SiteOpener обнулял весь сбор. Итог: 0 номеров с сайта (боевой кейс Центрофинанс,
tenant info@lkomega.ru, прогон 07.07 14:07, только сайт+карточка, ни одного code-номера).

- render()/renderFirm() ловят \Throwable -> null: падение/вис рендера НЕ фатально,
  curl-HTML с номерами 'в коде сайта' сохраняется
- render-page.cjs: networkidle -> domcontentloaded + пауза; рендер 30с+вис -> 3.7с (боевая проверка)
- сиды curlRunner/nodeRunner для офлайн-теста; регресс-тест воспроизводит потерю номеров

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:16:42 +03:00
Дмитрий a0a219add4 feat(портал-мобилка): каркас — сайдбар прячется на телефоне, снизу панель-вкладки и «Ещё»
AppLayout: ярус-отступ (десктоп/планшет 232px, телефон 0 + низ под панель), рендер
AppBottomNav/AppMoreDrawer на телефоне, гамбургер открывает «Ещё». AppSidebar:
проп tier + скрытие на телефоне. Десктоп/планшет без изменений.
2026-07-07 17:59:04 +03:00
Дмитрий ce1094e160 chore(autopodbor): AITUNNEL_AGGREGATOR_MODEL=gpt-4o в .env.example — слабая mini резала живые федеральные МФО как агрегаторы
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:54:31 +03:00
Дмитрий 461bcccf4d feat(портал-мобилка): AppMoreDrawer — выезжающее «Ещё» на телефоне
Разделы вне 5 нижних вкладок: Дашборд, Отчёты, Настройки (реквизиты), Импорт, Помощь.
Ни один экран не выкидываем.
2026-07-07 17:54:00 +03:00
Дмитрий 3957f3bd39 feat(портал-мобилка): AppBottomNav — нижнее меню-вкладки на телефоне 2026-07-07 17:50:36 +03:00
Дмитрий 63eb0a6b84 feat(портал-мобилка): composable useLayoutTier — ярус телефон/планшет/десктоп 2026-07-07 17:45:37 +03:00
Дмитрий de10c633e6 docs(handoff): исправлен устаревший статус — deep-study/render-cache/EXA уже на проде
Хендоф называл три фичи «невыкаченными, за тумблерами» — по факту (проверено
командой на боевом) все три на проде и флаги ON: AUTOPODBOR_DEEP_STUDY,
AUTOPODBOR_RENDER_CACHE, EXA_PROXY (тоннель отдаёт зарубежный IP). Уехали 06.07.
Следствие: баг «шаг 2 AutoMoney» — в уже-живом deep-study. Добавлена сегодняшняя
сортировка регионов (REGIONS_ALPHA), gitea-ссылка обновлена на 94cfc7ce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:15:20 +03:00
Дмитрий 94cfc7ced6 fix(projects): регионы в 4 выпадашках раздела «Проекты» — сортировка по сути названия
Те же выпадашки региона (новый проект, фильтр списка, массовое
add/remove регионов, карточка проекта) переведены на REGIONS_ALPHA —
единый порядок с автоподбором, республики по своему имени, а не кучей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:30:42 +03:00
Дмитрий 4451231c1c fix(autopodbor): регионы в выпадашках — сортировка по сути названия
Регионы выводились в конституционном порядке (республики → края →
области), из-за чего список выглядел кашей (Дагестан → Донецкая
Народная → Ингушетия → Кабардино-Балкарская). Добавлен REGIONS_ALPHA:
сортировка по «настоящему» имени, слова-типы (Республика/область/край/
автономный округ) отбрасываются — Татарстан на «Т», Адыгея на «А».
Подставлен во все 7 выпадашек автоподбора. Тест regionsAlpha.spec.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:27:44 +03:00
Дмитрий 52dcd6ccb2 docs(handoff): промт продолжения автоподбора 07.07 (состояние + открытое + рецепты деплоя)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:10:54 +03:00
Дмитрий d4aec22933 docs(pilot): доработки автоподбора 07.07 (не режем+денежный баг+повтор, Списания, плашки, «Отказаться»)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:05:51 +03:00
Дмитрий f888c4baec chore(autopodbor): кнопка «Отказаться» (было «Удалить») + автоподбор в CSV списаний + canon-sync schema.sql v8.63
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
- Кнопка на актуализации переименована «Удалить»→«Отказаться» (чтобы клиент не путал с удалением конкурента).
- CSV-выгрузка «Списаний» теперь включает автоподбор-строки (отдельным блоком, «Автоподбор конкурентов»);
  тест на export. charges 11/11.
- schema.sql canon-sync v8.63: +колонка dismissed_actualize_keys у autopodbor_competitors + CHANGELOG
  (заодно в цепочку шапки вписана пропущенная v8.62 CHECK autopodbor_charge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:03:43 +03:00
Дмитрий 43012c982e feat(autopodbor): «Удалить» на актуализации — мягкое отклонение находки
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Кнопка «Удалить» на карточках «На актуализацию» рядом с Добавить/Заменить.
Отклоняет ТОЛЬКО находку (новый сайт/адрес): фирма поля остаётся, находка → архив,
её новые ключи запоминаются у фирмы поля (новая колонка 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>
2026-07-07 15:50:59 +03:00
Дмитрий 7755a7213e fix(billing+autopodbor): автоподбор-списания в табе «Списания» + фикс вёрстки плашек «Предложений»
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
- «Списания» (TenantChargesController.index) теперь union lead_charges + autopodbor_charge
  (balance_transactions) — клиент видит списания за прогоны, не только в «Истории транзакций».
  Автоподбор-строки: kind=autopodbor, метка вместо сделки, «—» для tier. Фильтр charge_source
  автоподбор не подмешивает. 2 теста, всего charges 10/10.
- Плашки «Предложений» разъезжались за экран на длинных URL (utm/рекламные хвосты): grid-элементы
  min-width:auto не сжимались. Фикс: .ld-grid>* min-width:0 + перенос длинных ссылок.
- Фронт 16/16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:21:30 +03:00
Дмитрий 2e0f5a4d29 fix(autopodbor): лимит 0 = «не режем» (защита обрезки) + авто-повтор при пустом сборе
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Баг: при autopodbor_max_competitors=0 джоба делала array_slice($unique,0,0)=[]
→ сохраняла 0 конкурентов, НО всё равно списывала 50₽ (chargeForRun после обрезки,
проверка empty стои́т ДО неё). Теперь $max>0?slice:все — 0 значит «сохранить всех».
Плюс авто-повтор findCompetitors при пустом сборе (search_retry_on_empty, деф. 2):
моргание 2ГИС/Яндекс/EXA не показывает клиенту «ничего не нашлось». Реально пустая
ниша → empty без списания. 2 теста; ветка автоподбора 176/176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:01:29 +03:00
Дмитрий 9dac8c16ea docs(findings): шаг2 разворот — соседние изучения чистые, глюк только у AutoMoney (агрегатор)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:04:59 +03:00
Дмитрий ec07f2aeeb docs(findings): шаг 2 автоподбора — разворот по телефону мостит чужих (баг AutoMoney), корень + фикс-план
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:00:55 +03:00
Дмитрий 0c4d47f3c3 docs(pilot): хотфикс автоподбора 07.07 — tenant-контекст ДО чтения прогона (RLS)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:34:47 +03:00
Дмитрий a42ee07c42 fix(autopodbor): выставлять tenant-контекст ДО чтения прогона в джобах (прод-сбой под RLS)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Джобы Search/Study/Resolve делали AutopodborRun::findOrFail() ДО set_config
app.current_tenant_id. На проде роль crm_app_user под FORCE RLS (на боевом
кластере ни одна роль не BYPASSRLS) не видит строку без tenant-контекста →
ModelNotFound, все три кнопки автоподбора падали. На dev маскировалось
суперюзером postgres. Теперь tenantId передаётся в dispatch и контекст
выставляется первым. 3 регресс-теста на порядок (set до чтения runs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:22:22 +03:00
Дмитрий 5ace9635dc docs: фиксация состояния автоподбора 07.07 + промт следующей сессии
Accessibility (Pa11y live) / a11y (push) Has been cancelled
ПИЛОТ: укрепление тоннеля (sshd ClientAlive drop-in + сторож чистит порт на проде).
Добавлен NEXT-SESSION снимок: автоподбор live, EXA через тоннель+роботы, бэкап,
ротация пароля; открыт один пункт — робастный серверный прокси для EXA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:28:32 +03:00
Дмитрий fccf3e34bf docs(pilot): доделки автоподбора 07.07 — EXA через прокси+сторож, бэкап, ротация пароля
Accessibility (Pa11y live) / a11y (push) Has been cancelled
EXA/Cloudflare режет РФ IP → EXA_PROXY через обратный тоннель к Happ (сторож
ExaTunnel/Watchdog). Бэкап: логический дамп кластера невозможен (RLS), защита =
YC managed backup+PITR; мёртвый-дамп-скрипт обезврежен, добавлен монитор свежести.
Пароль crm_migrator ротирован (Lockbox авто-синк).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 04:19:04 +03:00
Дмитрий 9ade3115d1 feat(autopodbor): EXA через прокси (EXA_PROXY) — обход блокировки российских IP
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
EXA за Cloudflare режет российские IP, федеральный канал возвращал пусто с боевого.
Добавлен services.exa.proxy (env EXA_PROXY) → Guzzle proxy option в findSite/findSites.
Пусто → напрямую (dev). Тест: с proxy цепочка не ломается, домен извлекается (8/8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:21:39 +03:00
Дмитрий a1820e22b0 docs(pilot): автоподбор выкачен на боевой liderra.ru 06.07.2026
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Флаги ВСЕ ВКЛ, 4 таблицы autopodbor_* в кластере, воркеры find/study,
autopodbor_enabled=1, приёмка вживую пройдена. Ключи движка перенесены
в боевой .env. Известная дыра: liderra-backup.sh дампит мёртвую копию.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:42:11 +03:00
Дмитрий 9c8178bbf2 docs(release): промт-хендофф для боевого выката автоподбора (под компакт)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Собирает контекст новой сессии: что прочитать, состояние (ветка efbad075
смёржена/зелёная/запушена), ловушка сокета Managed PG, гейты (пароль crm_migrator
+ бэкап + выкатывай), последовательность выката и откат.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:01:09 +03:00
Дмитрий efbad075e8 test(autopodbor): актуализировать 4 устаревших фронт-теста создания проектов
Групповое и одиночное создание теперь ТРЕБУЮТ регион (vsyaRf=false по умолчанию;
«Вся РФ» — явная галочка, дефолта больше нет — подтверждено владельцем). Тесты
были под старый дефолт «Вся РФ» → doCreate бэйлился на гейте региона. Добавлен
явный выбор «Вся РФ» перед doCreate. Плюс requisites_required теперь открывает
форму реквизитов (reqGate), а не тост — тест переведён на новое поведение,
reqGate добавлен в defineExpose для наблюдаемости. Спек 6/6 зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 15:51:45 +03:00
Дмитрий a2729cee95 Merge remote-tracking branch 'gitea/main' into worktree-avtopodbor
# Conflicts:
#	app/config/services.php
#	app/tests/Pest.php
2026-07-06 14:50:58 +03:00
Дмитрий 69aa7af7a4 docs(plan): актуализировать план релиза — ловушка сокета Managed PG, grants F1, canon-sync F2, billing-audit, диск
Правки по находкам prod-deploy-validator и rls-reviewer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:44:28 +03:00
Дмитрий a75705c80e style(autopodbor): pint — причесать миграции/фетчер/тесты перед релизом
Стиль-only (braces_position, ordered_imports, fully_qualified_strict_types и т.п.),
поведение не меняется. Autopodbor 476/476 зелёный после причёсывания.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:35:22 +03:00
Дмитрий 8a6f674ac6 chore(autopodbor): убрать диагностическую времянку _shot.cjs из ветки
Скриншот-скрипт-времянка, случайно закоммичен ранее; при overlay-деплое
попал бы в app/scripts на прод. Мусор — удалён до выката.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:31:18 +03:00
Дмитрий 68c8eaead8 test(schema): обновить сторож метрик под v8.62 (автоподбор +4 таблицы)
SchemaDeltaTest жёстко ждал метрики v8.55 (74 таблицы/128 индексов/44 RLS),
но автоподбор добавил в schema.sql 4 таблицы (runs/competitors/sources/
merge_events) + 9 индексов + 4 RLS-политики → 78/137/48. Это был давно
красный сторож (не баг фичи). Обновлены ожидаемые числа + комментарий.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:10:52 +03:00
Дмитрий 04a94c922c docs(autopodbor): план полного боевого релиза + ранбук кэша карточек
План релиза всей системы автоподбора (190 коммитов, greenfield на проде, миграции
на денежную базу) с гейтами, откатом, находками prod-deploy-validator (ловушка
локального сокета vs Managed PG, диск) и rls-reviewer (grants после migrate, canon-sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:22:42 +03:00
Дмитрий 31cff0ca81 chore(schema): canon-sync v8.62 — 'autopodbor_charge' в CHECK balance_transactions
rls-reviewer при подготовке боевого выката нашёл: миграция 2026_06_28_110100
добавляет тип 'autopodbor_charge' в balance_transactions_type_check, но в тело
schema.sql это не было свёрнуто (CHECK заканчивался на 'migration'). Денежная
таблица — §5 п.8. Свёрнуто + запись в CHANGELOG. Структурно только значение CHECK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:22:40 +03:00
Дмитрий ff1c3ed210 test(autopodbor): закрепить phone_reverse_parallel=false в тесте композита
Тест «reverse = композит» жёстко ждал CompositePhoneReverse, но не пинил флаг
parallel — из-за dev-.env (AUTOPODBOR_PHONE_REVERSE_PARALLEL=true) собирался
ParallelPhoneReverse и тест падал. Явно пиним parallel=false: тест проверяет
именно композитную ветку, независимо от окружения. Autopodbor-набор 476/476.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:13:47 +03:00
Дмитрий 29b90c5759 feat(autopodbor): удаление предложения показывает пул связанных строк
Клик «Удалить» у предложения открывает окно со всеми предложениями,
найденными в том же месте (совпал url карточки/сайта в «где нашли»), с
галочками. По умолчанию отмечена только нажатая строка; клиент выбирает,
какие связанные строки удалить. «В работе» — прежнее окно. Массовое
удаление предложений — подтверждение со списком выбранных.

Только показ и порядок «сначала выбери, потом удали»; БД/бэкенд не тронуты.
Тесты: пул по url, строгое совпадение, дефолт-галочка, удаление отмеченных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:54:50 +03:00
Дмитрий 06fc4680f4 feat(autopodbor): кэш отрисованных карточек 2ГИС/Яндекс за тумблером
CachingPageFetcher — декоратор над загрузчиком карточек (SelfRenderClient/
XfetchClient): кэширует в общем Cache (Redis/file) по ключу URL на N дней ТОЛЬКО
карточки (/firm/ 2ГИС, /maps/org/ Яндекс). Поиск (/search/) и ленты — всегда мимо
кэша (свежее добывание новых фирм). Пустое не кэшируем — разовый сбой не отравит кэш.

Кэш ОБЩИЙ для шага 1 (поиск) и шага 2 (изучение+разворот) и повторных прогонов:
ключ по URL → любые экземпляры делят один кэш. Вшит в AutopodborServiceProvider
через wrapCache(); 2ГИС-база RoutingBatchFetcher и self-render card reader обёрнуты,
яндекс-Playwright/ленты — нет.

Флаг AUTOPODBOR_RENDER_CACHE (ВЫКЛ по умолчанию) + render_cache_ttl_days (5).
TDD: 8 тестов CachingPageFetcher + 4 теста проводки — зелёные. Живой замер ДСК:
тёплый прогон — 26 карточек из кэша, 0 обращений к 2ГИС/Яндексу, источников 11=11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:52:14 +03:00
Дмитрий ab6d1b59a1 docs(autopodbor): дизайн кэша отрисованных карточек (5 дней) + хендофф
Решение владельца: общий Redis-кэш карточек 2ГИС/Яндекс на 5 дней, единый для шага 1
(поиск) и шага 2 (изучение) и повторных прогонов. Кэшируем ТОЛЬКО карточки (/firm/,
/maps/org/); поиск (/search/) и ленты Яндекса — всегда свежие (добывание новых фирм не
ограничиваем). Кэш ограничивает свежесть только у уже найденных карточек — редко меняются.

Замер на САПС (счётчик скачиваний): 12 повторных открытий / ~54с внутри одного сбора
(карточка Метрополиса тянулась 3× — ingest+поиск+разворот); повторный сбор в течение 5 дней
даёт ~200с+ и резкое падение нагрузки на 2ГИС/Яндекс/прокси.

План реализации — через TDD за флагом AUTOPODBOR_RENDER_CACHE (ВЫКЛ по умолчанию), TTL
AUTOPODBOR_RENDER_CACHE_TTL_DAYS=5. Плюс промт-хендофф для следующей сессии.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:07:47 +03:00
Дмитрий 0131ecfc47 fix(autopodbor): якорь по данным клиента на шаге 2 + параллельный разворот
Качество. Привязанные клиентом карточки 2ГИС/Яндекс теперь используются как
«земля» и якорь холдинга (раньше выбрасывались). Слепой поиск по имени берётся
только при наличии якоря; AnchorGate: пустой якорь → drop (было keep-всё — корень
шума). Без сайта чужие фирмы по широкому имени («Притяжение» → стоматология,
салоны, эротический магазин) больше не попадают в карточку конкурента. Доказано на
живых данных (Сибагропромстрой): свежий полный сбор — 0 салонов, только номера
застройщика.

Скорость (за флагом AUTOPODBOR_PHONE_REVERSE_PARALLEL, ВЫКЛ по умолчанию).
ParallelPhoneReverse крутит 2ГИС- и Яндекс-разворот ОДНОВРЕМЕННО (итог по времени
= max, не сумма; оба источника отрабатывают полностью — ничего не режется).
Сверка последовательно vs параллельно: данные один в один, потерь нет. Таймаут
подпроцесса 600с (драйвер process иначе берёт дефолт Symfony 60с и убивал разворот).

Тесты: AnchorGate (пустой якорь → drop; телефон-якорь режет чужое), якорь
привязанной карточки режет салоны, ParallelPhoneReverse (слияние на sync-драйвере).
Весь Autopodbor 464/464, Pint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:54:48 +03:00
Дмитрий 113a3f5aca docs(autopodbor): итог сессии — виртуалка живёт, ускорение шага 1 и шага 2
Дописан блок «УСКОРЕНИЕ 06.07» в ранбук фазы 8: числа, что откатано и почему, где узкое место шага 2, следующий рычаг. Ссылка на коммит 9022b15b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 05:02:11 +03:00
Дмитрий 9022b15b42 perf: ускорение автоподбора — рендер-канал и шаг 2
render-service: умное ожидание вместо фикс-пауз. 2ГИС карточка 7.2 -> 1.6с, поиск 17 -> 5с, Яндекс-карточка 6.5 -> 2.8с. Качество проверено вживую.
шаг 2: таймаут открытия сайта 45 -> 15с, убран лишний повтор, карточки 2ГИС пачкой htmlBatch. Полнота сохранена, скорость выросла.
доки: ранбук выката рендер-виртуалки, фаза 8.

Ветка worktree-avtopodbor, не прод. Тесты 459/459, Pint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:58:46 +03:00
Дмитрий 31828d339e docs(autopodbor): handoff — стройка self-render завершена (код+прокси), осталась фаза 8
Весь код канала готов (459/459 тестов), рендер-сервис проверен на живом 2ГИС,
мобильный прокси Proxy.Market куплен (Россия/Москва/Tele2, безлимит, до 04.08).
Секреты прокси + RENDER_KEY — в gitignored render-service/.env (в гит не идут).
Осталась фаза 8: поднять виртуалку + включить флаг + ручная сверка владельцем.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:56:43 +03:00
Дмитрий b9620238c3 docs(render-service): настройка прокси Proxy.Market (mobile-static, HTTP)
Формат PROXIES под Proxy.Market: только HTTP-эндпоинт (Playwright не умеет
авторизацию SOCKS5). Один мобильный порт = один IP; для нашего объёма хватает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:20:47 +03:00
Дмитрий 7512ec2bad feat(autopodbor): полное прокси-покрытие Яндекса через виртуалку (фаза 7b)
Закрыт хвост §5.3: node-скриптовые точки Яндекса уведены на рендер-сервис, чтобы
под self_render ВЕСЬ Яндекс шёл через виртуалку+прокси (не крутил браузер на портале).

- YandexListSource (интерфейс) + LocalYandexListSource (xf4, локальный Playwright) +
  RenderServiceYandexListSource (self-render, POST type=yandex-list на виртуалку).
- PlaywrightYandexDirectory (листинг шаг 1) и PlaywrightYandexPhoneSearch (разворот)
  расшиты на YandexListSource — заодно стали тестируемы офлайн (6 новых тестов).
- PlaywrightYandexCardReader под self_render получает SelfRenderClient (был PageFetcher).
- Рендер-сервис: режим type=yandex-list (скролл ленты + сбор организаций → {orgs}),
  проброс scrolls.
- Провайдер: yandexListSource() по флагу; разворот-Яндекс на виртуалку+прокси.

Итог: при self_render ВКЛ на xf4 и на локальном браузере портала не остаётся НИ ОДНОЙ
точки — 2ГИС и весь Яндекс через виртуалку+прокси. 459/459 тестов, Pint+Larastan чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:18:00 +03:00
Дмитрий 1a0729a327 docs(autopodbor): статус self-render — фазы 1-7 готовы + хвост по Яндексу
План: таблица статусов фаз с коммитами (1-7 , 8 и приёмка — за владельцем).
Спека §5.3: помечен follow-up — node-скриптовые точки Яндекса (листинг шаг 1,
разворот) пока на локальном Playwright под self_render (не xf4, без прокси).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:07:26 +03:00
Дмитрий 316924f00e feat(autopodbor): проводка флага self_render в провайдере (фаза 7)
Флаг autopodbor.self_render переключает слой добычи 2ГИС/Яндекс пары фетчер+ридер:
 ВКЛ  → SelfRenderClient (виртуалка+прокси; 2ГИС→initialState) + TwoGisJsonReader;
 ВЫКЛ → XfetchClient (xf4) + HtmlTwoGisReader (как сейчас).
SelfRenderClient один покрывает и 2ГИС (initialState), и Яндекс (html) — заменяет
и xfetch, и локальный Playwright-роутинг. DeepStudyCollector под self_render получает
yandexDir через виртуалку. 453/453 теста зелёные, Pint+Larastan чисто.

Известный хвост (follow-up): PlaywrightYandexDirectory (листинг Яндекс шаг 1) и
YandexPhoneReverse Playwright-классы остаются на ЛОКАЛЬНОМ Playwright (не xf4, но и
без прокси) — для полного прокси-покрытия Яндекса их надо развязать на PageFetcher.
Гарантия §5.7 «ни одной точки на xf4 при ВКЛ» соблюдена (локальный Playwright ≠ xf4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:06:26 +03:00
Дмитрий de544c25b0 feat(autopodbor): рендер-сервис на виртуалке — Node+Playwright (фаза 6)
Рисовалка топологии Б (§5.6): портал зовёт по HTTP как xf4, она рисует и
отдаёт данные. Ни базы, ни Redis, ни денег. Смоук на живом 2ГИС: листинг 12
фирм, карточка 1 — чистый initialState.

- browser.mjs — постоянный браузер + маскировка + контекст-на-страницу +
  museum double-tap для поиска 2ГИС + отсечка картинок/шрифтов/медиа.
- proxy-pool.mjs — round-robin пул прокси из env PROXIES (резидентные/мобильные RU).
- server.mjs — POST /render {url,type}→{data}, auth X-Render-Key, семафор
  MAX_CONCURRENCY, /health.
- package.json, README.md, smoke.mjs, .gitignore (node_modules не в гит).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:00:48 +03:00
Дмитрий 55bf6348b3 feat(autopodbor): SelfRenderClient — HTTP-клиент к рендер-сервису (фаза 5)
HTTP-клиент к своей виртуалке (замена xf4). Реализует BatchPageFetcher (и
PageFetcher): для 2ГИС просит initialState, для Яндекса/сайтов — HTML. Секрет
в заголовке X-Render-Key (из .env). Без ключа/адреса — молча пусто (деградация).
Http::pool для батча, ретраи. 7/7 тестов, Pint чисто.

- config services.self_render (endpoint/key/concurrency, всё из .env).
- config autopodbor: флаги self_render + гранулярные self_render_2gis/_yandex
  (наследуют master), дефолт ВЫКЛ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:10 +03:00
Дмитрий ef2ea54d38 refactor(autopodbor): расшивка точек 2ГИС на шов TwoGisReader (фаза 4)
Расшиты 4 потребителя 2ГИС — вместо прямых вызовов парсеров зовут интерфейс
TwoGisReader (дефолт HtmlTwoGisReader = поведение 1:1). Готовит подмену на
self-render без слома xf4-пути. 443/443 теста автоподбора зелёные, Pint чисто.

- CategoryScraper: CategoryListingParser → TwoGisReader (шаг 1 листинг).
- CompetitorCardEnricher: 2ГИС-карточки через TwoGisReader, Яндекс на parseYandexCard.
- PhoneReverseExpander: полностью на TwoGisReader (2ГИС-only разворот).
- DeepStudyCollector (2 вскрытых стыка): конструктор XfetchClient→BatchPageFetcher
  (под SelfRenderClient); collectYandex — инлайновый фетчер вынесен в инъекцию
  ($yandexDir), под self_render пойдёт на рендер-сервис+прокси, а не локальный
  Playwright. 2ГИС-разбор через TwoGisReader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:53:10 +03:00
Дмитрий 46426d586e feat(autopodbor): шов TwoGisReader + HtmlTwoGisReader + TwoGisJsonReader (TDD)
Фазы 2-3 плана self-render. Только новые файлы — существующий код не тронут,
xf4-путь цел. 281/281 юнит-тестов зелёные, Pint чисто.

- TwoGisReader — интерфейс разбора 2ГИС (3 метода: listing/card/branchList),
  единый контракт поверх HTML (xf4) ИЛИ initialState-JSON (self-render).
- HtmlTwoGisReader — xf4-путь: тонкий делегат к CategoryListingParser/
  DirectoryParser, поведение 1:1 (4 теста контракт-идентичности на реальных
  фикстурах 2gis-*-kraslombard).
- TwoGisJsonReader — self-render путь: разбор window.initialState по карте
  путей спайка (pagination-список → entity.profile), фильтр type=branch
  (фирма-в-фирму, без зданий/улиц). 5 тестов на реальных фикстурах: 12 фирм
  листинга, телефон+сайт карточки, 2 пути поиска-по-телефону, деградация.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:40:39 +03:00
Дмитрий 87566d31f4 feat(autopodbor): спайк 2ГИС initialState — вердикт GO + фикстуры 3 типов
Разведка живого 2ГИС (worktree, НЕ прод): из window.initialState чисто
достаётся список фирм-в-фирму для всех 3 типов URL (листинг/карточка/
поиск-по-телефону). Главный риск проекта снят.

- Фикстуры (реальные initialState+HTML): tests/fixtures/autopodbor/2gis/
  listing (12 ломбардов), card (Ломбардико: сайт+телефон), phone (холдинг).
- Карта JSON-путей: pagination-список результатов + entity.profile (имя/
  город/рубрика/сайт/телефоны). Идти ТОЛЬКО по pagination, не по ключам.
- Вскрыт рецепт рендера museum double-tap: гейт «обновите браузер» дважды
  кодирует return_url (%20→«20», отсюда старый мусор). Обход — двойной заход.
- Findings: docs/superpowers/findings/2026-07-05-2gis-initialstate-spike.md
- План реализации (8 фаз): docs/superpowers/plans/2026-07-05-self-render-*.md
- Спека v4 обновлена: риск initialState снят, double-tap задокументирован.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:30:19 +03:00
Дмитрий 33d7dd5847 docs(autopodbor): handoff сессии «скорость → свой рендер-канал» + спека v4
Единый файл-передача: замер 25→9 мин, 3 глубокие ревизии (шаг2/разворот,
JSON=initialState-спайк, топология Б), утверждённые решения, следующий шаг
(writing-plans, первым — спайк GO/NO-GO), окружение и грабли. Переживёт компакт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:52:47 +03:00
Дмитрий a813a0be30 docs(autopodbor): спека v4 — топология Б (виртуалка = рендер-сервис, портал не трогаем)
Третий проход поднял межсистемные стыки: деньги списываются ВНУТРИ задания
(chargeForRun → balance_transactions), значит вариант A сажал биллинг/RLS/
очередь/оркестрацию на рендер-машину. Переход на вариант Б: виртуалка = тупой
Node-сервис рендера («свой xf4»), портал зовёт по HTTP на том же шве ->html().
Деньги/база/RLS/админка/проекты/другие очереди — ОСТАЮТСЯ на портале, бит-в-бит.
Добавлено §4.2 таблица неприкосновенности остальной системы; §5.6 рендер-сервис
(не app-узел, без БД/Redis); §12 SelfRenderClient как HTTP-клиент к виртуалке;
§8 фаза 5 без роли БД. Вариант Б снимает риски биллинга/RLS/очереди варианта A.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:48:14 +03:00
Дмитрий d8c255cf4c docs(autopodbor): спека v3 — де-риск JSON спайком + wiring + план Б
Второй построчный проход + живая разведка 2ГИС вскрыли главное: допущение
«JSON = чисто и просто» НЕ подтверждено. Источник = window.initialState
(356КБ), но извлечь список фирм нетривиально (наивный обход берёт города-
спутники), а телефонов/сайтов в листинге НЕТ (только в карточках) — вскрытие
карточек остаётся. Правки: §5.2 реальный источник+риск; §8 фаза 1 = СПАЙК
с GO/NO-GO до стройки; §12 точки подмены (DeepStudyCollector жёстко завязан
на XfetchClient — развязать на интерфейс; шаг 2 последователен — reuse, но
не кратно; гранулярность флага); §13 риски+план Б (initialState неподъёмна →
план Б, xf4 не рискуем зря).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:34:27 +03:00
Дмитрий 92b38a23be docs(autopodbor): спека — закрыты стыки после построчной ревизии
Инвентаризация кода вскрыла пропуски: 2ГИС/Яндекс живут не только в шаге 1.
Добавлено: §4.1 полная карта 7 точек добычи (шаг 1 листинг/карточки, шаг 2
изучение, разворот по телефонам 2ГИС+Яндекс, резолв) + 3 типа URL 2ГИС;
§5.2 явный стык JSON↔HTML через TwoGisJsonReader с инвариантом контракта;
§5.3 прокси на ВСЕХ 4 Яндекс-точках; §5.4 трафик прокси = деньги + отсечка
картинок; §5.6 роль БД с виртуалки; §5.7 гарантия «шаг 2 и разворот не
теряются, xf4 не остаётся нигде при флаге ВКЛ»; §7 тесты на 3 типа URL +
контракт-идентичность + регресс шага 2. Недельная работа сохраняется 1:1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:25:36 +03:00
Дмитрий a2e5fdd7fd docs(autopodbor): спека «свой канал 2ГИС+Яндекс на виртуалке» (замена xf4)
Дизайн утверждён владельцем в брейншторме: рендер своим браузером с
маскировкой + ротация RU-прокси вместо платного xf4, чистый разбор 2ГИС
через перехват встроенного JSON (убирает трэш), постоянный браузер,
всё на выделенной виртуалке (не на боевом портале), за флагом self_render
с откатом на xf4. Планка приёмки — фирма-в-фирму с живым 2ГИС.
UI/кнопки/функции не трогаются. Замер: 25 мин xf4 → 9 мин свой рендер.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:17:11 +03:00
Дмитрий 5c8b344bcf fix(autopodbor): разворот глубина 1 по умолчанию + массовое удаление предложений
Разворот по телефонам 2ГИС и Яндекс больше не уходит цепочкой «до талого»:
по умолчанию берём только фирмы с ЛИЧНЫМ номером фирмы — глубина 1. Именно
неограниченная цепочка уводила в чужие фирмы, давая мусорные сайты в
предложениях холдинга. Многошаговый разворот сохранён за параметром maxDepth,
по умолчанию 1. Изолировано: работает только под флагом phone_reverse, основной
пул сбора не затрагивается. Бэкенд автоподбора 430 из 430.

Фронт: на вкладке «Предложения» добавлена кнопка массового удаления выбранных
источников — bulkDeleteProps через removeSource, покрыта тестами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:33:31 +03:00
Дмитрий 43e29823a8 perf(autopodbor): разнести шаги по очередям — find (ИИ) и study (тупой слой)
Раньше поиск, резолв и изучение делили одну очередь autopodbor — при одном
воркере быстрый шаг 2 (изучение) стоял за тяжёлым шагом 1 (поиск с ИИ) другого
клиента. Теперь два процесса:
  • шаг 1 «собрать поле» (поиск + резолв по имени, ИИ+внешние) → autopodbor-find
  • шаг 2 «дайте источники / изучение» (без ИИ) → autopodbor-study
Идут параллельно, друг друга не держат. Замок WithoutOverlapping по runId цел.

Запуск теперь двумя воркерами:
  queue:work redis --queue=autopodbor-find  --timeout=3600
  queue:work redis --queue=autopodbor-study --timeout=3600

TDD: AutopodborQueueThrottleTest 4/4, автоподбор 434/434.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:28:51 +03:00
Дмитрий bdecd08dcb perf(autopodbor): мульти-ключ xfetch round-robin — параллельность ключи×concurrency
Сбор 2ГИС упирался в лимит xf4.ru ~3-4 запроса за раз на КЛЮЧ (выше 429).
XfetchClient теперь принимает список ключей и раскидывает запросы по кругу:
размер пачки = concurrency × число ключей, каждому URL — свой ключ. Живой залп
подтвердил: 9 параллельно (3 ключа × 3) без 429 → лимит per-key, ключи дают ×3.

Ключи — только в .env (XFETCH_API_KEYS через запятую), в код/гит не идут; пусто →
работает по-старому на одиночном XFETCH_API_KEY. Совместимость: конструктор
принимает и строку, и список. TDD: XfetchClientTest 11/11, автоподбор зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:28:19 +03:00
Дмитрий da8e24dda6 chore(schema): canon-sync автоподбора v8.61 — хвост competitors/runs + журнал слияний
Завершён отложенный canon-sync автоподбора в теле db/schema.sql (в v8.60 явно
помечен «⏸ ХВОСТ»). Свёрнуты ранее отложенные инкременты + добавлена новая таблица:

- autopodbor_competitors +box (+chk proposal|field|archived) +индекс tenant_box
  +phones JSONB +elements JSONB.
- autopodbor_runs +progress +result JSONB.
- НОВАЯ autopodbor_merge_events (журнал слияний + снимок absorbed; RLS ENABLE+FORCE
  tenant_isolation; FK user_id/survivor_id SET NULL; индекс tenant_id,created_at).

ALTER-миграции доguard-ены (elements/runs_progress/runs_result — Schema::hasColumn
early-return) → migrate:fresh зелёный end-to-end, автоподбор 428/428, squawk 0,
rls-reviewer OK. Снят предвыкатный блокер по схеме. Воркстри avtopodbor, НЕ на проде.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:28:38 +03:00
Дмитрий 7d6516e6b2 feat(autopodbor): журнал слияний + возврат поглощённой карточки
Слияние конкурентов больше не «чёрная дыра» (баг «САПС/Метрополис»: карточка
исчезла без следа, вернуть было нечем). Теперь каждое слияние пишет событие в
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>
2026-07-05 13:52:43 +03:00
Дмитрий 7d46548c6e fix(autopodbor): окно слияния показывает выживающую карточку и её имя по умолчанию
Баг «САПС/Метрополис»: клиент переименовал свежую карточку и слил — а выжила
ДРУГАЯ (с проектами/в поле), имя переименованной затёрлось вслепую, карточка
пропала. Теперь окно «Найти дубли» помечает карточку, которая ОСТАНЕТСЯ
(«· останется»), и берёт ЕЁ имя как имя объединённого по умолчанию — клиент
видит исход до подтверждения и не теряет карточку случайно.

Бэкенд (survivor-метка в duplicate-groups + AutopodborCompetitorMerger::survivorId)
уже в 928561bd; здесь фронт: api-тип, окно, метка, тест. Фронт 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:32:36 +03:00
Дмитрий 928561bd12 fix(autopodbor): переименование конкурента тянет имена проектов + слияние не роняет карточку в предложения
Две починки в «Конкурентном поле» + превью выжившего в окне склейки:

1. Переименование конкурента (PATCH competitors/{id}) не обновляло имена
   уже созданных проектов — они носят имя-снимок конкурента. Теперь при
   смене имени проекты синхронизируются (syncNamesForCompetitor): меняется
   только отображаемое имя, signal_identifier поставщика не трогаем.

2. Слияние дублей не пересматривало ящик выжившего. Если выжившим по
   возрасту/деньгам оказывалось предложение, объединённая карточка уезжала
   в «Предложения», даже когда одна из склеиваемых была в «Поле». Теперь
   объединённая получает сильнейший ящик (поле > предложение > архив) —
   только повышаем, никогда не понижаем.

3. Окно «Почистить поле» показывает, какая карточка останется после склейки
   (survivor во duplicateGroups) — не затираем имя вслепую.

Тесты: весь блок автоподбора зелёный (426). Larastan в этой рабочей копии
падает без вывода на любом файле — среда, не код.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:29:16 +03:00
Дмитрий a28058779b fix(autopodbor): дубли по телефону застройщика — снят порог «людного» номера
Кейс «Образцово»: у застройщика (СМ.Сити) один номер отдела продаж легитимно
висит на всех его ЖК (277-99-01 на 9 проектах). Прежний порог dropCrowdedKeys
(>4 карточек → «рекламный», выкинуть) прятал это, и окно «Найти дубли» не
показывало проекты застройщика — systematically (6 таких номеров в данных).

Решение владельца 05.07: порог по ТЕЛЕФОНУ (p:) убрать — клиент подтверждает
склейку вручную («Это разные»), тихой ложной склейки нет; рекламные-подменыши
и так отсекаются на сборе. Порог по АДРЕСУ (a:, бизнес-центр) оставлен.
Проверено на AutoMoney: его номер у 3 карточек — все реально Автодом24, группа
верная, монстр-группы снятие порога не даёт.

Тесты переписаны со старого поведения на новое (unit + feature), 421/421.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:47:40 +03:00
Дмитрий f06b336363 feat(autopodbor): кросс-ящик дублей поле↔предложения на экране поля (доведён до рабочего)
Кнопка «Найти и объединить дубли» на экране поля теперь ловит и предложения,
дублирующие карточки поля (scope=cross): счётчик учитывает их, окно помечает
«· предложение», объединение сливает предложение в карточку поля (выживает поле,
merger не трогали). Бэкенд оставляет только группы с ≥1 карточкой поля — пары
чисто-предложений на экране поля не мусорят.

Файл-статус сессии — docs/superpowers/findings/2026-07-05-avtopodbor-session-status.md.

Feature 154/154, фронт 32/32, Pint чистый, билд собран.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:19:38 +03:00
Дмитрий e8c53f80e7 feat(autopodbor): гейт «сайт-якорь» для разворота + кросс-ящик дублей + счётчик на кнопке
1. Разворот по телефонам запускается ТОЛЬКО при наличии своего сайта (seedDomain).
   Без сайта имя размыто (напр. «Южный берег» → автосервисы/гостиницы), сеедные
   телефоны грязные, а разворот их лишь умножает. Доказано вживую: ДСК/СМ-сити
   (сайт есть) — чисто; «Южный берег» (нет) — 70% мусора.

2. Поиск дублей научился кросс-ящик поле↔предложения (scope=cross): предложение-
   дубль карточки поля теперь всплывает (merge кросс-ящик уже умеет). В выдаче
   виден box каждой фирмы. Без scope — прежнее поведение (один ящик).

3. На кнопке «Найти и объединить дубли» показывается число найденных групп-дублей
   (считается на загрузке, обновляется после слияния/«это разные»).

Юнит 267/267, feature 153/153, фронт 15/15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:01:34 +03:00
Дмитрий 9fce28fda8 feat(autopodbor): Яндекс-разворот по телефонам на шаге 2 (рядом с 2ГИС)
Для застройщиков, которые сидят только в Яндексе. По каждому номеру ищем в
Яндекс.Картах, но список рыхлый — поэтому КАЖДУЮ карточку открываем и оставляем
только те, где искомый номер реально есть. Это отсекает «Похожие места рядом»,
рекламу и рыхлые совпадения по бренду. Оба справочника объединяются
(CompositePhoneReverse), дубли по домену схлопываются.

Переиспользована готовая механика Яндекса: render-yandex-list.cjs (список) +
DirectoryParser::parseYandexCard (карточка). Браузерные адаптеры за интерфейсами
(YandexPhoneSearch/YandexCardReader) — логика TDD-ится на фейках; порог общей
линии считается по подтверждённым карточкам.

За отдельным тумблером AUTOPODBOR_PHONE_REVERSE_YANDEX (по умолчанию ВЫКЛ,
требует phone_reverse). Юнит 267/267, feature 150/150. План и находки —
docs/superpowers/{plans,findings}/2026-07-05-*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:30:27 +03:00
Дмитрий 0e47b24dfc feat(autopodbor): заметная ошибка в окне создания и кнопка «Проверить дубли»
Ошибка создания проекта показывается заметно прямо в окне (красный блок), а не тихим
тостом внизу. При дубле источника — подсказка «проверьте на дубли» и кнопка, которая
перекидывает в «Конкурентное поле» и сразу открывает окно объединения дублей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:27:39 +03:00
Дмитрий bcb327aa13 fix(autopodbor): проект принадлежит ровно одному источнику и понятная причина отказа
Инвариант «один проект = один источник»: при переносе проекта на новый источник
снимаем привязку со всех прочих источников — проект больше не может висеть в двух
конкурентах сразу (жёсткий баг с дублями). Плюс разбор ошибки в автоподборе
показывает конкретную причину бэкенда вместо общей фразы «не удалось».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:02:40 +03:00
Дмитрий 36c3a7fe7f feat(autopodbor): разворот по телефонам на шаге 2 (сайты проектов застройщика)
По личным телефонам фирмы обратным поиском в 2ГИС вскрываем связанные карточки
холдинга (проекты застройщика) — забираем их сайты и телефоны, цепочкой «до талого».
Общий номер (у >4 фирм) пропускаем; visited-set + потолок карточек; уже известную
главную карточку не дублируем. Защита от AutoMoney: реклама отсечена парсером,
общий номер обрублен до разворота, находки не клеятся молча.

За тумблером AUTOPODBOR_PHONE_REVERSE (по умолчанию ВЫКЛ). Юнит 259/259, feature
146/146. Решение и план — в docs/superpowers/{findings,plans}/2026-07-05-*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 07:55:03 +03:00
Дмитрий f1352083a1 docs(autopodbor): решение по сайтам проектов застройщика (ДСК) — что упустили и план
Застройщик = холдинг из проектов-ЖК, каждый со своим сайтом; шаг 2 берёт только
главную карточку. Решение: проекты = элементы конкурента, авто-открытие из 2ГИС/
сайта (нужен го владельца), не переобрабатывать уже собранную главную. Эталон —
ДСК (tenant 38, competitor 720). Открытые вопросы О-1..О-4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 06:49:16 +03:00
Дмитрий 6a0635e60a fix(autopodbor): индикатор сбора подхватывается при возврате на карточку
Клиент ушёл со страницы во время сбора и вернулся — индикатор пропадал (жил только
на опросе в памяти браузера), казалось, что процесс исчез.

- competitor-эндпоинт отдаёт active_run (идущий по конкуренту сбор) — тест
- loadCompetitor подхватывает active_run в currentRun (не затирая итог завершённого)
- onMounted возобновляет опрос, если сбор ещё идёт → индикатор оживает, дожидаемся итога
- тесты: active_run (бэк), loadCompetitor подхват/сохранение, подхват опроса на экране

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 06:40:10 +03:00
Дмитрий 001ccbd9a7 fix(autopodbor): спиннер крутится при «уменьшить движение» (не выглядит зависшим)
У клиента включено «уменьшить движение» (Windows/браузер) — глобальное правило
motion.css гасило ВСЕ анимации (animation-iteration-count: 1 !important), поэтому
любой кружок замирал после одного оборота и казался зависшим.

Спиннеры-загрузчики (.ld-spin, .ld-collecting__spin) — законное исключение (WCAG
допускает индикатор загрузки): возвращаем медленное непрерывное вращение. Чинит
все кружки в приложении разом, не только автоподбор.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 06:28:08 +03:00
Дмитрий 2da6273784 fix(autopodbor): живой индикатор сбора на экране (кружок крутится, виден без окна)
Клиент жаловался: кружок «завис» и прогресс не видно, когда окно сбора закрыл.

- CollectingIndicator: собственный гарантированно крутящийся кружок (старый ld-spin
  из @import в scoped не анимировался — известный баг Vue) + текст очередь/этап.
- Индикатор ставится прямо на экран (конкурент и «поле») — виден, даже если окно закрыли:
  «вы в очереди №N» / «этап N из M» / «идёт сбор…».
- В окнах сбора заменил статичный кружок на этот индикатор.
- 4 теста компонента + тест экрана.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 06:22:48 +03:00
Дмитрий 26bebf4b7e feat(autopodbor): при слиянии конкурентов проекты переименовываются в имя выжившего
Имя проекта следует за конкурентом: после склейки дублей все проекты объединённого
получают имя выжившего конкурента, при совпадении различаем источником, а не цифрой.
Проекты не удаляются и не рвутся — меняется только имя.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:10:18 +03:00
Дмитрий 505b8ed059 feat(autopodbor): повторный сбор источников — фронт
Кнопка «🔄 Собрать источники ещё раз» вместо мёртвой «✓ Источники собраны».
Окно предупреждает, что повтор платный (даже если нового нет). Честный итог
после сбора (N новых / M ранее удалённых). Блок «ранее удалённые — вернуть?»
с кнопкой «Вернуть» (box archived→proposal).

- RunDto.result + collectResultMessage (чистый хелпер) + тесты
- store.restoreSource + тест
- компонент RefoundDeletedSources + тест
- FieldCompetitorScreen: кнопка-повтор, честный итог, блок «вернуть», текст окна

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 06:08:53 +03:00
Дмитрий 892ca06093 feat(autopodbor): повторный сбор источников — бэкенд
Клиент может собрать источники по конкуренту ещё раз. Новые → в предложения,
ранее удалённые (найдены снова) → помечаются для возврата, уже известные не
задваиваются и не трогаются.

- удаление источника стало мягким (box=archived) — помним, чтобы показать «ранее удалён»
- снят жёсткий стоп «уже изучали» в startStudy (повтор разрешён; от нахлёста — assertNoInFlight)
- колонка result (jsonb) на прогоне + cast + RunResource
- джоба изучения классифицирует источники и пишет итог {new_sources, refound_deleted, ids}
- тесты: мягкое удаление, повтор, классификация

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 06:08:12 +03:00
Дмитрий ec907d27fa feat(autopodbor): имя проекта без бессмысленной цифры и переименование при переносе
Имя проекта = имя конкурента; при совпадении различаем источником сайт/номер,
а не хвостом «2». Один проект у конкурента — просто «Сибиряк», несколько —
«Сибиряк · сибиряк.рф». При переносе источника к другому конкуренту проект
переименовывается на нового конкурента.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 05:28:24 +03:00
Дмитрий db3cb7473c feat(autopodbor): гейт реквизитов при создании проекта и защита от дублей источника
#1 requisites_required — показываем короткую форму реквизитов прямо в экране
Конкурентного поля и повторяем создание проекта после сохранения.

#4 дубль источника — при добавлении проверяем, нет ли такого же источника у другого
конкурента или уже созданного по нему проекта. Отдаём клиенту выбор: убрать дубль или
перенести проект к новому источнику по идентификатору. Проект не пересоздаётся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 05:13:28 +03:00
Дмитрий 3b32efb871 fix(autopodbor): пустые сайт/карточка элемента не ломают сохранение конкурента
Форма шлёт пустую строку в незаполненной строке сайта/карточки, middleware
ConvertEmptyStringsToNull делает из неё null — валидация elements.*.sites.*/
cards.* теперь допускает nullable (иначе 422). Ручной источник заводится сразу
в «работу» (box=field). Найдено незакоммиченным в воркстри (доделка #2 «элементы»),
тесты зелёные — коммичу отдельно, чтобы не потерять и не смешать с прогрессом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:31:38 +03:00
Дмитрий ffc5da7543 feat(autopodbor): показ очереди и этапа клиенту во время сбора (фронт)
Компонент RunCollectStatus: «вы в очереди №N» (пока прогон ждёт) или «этап N
из M: подпись» (пока идёт). Встроен в оба окна сбора (конкуренты/источники) и
экран загрузки ручного изучения. RunDto получил queue_position и progress.
Никаких фейковых процентов — только реальные этапы движка. 4 теста Vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:31:19 +03:00
Дмитрий 428b7b0f01 feat(autopodbor): прогресс прогона — очередь и этапы (бэкенд)
Клиент во время сбора видит место в общей очереди (пока ждёт) и текущий
этап (пока идёт). Очередь — длина общей Redis-очереди (честное ожидание,
без RLS, одинаково dev/прод). Этапы пишет сам движок через RunProgressChannel:
поиск (анализ→справочники→федералы→сбор→отбор), изучение — по каждому элементу.

- миграция progress (jsonb) на autopodbor_runs + cast
- RunResource отдаёт queue_position (AutopodborQueue) и progress
- RunProgressChannel (синглтон), джобы привязывают его к своему прогону
- SearchStages + инструментовка LiveFindCompetitors и RealCompetitorAgent
- 16 тестов (Feature+Unit), все зелёные

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 04:31:01 +03:00
Дмитрий f757d8adaf docs(autopodbor): handoff сессии 04-05.07 - что сделано по #2, что открыто (#1 прогресс, баг городов, похожесть)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:42:41 +03:00
Дмитрий b78babe7b4 feat(autopodbor): при слиянии выбор имени объединённого из найденных
Диалог дублей: селект имени среди выбранных к слиянию, по умолчанию первое.
Merger и ручка принимают опциональное имя. Обе экрана.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:07:59 +03:00
Дмитрий bd425308e6 feat(autopodbor): карточка конкурента показывает состав холдинга (элементы)
Если конкурент из 2+ элементов - блок со списком: имя, сайты, число карточек.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:45:46 +03:00
Дмитрий 5d19114c24 feat(autopodbor): при слиянии дублей выбор подмножества - галочки, слить 2 из 3 и т.п.
В группе из 3+ клиент отмечает, кого объединить. По умолчанию выбраны все
(группа из 2 - как раньше). Слить можно любое подмножество >=2. На обоих экранах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:44:02 +03:00
Дмитрий 50d6da5931 feat(autopodbor): шаг 2 изучает каждый элемент конкурента отдельно
Глубокое изучение строит отпечаток на КАЖДЫЙ элемент холдинга - имя элемента плюс
его первый сайт - и сводит телефоны со всех. Без элементов - как раньше.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:39:37 +03:00
Дмитрий 2130cd74e9 feat(autopodbor): формы создания/правки конкурента через элементы - правка не теряет карточки
Бэк: хелпер CompetitorElements нормализует элементы и выводит легаси site_url/directory_urls
из ВСЕХ карточек. Ручки manual/update принимают elements.
Фронт: компонент CompetitorElementsEditor встроен в формы создания и правки на экранах
Поле и Предложения. Прежний баг - пересбор directory_urls из двух полей - закрыт.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:32:16 +03:00
Дмитрий 47bc508246 docs(autopodbor): находки похожесть-погода и конкурент-из-элементов + план Часть 1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:14:54 +03:00
Дмитрий d7c63a2962 feat(autopodbor): конкурент из элементов - слияние без потерь
Конкурент хранит список элементов {name,sites,cards} - подкомпании/ЖК холдинга.
Слияние складывает элементы всех объединяемых, ничего не роняет.
Миграция бэкфилла: один элемент из текущих site_url + directory_urls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:14:39 +03:00
Дмитрий d2f3a171a9 fix(autopodbor): источник исчезает сразу при удалении + формы не закрываются при выделении текста
- стор removeSource чистит store.sources - карточка конкурента обновляется без reload
- 8 оверлеев на экранах Поле/Предложения переведены на useOverlayDismiss

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:14:18 +03:00
Дмитрий 66a0f2bb67 docs(автоподбор): снимок 04.07 — движок шага 2 на ветке + приёмка 385 + UI + что осталось
Полный статус: движок перенесён (134 теста, ревью «Ready»), 385 подтверждён 1:1 с
ЭТАЛОНом живьём, корневой баг «нашлось мало» = завис queue:work (починен перезапуском),
UI (метки/регион-тайпахед/групповое создание) закоммичен ac3d6975. Осталось: приёмка
412/414/416/420, открытый вопрос дефолта «Вся РФ», перенос на прод по слову.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:13:25 +03:00
Дмитрий ac3d6975c1 feat(автоподбор/UI): понятные метки источников + впечатываемый регион + групповое создание
FieldCompetitorScreen:
1) метки ✓ настоящий номер / 🎭 рекламный подменный + плейн-язык «где нашли»
   + легенда «как читать» (данные в БД и сверка с ЭТАЛОНом НЕ тронуты — перевод только в показе);
2) регион «впечатывается» — свой тайп-ахед в стиле экрана (без Vuetify, чтобы не ломать тесты)
   в окнах «Создать проект» и «Настройки проекта»; чипы для мультивыбора, подпись округа;
3) галка «Вся РФ (все регионы)» даёт баннер-объявление;
4) групповое создание проектов из всех выбранных источников без проекта (source_ids пачкой),
   заголовок/текст под «несколько». +тест на групповое создание. Фронт-спеки 24/24 зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:12:16 +03:00
Дмитрий 82e30d0548 test(автоподбор): end-to-end хранение строк глубокого сбора — близнецы = две записи в БД 2026-07-03 23:59:00 +03:00
Дмитрий 34a43c3969 style(автоподбор): Pint по пакету Study + провайдеру 2026-07-03 23:46:28 +03:00
Дмитрий 8a7b2ec12d feat(автоподбор): DI-сборка DeepStudyCollector за тумблером 2026-07-03 23:43:45 +03:00
Дмитрий 8fafbc0ba8 feat(автоподбор): развилка studyCompetitor по тумблеру deep_study (интерфейс SourceCollector) 2026-07-03 23:40:44 +03:00
Дмитрий 1cd215c23f feat(автоподбор): DeepStudyCollector — движок глобального сбора (близнецы порознь) 2026-07-03 23:32:23 +03:00
Дмитрий 9ff837bdb9 feat(автоподбор): SiteOpener — вскрытие сайта фирмы
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 23:23:53 +03:00
Дмитрий 1f85403fca test(автоподбор): FootprintSearch — гарантия «пустой ключ = 0 запросов» (assertNothingSent) 2026-07-03 23:22:50 +03:00
Дмитрий 3ca6af7a65 feat(автоподбор): FootprintSearch — EXA-поиск следа конкурента 2026-07-03 23:19:59 +03:00
Дмитрий 103448a00b feat(автоподбор): AnchorGate — фильтр «точно она» для сайта и карточки 2026-07-03 23:17:02 +03:00
Дмитрий 677582bb0a test(автоподбор): точное имя теста отпечатка (path+utm, не схема) 2026-07-03 23:04:48 +03:00
Дмитрий 5826728590 feat(автоподбор): отпечаток конкурента + запросы EXA 2026-07-03 23:01:37 +03:00
Дмитрий be43d91424 feat(автоподбор): PlatformClassifier — площадки по корзинам 2026-07-03 22:58:24 +03:00
Дмитрий 7e59048561 feat(автоподбор): DomainKinship — родство брендов, близнецы НЕ сливаются 2026-07-03 22:54:11 +03:00
Дмитрий fdf64eddbf feat(автоподбор): CompetitorGeo — гео по региону (засеян Красноярск) 2026-07-03 22:49:38 +03:00
Дмитрий 8b17ac077c style(автоподбор): выровнял комментарий deep_study под стиль real_find 2026-07-03 22:47:13 +03:00
Дмитрий afed14b0bd feat(автоподбор): тумблер deep_study + проброс is_federal в шаг 2 2026-07-03 22:43:58 +03:00
Дмитрий 2907a02d45 docs(автоподбор): план реализации переноса движка шага 2 (TDD, 12 задач)
12 задач по TDD: тумблер+is_federal, CompetitorGeo, DomainKinship (близнецы
НЕ сливать), PlatformClassifier, отпечаток+запросы EXA, AnchorGate,
FootprintSearch, SiteOpener, DeepStudyCollector (маппинг 1:1 со словарём
чипов песочницы), развилка по тумблеру, DI, приёмка на 5 против ЭТАЛОНа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:32:17 +03:00
Дмитрий e77ac09fbd docs(автоподбор): ЭТАЛОН приёмки шага 2 — 5 конкурентов, заморожен 1:1
Точный чек-лист «СТАНЕТ» по всем 5 (385/412/414/416/420), извлечён из
вмороженных артефактов bylo-stanet (коммит 2b18922d): сайты (оба близнеца
порознь), настоящие номера, пометки, «где нашли», справочники. Отклонение
недопустимо ни на миллиметр; единственное вне контроля — ротация самих
подменных коллтрекинг-номеров (🎭) провайдером.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:22:55 +03:00
Дмитрий 07fc46d524 docs(автоподбор): дизайн переноса движка «глобального шага 2» в боевой шаг 2
Закон: результат боевого шага 2 строго 1:1 с песочницей. Полный движок за
тумблером autopodbor.deep_study, старый путь — запасной. Явная НЕ-цель:
сайты-близнецы НЕ сливать (красломбард.рф и kraslombard24 — две отдельные
записи). Хранение/биллинг/идемпотентность не трогаем. Приёмка — те же 5
конкурентов через боевой шаг 2 = одобренные артефакты bylo-stanet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:17:19 +03:00
Дмитрий 2b18922db6 docs(автоподбор): песочница шага 2 — все 5 конкурентов на единой новой модели
Перегнал было/станет для 385 Автозайм124, 412 Драйв займ, 414 Банк ДОМ.РФ,
416 Ваш Инвестор, 420 КрасЛомбард на одном движке шага 2: 2ГИС ищется
поиском по имени фирмы (карточка находится, даже если её не было в выдаче),
Яндекс.Карты вскрываются Playwright, склейка кир/лат доменов, подменные
коллтрекинг-номера помечены отдельно от настоящих.

Артефакты — только dev, боевой шаг 2 не изменён.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:51:43 +03:00
Дмитрий ec4a2b178c feat(автоподбор): песочница «глобального шага 2» + артефакты было/станет
Прототип нового шага 2 «Собрать источники» — отдельный скрипт scripts/sandbox-step2.php,
боевой шаг 2 НЕ тронут. Отпечаток фирмы (домен+имя+тип+город) → поиск по отпечатку через EXA
(гео-скоуп по типу фед/рег) → фильтр «точно она» по якорному телефону → глубокое вскрытие
сайтов (код/скрытые/подменный коллтрекинг/Контакты) + 2ГИС(поиск по имени, JSON) + Яндекс(Playwright)
→ все номера с пометками. Генерит HTML «было/станет» на конкурента (было из БД, станет из прогона),
ссылки кликабельные. scripts/_shot.cjs — скриншот HTML в PNG.

Артефакты bylo-stanet/ на 5 конкурентов с «Предложениями» (tenant 36). Свежие со всеми фиксами —
КрасЛомбард и Банк ДОМ.РФ; остальным нужен финальный перегон. Детали, дизайн и 4 фикса
(схема сайта, Яндекс→Playwright, 2ГИС по имени/JSON/ретрай, близнецы кир/лат) — в файле находок.

НЕ в портале, НЕ на проде. Прототип для оценки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:22:13 +03:00
Дмитрий df83fa836c feat(автоподбор): Конкурентное поле — финальный проход склейки, вскрыть всё, группы дублей, телефоны, отсев рекламных номеров 2ГИС
Шаг 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>
2026-07-03 17:34:03 +03:00
Дмитрий 1fef2571e8 feat(y360): баланс почты Яндекс 360 — ручной ввод + кнопка Пополнить
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
email — денежный сервис; сумма вписывается в админке «Система» (Yandex360BalanceStore),
светофор по порогам, кнопка «Открыть оплату»/«Пополнить» → admin.yandex.ru/products.
Робот-скрейпер отклонён (SPA Яндекса враждебен ботам + автопополнение защищает баланс).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:34:13 +03:00
Дмитрий 65770f0e70 docs: снимок 02.07 — launch-gate ЯДРО на проде + автоподбор в воркстри + промт продолжения
Консолидированное состояние двух треков (launch-gate выкачен на liderra.ru core-only;
автоподбор работает только в воркстри) + квирки окружения/тестов + промт для
восстановления контекста после компакта.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:22:53 +03:00
Дмитрий 2384e3fb0d deploy(prod): launch-gate ЯДРО на liderra.ru — баланс блокирует запуск, не создание
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Ядро фичи для обычных проектов (без автоподбора): единый LaunchBalanceGate под
lockForUpdate(Tenant), ProjectService::create($launch)+setActive, bulk resume
«сколько влезло», ProjectController store/update/toggle, config/billing.php,
ProjectResource.preflight_blocked_at + фронт экранов проектов (сообщения в рублях,
метка «не запущен», группы). Схема БД не менялась (preflight_blocked_at уже был).
Cherry-pick ТОЛЬКО ядра поверх main 609c7ae9; автоподбор НЕ выкатывается.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:09:38 +03:00
Дмитрий 4c05af7fda docs(биллинг): статус фичи launch-gate — реализовано, на gitea, follow-up
Спека помечена РЕАЛИЗОВАНО (коммиты f78ab4af/9ff7bc8f/8a8783df), ревью opus без
критичных находок. Зафиксирован follow-up: прод-флаг BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS,
DRY-сведение оставшихся копий правила, пред-существующая партиция-хрупкость тест-инфры.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:26:41 +03:00
Дмитрий 8a8783df4a fix(биллинг): повышение лимита проекта — evaluate+запись под lockForUpdate(Tenant)
По итогам финального ревью: путь повышения дневного лимита в ProjectController@update
прогонял балансовый гейт и писал новый лимит БЕЗ замка на тенанта (в отличие от
create/setActive). Две параллельные правки лимита одного тенанта могли обе пройти
гейт и суммарно превысить ёмкость баланса (TOCTOU). Обёрнуто в DB::transaction +
Tenant::lockForUpdate() при повышении лимита — evaluate и запись под одним локом.

UpdateLimitGateTest 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:21:17 +03:00
Дмитрий 9ff7bc8fa0 feat(биллинг-фронт): баланс на запуске — сообщения в рублях, метка «не запущен», группы
Фронт для правила «баланс блокирует запуск, а не создание»:
- ProjectLimitOverloadDialog переформулирован в «Проект не запущен — пополните
  ~X ₽ или уменьшите объём» (сумма в рублях topup_rub).
- NewProjectDialog: создание всегда успешно; при launch.deferred показывает
  сообщение (не блок). Update-лимита 409 → тот же диалог.
- Возобновление (toggle-active) ловит 409 balance_insufficient (стор не
  переключает is_active), показывает диалог; постоянная метка «Не запущен —
  не хватает баланса» на карточке/в панели по preflight_blocked_at/balance_blocked.
- BulkActionsBar: сводка «Запущено N, отложено M — не хватает баланса» на resume.
- Автоподбор: CreateScreen показывает сводку запуска (рубли); FieldCompetitor
  массовые pause/resume через общий POST /api/projects/bulk (наследует BULK_MAX,
  слепок-защиту, балансовый гейт).
- ProjectResource отдаёт preflight_blocked_at.

Новые/обновлённые фронт-тесты зелёные. Пред-существующий красный baseline
фронт-набора (ErrorView/Settings/Legal/… + ProjectsView v-show) не трогался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:04:16 +03:00
Дмитрий f78ab4af3d feat(биллинг): баланс блокирует запуск проекта, а не создание — единый гейт
Создавать проекты можно всегда; баланс проверяется в момент ЗАПУСКА
(создать-и-запустить / запустить / возобновить одиночно и пачкой / автоподбор)
под замком на клиента (без гонок). Не хватает — проект остаётся на паузе с
меткой preflight_blocked_at, клиенту сообщение в рублях (сколько пополнить).
Групповой запуск «сколько влезло». Нет активного тарифа на дату → запуск
запрещён (fail-closed). Гейт реквизитов первого проекта добавлен и в автоподбор.

- LaunchBalanceGate — единый гейт вместо 3 копий preflight (ProjectController
  store/update, AutopodborController), под DB::transaction + lockForUpdate(Tenant).
- ProjectService::create($launch) + новый setActive(); bulk resume «сколько влезло».
- AutopodborProjectCreator: пачка в транзакции через общий ProjectService::create.
- Идемпотентность box/phone_type миграций автоподбора (Schema::hasColumn guard).
- Тест-инфра: afterRefreshingDatabase восстанавливает месячные партиции.

Тесты фичи 40/40 зелёные. Спека и план — docs/superpowers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:07:22 +03:00
Дмитрий 609c7ae955 feat(external): на плитке внешних сервисов — и баланс, и статус
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Мини-плашка теперь показывает деньги (или —) И слово статуса (жив/выключено/ok)
рядом, как в детализации. Убрана мёртвая serviceValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:36:31 +03:00
Дмитрий 70ae62af4f fix(external): Баланс = только деньги; живость — в колонку Статус
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Баланс/статус различаем по типу сервиса (LIVENESS_ONLY_KEYS), не по null-балансу
(денежный Поставщик с сорванным автологином больше не показывается «выключено»).
Мини-плитка переименована «Балансы сервисов»→«Внешние сервисы».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:26:06 +03:00
Дмитрий b7bc52be85 feat(external): фронт — плитка «Внешние сервисы» (баланс + живость)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Статус-текст для сервисов без денежного баланса (жив/не отвечает/выключено),
метки и иконки почты/ЮKassa/Jivo/капчи, обновлённые подписи плитки и дрилла.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:53 +03:00
Дмитрий 21e1b7982f feat(external): email-алерт при переходе внешнего сервиса в красный
Edge-trigger: одно письмо на ops-адрес при первом покраснении (баланс на
исходе или сервис упал); повторное красное не спамит. Mailable + blade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:53 +03:00
Дмитрий 156f279c56 feat(external): джоба пишет строки живости внешних сервисов
Стабы fakeProvider/fakeProbe вынесены в tests/Pest.php (нужны в двух файлах).
Старые балансовые тесты отключают пробы живости (useLivenessProbes([])).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:52 +03:00
Дмитрий a797ff9e24 feat(external): CaptchaLivenessProbe — статус капчи
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:52 +03:00
Дмитрий d3f7e7a995 feat(external): JivoLivenessProbe — живость чата
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:51 +03:00
Дмитрий 4a4916268b feat(external): YooKassaLivenessProbe — живость платёжного шлюза
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:50 +03:00
Дмитрий 159b520cb8 feat(external): SmtpLivenessProbe — живость почты Yandex 360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:50 +03:00
Дмитрий b05ec70897 feat(external): DTO LivenessReading + интерфейс LivenessProbe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:03:49 +03:00
Дмитрий a88c4034b7 docs(автоподбор): снимок 02.07 — живой движок работает end-to-end + ускорение 2ГИС + промт продолжения
Зафиксировано состояние: фича Stage 1+2 собрана, живой сбор проверен (прогон #12 ~8 мин, 139 фирм 2ГИС,
14 сохранено, 300₽), коммит b3f5158e, квирки (лимит xfetch, EXA OS-env, очередь-локально, node-путь) и
промт для восстановления контекста. Детали движка — в памяти project-autopodbor-engine-live-working-2026-07-02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 06:30:03 +03:00
Дмитрий b3f5158e48 perf/fix автоподбор: параллельный сбор 2ГИС по 3 + большие таймауты + безопасное письмо + node из config
Ускорение шага 1: CategoryScraper грузит страницы категории 2ГИС не по одной, а пачками поперёк рубрик
через BatchPageFetcher. На остывшем xfetch 2ГИС упал с ~23 мин до ~5 мин и собрал 139 фирм вместо 12.
Общий лимит одновременных xfetch снижен 4→3 (безопаснее под оконный rate-limit сервиса).

Таймауты ИИ/EXA подняты с запасом (латентность моделей плавает, сбор асинхронный, клиент ждёт):
sonar 120→300с, отсев агрегаторов 90→240с, общий AITUNNEL 30→120с, EXA 30→90с. Джоба: один прогон
без авто-ретраев (tries 3→1, чтобы таймаут не перезапускал платный движок трижды) и общий таймаут 900→1800с.

Фиксы, найденные на живом прогоне:
- Письмо «готово» больше не роняет прогон при недоступной почте (тесты на сбой SMTP при status done/empty).
- Яндекс-загрузчик берёт путь к node из config (autopodbor.node_bin, дефолт node) — из-под PHP node
  теперь находится; на живом прогоне Яндекс дал 78 фирм вместо 0.

По TDD. Бэкенд автоподбора 262/262, Pint чисто. Живой прогон end-to-end: ~8 мин, 14 новых конкурентов,
списание 300 ₽ корректно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:43:34 +03:00
Дмитрий 76c67e1fc5 feat автоподбор параллельная загрузка карточек справочника пачками по 4
Замер боевого лимита xf4.ru показал безопасный потолок 4 одновременных запроса — выше идут 429 Too Many Requests, плюс есть лимит за окно времени. Сбор до 25 карточек филиалов в XfetchDirectoryFetcher грузился по одной секунды на каждую — узкое место шага 2.

Новый интерфейс BatchPageFetcher + XfetchClient::htmlBatch грузит страницы порциями по services.xfetch.concurrency дефолт 4, параллелит внутри порции через Http::pool, ретраит пустые и 429. XfetchDirectoryFetcher берёт карточки одной пачкой если загрузчик batch-совместимый, иначе по одной. Два справочника шага 2 оставлены последовательно намеренно — иначе 2x4=8 за лимитом. Число выносится в XFETCH_CONCURRENCY без деплоя.

По TDD. Бэкенд автоподбора 258/258, Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:52:18 +03:00
Дмитрий 9a9164fee2 feat автоподбор Stage 2 актуализация — новый сайт фирмы поля через Добавить/Заменить в форму проекта
Секция «На актуализацию»: дельта-карточка показывает текущие сайты и карточки фирмы поля + новый сайт кликабельными ссылками, кнопки Добавить и Заменить ведут через подтверждение «Создать проект?» в существующую форму CreateScreen с подставленным новым источником. Имя фирмы берётся из поля, не перезатирается.

Бэкенд: endpoint POST /competitors/{id}/actualize action=add|replace new_site. add заводит сайт-источник рядом со старым, replace архивирует старые сайт-источники и заводит новый, при активном проекте по старому сайту возвращает 409 manage_via_project. /proposals обогащён полем matched с текущими сайтами и карточками совпавшей фирмы.

По TDD. Бэкенд автоподбора 254/254, фронт автоподбора 64/64, ESLint чисто, Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:27:00 +03:00
Дмитрий 82123057f5 docs(автоподбор): спека + план фичи сверки находок + файл состояния Stage 1 и промт продолжения
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:47:12 +03:00
Дмитрий 5a861e0e85 style(автоподбор): pint-форматирование — скобки конструкторов, порядок импортов, выравнивание; логика без изменений
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:45:37 +03:00
Дмитрий 6d48503a0e feat(автоподбор): сверка находок со состоянием клиента — 3 группы предложений + скрытые дубли, мягкое удаление в архив, убран вычет себя
Stage 1: опознавалка фирмы по сайту/карточкам, ProposalClassifier new/actualize/archived/hidden, box=archived, bulk удалить всех ранее удалённых. Backend 249/249, front autopodbor 61/61.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:45:11 +03:00
Дмитрий fc4e4bacbf feat(автоподбор): центр похожести из описания ниши, а не только доменов-примеров
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:44:43 +03:00
Дмитрий 68341e5576 fix(автоподбор): дедуп ДО отсева агрегаторов + файл состояния/промт продолжения
Отсев агрегаторов шёл по СЫРЬЮ до склейки — gpt-4o-mini залетало 227 позиций
с дублями (Ломбардико ×6). Порядок в FindCompetitorsAssembler: сначала
склейка/дедуп, потом отсев — классифицируем ~90 уникальных, а не 227.

Плюс файл состояния сессии (что сделано/осталось) + промт восстановления
контекста для компакта. TDD, 234/234 зелёные. НЕ прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:27:26 +03:00
Дмитрий f1ab608444 feat(автоподбор): масштаб и чистота шага 1 — пул EXA, очередь-throttle, батч агрегатора, узкие рубрики, дедуп по словарю
Масштаб (лимиты внешних сервисов — на КЛЮЧ, общий на всех клиентов):
- EXA: параллельный пул findSites (concurrency=5) вместо 40 запросов по одному.
- Агрегатор: батч вместо ~90 запросов по одному + чанкинг по 40 и длинный
  таймаут 90с (большой список gpt-4o-mini не успевал в 30с).
- Очередь autopodbor + WithoutOverlapping на всех 3 джобах — глобальный
  потолок = число воркеров; assertNoInFlight держит один подбор на клиента.

Чистота выдачи (универсально, без зашитых ниш):
- Анализатор: узкие рубрики, запрет зонтичных слов («финансовые услуги» тащила
  юрфирмы/банки).
- Дедуп: ключ имени минус слова из СЛОВАРЯ рубрик прогона («Яричъ Ломбард»→«Яричъ»);
  слово-категория из данных, не из списка. Гард: склейка только если остаётся
  ровно один слово-токен (генерики вроде «займы под залог» не трогаем).

TDD, 233/233 (unit+feature) зелёные. НЕ прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:18:09 +03:00
Дмитрий c259855349 feat(автоподбор): рубрика из списка Яндекса в описание + межотраслевой промпт
Playwright тянет рубрику фирмы прямо из СПИСКА Яндекса («Ломбард, автоломбард»,
«Микрофинансовая организация», «Банк») без захода в карточку — быстро и бесплатно.
Рубрика идёт в описание конкурента → в похожесть-эмбеддинги (раньше меряли по
голому имени, хлам и целевое сбивались). Промпт анализатора обезличен: примеры
из РАЗНЫХ отраслей (стоматология/автосервис/доставка) + «подбери под описание
клиента» — движок универсален, не подточен под нишу займов Омеги. TDD 216/216, НЕ прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:00:41 +03:00
Дмитрий 01a057c66c feat(автоподбор): Яндекс дедупит по slug-коду фирмы + код в склейку
Яндекс отдавал по строке на КАЖДЫЙ филиал (дедуп по id точки) — 214 строк,
из них хлам-повторы одной сети (Корунд ×15). Новый YandexListParser дедупит
по SLUG (код фирмы от источника /maps/org/<slug>/id): одна фирма = одна строка,
разные написания одной фирмы склеены, разные фирмы с разным кодом раздельны.
Код справочника (ya:slug) уходит в union-find склейку как опознавательный
признак. Живьём 214 -> 94. TDD, 215/215 (unit+feature) зелёные. НЕ прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:24:00 +03:00
Дмитрий 6e970d7231 fix(автоподбор): канал В получает чистый стоп-лист — дубли-филиалы схлопнуты
Стоп-лист известных для ИИ канала В дедуплицировался только в проверке
видели/не видели, а в промпт уходил с повторами (Корунд ×27, КрасЛомбард ×19).
Теперь чистим и сам список по ключу имени — ИИ видит каждое имя один раз:
короче, дешевле по токенам, точнее. TDD, 137/137 unit зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:27:45 +03:00
Дмитрий f9f4743392 docs(автоподбор): файл состояния движка + промт восстановления контекста (01.07)
Единая точка: что сделано (движок шага 1 пересобран — АНАЛИЗ→канал А 2ГИС(xfetch)+Яндекс
(Playwright)→слияние→канал В федералы(EXA)→сборка; 136 unit+74 feature зелёные, HEAD dee2ebbc),
что осталось (полный живой прогон+сверка на 72, калибровка, параллельность), ключи/окружение,
карта кода, промт для восстановления после компакта.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 04:55:51 +03:00
Дмитрий dee2ebbcf8 feat(автоподбор): второй справочник канала А — Яндекс.Карты через локальный Playwright + слияние
Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright.
render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС).
YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка
(сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс,
слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну
карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml →
общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 04:53:58 +03:00
Дмитрий 5d40de664e feat(автоподбор): лёгкий канал А — имя+сайт+карточка из списка 2ГИС без захода в карточку
Шаг 1 = только имя+сайт+ссылка на справочник (телефоны/описание = шаг 2). Убраны медленные
per-card открытия: со страницы категории 2ГИС берём по фирме имя + ссылку карточки + САЙТ сразу
(CategoryListingParser: сайт из редиректа «Перейти на сайт», firmId в пути = id фирмы; мусор
avito/max.ru/трекеры/иностр.TLD отсеиваем). CategoryScraper отдаёт строки {name,card_url,site}
с пагинацией. LiveFindCompetitors упрощён: канал А из списка (без резолва), канал В — имена →
сайт EXA, без сайта (нет якоря) выкидываем. Живой канал А на новом ключе xfetch: 48 реальных
фирм Красноярска, 17 с сайтом, ~4.5 мин, без per-card. Филиалы схлопывает merge (union-find).
Модуль 135 unit + 74 feature зелёные. За флагом autopodbor.real_find; на проде не меняется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 03:31:16 +03:00
Дмитрий 5a65165114 feat(автоподбор): движок шага 1 пересобран под финал v4 (каналы А+В, EXA)
Замена вырожденного «одна фраза → одна страница» на §12/§11.3 финал:
- Шаг АНАЛИЗ (ChannelA\AitunnelQueryAnalyzer): описание → запросы-рубрики (мелкая модель).
- Канал А (ChannelA\CategoryScraper): скрейп категории 2ГИС с пагинацией → резолв карточек.
- Канал В (ChannelB\*): ОДНА модель sonar-reasoning-pro × 2 прохода → ТОЛЬКО имена
  федералов; стоп-лист = имена из А + примеры; сайт федерала через EXA (ExaSiteFinder),
  т.к. у федерала нет карточки в 2ГИС/Яндексе на регион.
- Оркестратор LiveFindCompetitors переписан: АНАЛИЗ→А→В→слияние→отсев→дедуп→похожесть→DTO.
- Провайдер перепрошит; config services.php +research_model/exa.
Похожесть — эмбеддер-модель (математически), резолвер/дедуп — без изменений.
Всё за тонкими границами, офлайн-тесты на фикстурах: модуль 130 unit + 74 feature зелёные.
Провайдер за флагом autopodbor.real_find; на проде не меняется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:06:10 +03:00
Дмитрий d468471707 docs(автоподбор): зафиксирован последний движок §12 v4 + сверка PHP
Свод воедино: методика §12 (3 канала А/В/0 → резолвер → DaData → дедуп →
эмбеддинги → DTO) + все дословные промпты (5 моделей-исследователей,
нормализатор, фильтр агрегаторов). Честная сверка: PHP LiveFindCompetitors
собрал только нижнюю половину (резолвер/дедуп/похожесть/телефоны) и берёт
1 фразу/1 страницу → 3 находки; верхняя половина (АНАЛИЗ+мульти-запрос+
пагинация канала А, канал В 5-моделей) не перенесена. Эталон работы движка —
прогон 29.06 (72 конкурента). План переделки PHP под §12 один-в-один.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:03:48 +03:00
Дмитрий f940c060b8 docs(автоподбор): контекст-промпт продолжения — живой портал :8001 (белый экран) + полное состояние 2026-06-30 19:11:39 +03:00
Дмитрий 062d46e15e feat(автоподбор): письмо «подбор готов» — клиент не ждёт у экрана (фон + уведомление)
RunAutopodborSearchJob по завершении (done/empty) шлёт AutopodborReadyMail на contact_email тенанта
(с числом найденных + ссылкой на портал). Письмо не роняет подбор при недоступной почте (try/catch).
Клиент ставит задачу, работает дальше, получает «готово» письмом.

Тесты: SearchJob 4/4 (вкл. Mail::assertSent); Автоподбор unit+feature 183/183; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:02:28 +03:00
Дмитрий 7e1ff09abe feat(автоподбор): живой ИИ через AITUNNEL — эмбеддинг-похожесть (%) + LLM-отсев агрегаторов
AitunnelEmbedder (text-embedding-3-small) → косинус-похожесть конкурента на профиль клиента (%).
AitunnelAggregatorClassifier (chat) → «поставщик или площадка?» — авто-отсев Авито/Zoon/Банки.ру.
Оба деградируют сами при пустом ключе (пустые векторы → 0%, null → не выкидываем), поэтому
подключены всегда. Ключ — только в .env (services.aitunnel.key), в гит не попадает.

Тесты: AITUNNEL клиенты 9/9; Автоподбор unit+feature 182/182 (флаг real_find в тестах
принудительно ВЫКЛ через phpunit.xml — иначе findCompetitors ходил бы в сеть); Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:56:05 +03:00
Дмитрий 405e4bb182 fix(автоподбор): таймаут задания поиска 900с (живой подбор идёт минутами)
RunAutopodborSearchJob.timeout=900 — живой поиск (2ГИС+Яндекс через антибот + обход карточек)
идёт ~4-5 мин; дефолтные 60с убивали задание на середине. Очередь поднимать с --timeout>=900.
2026-06-30 18:49:18 +03:00
Дмитрий adc3590ab6 fix(автоподбор): живой резолв Яндекса (город в заголовке не на фикс. месте) + ретрай флакующей выдачи
Найдено живым прогоном по нише «ломбард/Красноярск»:
1) Яндекс-заголовок «Имя, рубрика, ГОРОД, улица, дом» — город НЕ последний сегмент; брал «дом»
   как город → все карточки отбраковывались. Теперь YandexResolver: имя=первый сегмент, город —
   по НАЛИЧИЮ в заголовке (DirectoryFields::titleName/titleHasCity). 2ГИС (город последний) — без изменений.
2) ResolvingAgent (ручной resolveByName) теперь берёт ГОРОД центра субъекта (RegionCity), не имя региона.
3) Страница выдачи 2ГИС/Яндекс флакует (капча/пустой layout) → LiveFindCompetitors повторяет поиск,
   пока не появятся фирмы (searchRetries).

Тесты: модуль Автоподбора unit 99/99 + live 4/4; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:44:27 +03:00
Дмитрий 48e65d231c feat(автоподбор): шаг1 — проводка живого findCompetitors за флагом autopodbor.real_find
LivePageFetcher (2ГИС→xfetch, Яндекс→xfetch+fallback локальный Playwright). Провайдер при
config('autopodbor.real_find')=true собирает LiveFindCompetitors (без ИИ-ключа: null-классификатор
+ нулевой эмбеддер → сырой список без отсева площадок и без %). RealCompetitorAgent.findCompetitors
использует живой движок, если подключён, иначе заглушку. Флаг по умолчанию ВЫКЛ — на проде без изменений.

Тесты: Автоподбор unit+feature 172/172 (флаг выкл — биндинг цел); Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:26:38 +03:00
Дмитрий 84e769e454 feat(автоподбор): шаг1 — живой findCompetitors (канал А: ниша → 2ГИС+Яндекс → резолв → сборка)
LiveFindCompetitors: ниша из формы → поиск 2ГИС (по слагу города) + Яндекс (по «ниша город») →
парсер выдачи → резолв каждой карточки напрямую → FindCompetitorsAssembler (фильтр/слияние/
похожесть → DTO §7.2). Добыча за PageFetcher — обход тестируется офлайн на фикстурах.
RegionCity — слаг/имя города центра субъекта (как RegionAreaCode, только уверенные).

Тесты: live 3/3 (склейка 2ГИС+Яндекс одной фирмы, вычет своего сайта, пустая выдача);
модуль Автоподбора unit 99/99; Pint чисто. Провайдер ещё не флипнут — следующий шаг.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:23:29 +03:00
Дмитрий 0636a3c1b4 docs(автоподбор): план шага1 — весь движок собран (A–F+C+D+H-ядро), осталось живое подключение+флип 2026-06-30 18:04:21 +03:00
Дмитрий fcff8ecd47 feat(автоподбор): шаг1 H-ядро — сборка findCompetitors (фильтр→слияние→похожесть→DTO)
FindCompetitorsAssembler: резолвленные кандидаты каналов → отсев агрегаторов (C) →
слияние+дедуп+вычет клиента (E) → отсев федералов если не нужны → похожесть-эмбеддинги (F) →
срез top-N → DTO FindCompetitorsResult (§7.2). Чистая сборка — всё ядро движка v4 складывается
вместе и протестировано офлайн на реальных сервисах + фейках границ.

НЕ флипает провайдер: добыча страниц/имён каналов (живые A-fetch/B/0) и включение боевого
движка за флагом — отдельный шаг (нужно «го» + живой прогон).

Тесты: assembler 2/2; модуль Автоподбора unit 96/96; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:03:25 +03:00
Дмитрий 0fc589792f feat(автоподбор): шаг1 C — отсев агрегаторов LLM-классификатором (не статик-список)
AggregatorFilter + граница AggregatorClassifier (§12.6): «поставщик услуги или площадка?» —
LLM-рассуждением (за границей), а не мёртвым статик-списком. Консервативно: выкидываем только
при уверенном «агрегатор»; при неуверенности (null) конкурента не теряем.

Тесты: aggregator 3/3; Pint чисто. (живой LLM-классификатор подключается на H/живом прогоне.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:59:47 +03:00
Дмитрий 026bc48d41 feat(автоподбор): шаг1 D — обогащение телефонов через DaData (переиспользует живой клиент)
CompetitorPhoneEnricher: телефоны карточки → тип (city/mobile/tollfree) + регион + годность (qc=0)
через существующий DaDataPhoneClient (тот же, что резолв региона лида — §12.4, не дубль).
DaData недоступна → деградация: тип по префиксу (PhoneType), регион пуст, негоден — подбор не падает.
Номера не синтезируются, только классифицируются (берём опубликованные фирмой).

Ключ DaData — в config services.dadata (env, прод). Тест офлайн через Http::fake (реальная форма ответа).

Тесты: phone 4/4; модуль Автоподбора unit 91/91; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:57:22 +03:00
Дмитрий f7ec94e107 docs(автоподбор): план шага1 — прогресс A/E/F/B готовы, осталось живое C/D/G/H 2026-06-30 17:47:30 +03:00
Дмитрий 0d5e06e895 feat(автоподбор): шаг1 B (офлайн) — парсер выдачи 2ГИС/Яндекс (канал А)
SearchResultsParser: страница категория-поиска → список фирм/организаций {ссылка, имя-подсказка}.
2ГИС — путь /<город>/firm/<id> + имя из вложенного span; Яндекс — /maps/org/<seo>/<id> + aria-label.
Дедуп по ссылке. Авторитетное имя/поля даёт резолвер (под-блок A), открывая каждую карточку.
Пагинация/мульти-запрос — живая часть (блок H/живой прогон).

Фикстуры — реальные сниппеты выдачи (Красломбард, Красноярск): 2ГИС через xfetch, Яндекс
локальным Playwright.

Тесты: search 3/3; модуль Автоподбора unit 87/87; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:46:44 +03:00
Дмитрий b900874a72 feat(автоподбор): шаг1 F (офлайн) — похожесть эмбеддингами (косинус), не «мнение модели»
EmbeddingRelevance: профиль клиента (примеры имя+описание) → центроид; кандидат (имя+описание)
→ косинус → relevance_pct [0..100]; сортировка по убыванию (§12.5). Векторы — за границей
Embedder (живой AITUNNEL text-embedding-3-small подключается на блоке H/живом прогоне, ключ
по §12.9). Вся логика ранжирования протестирована офлайн на детерминированном эмбеддере.

Тесты: similarity 3/3; модуль Автоподбора unit 84/84; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:40:10 +03:00
Дмитрий 64d6703bb3 feat(автоподбор): шаг1 E — сильное слияние конкурентов (union-find + вычет клиента)
AutopodborDedup::mergeCompetitors — склейка одного конкурента из 3 каналов под разными
написаниями, доменом-vs-именем или общим телефоном (union-find по корню имени/домена/номеру).
Объединяет ссылки справочников и телефоны, is_federal — местная карточка перевешивает.
Вычитает самого клиента (его имя/сайт не попадают в конкурентов). Усиливает §12.11.

Размещено в существующем backend-дедупе (§7.2 «дедуп на стороне бэкенда»), а не дублем
в движке. Старый dedupCompetitors и его тесты не тронуты.

Нормалайзер: nameKey/domainRoot (alnum-ключ, ё→е) — имя «Драйв займ» сцепляется с «драйвзайм.рф».

Тесты: merge 6/6; модуль Автоподбора unit 81/81; feature dedup 3/3; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:36:59 +03:00
Дмитрий 4de9474a27 docs(автоподбор): план шага1 — под-блок A (резолвер) отмечен готовым 2026-06-30 17:29:53 +03:00
Дмитрий b2c89d12bd feat(автоподбор): шаг1 A4+A5 — CompetitorResolver + resolveByName (под-блок A готов)
A4 CompetitorResolver (§12.3): имя+город → 2ГИС-в-городе (прямая ссылка канала А) →
иначе Яндекс (поиск имя+город → первый org → проверка) → иначе local=false.
is_federal ПО ФАКТУ: нет местной карточки + есть сайт = федерал; есть карточка = местный.
Транспорт за PageFetcher — вся логика офлайн на фикстурах.

A5 ResolvingAgent (§7.2 resolveByName): имя+region_code → кандидат
{name,description,site_url,directory_urls,provenance} из настоящей карточки; нет филиала
→ честный кандидат-заглушка. Свободное сравнение город↔субъект (DirectoryFields::localeMatches).

Хелперы тестов (stubPages/autopodborFixture) вынесены в tests/Pest.php.
Провайдер НЕ флипнут (findCompetitors всё ещё заглушка до под-блока H).

Тесты: Resolve 17/17; модуль Автоподбора unit 75/75; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:28:14 +03:00
Дмитрий fc907c2564 feat(автоподбор): шаг1 A3 — YandexResolver + общий DirectoryFields
Разбор реальной org-карточки Яндекс.Карт (/maps/org/<seo>/<id>) в ResolvedCompetitor:
имя/город из <title>, чистый сайт из business-urls (itemprop=url, без utm),
телефоны type=phone (→7XXXXXXXXXX), описание из categories[].name. Отбраковка
чужой фирмы того же имени: имя и город обязаны совпасть с ожидаемыми, иначе null.

Рефактор: общие извлекатели имя+город и телефоны вынесены в DirectoryFields
(используют и 2ГИС, и Яндекс) — без дублирования.

Фикстура — реальная карточка КрасЛомбарда, снята локальным Playwright (render-page.cjs).

Тесты: модуль Автоподбора unit 68/68; Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:17:33 +03:00
Дмитрий 7746ccc54d feat(автоподбор): шаг1 A2 — TwoGisResolver, разбор карточки 2ГИС
Разбор реальной карточки филиала 2ГИС (/<город>/firm/<id>) в ResolvedCompetitor:
имя из <title>, чистый сайт из contact_groups (url, не редирект link.2gis),
телефоны type=phone (нормализованы к 7XXXXXXXXXX), город из title, описание из
rubrics. Терпим к хвосту ?stat=. Пустая оболочка 2ГИС → null.

Фикстура — реальная карточка КрасЛомбарда из живого прогона шага 2 (публичный
бизнес-телефон конкурента, не клиентские ПДн); allowlist gitleaks для app/tests/fixtures/.

Тесты: A2 3/3; модуль Автоподбора unit 64/64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:07:33 +03:00
Дмитрий 858d8be1b2 docs(автоподбор): контекст-промпт продолжения шага 1 — за основу движок v4 (2 дня прогонов)
Резюме для подхвата после компакта: брать ПОСЛЕДНИЙ вариант (§12 v4), ранние
не воскрешать (ИНН выкинут, похожесть=эмбеддинги, агрегаторы=LLM, is_federal по факту).
Следующая задача A2 TwoGisResolver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:51:40 +03:00
Дмитрий 2cf8b74763 feat(автоподбор): шаг 1 A1 — DTO ResolvedCompetitor (фундамент резолвера)
Карточка резолва имени: name/site/phones/directoryUrl/source/region/description/
isFederal + isLocal(). Без ИНН (решение владельца). Офлайн-тест 3/3.
Фундамент под-блока A плана шага 1.

НЕ на проде, воркстри avtopodbor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:37:05 +03:00
Дмитрий 2e37a7a380 docs(автоподбор): план шага 1 — реальный findCompetitors, фундамент-первым
Декомпозиция движка v4 (§12) на под-блоки A–H: резолвер (фундамент, офлайн-TDD) →
слияние/похожесть → каналы А/В/0 → DaData → оркестрация+флип за флагом. Живые
блоки с платными API — отдельными заходами после «го» владельца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:35:48 +03:00
Дмитрий 92e2eef702 docs(автоподбор): статус — шаг 2 встроен в боевой путь и проверен живым прогоном
Обновлён GOTOVNOST: интеграция шага 2 (провод реального агента, где-нашли→БД→API,
код города по региону, ретрай 2ГИС) сделана и проверена вживую на kraslombard24.ru.
Следующий блок — шаг 1 (реальный findCompetitors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:29:09 +03:00
Дмитрий bfda9f8d46 fix(автоподбор): ретрай загрузки списка филиалов 2ГИС при флаки-рендере
Живой прогон вскрыл: рендер списка 2ГИС иногда отдаёт непустую «оболочку»
без ссылок на филиалы — ретрай в XfetchClient ловит только пустой ответ,
а тут карточки терялись. XfetchDirectoryFetcher теперь повторяет загрузку
списка, пока не появятся ссылки на филиалы, до 3 раз.

Проверено вживую на kraslombard24.ru: центральный 8(391)292-00-00
подтверждён И кодом сайта, И 2ГИС с адресом «улица Ладо Кецховели, 30»;
where_found/office/confirmations доходят до БД и API. 0 фальшивок.

- тест повтора списка при флаке; бэкенд автоподбора 131/131

НЕ на проде, воркстри avtopodbor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:27:43 +03:00
Дмитрий efdb3ee2c3 feat(автоподбор): запасная достройка короткого номера по коду города региона
Если на сайте конкурента короткий локальный номер без кода города И на странице
нет ни одного полного номера, движок берёт код по региону конкурента. Основной
путь прежний — код со страницы; региональный код лишь запасной.

- RegionAreaCode: субъект РФ → телефонный код адм. центра, только уверенные
  3-значные коды миллионников, ключ по имени из RussianRegions, неизвестный → null
- RealCompetitorAgent резолвит regionCode и пробрасывает в CandidateBuilder→HtmlPhoneScanner
- ограничение задокументировано: код центра, не выдумываем при отсутствии в карте
- тесты карты и сквозной достройки; бэкенд автоподбора 130/130

НЕ на проде, воркстри avtopodbor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:10:00 +03:00
Дмитрий 5851ac48d3 feat(автоподбор): включён настоящий движок шага 2 + ретрай флаки 2ГИС
Провайдер биндит RealCompetitorAgent вместо заглушки: сайт конкурента —
обычный curl плюс локальный Playwright, справочники 2ГИС/Яндекс — через
антибот xfetch. Поиск/резолв шага 1 ещё не реальны — делегируются заглушке
через fallback, поведение поиска не изменилось.

- AutopodborServiceProvider биндит RealCompetitorAgent с CompositeFetcher
- XfetchClient повторяет запрос при пустом флаки-рендере 2ГИС, до 3 раз
- тесты привязки контейнера плюс ретрая; study-тест пинит заглушку явно
- тесты заглушки строят FakeCompetitorAgent напрямую
- бэкенд автоподбора 126/126

НЕ на проде, воркстри avtopodbor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:51:31 +03:00
Дмитрий a42647c6fe feat(автоподбор): богатый провенанс источника — список «где нашли», офис, подтверждения
Шаг 2 «Конкурентного поля»: один номер встречается в нескольких местах —
код сайта плюс карточки 2ГИС/Яндекс с разными адресами. Раньше хранилось
одно provenance_url/label — список терялся. Теперь сквозной провод
движок→контракт→джоб→БД→API; фронт уже умел показывать кликабельным
списком с подтверждениями.

- autopodbor_sources +3 колонки where_found/office/confirmations
  миграция 2026_06_30_120000, идемпотентная, RLS-review APPROVE 7/7
- canon-sync schema.sql v8.59 плюс CHANGELOG, вкл. catch-up phone_type/box 29.06
- тесты бэкенда автоподбора 122/122

НЕ на проде, воркстри avtopodbor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:40:30 +03:00
Дмитрий c2d5592696 docs(автоподбор): статус готовности + контекст-промпт продолжения
- GOTOVNOST-status: к внедрению НЕ готов; шаг 2 собран по частям, не встроен;
  шаг 1 — реальной логики никогда не писали (всегда заглушка), из репо ничего
  не терялось (проверено git); собрать шаг 1 = написать одну findCompetitors.
- KONTEKST-prodolzhenie: промпт для корректного подхвата контекста + план.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:08:53 +03:00
Дмитрий 3f3a142b76 fix(безопасность): XSS в ссылках «где нашли» + path traversal в serve.js
Сек-ревью коммита нашло две дыры:
1. FieldCompetitorScreen: href из where_found рендерился без проверки схемы —
   javascript:-ссылка выполнила бы скрипт при клике. Добавлен safeUrl():
   кликабельны только http/https, остальное — простым текстом. +TDD-тест.
2. serve.js (локальный dev-сервер прототипов): обход каталога. realpath-сверка
   с ROOT + require isFile + bind 127.0.0.1.

Autopodbor FieldCompetitor 13/13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:53:04 +03:00
Дмитрий 6e5f47962c docs(автоподбор шаг2): живой показ КрасЛомбарда + сводка + резервный Playwright-путь
- Скриншоты живого прогона: страница глазами клиента (мой рендер) и НАСТОЯЩИЙ
  экран портала FieldCompetitorScreen с живыми данными (findings/*.png).
- Сводка STEP2-SVODKA: §4 чистый прогон (29 номеров + 2ГИС), §6.7 привязка
  офис↔номер, §6.8 ретрай флаки 2ГИС.
- R&D, план-промпт, прототипы sbor1/sbor2 + живая собранная страница.
- Резервный Playwright-путь справочников (CurlPlaywrightFetcher.directory +
  render-firm.cjs) — заменён xfetch; хвост SSRF §6.4 не на боевом пути.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:48:26 +03:00
Дмитрий 9ba11e4bd0 feat(автоподбор шаг2): экран конкурента — «где нашли» ссылками + сортировка
Экран FieldCompetitorScreen:
- «где нашли» теперь кликабельные ссылки на источники (where_found из DTO),
  адреса филиалов 2ГИС видны и кликабельны под номером;
- сортировка источников: больше подтверждений — выше, подменные — вниз;
- строка адреса офиса, счётчик подтверждений.
DTO SourceDto расширен полями where_found/confirmations/office (опциональны —
обратная совместимость: без них падаем на старое provenance_label).
Histoire-стори с живыми данными КрасЛомбарда (рендер настоящего компонента).

TDD: +2 теста, autopodbor-экраны 24/24. Бэкенд-провод where_found — отдельно (Plan C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:47:55 +03:00
Дмитрий cb688c334f fix(автоподбор шаг2): не плодить фальшивки из обрезков + надёжнее парсер 2ГИС
Два бага, вскрытых живым прогоном по КрасЛомбару (оба в обработке, не в xfetch):

1. Достройка коротких номеров лепила фальшивку из обрезка полного номера:
   сайт делит номер на tel:+7 (391) 271 и tel:271-33-33. Обрезок 7391271
   (страна+код города) ошибочно достраивался в 73917391271. Теперь обрезок
   «7/8 + код города» распознаётся и выкидывается; настоящие локальные
   (включая московские 771-..) — целы. Логику вынес в classifyShort/areaCode.

2. parseBranchList брал ссылку филиала только из href — на части прорисовок
   2ГИС ссылка лежит в JSON-данных, филиалы терялись. Берём путь /city/firm/<id>
   откуда угодно (с дедупом).

TDD: Autopodbor 49/49.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:27:27 +03:00
Дмитрий f248c37d1b feat(автоподбор шаг2): CompositeFetcher — сайт и справочники разными путями
Один Fetcher для движка: site() → богатый загрузчик (curl+Playwright,
видимые/пул/контакты), directory() → антибот-загрузчик (xfetch).
Готовит провод реального агента (Plan C). TDD: Autopodbor 47/47.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:41:18 +03:00
Дмитрий 1b76cfec15 feat(автоподбор шаг2): справочники 2ГИС через xfetch.ru
Антибот 2ГИС бьём сервисом xfetch.ru (render:true, timeout:20 —
без timeout страница не дорисовывается). Доказано на живом КрасЛомбаре:
поиск → 12 филиалов → телефон + адрес каждой карточки.

- PageFetcher — граница «достать HTML» (тестируется без сети)
- XfetchClient — POST к xfetch, декод base64; без ключа молча пусто
- XfetchDirectoryFetcher — список→филиалы→карточки через DirectoryParser
- DirectoryParser — чтение списка и карточки 2ГИС (был в хвостах)
- config services.xfetch + .env.example; ключ только в .env (gitignored)

Яндекс.Карты — отдельно (другой формат URL карточек). TDD: Autopodbor 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:38:18 +03:00
Дмитрий be92afebd3 test(автоподбор шаг2): достройка номеров не привязана к региону
Москва 495, Питер 812, Екатеринбург 343 — код города берётся со страницы
конкурента или из города запроса, никакой привязки к Красноярску. Защита
от регрессии. Autopodbor 41/41.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:16:18 +03:00
Дмитрий b78c3edb8c fix(автоподбор шаг2): не терять номера офисов, написанные без кода города
Сканер кода сайта выкидывал короткие локальные номера (271-33-33,
2-828-828 и т.п.) как мусор — терялись реальные телефоны филиалов.
Теперь короткий номер из tel:/schema/microdata достраивается кодом
города: сперва по преобладающему коду полных номеров той же страницы,
иначе по коду региона запроса; если код не определить — номер не
теряется, а помечается «требует проверки» (phoneKind=uncertain).
Из тела текста короткие формы не достраиваются (защита от ложных).

TDD: 6 новых тестов, весь Autopodbor 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:11:27 +03:00
Дмитрий 6f4e6de9a3 fix(автоподбор): укрепление fetcher — TLS-проверка, SSRF-страж, без редиректов
- CURLOPT_SSL_VERIFYPEER/VERIFYHOST включены
- isSafeUrl: только http/https, блок loopback/приватных/служебных IP
- FOLLOWLOCATION выключен, протоколы ограничены HTTP/HTTPS
- render-page.cjs валидирует схему URL перед навигацией

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:45:14 +03:00
Дмитрий aedefa3a94 docs(автоподбор): план Plan A (ядро) + resume-промпт
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:47 +03:00
Дмитрий fe1c5fbabf style(автоподбор): pint-форматирование смежных DTO/интерфейса
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:28 +03:00
Дмитрий 7d5ab011ea feat(автоподбор): живой CurlPlaywrightFetcher (curl + Playwright-рендер)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:08 +03:00
Дмитрий dc290147b1 feat(автоподбор): RealCompetitorAgent — оркестрация добычи и маппинг в контракт
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:06 +03:00
Дмитрий 5ad50d8b8d feat(автоподбор): CandidateBuilder — код/подменный/пул/контакты/справочники
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:05 +03:00
Дмитрий 1d124afb76 feat(автоподбор): интерфейс Fetcher + тест-дубль FakeFetcher
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:42:04 +03:00
Дмитрий 97587010a3 feat(автоподбор): DTO FetchedSite и DirectoryCard для добычи
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:41:21 +03:00
Дмитрий fbebe40383 docs(автоподбор): план Plan B — добыча + RealCompetitorAgent (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:41:19 +03:00
Дмитрий d93404bc62 feat(автоподбор): SourceAggregator — дедуп, классификация, скрытие пула, сортировка + live-фикстура
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:16:48 +03:00
Дмитрий 0a450bf679 feat(автоподбор): CalltrackingDetector + DTO CollectedSource — детект подмены и источник с провенансом
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:16:35 +03:00
Дмитрий d50a3d5108 feat(автоподбор): HtmlPhoneScanner — номера из кода (tel/schema/microdata/тело/email)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:16:22 +03:00
Дмитрий bc462d25fa feat(автоподбор): PhoneType — тип номера по коду
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:16:05 +03:00
Дмитрий 1b3683c6b1 fix(конкурентное поле): 6 находок теста «тупого клиента» — ошибки, регион, дедуп, миграции
- адресные сообщения в окнах сбора/изучения (маппер autopodborErrorMessage)
- регион по умолчанию = пустой плейсхолдер «выберите регион»
- кнопка «Собрать источники» у изучённого конкурента → «Источники собраны»
- сквозной дедуп предложений между прогонами (без двойного списания, ретрай цел)
- убран захардкоженный admin_user_id с фронта (id ставит бэкенд)
- идемпотентный гард в 3 миграции автоподбора (migrate:fresh снова зелёный)
- заглушка Агента: +тип 8-800 (tollfree) для полноты эмуляции

Тесты: Pest автоподбор 82/82, Vitest 62/62, vite build зелёный.

эскейп: фиксируй (авторизовано владельцем)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 06:42:33 +03:00
Дмитрий 793b20a39c feat(конкурентное поле): доводка фронта до прототипа — F1/F2/F3 + чистка M2
Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт):

- F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» —
  карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт +
  Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка
  «Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform.
- F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен
  доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита,
  сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню.
- F3: колонка «когда списывается» в панели доп.услуг биллинга.
- M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не
  было переходов; ручное добавление живёт окном на «Поле».

Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build .
НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:57:58 +03:00
Дмитрий 3561028dd2 docs(Конкурентное поле): прототип, план реализации, эстафета и Playwright-сверка с Омегой
HANDOFF (состояние/решения/окружение), impl-plan (фазы+догрузки), кликабельный прототип
2026-06-29-konkurentnoe-pole-proto.html (визуал-эталон), omega-visual-check (живая сверка
рабочего места с реальными конкурентами Омеги, скрины FIELD-*/PROTO-*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:20:25 +03:00
Дмитрий 4387333118 feat(Конкурентное поле): рабочее место конкуренты→источники→проекты (поверх автоподбора)
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.

Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.

API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).

Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».

Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:18:46 +03:00
Дмитрий 253d1b7f39 feat(ui): тип лица полными словами, зелёные дни недели, скрытие банк-реквизитов у физлица
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
- Настройки→Реквизиты и диалог проекта: «Физлицо»→«Физическое лицо», «Юрлицо»→«Юридическое лицо» (ключи value не тронуты)
- У физлица скрыт блок «Реквизиты для оплаты» (банковских реквизитов нет; оплата по счёту — только для юр/ИП)
- Диалог проекта: выбранные дни недели залиты зелёным #0f6e56 как в ProjectDetailsDrawer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:36:58 +03:00
Дмитрий 093cc8729b docs(ПИЛОТ): снимок 29.06 — фича «оплата по счёту» (Этап 1) выкачена на прод + реквизиты ИП в legal_entities
Accessibility (Pa11y live) / a11y (push) Has been cancelled
2026-06-29 11:47:34 +03:00
Дмитрий cdfae077a3 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:32:21 +03:00
Дмитрий 2281907b8a docs(ПИЛОТ): снимок 28.06 — сквозная сверка прод↔git↔бэкап (1:1) + Тенанты + фронт-стенд зелёный
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Прод приведён к gitea-main 84dfbc85 один-в-один (rsync-checksum, потерь нет);
бэкап цел; экран Тенанты на серверную пагинацию выкачен; 992 фронт-теста зелёные;
деньги t2=1 839 405₽ целы. Остатки не-git на проде объяснены (.bak-precutover до 03.07).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:10:10 +03:00
Дмитрий 3d4261cba1 chore(схема): NB о дрейфе счётчика RLS-политик (тело 49 vs канон 47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:19:51 +03:00
Дмитрий ef815c0b8c fix(автоподбор): идемпотентность джоб при ретрае + zero-price short-circuit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:17:44 +03:00
Дмитрий 9b4622da85 chore(схема): canon-sync schema.sql v8.58 — 3 таблицы автоподбора + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:09:34 +03:00
Дмитрий 23263d18a0 test(автоподбор): сквозной smoke по всем экранам + defineExpose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:00:22 +03:00
Дмитрий 5ba553a0cc feat(автоподбор): фронт — экран изменения проекта
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:53:36 +03:00
Дмитрий 48509572b5 feat(автоподбор): фронт — экраны создания проектов и готово
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:47:56 +03:00
Дмитрий 3bc4325b78 feat(автоподбор): фронт — экран источников конкурента (выбор, ручной источник)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:41:07 +03:00
Дмитрий 361d02a256 feat(автоподбор): фронт — экраны загрузки и списка конкурентов
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:33:20 +03:00
Дмитрий 33ac1a5954 feat(автоподбор): фронт — экраны форм подбора и своего конкурента
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:22:36 +03:00
Дмитрий 17d93a144b feat(автоподбор): эндпоинт конкурентов прогона + competitor_id в RunResource
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:16 +03:00
Дмитрий aa807c0ed4 feat(автоподбор): фронт — каркас экрана, вход, роут, пункт меню
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:10:29 +03:00
Дмитрий e52e958484 feat(автоподбор): фронт — api-клиент и Pinia store
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:58:51 +03:00
Дмитрий 8cc6511edd feat(автоподбор): ручные эндпоинты — manual-study и добавление источника
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:50:37 +03:00
Дмитрий 02d2163e75 feat(автоподбор): API ядро — контроллер, роуты, ресурсы
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:42:41 +03:00
Дмитрий 3c8886c97f feat(автоподбор): stripBadge — чистое имя конкурента без значка
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:33:11 +03:00
Дмитрий f208fe2f65 feat(автоподбор): создатель проектов из источников (имя+значок+суффикс)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:27:04 +03:00
Дмитрий 98b26f6191 feat(автоподбор): RunService — старт, гейт баланса, один in-flight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:20:54 +03:00
Дмитрий d9b3e8dbe1 feat(автоподбор): джоба резолва по названию
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:16:14 +03:00
Дмитрий a3b68dbb95 docs(автоподбор): записка для продолжения после компакта (state + что прочитать)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:54:44 +03:00
Дмитрий 78d1965430 feat(автоподбор): джоба шага 2 (изучение конкурента, источники)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:49:57 +03:00
Дмитрий 1de6984035 feat(автоподбор): джоба шага 1 (подбор конкурентов)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:46:42 +03:00
Дмитрий 4042890b0a feat(автоподбор): идемпотентное списание за прогон (bcmath, only-on-success)
- AutopodborChargeService::chargeForRun — DB::transaction + lockForUpdate
  на AutopodborRun (guard идемпотентности по balance_transaction_id) и Tenant;
  bcmath (bcsub/bccomp/bcmul), никаких float; throw InsufficientBalanceException
  до любых изменений баланса при нехватке средств.
- Миграция 2026_06_28_110100: расширяет CHECK constraint
  balance_transactions_type_check — добавляет 'autopodbor_charge'.
- Тест: 2 money-инварианта (идемпотентность + noop при нехватке).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:41:06 +03:00
Дмитрий 77498df63b feat(автоподбор): дедуп конкурентов/источников/проектов
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:35:38 +03:00
Дмитрий 6789879a2c feat(автоподбор): нормализатор домена/телефона + dedup-ключи
AutopodborNormalizer: domainHead (схема/www/путь/порт → голова),
phone (через PhoneNormalizer::normalize → 7xxxxxxxxxx без плюса),
sourceKey и competitorKey для дедупликации конкурентов и источников.
4 теста, 9 assertions, все GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:32:23 +03:00
Дмитрий 3b9c1b8bdc feat(автоподбор): интерфейс движка CompetitorAgent + заглушка + binding
- CompetitorAgent interface: findCompetitors / studyCompetitor / resolveByName
- FakeCompetitorAgent: 4 конкурента, 5 источников, 1 кандидат по имени
- AutopodborServiceProvider: bind(CompetitorAgent → FakeCompetitorAgent)
- Регистрация провайдера в bootstrap/providers.php (Laravel 11+)
- Pest.php: extend TestCase для Unit/Autopodbor (контейнер в Unit-тестах)
- Тест: 1/1 PASS, 10 assertions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:28:56 +03:00
Дмитрий 0a111d9f85 feat(автоподбор): DTO контракта движка (6 шт.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:20:10 +03:00
Дмитрий 3c2bb18537 feat(автоподбор): тип проводки autopodbor_charge + ключи настроек
- BalanceTransaction::TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'
- сид-миграция 4 ключей system_settings (idempotent):
  autopodbor_enabled (bool, 0), autopodbor_price_search_rub (decimal, 0),
  autopodbor_price_study_rub (decimal, 0), autopodbor_max_competitors (int, 15)
- Unit + Feature тесты, оба PASS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:16:59 +03:00
Дмитрий df19af99f9 feat(автоподбор): Eloquent-модели run/competitor/source
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:12:35 +03:00
Дмитрий b5c88b2f1d docs(автоподбор): план — выделенная тестовая БД liderra_testing_apk + RLS-харднинг конвенция
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:08:59 +03:00
Дмитрий 2de1f1e35f fix(автоподбор): RLS NULLIF-харднинг (v8.57) + CHANGELOG v8.58 для 3 таблиц
Политики tenant_isolation приведены к каноничной харднинг-форме
NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
(после переноса на gitea/main с v8.57). Запись CHANGELOG для всех 3 таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:02:40 +03:00
Дмитрий 84dfbc857a test(фронт): привёл стенд в зелёный — 10 протухших спеков под актуальные компоненты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Все падения — устаревшие ожидания тестов (компоненты менялись намеренно):
SettingsView (роутер+вкладка Реквизиты+события), LegalDoc (реальные доки под ЮKassa),
ProjectsView (BulkActionsBar v-show→isVisible), ErrorView (убран фейк REQ/INC),
PricingTiers (формат «500 ₽»), KanbanCard (costKopecks→«—»), ChangePassword (дата из API),
DealDetail (русские ярлыки статусов), DealsView (RuDateField на v-menu), SupplierIntegration
(window.confirm→v-dialog). Изменены ТОЛЬКО тесты, компоненты не тронуты.
Полный прогон: 127 файлов / 992 теста зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:01 +03:00
Дмитрий cc73a70f9e feat(автоподбор): таблица autopodbor_sources + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:27 +03:00
Дмитрий 786f796223 feat(автоподбор): таблица autopodbor_competitors + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:26 +03:00
Дмитрий e7660edd79 feat(автоподбор): таблица autopodbor_runs + RLS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:58:10 +03:00
Дмитрий 1fe071f203 docs(автоподбор): дизайн-документ + план реализации
Дизайн и пошаговый план фичи «Автоподбор конкурентов» (ИИ-агент находит
конкурентов и их источники). Движок — отдельной сессией, здесь розетка+заглушка.
План сверен с кодом: RLS app.current_tenant_id, tenant-контекст SET LOCAL,
тестовая БД liderra_testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:57:27 +03:00
Дмитрий c92d498b57 feat(админка): экран Тенанты на серверную пагинацию/поиск/фильтры (масштаб 1000+)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.

Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:06:56 +03:00
Дмитрий 2911f3ac0e docs(ПИЛОТ): снимок 28.06 — починен тихо сломанный биллинг-сторож (RLS) + playwright durable
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Свод за заход «закрывай хвосты»: разбор и фикс preflight-sweep/reminder (no-op с 26.06
из-за RLS-роли очереди), self-heal 4 проектов на проде, деньги t2 целы, playwright в deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:19:24 +03:00
Дмитрий 75dded78a1 fix(биллинг): sweep и reminder перебирают тенантов через BYPASSRLS + playwright в зависимостях
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Корень: после переезда на Managed PG очередь ходит под ролью crm_app_user (RLS),
и Tenant::query() в BalancePreflightSweepJob/BalanceFrozenReminderJob отдавал 0 строк
без app.current_tenant_id — биллинг-преflight молча стал no-op с 26.06 (ни заморозок,
ни снятия проектных блоков). Перечень тенантов теперь берётся через pgsql_supplier
(BYPASSRLS), модель грузится внутри per-tenant SET LOCAL контекста. Логика проверена
на боевых данных: t25/t26 снимутся, t27/t30 заморозятся.

Playwright рантайма supplier-портала объявлен в dependencies ровно 1.59.0 под
chromium-1217 + package-lock синхронизирован; деплой ставит его npm ci --omit=dev,
durable к чистке node_modules.

Тесты Billing 18/18, pint/phpstan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:11:36 +03:00
Дмитрий cab0347fd2 merge: Этап B+C — кликабельные группы Заказа + ссылки Здоровья + Открыть всё
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 10:24:35 +03:00
Дмитрий b2f08f28d5 feat(дашборд): Этап B+C — кликабельные группы Заказа, ссылки Здоровья, «Открыть всё»
B: строки групп «Заказа» кликабельны → проекты у поставщика (поиск по источнику) +
кнопка «Открыть проекты у поставщика». C: подсистемы «Здоровья» кликабельны →
Инциденты/Система/Интеграция с поставщиком; «Финансы» → Биллинг/Все клиенты;
«Клиенты» → Все клиенты. Сквозная вложенность дашборда замкнута до источников.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:23:30 +03:00
Дмитрий 00d32ef182 merge: Этап A — сквозная вложенность Лиды до источника в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 10:16:14 +03:00
Дмитрий 6536c19c96 feat(дашборд): Этап A — сквозная вложенность Лиды до источника
Экран «Лиды» (/admin/leads): серверный список с фильтрами (дата/канал/поставщик/
статус/поиск) + пагинация (масштаб 10⁴+ лидов). Карточка лида (/admin/leads/{id}):
полная цепочка — ОТКУДА (поставщик B1/B2/B3 + канал + источник + регион) → КОМУ
(сделки клиентов через deals.source_crm_id = supplier_leads.vid). Дашборд: drill
Лиды +топ-10 последних + «Открыть все лиды →». Nav-пункт «Лиды». ПДн-телефон
маскируется (152-ФЗ). Тесты: backend 3 + FE 5 (38 FE всего зелёные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:14:47 +03:00
Дмитрий 14bb8a017c merge: выбор периода (свой диапазон) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 09:56:15 +03:00
Дмитрий 5c68b24c7b feat(дашборд): выбор периода — свой диапазон дат + спека вложенности/масштаба
Фундамент под сквозную вложенность: periodRange() читает date_from/date_to
(приоритет) либо preset; Финансы и Клиенты считаются по выбранному периоду через
whereBetween. FE: «Свой период» + два date-поля + «Применить» → date_from/date_to.
Спека дизайна A+B+C+масштаб сохранена. Baseline перегенерирован (getJson тестов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:54:09 +03:00
Дмитрий a43f3df4c1 merge: пункт Командный центр в левом меню админки
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 08:43:14 +03:00
Дмитрий d961d1617a feat(админка): пункт «Командный центр» в левом меню
Дашборд не было видно в сайдбаре — уйдя с него, нельзя было быстро вернуться.
Добавлен первый nav-пункт «Командный центр» → /admin/dashboard (иконка dashboard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:42:19 +03:00
Дмитрий 7b44e743a4 merge: плитка Клиенты (активность+новые+спящие) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 08:29:50 +03:00
Дмитрий 1ecb965981 feat(дашборд): плитка «Клиенты» — активность + новые + спящие
6-я плитка «👥 Клиенты» со светофором (amber если есть спящие) + drill:
KPI за период (всего активных / новых / заходили / получали лиды / платили),
список новых клиентов (с датой входа/лидами/балансом) и «спящих» (активные
без входа 14+ дней или ни разу = не активировались). Клик по строке → карточка
клиента. Backend: clients() endpoint + clientsTile в summary (cross-tenant через
pgsql_admin); сигналы — users.last_login_at, deals, balance_transactions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:28:47 +03:00
Дмитрий 1fe68e7367 merge: баланс поставщика = номера × 20₽ (балансы) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:55:02 +03:00
Дмитрий 22ad20337a feat(балансы): баланс поставщика = остаток номеров × 20 ₽
У кабинета crm.bp-gr нет денежного баланса — есть «Баланс ГЦК» (остаток номеров)
в выпадашке шапки (table.balancetbl). supplier-balance.js логинится, раскрывает
выпадашку, читает «Баланс ГЦК» -> {numbers}. Провайдер: деньги = numbers ×
number_price_rub (20 ₽/шт, подтверждено владельцем). Live: 3096 -> 61 920 ₽.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:54:10 +03:00
Дмитрий 89808c1f47 merge: фикс джобы балансов (свежий builder/итерация) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:32:59 +03:00
Дмитрий fa404e98ec fix(балансы): свежий query-builder на итерацию джобы (PK violation на 2-м прогоне)
Переиспользование одного DB-билдера в цикле накапливало where-клаузы →
updateOrInsert уходил в INSERT существующей строки → SQLSTATE 23505 на проде
при повторном сборе. Билдер теперь создаётся внутри цикла. + тест на 2 прогона.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:32:17 +03:00
Дмитрий eacaee493f merge: фикс DaData X-Secret + кламп days_left (балансы) в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:26:51 +03:00
Дмитрий c03e2b319b fix(балансы): DaData X-Secret заголовок + кламп days_left к 0
- DadataBalanceProvider: эндпоинт profile/balance требует X-Secret вместе с Token
  (был HTTP 401 на проде при первом сборе); добавлен заголовок при наличии secret.
- BalanceHealth: отрицательный баланс больше не даёт «−1 дн.» (кламп max(0, days)).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:25:21 +03:00
Дмитрий 36a27cb22c merge: фича Балансы внешних сервисов (плитка дашборда + кнопки Пополнить) в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-28 07:18:31 +03:00
Дмитрий 505dd5711e docs(план): Amendment A — кнопки «Пополнить» в плитке балансов
Требование владельца 28.06: прямая ссылка оплаты у каждого сервиса в дашборде
(на планшете). topup_url — статика из конфига; YC строится из billing_account_id.
2026-06-28 07:13:21 +03:00
Дмитрий 93e8393014 feat(балансы-fe): плитка «Балансы сервисов» + drill + кнопки «Пополнить»
- 5-я плитка дашборда со светофором (worst-of сервисов, поддержка grey=нет данных)
- Drill-таблица: Сервис · Баланс · Хватит на N дней · Статус · кнопка «Пополнить»
- Кнопка «Пополнить» (target=_blank) → страница оплаты сервиса; YC — прямо на биллинг
- Клиент getDashboardBalances + типы; Vitest 12/12 (тайл, drill, href кнопки)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:12:45 +03:00
Дмитрий 88e816c576 feat(балансы): backend плитки балансов внешних сервисов
Ежедневный контроль баланса DaData/Поставщик/Yandex Cloud плиткой дашборда.

- Таблица external_service_balances (pgsql_supplier, BYPASSRLS, last-value upsert)
- BalanceHealth: чистая логика светофора (red <floor или <3д; amber <floor или <7д)
- BalanceProvider+DTO; провайдеры DaData(API)/YC(OAuth→IAM→billing)/Supplier(Playwright)
- RefreshExternalBalancesJob: изоляция провайдеров (try/catch), расписание 06:30 МСК
- AdminDashboardController::balances() + плитка в summary + topup_url (кнопка «Пополнить»)
- Тесты: BalanceHealth, 3 провайдера, джоба, endpoint (102 теста зелёные)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:12:14 +03:00
Дмитрий 95ea4b764e docs(план): реализация «Балансы внешних сервисов» (дашборд)
План 12 задач (TDD): таблица external_service_balances, чистый BalanceHealth,
3 провайдера (DaData API, Yandex Cloud OAuth→IAM→billing, Supplier через Playwright),
ежедневная джоба, эндпоинт+плитка+drill, тесты, выкат. Доступы готовы на проде
(DaData ключ, YC OAuth в .env, Supplier Playwright). Supplier-баланс — разведка
селектора кабинета первым шагом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 05:39:51 +03:00
Дмитрий e17433e069 docs(спец): дизайн «Балансы внешних сервисов» (дашборд)
Ежедневный контроль балансов 3 внешних сервисов (Поставщик crm.bp-gr, DaData,
Yandex Cloud) плиткой в Командном центре: баланс + «хватит на N дней» + светофор
(пороги ₽ и дни). Адаптер на сервис, ежедневная задача, таблица
external_service_balances, чистый BalanceHealth. Unisender убран (почта = Yandex SMTP).
Брейншторм одобрен владельцем 28.06.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 05:05:05 +03:00
695 changed files with 69237 additions and 5326 deletions
+5
View File
@@ -87,6 +87,11 @@ paths = [
'''app/composer\.lock''',
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
'''app/tests/.*\.php''',
# Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и
# синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном
# конкурента (опубликован в открытом справочнике), не клиентские ПДн.
# Та же категория, что app/tests/*.php выше.
'''app/tests/fixtures/.*''',
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
'''app/database/seeders/.*\.php''',
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
+9 -6
View File
@@ -84,12 +84,6 @@ MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_EMAIL=support@liderra.ru
JIVO_WIDGET_ID=
JIVO_BOT_WEBHOOK_SECRET=
JIVO_BOT_OUTBOUND_URL=
JIVO_BOT_TOKEN=
JIVO_BOT_TOURS_ENABLED=false
YANDEX_GPT_API_KEY=
YANDEX_GPT_FOLDER_ID=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -102,3 +96,12 @@ VITE_APP_NAME="${APP_NAME}"
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
XFETCH_API_KEY=
# Автоподбор шаг1: модель ИИ-фильтра «поставщик или площадка-агрегатор».
# ОБЯЗАТЕЛЬНО gpt-4o (сильная): слабая gpt-4o-mini наугад штампует живые
# федеральные МФО (Екапуста/Webbankir/СрочноДеньги…) как «агрегаторы» и режет
# их из подбора. Пусто → фолбэк на gpt-4o-mini (баг «режет федералов», 07.07.2026).
AITUNNEL_AGGREGATOR_MODEL=gpt-4o
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Autopodbor\AutopodborStudyScheduler;
use Illuminate\Console\Command;
class AutopodborStudyTickCommand extends Command
{
protected $signature = 'autopodbor:study-tick';
protected $description = 'Страховка: если дорожка шага 2 свободна, а очередь не пуста — запустить следующего.';
public function handle(AutopodborStudyScheduler $scheduler): int
{
$scheduler->tick();
return self::SUCCESS;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SaasInvoice;
use Illuminate\Console\Command;
/**
* Помечает просроченные неоплаченные счета статусом overdue (Этап 1 «оплата по счёту»).
* Только issued overdue по expires_at; оплаченные/отменённые не трогаются.
*/
class ExpireInvoicesCommand extends Command
{
protected $signature = 'invoices:expire';
protected $description = 'Помечает просроченные неоплаченные счета статусом overdue';
public function handle(): int
{
SaasInvoice::where('status', SaasInvoice::STATUS_ISSUED)
->where('expires_at', '<', now())
->update(['status' => SaasInvoice::STATUS_OVERDUE]);
return self::SUCCESS;
}
}
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\KnowledgeChunk;
use App\Support\Help\HelpArticleParser;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Переиндексация базы знаний бота из resources/help/*.md (спека §3).
* Полная перезаливка в транзакции: удалённые статьи исчезают, новые появляются.
* Ночной schedule 04:30 + ручной запуск при срочном обновлении инструкции.
*/
class HelpRebuildKnowledgeCommand extends Command
{
protected $signature = 'help:rebuild-knowledge';
protected $description = 'Перечитать статьи resources/help и обновить knowledge_chunks';
public function handle(HelpArticleParser $parser): int
{
$dir = resource_path('help');
$files = glob($dir.'/*.md') ?: [];
if ($files === []) {
$this->error("В {$dir} нет статей *.md — база знаний осталась прежней.");
return self::FAILURE;
}
$articles = [];
foreach ($files as $file) {
$articles[] = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
}
DB::transaction(function () use ($articles): void {
KnowledgeChunk::query()->delete();
foreach ($articles as $article) {
foreach ($article->chunks as $i => $chunk) {
KnowledgeChunk::create([
'source_path' => $article->sourcePath,
'title' => $article->title,
'tour' => $article->tour,
'topics' => $article->topics,
'chunk_index' => $i,
'content' => $chunk,
]);
}
}
});
$this->info(sprintf('Проиндексировано статей: %d.', count($articles)));
return self::SUCCESS;
}
}
@@ -33,7 +33,8 @@ class IncidentsWatchFailures extends Command
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) возможное падение кабинета}';
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
@@ -47,6 +48,7 @@ class IncidentsWatchFailures extends Command
$dedupMinutes = (int) $this->option('dedup-window');
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
$thresholdManualQueue = (int) $this->option('threshold-manual-queue');
$since = Carbon::now()->subMinutes($windowMinutes);
$since24h = Carbon::now()->subHours(24);
@@ -221,6 +223,35 @@ class IncidentsWatchFailures extends Command
}
}
// ===== БЛОК 6: supplier-manual-queue spike =====
// Пачка проектов, ушедших в ручную очередь за короткое окно = вероятное падение
// кабинета поставщика. Инцидент дедупим (60 мин, чтобы не засорять журнал), НО письмо
// шлём на КАЖДОМ прогоне пока спайк держится — «сигналит как ненормальный» при реальной
// аварии, одним сводным письмом на оба адреса (не N писем на проект).
if ($thresholdManualQueue > 0) {
$manualQueueCount = (int) DB::connection(self::DB_CONNECTION)
->table('supplier_manual_sync_queue')
->where('created_at', '>=', $since)
->count();
if ($manualQueueCount >= $thresholdManualQueue) {
$dedupKey = 'supplier-manual-queue-spike';
$summary = "Похоже, кабинет поставщика упал: {$manualQueueCount} проект(ов) ушло в ручную очередь за {$windowMinutes} мин.";
// Инцидент — только если открытого с той же сигнатурой нет (дедуп 60 мин).
if (! $this->isDup($dedupKey, $dedupAt)) {
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey, sendMail: false);
$created++;
}
// Письмо — каждый прогон при активном спайке, на оба адреса.
Mail::to(['kdv1@bk.ru', 'ops@liderra.ru'])
->send(new IncidentDetectedMail($summary, 'high'));
$this->info("Supplier manual-queue spike [high]: {$manualQueueCount} in {$windowMinutes}m");
}
}
$this->info("Done. Created {$created} incident(s).");
return self::SUCCESS;
@@ -245,6 +276,7 @@ class IncidentsWatchFailures extends Command
Carbon $startedAt,
Carbon $now,
string $dedupKey = '',
bool $sendMail = true,
): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => $type,
@@ -259,7 +291,7 @@ class IncidentsWatchFailures extends Command
'updated_at' => $now,
]);
if ($severity === 'high') {
if ($sendMail && $severity === 'high') {
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
use RuntimeException;
final class BatchBudgetException extends RuntimeException
{
public function __construct(public readonly string $topupRub, public readonly int $maxAffordable)
{
parent::__construct('insufficient_balance_for_batch');
}
}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
class RunInFlightException extends \RuntimeException {}
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Dashboard\SupplyReconciliation;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -18,41 +19,63 @@ use Illuminate\Support\Facades\DB;
*/
class AdminDashboardController extends Controller
{
/** Период из query: today | 7d | 30d | 60d | 90d (дефолт 7d). */
private function periodStart(Request $request): Carbon
/**
* Диапазон периода из query: либо date_from/date_to (свой период, приоритет),
* либо preset period=today|7d|30d|60d|90d (дефолт 7d). Возвращает [from, to]:
* to верхняя граница (конец дня date_to при своём периоде, иначе now).
*
* @return array{0:Carbon,1:Carbon}
*/
private function periodRange(Request $request): array
{
return match ((string) $request->query('period', '7d')) {
$df = (string) $request->query('date_from', '');
$dt = (string) $request->query('date_to', '');
if ($df !== '' && $dt !== '') {
try {
return [Carbon::parse($df)->startOfDay(), Carbon::parse($dt)->endOfDay()];
} catch (\Throwable) {
// невалидные даты → падаем на preset ниже
}
}
$from = match ((string) $request->query('period', '7d')) {
'today' => now()->startOfDay(),
'30d' => now()->subDays(30),
'60d' => now()->subDays(60),
'90d' => now()->subDays(90),
default => now()->subDays(7),
};
return [$from, now()];
}
/** GET /api/admin/dashboard — сводка L1 (плитки Финансы + Здоровье). */
/** GET /api/admin/dashboard — сводка L1 (все плитки). */
public function summary(Request $request): JsonResponse
{
$from = $this->periodStart($request);
[$from, $to] = $this->periodRange($request);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'finance' => $this->financeTile($from),
'date_from' => $request->query('date_from'),
'date_to' => $request->query('date_to'),
'finance' => $this->financeTile($from, $to),
'health' => $this->healthTile(),
'leads' => $this->leadsTile(),
'supply' => $this->supplyTile(),
'balances' => $this->balancesTile(),
'clients' => $this->clientsTile($from, $to),
]);
}
/** @return array<string,mixed> */
private function financeTile(Carbon $from): array
private function financeTile(Carbon $from, Carbon $to): array
{
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$charges = (float) DB::table('balance_transactions')
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub');
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$active = DB::table('tenants')->where('status', 'active')->whereNull('deleted_at')->count();
$newClients = DB::table('tenants')->where('created_at', '>=', $from)->whereNull('deleted_at')->count();
$newClients = DB::table('tenants')->whereBetween('created_at', [$from, $to])->whereNull('deleted_at')->count();
$negative = DB::table('tenants')->whereNull('deleted_at')->where('balance_rub', '<', 0)->count();
return [
@@ -68,12 +91,12 @@ class AdminDashboardController extends Controller
/** GET /api/admin/dashboard/finance — детали Финансов (L2). */
public function finance(Request $request): JsonResponse
{
$from = $this->periodStart($request);
[$from, $to] = $this->periodRange($request);
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$charges = abs((float) DB::table('balance_transactions')
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub'));
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub'));
// «Требуют внимания»: баланс < 0 (по возрастанию — самые глубокие минусы сверху).
$attention = DB::table('tenants')->whereNull('deleted_at')
@@ -93,7 +116,7 @@ class AdminDashboardController extends Controller
$top = DB::table('balance_transactions')
->join('tenants', 'tenants.id', '=', 'balance_transactions.tenant_id')
->where('balance_transactions.type', 'topup')
->where('balance_transactions.created_at', '>=', $from)
->whereBetween('balance_transactions.created_at', [$from, $to])
->whereNull('tenants.deleted_at')
->groupBy('tenants.id', 'tenants.organization_name')
->orderByRaw('SUM(balance_transactions.amount_rub) DESC')
@@ -256,11 +279,29 @@ class AdminDashboardController extends Controller
];
}
/** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */
/** GET /api/admin/dashboard/leads — KPI распределения лидов + топ-10 последних (L2). */
public function leads(): JsonResponse
{
$m = $this->leadsMetrics();
// Топ-10 последних лидов для drill (полный список — на экране /admin/leads).
$recent = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
->orderByDesc('sl.received_at')
->limit(10)
->get(['sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.processed_at',
'sl.deals_created_count', 'sp.signal_type as channel', 'sp.unique_key'])
->map(fn ($r) => [
'id' => (int) $r->id,
'received_at' => $r->received_at,
'platform' => $r->platform,
'channel' => $r->channel,
'source' => $r->unique_key,
'phone_masked' => $this->maskPhoneShort($r->phone),
'delivered' => ((int) ($r->deals_created_count ?? 0)) > 0,
'processed' => $r->processed_at !== null,
]);
return response()->json([
'light' => $m['light'],
'kpi' => [
@@ -269,9 +310,21 @@ class AdminDashboardController extends Controller
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
],
'recent' => $recent,
]);
}
/** Короткая маска телефона для drill (152-ФЗ). */
private function maskPhoneShort(?string $phone): string
{
if (! $phone) {
return '—';
}
$d = preg_replace('/\D/', '', $phone);
return strlen((string) $d) >= 4 ? substr((string) $d, 0, 2).'***'.substr((string) $d, -2) : '***';
}
// === Этап 2: Заказ у поставщика ===
/**
@@ -343,6 +396,173 @@ class AdminDashboardController extends Controller
];
}
// === Балансы внешних сервисов (28.06) ===
/** Порядок «опасности» светофора: больше = хуже. */
private const LIGHT_ORDER = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3];
/**
* Прямая ссылка «Пополнить» для сервиса (статика из конфига; в БД не хранится).
* Владелец с планшета: увидел минус ткнул попал на страницу оплаты.
*/
private function topupUrl(string $key): ?string
{
return match ($key) {
'dadata' => (string) config('services.dadata.topup_url') ?: null,
'supplier' => (string) config('services.supplier.topup_url') ?: null,
'yandex_cloud' => $this->ycTopupUrl(),
'email' => (string) config('services.yandex360.topup_url') ?: null,
default => null,
};
}
private function ycTopupUrl(): ?string
{
$base = (string) config('services.yandex_cloud.console_billing_url');
$acc = (string) config('services.yandex_cloud.billing_account_id');
if ($base === '' || $acc === '') {
return null;
}
return rtrim($base, '/').'/'.$acc.'/payments';
}
/** @return array<string,mixed> */
private function balancesTile(): array
{
$rows = DB::table('external_service_balances')->get();
$light = $rows->isEmpty() ? 'grey'
: $rows->map(fn ($r) => $r->ok ? $r->light : 'grey')
->sortByDesc(fn ($l) => self::LIGHT_ORDER[$l] ?? 0)->first();
return [
'light' => $light,
'count' => $rows->count(),
'red' => $rows->where('ok', true)->where('light', 'red')->count(),
];
}
/** GET /api/admin/dashboard/balances — балансы внешних сервисов (L2). */
public function balances(): JsonResponse
{
$rows = DB::table('external_service_balances')->get()->map(fn ($r) => [
'service_key' => $r->service_key,
'balance_amount' => $r->balance_amount,
'currency' => $r->currency,
'daily_spend_estimate' => $r->daily_spend_estimate,
'days_left' => $r->days_left,
'light' => $r->ok ? $r->light : 'grey',
'ok' => (bool) $r->ok,
'error' => $r->error,
'checked_at' => $r->checked_at,
'topup_url' => $this->topupUrl($r->service_key),
])->values();
$light = $rows->isEmpty() ? 'grey'
: $rows->sortByDesc(fn ($s) => self::LIGHT_ORDER[$s['light']] ?? 0)->first()['light'];
return response()->json(['light' => $light, 'services' => $rows]);
}
// === Клиенты (активность) ===
/** Клиент «спит», если его тенант не заходил дольше этого срока (или ни разу). */
private const DORMANT_DAYS = 14;
/** @return array{total_active:int,new_count:int,logged_in:int,got_leads:int,paid:int} */
private function clientActivityKpi(Carbon $from, Carbon $to): array
{
return [
'total_active' => DB::table('tenants')->whereNull('deleted_at')->where('status', 'active')->count(),
'new_count' => DB::table('tenants')->whereNull('deleted_at')->whereBetween('created_at', [$from, $to])->count(),
'logged_in' => DB::table('users')->whereBetween('last_login_at', [$from, $to])->distinct()->count('tenant_id'),
'got_leads' => DB::table('deals')->whereBetween('received_at', [$from, $to])->where('is_test', false)
->whereNull('deleted_at')->distinct()->count('tenant_id'),
'paid' => DB::table('balance_transactions')->where('type', 'topup')->whereBetween('created_at', [$from, $to])
->distinct()->count('tenant_id'),
];
}
/** Активные тенанты без входа дольше DORMANT_DAYS (или ни разу) — «спящие». */
private function dormantQuery(): Builder
{
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
->groupBy('tenant_id');
return DB::table('tenants')
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
->whereNull('tenants.deleted_at')
->where('tenants.status', 'active')
->where(function ($q) {
$q->whereNull('ll.last_login_at')
->orWhere('ll.last_login_at', '<', now()->subDays(self::DORMANT_DAYS));
});
}
/** @return array<string,mixed> */
private function clientsTile(Carbon $from, Carbon $to): array
{
$kpi = $this->clientActivityKpi($from, $to);
$dormant = (clone $this->dormantQuery())->count();
return [
'light' => $dormant > 0 ? 'amber' : 'green',
'total_active' => $kpi['total_active'],
'new_count' => $kpi['new_count'],
'logged_in' => $kpi['logged_in'],
'dormant' => $dormant,
];
}
/** GET /api/admin/dashboard/clients — активность клиентов + новые + спящие (L2). */
public function clients(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$kpi = $this->clientActivityKpi($from, $to);
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
->groupBy('tenant_id');
$newClients = DB::table('tenants')
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
->whereNull('tenants.deleted_at')
->whereBetween('tenants.created_at', [$from, $to])
->orderByDesc('tenants.created_at')
->limit(50)
->get([
'tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.status',
'tenants.created_at', 'tenants.balance_rub', 'tenants.delivered_in_month', 'll.last_login_at',
])
->map(fn ($t) => [
'id' => (int) $t->id,
'organization_name' => $t->organization_name ?: $t->subdomain,
'subdomain' => $t->subdomain,
'status' => $t->status,
'created_at' => $t->created_at,
'last_login_at' => $t->last_login_at,
'delivered_in_month' => (int) $t->delivered_in_month,
'balance_rub' => (string) $t->balance_rub,
]);
$dormant = (clone $this->dormantQuery())
->orderByRaw('ll.last_login_at ASC NULLS FIRST')
->limit(50)
->get(['tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.balance_rub', 'll.last_login_at'])
->map(fn ($t) => [
'id' => (int) $t->id,
'organization_name' => $t->organization_name ?: $t->subdomain,
'subdomain' => $t->subdomain,
'last_login_at' => $t->last_login_at,
'balance_rub' => (string) $t->balance_rub,
]);
return response()->json([
'kpi' => $kpi,
'new_clients' => $newClients,
'dormant' => $dormant,
]);
}
/** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */
public function supply(): JsonResponse
{
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin: список счетов + ручная отметка оплаты (Этап 1 «оплата по счёту»).
* Зона saas-admin/admin-db. Зачисление делегируется InvoicePaymentService
* (идемпотентно, под tenant RLS-контекстом).
*/
class AdminInvoiceController extends Controller
{
public function __construct(private readonly InvoicePaymentService $payments) {}
public function index(Request $request): JsonResponse
{
$perPage = min(100, max(10, (int) $request->query('per_page', 25)));
$query = DB::table('saas_invoices as i')
->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id')
->select(
'i.id', 'i.invoice_number', 'i.amount_total', 'i.status',
'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.organization_name as tenant_name', 'i.payer_name'
);
$status = $request->query('status');
if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) {
$query->where('i.status', $status);
}
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('i.invoice_number', 'ilike', "%{$search}%")
->orWhere('i.payer_name', 'ilike', "%{$search}%")
->orWhere('t.organization_name', 'ilike', "%{$search}%");
});
}
$page = $query->orderByDesc('i.issued_at')->paginate($perPage);
return response()->json([
'data' => array_map(static fn ($r) => (array) $r, $page->items()),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'total' => $page->total(),
'per_page' => $page->perPage(),
],
]);
}
public function markPaid(Request $request, int $id): JsonResponse
{
$this->payments->markPaid($id);
return response()->json(['status' => 'ok']);
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin «Лиды» (L3) сквозная вложенность дашборда до конечного источника.
* Серверная пагинация/фильтры (масштаб: десятки тысяч лидов).
* Цепочка: supplier_leads.supplier_project_id источник (канал+identifier),
* platform = поставщик (B1/B2/B3), resolved_subject_code = регион,
* deals.source_crm_id = supplier_leads.vid сделки клиентов.
* Группа ['saas-admin','admin-db'] cross-tenant через pgsql_admin.
* Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md
*/
class AdminLeadsController extends Controller
{
private const PER_PAGE_DEFAULT = 25;
private const PER_PAGE_MAX = 100;
private const STUCK_HOURS = 4;
/** Маска телефона по 152-ФЗ: «+7 9** *** ** 07» (видны код страны и 2 последние). */
private function maskPhone(?string $phone): string
{
if (! $phone) {
return '—';
}
$digits = preg_replace('/\D/', '', $phone);
if (strlen((string) $digits) < 4) {
return '***';
}
$last2 = substr((string) $digits, -2);
$first = substr((string) $digits, 0, 2);
return $first.'** *** ** '.$last2;
}
/** Производный статус лида для UI. */
private function statusOf(object $r): string
{
if ($r->error !== null && $r->error !== '') {
return 'error';
}
if ($r->processed_at !== null) {
return ((int) ($r->deals_created_count ?? 0)) > 0 ? 'delivered' : 'no_match';
}
return 'pending'; // визуально «завис» определяет фронт по времени, но базово pending
}
/** Базовый запрос лидов с присоединённым источником (supplier_projects). */
private function baseQuery(Request $request): Builder
{
$q = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id');
if (($df = (string) $request->query('date_from', '')) !== '' && ($dt = (string) $request->query('date_to', '')) !== '') {
$q->whereBetween('sl.received_at', [$df.' 00:00:00', $dt.' 23:59:59']);
}
if (($channel = (string) $request->query('channel', '')) !== '') {
$q->where('sp.signal_type', $channel);
}
if (($platform = (string) $request->query('platform', '')) !== '') {
$q->where('sl.platform', $platform);
}
if (($search = trim((string) $request->query('search', ''))) !== '') {
$q->where(function ($w) use ($search) {
$w->where('sl.phone', 'like', '%'.$search.'%')
->orWhere('sp.unique_key', 'like', '%'.$search.'%')
->orWhere('sl.vid', '=', ctype_digit($search) ? (int) $search : 0);
});
}
if (($status = (string) $request->query('status', '')) !== '') {
$this->applyStatusFilter($q, $status);
}
if (($tenantId = (int) $request->query('tenant_id', 0)) > 0) {
$q->whereExists(function ($e) use ($tenantId) {
$e->select(DB::raw(1))->from('deals')
->whereColumn('deals.source_crm_id', 'sl.vid')
->where('deals.tenant_id', $tenantId);
});
}
return $q;
}
private function applyStatusFilter(Builder $q, string $status): void
{
match ($status) {
'error' => $q->whereNotNull('sl.error')->where('sl.error', '<>', ''),
'delivered' => $q->whereNotNull('sl.processed_at')->where('sl.deals_created_count', '>', 0),
'no_match' => $q->whereNotNull('sl.processed_at')
->where(fn ($w) => $w->whereNull('sl.deals_created_count')->orWhere('sl.deals_created_count', '=', 0)),
'stuck' => $q->whereNull('sl.processed_at')->where('sl.received_at', '<', now()->subHours(self::STUCK_HOURS)),
'pending' => $q->whereNull('sl.processed_at'),
default => null,
};
}
/** @return array<string,mixed> */
private function rowToArray(object $r): array
{
return [
'id' => (int) $r->id,
'received_at' => $r->received_at,
'platform' => $r->platform,
'channel' => $r->channel,
'source' => $r->unique_key,
'region_code' => $r->resolved_subject_code !== null ? (int) $r->resolved_subject_code : null,
'phone_masked' => $this->maskPhone($r->phone),
'deals_created_count' => (int) ($r->deals_created_count ?? 0),
'status' => $this->statusOf($r),
];
}
/** GET /api/admin/leads — серверный список с фильтрами/пагинацией. */
public function index(Request $request): JsonResponse
{
$perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->query('per_page', self::PER_PAGE_DEFAULT)));
$page = max(1, (int) $request->query('page', 1));
$base = $this->baseQuery($request);
$total = (clone $base)->count();
$rows = $base
->orderByDesc('sl.received_at')
->offset(($page - 1) * $perPage)
->limit($perPage)
->get([
'sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.deals_created_count',
'sl.processed_at', 'sl.error', 'sl.resolved_subject_code',
'sp.signal_type as channel', 'sp.unique_key',
])
->map(fn ($r) => $this->rowToArray($r));
return response()->json([
'data' => $rows,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
]);
}
/** GET /api/admin/leads/{id} — карточка лида: источник + сделки клиентов (цепочка). */
public function show(int $id): JsonResponse
{
$lead = DB::table('supplier_leads as sl')
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
->where('sl.id', $id)
->first([
'sl.id', 'sl.received_at', 'sl.processed_at', 'sl.error', 'sl.platform', 'sl.phone',
'sl.vid', 'sl.deals_created_count', 'sl.resolved_subject_code', 'sl.region_source',
'sl.phone_operator', 'sp.signal_type as channel', 'sp.unique_key', 'sp.id as supplier_project_id',
]);
if ($lead === null) {
return response()->json(['message' => 'Лид не найден'], 404);
}
$deals = DB::table('deals')
->join('tenants', 'tenants.id', '=', 'deals.tenant_id')
->where('deals.source_crm_id', $lead->vid)
->orderByDesc('deals.received_at')
->limit(50)
->get([
'deals.id', 'deals.tenant_id', 'tenants.organization_name', 'tenants.subdomain',
'deals.status', 'deals.project_id', 'deals.received_at',
])
->map(fn ($d) => [
'id' => (int) $d->id,
'tenant_id' => (int) $d->tenant_id,
'tenant_name' => $d->organization_name ?: $d->subdomain,
'subdomain' => $d->subdomain,
'status' => $d->status,
'project_id' => $d->project_id !== null ? (int) $d->project_id : null,
'received_at' => $d->received_at,
]);
return response()->json([
'lead' => [
'id' => (int) $lead->id,
'platform' => $lead->platform,
'phone_masked' => $this->maskPhone($lead->phone),
'received_at' => $lead->received_at,
'processed_at' => $lead->processed_at,
'error' => $lead->error,
'region_code' => $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
'region_source' => $lead->region_source,
'phone_operator' => $lead->phone_operator,
'deals_created_count' => (int) ($lead->deals_created_count ?? 0),
'status' => $this->statusOf($lead),
],
'source' => [
'platform' => $lead->platform,
'channel' => $lead->channel,
'identifier' => $lead->unique_key,
'supplier_project_id' => $lead->supplier_project_id !== null ? (int) $lead->supplier_project_id : null,
],
'deals' => $deals,
]);
}
}
@@ -31,12 +31,21 @@ use Illuminate\Support\Facades\DB;
*/
class AdminSystemSettingsController extends Controller
{
/** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */
private const SENSITIVE_KEYS = ['supplier_webhook_secret'];
/** GET /api/admin/system-settings */
public function index(): JsonResponse
{
return response()->json([
'settings' => SystemSetting::orderBy('key')->get(),
]);
$settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) {
if (in_array($s->key, self::SENSITIVE_KEYS, true)) {
$s->value = $s->value === '' ? '' : '••• (скрыто)';
}
return $s;
});
return response()->json(['settings' => $settings]);
}
/** PUT /api/admin/system-settings/{key} */
@@ -30,10 +30,14 @@ class AdminTenantsController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', '');
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
$statuses = $this->csvParam($request, 'statuses');
$tariffs = $this->csvParam($request, 'tariffs');
$search = trim((string) $request->query('search', ''));
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
@@ -59,8 +63,22 @@ class AdminTenantsController extends Controller
])
->whereNull('tenants.deleted_at');
if ($status !== '') {
$query->where('tenants.status', $status);
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
// trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба
// (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу.
if ($statuses !== []) {
$query->whereIn(DB::raw("(CASE
WHEN tenants.is_trial THEN 'trial'
WHEN tenants.status = 'suspended' THEN 'suspended'
WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue'
WHEN tenants.status = 'active' THEN 'active'
ELSE 'suspended'
END)"), $statuses);
} elseif ($status !== '') {
$query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке
}
if ($tariffs !== []) {
$query->whereIn('tariff_plans.name', $tariffs);
}
if ($search !== '') {
$like = '%'.$search.'%';
@@ -451,6 +469,19 @@ class AdminTenantsController extends Controller
];
}
/**
* Разбирает csv-параметр запроса в список непустых trimmed-строк.
*
* @return list<string>
*/
private function csvParam(Request $request, string $key): array
{
return array_values(array_filter(array_map(
'trim',
explode(',', (string) $request->query($key, '')),
)));
}
/**
* Aggregate-stats для page-head: total / active / trial / overdue / revenue.
* Считается отдельным запросом без фильтров (показывает глобальную картину
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\External\Yandex360BalanceStore;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* SaaS-admin Система: ручной ввод баланса Яндекс 360 (почта).
* Владелец периодически вписывает сумму из кабинета (у Яндекса нет API баланса).
*/
class AdminYandex360BalanceController extends Controller
{
public function __construct(private readonly Yandex360BalanceStore $store) {}
/** GET /api/admin/settings/yandex360-balance */
public function show(): JsonResponse
{
return response()->json($this->store->status());
}
/** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */
public function update(Request $request): JsonResponse
{
$raw = $request->input('balance');
$balance = ($raw === null || $raw === '') ? null : (float) $raw;
if ($balance !== null && $balance < 0) {
return response()->json([
'message' => 'Баланс не может быть отрицательным.',
'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']],
], 422);
}
$this->store->set($balance);
return response()->json($this->store->status());
}
}
File diff suppressed because it is too large Load Diff
@@ -307,7 +307,14 @@ class BillingController extends Controller
$rows = DB::table('saas_invoices')
->where('tenant_id', $tenantId)
->orderBy('issued_at', 'desc')
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']);
// Какие счета уже имеют закрывающий документ (акт) — для кнопки «Скачать акт».
$actInvoiceIds = DB::table('saas_upd_documents')
->where('tenant_id', $tenantId)
->whereNotNull('invoice_id')
->pluck('invoice_id')
->flip();
return response()->json([
'data' => $rows->map(static fn (\stdClass $r): array => [
@@ -316,7 +323,11 @@ class BillingController extends Controller
'amount_total' => $r->amount_total,
'status' => $r->status,
'issued_at' => $r->issued_at,
'expires_at' => $r->expires_at,
'has_pdf' => $r->pdf_path !== null,
'has_act' => isset($actInvoiceIds[$r->id]),
'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null,
'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null,
])->all(),
]);
}
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\AutopodborSource;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
@@ -104,7 +105,7 @@ class DealController extends Controller
$query = Deal::query()
->select('deals.*')
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders,is_active', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -206,6 +207,25 @@ class DealController extends Controller
});
}
// Автоподбор: по каждой сделке — конкурент + источник (связь
// deal.project_id → autopodbor_sources.created_project_id → competitor).
// Батч-запрос по project_id всей страницы (без N+1). Своя транзакция с
// app.current_tenant_id — иначе RLS на autopodbor_* вернёт 0 строк.
$sourcesByProject = collect();
$projectIds = $deals->pluck('project_id')->filter()->unique()->values()->all();
if ($projectIds !== []) {
$sourcesByProject = DB::transaction(function () use ($tenantId, $projectIds) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return AutopodborSource::query()
->where('tenant_id', $tenantId)
->whereIn('created_project_id', $projectIds)
->with('competitor:id,name')
->get()
->groupBy('created_project_id');
});
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
@@ -230,6 +250,21 @@ class DealController extends Controller
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'cost_kopecks' => $costByDeal[$d->id] ?? null,
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
...(function () use ($d, $sourcesByProject) {
$group = $sourcesByProject[$d->project_id] ?? collect();
$repr = $group->firstWhere('box', 'field') ?? $group->first();
return [
'competitor_id' => $repr?->competitor_id,
'competitor_name' => $repr?->competitor?->name,
'source_identifier' => $repr?->identifier,
'source_signal_type' => $repr?->signal_type,
'source_project_id' => $repr ? $d->project_id : null,
'source_is_active' => $repr ? (bool) $d->project?->is_active : null,
'source_extra_count' => max(0, $group->count() - 1),
];
})(),
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Клиентские эндпоинты «оплата по счёту» (под middleware auth:sanctum + tenant).
* Создание счёта (самообслуживание), скачивание PDF счёта и акта (tenant-scoped).
*/
class InvoiceController extends Controller
{
public function __construct(private readonly InvoiceService $invoices) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
try {
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'amount_total' => $invoice->amount_total,
'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf",
]], 201);
}
public function pdf(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404);
return $this->inlinePdf($invoice->pdf_path, 'Schet-'.$invoice->invoice_number);
}
public function act(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail();
abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404);
return $this->inlinePdf($act->pdf_path, 'Akt-'.$act->upd_number);
}
/**
* Отдать PDF для просмотра в браузере (inline) с ASCII-безопасным именем
* кириллица в Content-Disposition ломала имя файла в браузере (random GUID).
*/
private function inlinePdf(string $path, string $baseName): Response
{
$content = Storage::disk('local')->get($path);
$filename = Str::ascii($baseName).'.pdf'; // напр. Schet-SCh-2026-00001.pdf
return response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Bot\ProcessJivoMessageJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Приём событий Jivo Bot API (спека §§2,5). Образец защиты SupplierWebhookController:
* секрет в URL (≥32 симв., config services.jivo_bot.webhook_secret), hash_equals,
* несовпадение 404 (не палим endpoint). Ack мгновенный (лимит Jivo 3 сек):
* вся работа в ProcessJivoMessageJob. Обрабатываем только CLIENT_MESSAGE
* с непустым текстом; служебные события подтверждаем и игнорируем.
*/
class JivoBotController extends Controller
{
public function receive(Request $request, string $secret = ''): JsonResponse
{
$expected = (string) config('services.jivo_bot.webhook_secret');
if ($expected === '' || strlen($expected) < 32 || ! hash_equals($expected, $secret)) {
return response()->json(['message' => 'Not found.'], 404);
}
$event = (string) $request->input('event', '');
$text = trim((string) $request->input('message.text', ''));
$chatId = (string) $request->input('chat_id', '');
if ($event === 'CLIENT_MESSAGE' && $text !== '' && $chatId !== '') {
ProcessJivoMessageJob::dispatch($chatId, (string) $request->input('client_id', ''), $text);
}
return response()->json(['ok' => true]);
}
}
@@ -9,15 +9,14 @@ use App\Http\Requests\BulkProjectActionRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalancePreflightService;
use App\Services\Billing\LaunchBalanceGate;
use App\Services\Project\ProjectService;
use App\Services\Requisites\RequisitesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Проекты tenant'а расширенный API для ProjectsView + NewDealDialog.
@@ -134,35 +133,18 @@ class ProjectController extends Controller
return response()->json(['error' => 'requisites_required'], 422);
}
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
unset($validated['force_save_blocked']); // больше не блокируем создание
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
$project = $this->projects->create($tenant, $validated, launch: true);
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
if (! $preflight['passes'] && ! $forceSaveBlocked) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBeRequired,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
$project = $this->projects->create($tenant, $validated);
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
return response()->json([
'data' => new ProjectResource($project->loadCount('supplierProjects')),
'launch' => [
'launched' => $project->launch_deferred ? 0 : 1,
'deferred' => $project->launch_deferred ? 1 : 0,
'balance' => $project->gate_payload,
],
], 201);
}
/** PATCH /api/projects/{id} */
@@ -171,34 +153,28 @@ class ProjectController extends Controller
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$validated = $request->validated();
$tenant = $request->user()->tenant;
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
unset($validated['force_save_blocked']);
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
// проекта + лимиты остальных активных не-blocked.
if (array_key_exists('daily_limit_target', $validated)) {
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('id', '!=', $project->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
$isLimitRaise = array_key_exists('daily_limit_target', $validated)
&& (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target
&& $project->is_active && $project->preflight_blocked_at === null;
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
if ($isLimitRaise) {
$newLimit = (int) $validated['daily_limit_target'];
$tenantId = $tenant->id;
if (! $preflight['passes'] && ! $forceSaveBlocked) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBeRequired,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse {
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
if (! $preflight['passes'] && $forceSaveBlocked) {
$validated['preflight_blocked_at'] = now();
}
$gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]);
if (! $gate->passes) {
return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409);
}
$updated = $this->projects->update($project, $validated);
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
});
}
$updated = $this->projects->update($project, $validated);
@@ -206,34 +182,6 @@ class ProjectController extends Controller
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
}
/**
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
if ($tiers->isEmpty()) {
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
}
$result = (new BalancePreflightService)->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $requiredLeads,
tiers: $tiers,
);
return [
'passes' => $result->passes,
'capacity_leads' => $result->capacityLeads,
'deficit_leads' => $result->deficitLeads,
];
}
/** GET /api/projects/{id} */
public function show(Request $request, int $id): JsonResponse
{
@@ -269,23 +217,13 @@ class ProjectController extends Controller
$request->validate(['is_active' => ['required', 'boolean']]);
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
$newActive = $request->boolean('is_active');
$project->update([
'is_active' => $newActive,
'paused_at' => $newActive ? null : now(),
]);
$result = $this->projects->setActive($project, $request->boolean('is_active'));
// #10: pause/resume must reach the supplier. The job's group recompute pushes
// status=paused when no active project of the group remains (resume → active).
// G (балансовый блок): заблокированный за нехваткой баланса проект не
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
if ($project->preflight_blocked_at === null) {
SyncSupplierProjectJob::dispatch($project->id);
if ($result->activate_deferred ?? false) {
return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409);
}
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]);
}
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
@@ -18,7 +18,7 @@ use Illuminate\Support\Facades\Auth;
/**
* Самозапись клиента (G1/SP1): register confirm-email (вход).
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
* pending_email_confirm, активируется и получает 300 при подтверждении.
* pending_email_confirm, активируется и получает 1000 при подтверждении.
*/
class RegistrationController extends Controller
{
@@ -7,8 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -38,16 +37,37 @@ class TenantChargesController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
// Explicit tenant_id фильтр — defense-in-depth поверх RLS. В тестах PG
// superuser BYPASSRLS, и без явного where() запрос вернул бы строки
// других тенантов (см. InAppNotificationController паттерн).
$query = LeadCharge::query()
$period = $request->query('period');
$source = $request->query('charge_source');
// Explicit tenant_id фильтр — defense-in-depth поверх RLS (в тестах PG superuser BYPASSRLS).
// Лид-списания.
$lead = DB::table('lead_charges')
->where('tenant_id', $tenantId)
->orderBy('charged_at', 'desc');
->selectRaw("id, charged_at, 'lead' as kind, deal_id, tier_no, charge_source, price_per_lead_kopecks, cast(null as text) as label");
$this->applyPeriodTo($lead, 'charged_at', $period);
if (is_string($source) && $source !== '') {
$lead->where('charge_source', $source);
}
$this->applyFilters($query, $request);
// Списания автоподбора (balance_transactions) — тоже показываем в «Списаниях», иначе клиент
// видит их только в «Истории транзакций». Фильтр charge_source (prepaid/rub) к автоподбору
// неприменим → при выбранном источнике автоподбор не подмешиваем.
if (! is_string($source) || $source === '') {
$auto = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'autopodbor_charge')
->selectRaw("id, created_at as charged_at, 'autopodbor' as kind, cast(null as bigint) as deal_id, cast(null as integer) as tier_no, cast(null as varchar) as charge_source, cast(round(abs(amount_rub) * 100) as bigint) as price_per_lead_kopecks, cast('Автоподбор конкурентов' as text) as label");
$this->applyPeriodTo($auto, 'created_at', $period);
$rows = $lead->unionAll($auto);
} else {
$rows = $lead;
}
$page = $query->paginate(20);
$page = DB::query()->fromSub($rows, 'c')
->orderByDesc('charged_at')
->orderByDesc('id')
->paginate(20);
return response()->json([
'data' => $page->items(),
@@ -141,6 +161,29 @@ class TenantChargesController extends Controller
}
});
// Списания автоподбора — тоже в CSV (после лид-строк). charge_source к ним неприменим,
// поэтому при выбранном источнике их не выгружаем (как и в списке).
if ($source === null || $source === '') {
$autoq = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'autopodbor_charge')
->orderByDesc('created_at')
->orderByDesc('id');
$this->applyPeriodTo($autoq, 'created_at', $period);
$autoq->chunk(500, function ($rows) use ($out) {
foreach ($rows as $r) {
fputcsv($out, [
Carbon::parse($r->created_at)->toIso8601String(),
'Автоподбор конкурентов',
'',
'',
number_format(abs((float) $r->amount_rub), 2, '.', ''),
$r->balance_rub_after ?? '',
]);
}
});
}
fclose($out);
});
}, 200, [
@@ -150,22 +193,12 @@ class TenantChargesController extends Controller
}
/**
* @param Builder<LeadCharge> $query
* Фильтр периода на произвольную колонку/билдер (lead_charges.charged_at либо
* balance_transactions.created_at) единый для лид- и автоподбор-веток union.
*
* @param Builder $query
*/
private function applyFilters($query, Request $request): void
{
$this->applyPeriodFilter($query, $request->query('period'));
$source = $request->query('charge_source');
if (is_string($source) && $source !== '') {
$query->where('charge_source', $source);
}
}
/**
* @param Builder<LeadCharge> $query
*/
private function applyPeriodFilter($query, mixed $period): void
private function applyPeriodTo($query, string $column, mixed $period): void
{
if (! is_string($period) || $period === '') {
return;
@@ -174,14 +207,14 @@ class TenantChargesController extends Controller
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where('charged_at', '>=', $now->copy()->startOfMonth());
$query->where($column, '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween('charged_at', [
$query->whereBetween($column, [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where('charged_at', '>=', $now->copy()->subDays(90));
$query->where($column, '>=', $now->copy()->subDays(90));
}
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CompetitorResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'is_federal' => $this->is_federal,
'relevance_pct' => $this->relevance_pct,
'origin' => $this->origin,
'box' => $this->box,
'site_url' => $this->site_url,
'directory_urls' => $this->directory_urls,
'elements' => $this->elements ?? [],
'studied_at' => $this->studied_at?->toIso8601String(),
'study_run_id' => $this->study_run_id,
'search_run_id' => $this->search_run_id,
];
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\AutopodborQueue;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class RunResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'kind' => $this->kind,
'competitor_id' => $this->competitor_id,
'status' => $this->status,
'region_code' => $this->region_code,
'params' => $this->params,
'price_rub_charged' => $this->price_rub_charged,
'error_code' => $this->error_code,
// Клиенту показать, что идёт работа: место в общей очереди (пока queued) и текущий этап (пока running).
'queue_position' => $this->status === 'queued' ? app(AutopodborQueue::class)->pending() : null,
'progress' => $this->progress,
'result' => $this->result,
'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(),
'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(),
'started_at' => $this->started_at?->toIso8601String(),
'finished_at' => $this->finished_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SourceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'competitor_id' => $this->competitor_id,
'signal_type' => $this->signal_type,
'identifier' => $this->identifier,
'phone_kind' => $this->phone_kind,
'phone_type' => $this->phone_type,
'box' => $this->box,
'provenance_url' => $this->provenance_url,
'provenance_label' => $this->provenance_label,
'created_project_id' => $this->created_project_id,
'where_found' => $this->where_found ?? [],
'office' => $this->office,
'confirmations' => $this->confirmations,
];
}
}
@@ -52,6 +52,8 @@ class ProjectResource extends JsonResource
'last_synced_at' => $this->aggregateLastSyncedAt(),
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
'balance_blocked' => $this->preflight_blocked_at !== null,
// Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс».
'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(),
'supplier_links' => $this->when(
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\AutopodborDedup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class RunAutopodborResolveJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Резолв по имени — тоже шаг 1 (ИИ-поиск кандидатов), поэтому на одной очереди с поиском.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай).
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
try {
$p = $run->params;
$res = $agent->resolveByName(new ResolveByNameRequest(
name: $p['name'],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupCompetitors($res->candidates);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
foreach ($unique as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => null,
'origin' => 'resolve',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
'provenance' => $c['provenance'] ?? [],
]
);
}
$run->update(['status' => 'done', 'finished_at' => now()]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Mail\AutopodborReadyMail;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\CompetitorIdentity;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class RunAutopodborSearchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// ОДИН прогон без авто-ретраев: движок платный (xfetch+ИИ+EXA), и повтор всего прогона при
// таймауте = тройная цена и время. Медленный-но-настоящий ответ теперь ждём большими таймаутами
// стадий (config services.aitunnel/exa), а не гоняем движок трижды.
public int $tries = 1;
// Живой поиск (2ГИС+Яндекс через антибот + канал В sonar ×2 + EXA + «вскрыть всё»: параллельное
// открытие карточек без лимита) идёт минутами — большой запас, чтобы не убить прогон на середине.
public int $timeout = 3600;
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Шаг 1 «собрать поле» (ИИ + внешние сервисы) — своя очередь, отдельный воркер-процесс,
// чтобы тяжёлый поиск не стоял в одной линии с быстрым шагом 2 (изучением) другого клиента.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом
* (ретрай/повторный dispatch). Ключ runId.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, CompetitorIdentity $identity, AutopodborNormalizer $normalizer, RunProgressChannel $progress): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок будет отмечать этапы именно сюда.
$progress->bind($run->id);
try {
$p = $run->params;
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
$request = new FindCompetitorsRequest(
regionCode: (int) $run->region_code,
examples: $p['examples'] ?? [],
aboutSelf: $p['about_self'] ?? [],
includeFederal: (bool) ($p['include_federal'] ?? false),
maxCompetitors: $max,
);
// Авто-повтор при ПУСТОМ сборе: внешние источники (2ГИС/Яндекс/EXA) иногда моргают и
// отдают пусто — вместо «ничего не нашлось» пробуем ещё раз (по умолчанию до 2 доп. попыток).
// Реально пустая ниша (все попытки пусты) идёт дальше штатным путём «empty» — без списания.
$retries = max(0, (int) config('autopodbor.search_retry_on_empty', 2));
$attempt = 0;
do {
$attempt++;
$res = $agent->findCompetitors($request);
} while ($res->competitors === [] && $attempt <= $retries);
$unique = $dedup->dedupCompetitors($res->competitors);
// На записи выкидываем ТОЛЬКО полный дубль АКТИВНОЙ фирмы (поле/предложения) — та же
// опознавалка и ни одной новой (это «скрытая» группа, спека §8). Находку, совпавшую с
// АРХИВОМ удалённых, сохраняем — её покажем как «ранее удалён». Деление на группы —
// при показе /proposals. Исключаем свой же прогон (ретрай не должен схлопнуть свои
// результаты). Конкурентов без опознавалок (нет сайта/карточек) не с чем сверять — оставляем.
$activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where('box', '!=', 'archived')
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->get(['site_url', 'directory_urls'])
->map(fn ($c) => array_flip($identity->keys([
'site_url' => $c->site_url,
'directory_urls' => $c->directory_urls ?? [],
])))
->all();
$unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool {
$keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]);
if ($keys === []) {
return true;
}
foreach ($activeKeySets as $have) {
$shares = false;
$hasNew = false;
foreach ($keys as $k) {
if (isset($have[$k])) {
$shares = true;
} else {
$hasNew = true;
}
}
if ($shares && ! $hasNew) {
return false; // полный дубль активной фирмы → скрыть
}
}
return true;
}));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
$this->notifyReady($run, 0);
return;
}
// $max === 0 → «не режем»: сохраняем ВСЕХ найденных. Иначе array_slice(…, 0, 0) обнулил бы
// непустой результат (баг: прогон сохранял 0 конкурентов, но всё равно списывал).
$saved = $max > 0 ? array_slice($unique, 0, $max) : $unique;
foreach ($saved as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => $c['relevance_pct'] ?? null,
'origin' => 'auto',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
// Телефоны (нормализованные) — для показа совпадений клиенту на «Предложениях».
// В тихую склейку НЕ идут (решение 03.07) — только клиентская «Найти дубли».
'phones' => array_values(array_unique(array_filter(array_map(
static fn ($p): string => $normalizer->phone((string) $p),
$c['phones'] ?? [],
)))),
'provenance' => $c['provenance'] ?? [],
]
);
}
$price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0');
$charge->chargeForRun($run, $price);
$run->update(['status' => 'done', 'finished_at' => now()]);
$this->notifyReady($run, count($saved));
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
/**
* Письмо клиенту «подбор готов» чтобы он не ждал у экрана. Не роняет успешный подбор,
* если почта недоступна (try/catch + report).
*/
private function notifyReady(AutopodborRun $run, int $found): void
{
try {
$email = Tenant::query()->whereKey($run->tenant_id)->value('contact_email');
if (is_string($email) && $email !== '') {
Mail::to($email)->send(new AutopodborReadyMail($run, $found));
}
} catch (\Throwable $e) {
report($e);
}
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\AutopodborStudyScheduler;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class RunAutopodborStudyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Шаг 2 «изучение / дайте источники» — тупой слой (без ИИ). ОТДЕЛЬНАЯ очередь + свой воркер,
// чтобы изучение шло параллельно поиску, а не ждало его в общей линии.
$this->onQueue('autopodbor-study');
}
/**
* Защита от нахлёста: один прогон изучения не обрабатывается двумя воркерами разом (ретрай).
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(
CompetitorAgent $agent,
AutopodborDedup $dedup,
AutopodborChargeService $charge,
AutopodborNormalizer $norm,
RunProgressChannel $progress,
AutopodborStudyScheduler $scheduler,
): void {
// Внешний try/finally: распорядитель должен взять следующего кандидата ПОСЛЕ ЛЮБОГО
// завершения этого прогона — done/empty/failed, включая ранние return'ы и проброшенное
// исключение из внутреннего catch. Дорожка шага 2 одна на весь портал (см.
// AutopodborStudyScheduler) — если не звать tick() здесь, освободившаяся дорожка простаивала
// бы до следующего крон-тика (autopodbor:study-tick, страховка раз в минуту).
try {
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
$progress->bind($run->id);
try {
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
competitor: [
'name' => $comp->name,
'site_url' => $comp->site_url,
'directory_urls' => $comp->directory_urls ?? [],
'elements' => $comp->elements ?? [],
'is_federal' => (bool) $comp->is_federal,
],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
$newCount = 0;
$refoundDeletedIds = [];
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
$existing = AutopodborSource::where('competitor_id', $comp->id)
->where('dedup_key', $s['dedup_key'])
->first();
AutopodborSource::updateOrCreate(
[
'competitor_id' => $comp->id,
'dedup_key' => $s['dedup_key'],
],
[
'tenant_id' => $run->tenant_id,
'study_run_id' => $run->id,
'signal_type' => $s['signal_type'],
'identifier' => $identifier,
'phone_kind' => $s['phone_kind'] ?? null,
'phone_type' => $s['phone_type'] ?? null,
'provenance_url' => $s['provenance_url'] ?? null,
'provenance_label' => $s['provenance_label'] ?? null,
'where_found' => $s['where_found'] ?? null,
'office' => $s['office'] ?? null,
'confirmations' => $s['confirmations'] ?? 1,
]
);
if ($existing === null) {
$newCount++; // совсем новый → в предложения
} elseif ($existing->box === 'archived') {
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
}
}
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
$charge->chargeForRun($run, $price);
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
$run->update([
'status' => 'done',
'finished_at' => now(),
'result' => [
'new_sources' => $newCount,
'refound_deleted' => count($refoundDeletedIds),
'refound_deleted_ids' => array_values($refoundDeletedIds),
],
]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
} finally {
$scheduler->tick();
}
}
}
@@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
Tenant::query()
// Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS).
// Список замороженных тенантов брать через дефолтное соединение нельзя — без
// app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же
// баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS).
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
->whereNotNull('frozen_by_balance_at')
->whereNull('deleted_at')
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->processTenant($tenant, $service, $tiers);
}
});
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->processTenant((int) $tenantId, $service, $tiers);
}
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
{
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
// SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find,
// и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent()
// идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен.
DB::transaction(function () use ($tenantId, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$tenant = Tenant::find($tenantId);
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
return; // разморожен/удалён между pluck и обработкой.
}
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
});
}
private function matchWindow(int $hours): ?string
@@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->evaluateTenant($tenant, $service, $tiers);
}
});
// Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user
// (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без
// app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и
// sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id
// через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает
// для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст.
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
->whereNull('deleted_at')
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->evaluateTenant((int) $tenantId, $service, $tiers);
}
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
{
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
// RLS-policy на projects падает с "unrecognized configuration parameter".
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
DB::transaction(function () use ($tenant, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
DB::transaction(function () use ($tenantId, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find
// вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна.
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return; // удалён между pluck и обработкой — пропускаем.
}
$required = $tenant->requiredLeadsForTomorrow();
$result = $service->evaluate(
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Bot;
use App\Models\BotDialog;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\JivoBotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Оркестратор ответа бота (спека §2). Очередь `bot` отдельный worker на проде,
* чтобы поток лидов не задерживал ответы чата (скорость требование №1).
* timeout 12с < 15с Jivo-страховки: не успели Jivo сам позовёт оператора.
* $tries=1: ретраить разговор бессмысленно, клиент уже у живого оператора.
*/
class ProcessJivoMessageJob implements ShouldQueue
{
use Queueable;
public int $timeout = 12;
public int $tries = 1;
public function __construct(
public readonly string $chatId,
public readonly string $clientId,
public readonly string $text,
) {
$this->onQueue('bot');
}
public function handle(): void
{
$startedAt = hrtime(true);
BotDialog::create([
'jivo_chat_id' => $this->chatId,
'direction' => 'in',
'message' => $this->text,
'created_at' => now(),
]);
$answer = app(BotAnswerService::class)->answer($this->text);
$jivo = app(JivoBotClient::class);
$jivo->sendMessage($this->chatId, $this->clientId, $answer->text);
if ($answer->escalate) {
$jivo->inviteAgent($this->chatId, $this->clientId);
}
BotDialog::create([
'jivo_chat_id' => $this->chatId,
'direction' => 'out',
'message' => $answer->text,
'matched_chunks' => $answer->matchedChunkIds,
'latency_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000),
'escalated' => $answer->escalate,
'created_at' => now(),
]);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Jobs\External;
use App\Mail\ExternalServiceDownMail;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\CaptchaLivenessProbe;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\JivoLivenessProbe;
use App\Services\External\LivenessProbe;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\Yandex360BalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use App\Services\External\YooKassaLivenessProbe;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
* Каждый провайдер изолирован: fetch() не бросает; ok=false оставляет ПРОШЛЫЙ баланс
* + метку ошибки (плитка не падает, показывает «данные от ДАТА»). Пишет под
* crm_supplier_worker (BYPASSRLS) таблица системная, как supplier_sync_runs.
*
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
*/
class RefreshExternalBalancesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS для записи системной таблицы
/** @return array<int,class-string<BalanceProvider>> */
private function providers(): array
{
return [
DadataBalanceProvider::class,
SupplierBalanceProvider::class,
YandexCloudBalanceProvider::class,
Yandex360BalanceProvider::class,
];
}
/** @var array<int,LivenessProbe>|null Подмена списка проб в тестах; null → дефолт. */
private static ?array $livenessOverride = null;
/** @param array<int,LivenessProbe> $probes */
public static function useLivenessProbes(array $probes): void
{
self::$livenessOverride = $probes;
}
public static function resetLivenessProbes(): void
{
self::$livenessOverride = null;
}
/** @return array<int,LivenessProbe> */
private function livenessProbes(): array
{
if (self::$livenessOverride !== null) {
return self::$livenessOverride;
}
return [
app(YooKassaLivenessProbe::class),
app(JivoLivenessProbe::class),
app(CaptchaLivenessProbe::class),
];
}
public function handle(): void
{
// Прежние цвета (для edge-trigger алерта): service_key => light.
$priorLights = DB::connection(self::DB_CONNECTION)
->table('external_service_balances')
->pluck('light', 'service_key')
->all();
/** @var array<int,array{key:string,detail:string}> $newlyRed */
$newlyRed = [];
foreach ($this->providers() as $cls) {
/** @var BalanceProvider $p */
$p = app($cls);
$key = $p->serviceKey();
$reading = $p->fetch(); // не бросает
// Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера
// накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert
// ошибочно идёт в INSERT существующей строки → нарушение PK.
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
if (! $reading->ok) {
// Оставляем прошлый баланс, помечаем ok=false + ошибку.
$table->updateOrInsert(
['service_key' => $key],
[
'ok' => false,
'error' => $reading->error,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
continue;
}
[$red, $amber] = $this->floors($key);
$h = BalanceHealth::evaluate((float) $reading->balance, $reading->dailySpend, $red, $amber);
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => $reading->balance,
'currency' => $reading->currency,
'daily_spend_estimate' => $reading->dailySpend,
'days_left' => $h['days_left'],
'light' => $h['light'],
'ok' => true,
'error' => null,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
if ($h['light'] === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => 'баланс на исходе'];
}
}
// Пробы живости (сервисы без денежного баланса): пишем в ту же таблицу.
foreach ($this->livenessProbes() as $probe) {
$reading = $probe->check(); // не бросает
$key = $probe->serviceKey();
// Свежий builder на каждую итерацию (как в балансовом цикле).
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => null,
'currency' => 'RUB',
'daily_spend_estimate' => null,
'days_left' => null,
'light' => $reading->light,
// ok=true у green/red (статус определённый), false у grey (не смогли/не применимо).
'ok' => $reading->light !== 'grey',
// error несёт человеческую подпись для red/grey (для плитки); у green — null.
'error' => $reading->light === 'green' ? null : $reading->detail,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
if ($reading->light === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => $reading->detail];
}
}
// Edge-trigger: одно письмо, если появились новые красные сервисы.
if ($newlyRed !== []) {
$to = (string) config('services.monitoring.alert_email', 'ops@liderra.ru');
Mail::to($to)->send(new ExternalServiceDownMail($newlyRed, now()));
}
}
/** @return array{0:float,1:float} [red_floor, amber_floor] */
private function floors(string $key): array
{
return match ($key) {
'dadata' => [
(float) config('services.dadata.red_floor_rub', 500),
(float) config('services.dadata.amber_floor_rub', 2000),
],
'yandex_cloud' => [
(float) config('services.yandex_cloud.red_floor_rub', 1000),
(float) config('services.yandex_cloud.amber_floor_rub', 5000),
],
'supplier' => [
(float) config('services.supplier.red_floor_rub', 5000),
(float) config('services.supplier.amber_floor_rub', 15000),
],
'email' => [
(float) config('services.yandex360.red_floor_rub', 500),
(float) config('services.yandex360.amber_floor_rub', 2000),
],
default => [0.0, 0.0],
};
}
}
+36 -39
View File
@@ -100,36 +100,32 @@ final class CsvReconcileJob implements ShouldQueue
'created_at' => now(),
]);
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
$portal->waitReportReady($reportId);
$csv = $portal->downloadReport($reportId);
// Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом
// ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt).
// Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15,
// а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid
// (точная личность лида) исключает фантомы и ловит подмену при совпадении количества.
$delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd);
$totalCsvRows = count($delivered);
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
$csvByKey = [];
foreach ($parser->parse($csv) as $row) {
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
// Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid
// (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен
// числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки —
// unique-violation в норме не наступает (иначе abort транзакции).
$existingVids = [];
$deliveredVids = array_map('intval', array_keys($delivered));
if ($deliveredVids !== []) {
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->whereIn('vid', $deliveredVids)
->pluck('vid')
->each(function ($vid) use (&$existingVids): void {
$existingVids[(int) $vid] = true;
});
}
$totalCsvRows = count($csvByKey);
// Существующие лиды за окно → set ключей phone|project.
$existingKeys = [];
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->where('received_at', '>=', $windowStart)
->select('phone', 'raw_payload')
->orderBy('id')
->chunk(500, function ($leads) use (&$existingKeys): void {
foreach ($leads as $lead) {
$payload = is_string($lead->raw_payload)
? json_decode($lead->raw_payload, true)
: (array) $lead->raw_payload;
$project = (string) ($payload['project'] ?? '');
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
}
});
$missing = array_diff_key($csvByKey, $existingKeys);
// Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял).
$missing = array_diff_key($delivered, $existingVids);
$recoveredCount = 0;
$unparseableCount = 0;
@@ -149,11 +145,18 @@ final class CsvReconcileJob implements ShouldQueue
}
try {
// vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает
// recovery идемпотентным с поздним webhook: SupplierWebhookController при том же
// vid вернёт существующий лид, второй сделки не будет.
$lead = SupplierLead::create([
'vid' => null,
'vid' => $row['vid'],
'platform' => $platform,
'phone' => (string) $row['phone'],
'raw_payload' => $row,
'raw_payload' => [
'project' => $row['project'],
'phone' => (string) $row['phone'],
'vid' => $row['vid'],
],
'received_at' => now(),
'recovered_from_csv_at' => now(),
'source' => 'csv_recovery',
@@ -162,8 +165,10 @@ final class CsvReconcileJob implements ShouldQueue
RouteSupplierLeadJob::dispatch($lead->id);
$recoveredCount++;
} catch (QueryException $e) {
Log::warning('csv_reconcile.lead_insert_failed', [
'phone' => $row['phone'],
// Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом)
// ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен.
Log::info('csv_reconcile.lead_insert_skipped_race', [
'vid' => $row['vid'],
'project' => $row['project'],
'error' => $e->getMessage(),
]);
@@ -256,14 +261,6 @@ final class CsvReconcileJob implements ShouldQueue
}
}
/**
* Ключ дедупа: нормализованный phone + project.
*/
private function dedupKey(string $phone, string $project): string
{
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
@@ -134,6 +134,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
$groups[$key]['projects'][] = $project;
}
// Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе.
/** @var array<string, bool> $activeKeys */
$activeKeys = array_fill_keys(array_keys($groups), true);
// 3. Sync each group
try {
foreach ($groups as $group) {
@@ -192,6 +196,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
}
}
// 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим
// заказы источников, у которых на сегодняшний слепок не осталось ни одного
// активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth).
if (! $aborted) {
$this->deactivateOrphanedOrders($activeKeys);
}
} finally {
$this->recordRunSummary(
startedAt: $startedAt,
@@ -205,6 +216,103 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
/**
* Deactivation (18:00, money-critical owner-reported 2026-07-08, verified on prod live DB):
* гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни
* одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00,
* поэтому единственное окно остановить заказ этот 18:00-прогон ДО снимка. Без этого
* осиротевший заказ оставался включённым (галочка вкл, лимит > 0) поставщик лил лиды и
* списывал за остановленный клиентом проект.
*
* Активность из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active:
* слепок-инвариант проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает
* поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет.
*
* Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей
* (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с
* ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since
* ТОЛЬКО после успешной отправки (иначе ретрай следующим прогоном). Реактивацию при
* возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active).
*
* @param array<string, bool> $activeKeys ключи «signal_type|identifier» активных групп
*/
private function deactivateOrphanedOrders(array $activeKeys): int
{
$paused = 0;
// get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле;
// набор ограничен (активные + ещё-не-погашенные осиротевшие заказы).
$candidates = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->whereNull('inactive_since')
->orderBy('id')
->get();
foreach ($candidates as $sp) {
if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) {
continue; // источник ещё активен в сегодняшнем слепке — не трогаем
}
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
break; // время вышло — недогашенное подберёт следующий прогон
}
$regions = array_map('intval', (array) ($sp->current_regions ?? []));
$workdays = $sp->current_workdays !== null && $sp->current_workdays !== []
? array_map('intval', (array) $sp->current_workdays)
: [1, 2, 3, 4, 5, 6, 7];
$tag = count($regions) === 1
? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0])
: 'РФ';
$dto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $sp->signal_type,
uniqueKey: (string) $sp->unique_key,
limit: max(1, (int) $sp->current_limit),
workdays: $workdays,
regions: $regions,
regionsReverse: false,
status: 'paused',
tag: $tag,
platforms: [$sp->platform],
);
try {
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
} catch (Throwable $e) {
// Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном.
Log::warning('supplier.sync.deactivate_failed', [
'unique_key' => (string) $sp->unique_key,
'platform' => $sp->platform,
'error' => $e->getMessage(),
]);
continue;
}
$sp->forceFill([
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
$paused++;
}
if ($paused > 0) {
Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]);
}
return $paused;
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\AutopodborRun;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Уведомление клиенту, что фоновый подбор конкурентов завершён (клиент не ждёт у экрана
* поставил задачу, работает дальше, получает письмо «готово»).
*/
class AutopodborReadyMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public AutopodborRun $run,
public int $found,
) {}
public function envelope(): Envelope
{
$subject = $this->found > 0
? "Подбор конкурентов готов: {$this->found} — Лидерра"
: 'Подбор конкурентов готов — Лидерра';
return new Envelope(subject: $subject);
}
public function content(): Content
{
return new Content(
markdown: 'mail.autopodbor-ready',
with: [
'found' => $this->found,
'url' => rtrim((string) config('app.url'), '/').'/autopodbor',
],
);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-алерт: один или несколько внешних сервисов впервые перешли в «красный»
* (упали или деньги на исходе). Шлётся из RefreshExternalBalancesJob по edge-trigger.
*/
final class ExternalServiceDownMail extends Mailable
{
use Queueable;
use SerializesModels;
/** @param array<int,array{key:string,detail:string}> $services */
public function __construct(
public readonly array $services,
public readonly CarbonInterface $checkedAt,
) {}
public function envelope(): Envelope
{
$names = implode(', ', array_map(fn ($s) => $s['key'], $this->services));
return new Envelope(subject: 'Лидерра: внешний сервис недоступен / баланс на исходе — '.$names);
}
public function content(): Content
{
return new Content(view: 'emails.external_service_down');
}
}
+26
View File
@@ -8,9 +8,11 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
/**
* Email-уведомление об оплате тарифного счёта (ТЗ §18.5, событие
@@ -31,6 +33,10 @@ class InvoicePaidNotification extends Mailable
public string $amountRub,
public ?string $invoiceNumber,
public ?string $tariffName,
/** Относительный путь PDF-акта на диске 'local' (для вложения). */
public ?string $actPdfPath = null,
/** Номер акта — для имени файла вложения. */
public ?string $actNumber = null,
) {}
public function envelope(): Envelope
@@ -53,4 +59,24 @@ class InvoicePaidNotification extends Mailable
],
);
}
/**
* Вложение: PDF закрывающего документа (Акт), если он сформирован.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
if ($this->actPdfPath === null) {
return [];
}
$name = 'Akt-'.Str::ascii((string) $this->actNumber).'.pdf';
return [
Attachment::fromStorageDisk('local', $this->actPdfPath)
->as($name)
->withMime('application/pdf'),
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborCompetitor extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'search_run_id',
'name',
'description',
'is_federal',
'relevance_pct',
'origin',
'site_url',
'directory_urls',
'phones',
'provenance',
'dedup_key',
'study_run_id',
'studied_at',
'box',
'elements',
'dismissed_actualize_keys',
];
protected $casts = [
'is_federal' => 'bool',
'directory_urls' => 'array',
'elements' => 'array',
'phones' => 'array',
'provenance' => 'array',
'dismissed_actualize_keys' => 'array',
'studied_at' => 'datetime',
'created_at' => 'datetime',
];
public function sources(): HasMany
{
return $this->hasMany(AutopodborSource::class, 'competitor_id');
}
public function searchRun(): BelongsTo
{
return $this->belongsTo(AutopodborRun::class, 'search_run_id');
}
public function studyRun(): BelongsTo
{
return $this->belongsTo(AutopodborRun::class, 'study_run_id');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Событие слияния конкурентов: что во что слили + снимок поглощённых карточек.
* Журнал делает склейку прозрачной и обратимой (вернуть поглощённую карточку из снимка).
*/
class AutopodborMergeEvent extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'user_id',
'survivor_id',
'survivor_name',
'reason',
'absorbed',
'restored',
];
protected $casts = [
'absorbed' => 'array',
'restored' => 'bool',
'created_at' => 'datetime',
];
public function survivor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'survivor_id');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborRun extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'kind',
'status',
'region_code',
'params',
'competitor_id',
'batch_id',
'price_rub_charged',
'balance_transaction_id',
'error_code',
'started_at',
'finished_at',
'progress',
'result',
];
protected $casts = [
'params' => 'array',
'progress' => 'array',
'result' => 'array',
'price_rub_charged' => 'decimal:2',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'created_at' => 'datetime',
];
public function competitors(): HasMany
{
return $this->hasMany(AutopodborCompetitor::class, 'search_run_id');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutopodborSource extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'competitor_id',
'study_run_id',
'signal_type',
'identifier',
'phone_kind',
'phone_type',
'provenance_url',
'provenance_label',
'dedup_key',
'created_project_id',
'box',
'where_found',
'office',
'confirmations',
];
protected $casts = [
'created_at' => 'datetime',
'where_found' => 'array',
'confirmations' => 'integer',
];
public function competitor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id');
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'created_project_id');
}
}
+2
View File
@@ -42,6 +42,8 @@ class BalanceTransaction extends Model
public const TYPE_MIGRATION = 'migration';
public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge';
public $timestamps = false;
protected $fillable = [
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Строка журнала диалога бота (direction: in/out). created_at only — updated_at нет. */
class BotDialog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = ['matched_chunks' => 'array', 'escalated' => 'bool', 'created_at' => 'datetime'];
}
-13
View File
@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Чанк базы знаний бота. search_tsv — generated column, в PHP не трогаем. */
class KnowledgeChunk extends Model
{
protected $guarded = [];
}
+1 -1
View File
@@ -19,6 +19,6 @@ class LegalEntity extends Model
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
'bank_bik', 'bank_corr', 'director_name', 'director_post',
'director_basis', 'vat_mode',
'director_basis', 'vat_mode', 'is_default',
];
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* Счёт на оплату (schema.sql table saas_invoices). RLS по tenant_id.
* Этап 1 «оплата по счёту»: выставляется клиентом, оплачивается банковским
* переводом, отмечается администратором (InvoicePaymentService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $invoice_number
* @property string $payer_type
* @property string|null $payer_name
* @property string|null $payer_inn
* @property string|null $payer_kpp
* @property string|null $payer_address
* @property string|null $payer_email
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property string|null $payment_purpose
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
* @property Carbon|null $expires_at
* @property Carbon|null $paid_at
* @property Carbon|null $cancelled_at
*/
class SaasInvoice extends Model
{
public $timestamps = false;
public const STATUS_DRAFT = 'draft';
public const STATUS_ISSUED = 'issued';
public const STATUS_PAID = 'paid';
public const STATUS_OVERDUE = 'overdue';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'invoice_number',
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
'transaction_id', 'pdf_path', 'status',
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
'expires_at' => 'datetime',
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
/** @return HasMany<SaasInvoiceItem, $this> */
public function items(): HasMany
{
return $this->hasMany(SaasInvoiceItem::class, 'invoice_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Позиция счёта (schema.sql table saas_invoice_items). RLS косвенно через invoice_id.
*
* @property int $id
* @property int $invoice_id
* @property string $name
* @property string|null $okpd2
* @property string $quantity
* @property string $unit
* @property string $price
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
*/
class SaasInvoiceItem extends Model
{
public $timestamps = false;
protected $fillable = [
'invoice_id', 'name', 'okpd2', 'quantity', 'unit',
'price', 'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
];
protected function casts(): array
{
return [
'quantity' => 'decimal:3',
'price' => 'decimal:2',
'amount_net' => 'decimal:2',
'amount_total' => 'decimal:2',
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Закрывающий документ (schema.sql table saas_upd_documents). RLS по tenant_id.
* Для УСН без НДС используем upd_function='ДОП' (передаточный документ без
* счёта-фактуры) формируется как Акт об оказании услуг (ActService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $upd_number
* @property string $upd_function
* @property int|null $correction_for
* @property string $buyer_type
* @property string|null $buyer_name
* @property string|null $buyer_inn
* @property string|null $buyer_kpp
* @property string|null $buyer_address
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property int|null $invoice_id
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
*/
class SaasUpdDocument extends Model
{
public $timestamps = false;
protected $table = 'saas_upd_documents';
public const FUNCTION_DOP = 'ДОП';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'upd_number', 'upd_function', 'correction_for',
'buyer_type', 'buyer_name', 'buyer_inn', 'buyer_kpp', 'buyer_address',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
'invoice_id', 'transaction_id', 'pdf_path', 'status', 'issued_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
];
}
}
-2
View File
@@ -21,7 +21,6 @@ use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -49,7 +48,6 @@ class AppServiceProvider extends ServiceProvider
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
@@ -0,0 +1,226 @@
<?php
namespace App\Providers;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier;
use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer;
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
use App\Services\Autopodbor\Agent\ChannelA\PlaywrightYandexDirectory;
use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher;
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\CompetitorCardEnricher;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\CachingPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher;
use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher;
use App\Services\Autopodbor\Agent\Fetch\LivePageFetcher;
use App\Services\Autopodbor\Agent\Fetch\LocalYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\PlaywrightPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\RoutingBatchFetcher;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\Agent\Study\CompositePhoneReverse;
use App\Services\Autopodbor\Agent\Study\DeepStudyCollector;
use App\Services\Autopodbor\Agent\Study\ParallelPhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverseExpander;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
use App\Services\Autopodbor\Agent\Study\Yandex\PlaywrightYandexCardReader;
use App\Services\Autopodbor\Agent\Study\Yandex\PlaywrightYandexPhoneSearch;
use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\RunProgressChannel;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider
{
public function register(): void
{
// Один канал прогресса на процесс: джоба привязывает его к своему прогону, движок пишет этапы.
$this->app->singleton(RunProgressChannel::class);
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl +
// локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru ИЛИ свой рендер-сервис.
//
// Слой добычи 2ГИС/Яндекс переключается флагом autopodbor.self_render (§4, §12):
// ВКЛ → SelfRenderClient (виртуалка+прокси; 2ГИС отдаёт initialState) + TwoGisJsonReader.
// ВЫКЛ → XfetchClient (xf4) + HtmlTwoGisReader — как сейчас.
// Фетчер и 2ГИС-ридер меняются ПАРОЙ (иначе JSON уйдёт в HTML-парсер и вернёт пусто).
//
// Шаг 1 (поиск): при autopodbor.real_find — ЖИВОЙ движок; без ИИ-ключа отсев/похожесть отключены
// (деградация). Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку.
$this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent {
$selfRender = (bool) config('autopodbor.self_render');
$sr = $selfRender ? $this->selfRenderClient() : null;
// Загрузчик xf4 (нужен всегда для xf4-пути и как база RoutingBatchFetcher под Яндекс).
$xfetch = new XfetchClient(
apiKey: config('services.xfetch.key'),
endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'),
concurrency: (int) config('services.xfetch.concurrency', 4),
);
// Основной загрузчик КАРТОЧЕК: self-render ИЛИ xf4 — обёрнут кэшем (§render_cache).
// Ключ по URL в Redis → шаг 1 и шаг 2 делят ОДИН кэш; кэшируются только карточки
// (/firm/, /maps/org/), поиск/ленты (/search/) идут мимо (passthrough). Флаг ВЫКЛ → как есть.
$pages = $this->wrapCache($sr ?? $xfetch);
// В self-render режиме обёрнутый загрузчик = обёрнутый self-render (тот же экземпляр $pages).
$srCards = $sr !== null ? $pages : null;
$reader = $this->twoGisReader();
// Таймаут 15с (было 45): сайт конкурента отвечает за 1-3с; режем только висяки, не теряя живых.
$siteFetcher = new CurlPlaywrightFetcher(timeout: 15);
$fetcher = new CompositeFetcher(
siteFetcher: $siteFetcher,
directoryFetcher: new XfetchDirectoryFetcher($pages),
);
$deep = config('autopodbor.deep_study')
? new DeepStudyCollector(
xfetch: $pages,
opener: new SiteOpener($siteFetcher),
reverse: config('autopodbor.phone_reverse')
? $this->buildPhoneReverse($pages, $reader)
: null,
reader: $reader,
// self-render: Яндекс-справочник шага 2 — через виртуалку+прокси; иначе (null) движок
// строит дефолт (локальный Playwright). §5.3/§5.7. Загрузчик карточек — обёрнутый кэшем.
yandexDir: $srCards !== null ? new XfetchDirectoryFetcher($srCards, new DirectoryParser) : null,
)
: null;
$liveFind = config('autopodbor.real_find')
? $this->buildLiveFind($xfetch, $srCards, $reader)
: null;
return new RealCompetitorAgent(
$fetcher,
new FakeCompetitorAgent,
liveFind: $liveFind,
deep: $deep,
progress: $app->make(RunProgressChannel::class),
);
});
}
/**
* Обернуть загрузчик КАРТОЧЕК общим кэшем (§render_cache). ВКЛ {@see CachingPageFetcher}
* (карточки /firm/, /maps/org/ в Redis на N дней; поиск/ленты мимо). ВЫКЛ фетчер как есть.
* Ключ по URL любые обёрнутые экземпляры делят один кэш (шаг 1 шаг 2 повторный прогон).
*/
public function wrapCache(BatchPageFetcher $inner): BatchPageFetcher
{
return config('autopodbor.render_cache')
? new CachingPageFetcher($inner, (int) config('autopodbor.render_cache_ttl_days', 5))
: $inner;
}
/** Клиент своего рендер-сервиса (виртуалка). Без endpoint/key деградирует в пусто (§5.6). */
private function selfRenderClient(): SelfRenderClient
{
return new SelfRenderClient(
endpoint: (string) config('services.self_render.endpoint', ''),
key: (string) config('services.self_render.key', ''),
concurrency: (int) config('services.self_render.concurrency', 8),
);
}
/** 2ГИС-ридер под текущий флаг: initialState-JSON (self-render) ИЛИ HTML (xf4). Пара к фетчеру. */
public function twoGisReader(): TwoGisReader
{
return config('autopodbor.self_render') ? new TwoGisJsonReader : new HtmlTwoGisReader;
}
/** Источник ленты Яндекс.Карт: виртуалка+прокси (self-render) ИЛИ локальный Playwright (xf4). */
public function yandexListSource(): YandexListSource
{
if (config('autopodbor.self_render')) {
return new RenderServiceYandexListSource(
(string) config('services.self_render.endpoint', ''),
(string) config('services.self_render.key', ''),
);
}
return new LocalYandexListSource((string) config('autopodbor.node_bin', 'node'));
}
private function buildPhoneReverse(BatchPageFetcher $pages, TwoGisReader $reader): PhoneReverse
{
$gis = new PhoneReverseExpander($pages, $reader);
if (! config('autopodbor.phone_reverse_yandex')) {
return $gis;
}
$node = (string) config('autopodbor.node_bin', 'node');
// Яндекс-разворот: под self_render — виртуалка+прокси (лента + чтение карточки), иначе локальный Playwright.
// Чтение карточек (/maps/org/) под self_render оборачиваем общим кэшем; Playwright (xf4) — нет.
$cardFetcher = config('autopodbor.self_render')
? $this->wrapCache($this->selfRenderClient())
: new PlaywrightPageFetcher($node);
$yandex = new YandexPhoneReverseExpander(
new PlaywrightYandexPhoneSearch($this->yandexListSource()),
new PlaywrightYandexCardReader($cardFetcher),
);
// phone_reverse_parallel: 2ГИС и Яндекс крутятся ОДНОВРЕМЕННО (итог = max, оба источника целы).
// Иначе — последовательный композит (как раньше). По умолчанию ВЫКЛ, откат — флаг в false.
return config('autopodbor.phone_reverse_parallel')
? new ParallelPhoneReverse($gis, $yandex)
: new CompositePhoneReverse([$gis, $yandex]);
}
/**
* Живой движок поиска шага 1 ФИНАЛ v4: АНАЛИЗ (мелкая модель запросы-рубрики) КАНАЛ А (скрейп
* категории 2ГИС с пагинацией карточки) КАНАЛ В (модель ИМЕНА федералов; их САЙТ через EXA)
* сборка (отсев агрегаторов + дедуп + похожесть + DTO). Все ИИ/exa/фетч-клиенты деградируют при пустом
* ключе. Слой 2ГИС/Яндекс self-render ($sr) ИЛИ xf4 ($xfetch), пара с $reader.
*/
private function buildLiveFind(XfetchClient $xfetch, ?BatchPageFetcher $srCards, TwoGisReader $reader): LiveFindCompetitors
{
$http = $this->app->make(HttpFactory::class);
$node = (string) config('autopodbor.node_bin', 'node');
// Скрейп листинга 2ГИС (страницы /search/ — не кэшируются): self-render ИЛИ LivePageFetcher(xfetch).
$catPages = $srCards ?? new LivePageFetcher($xfetch);
// Обогатитель КАРТОЧЕК: self-render (обёрнут кэшем) ИЛИ RoutingBatchFetcher, где 2ГИС-база
// обёрнута кэшем (/firm/ из кэша), а карточки Яндекса — локальный Playwright (кэш не трогаем).
$enrichPages = $srCards ?? new RoutingBatchFetcher($this->wrapCache($xfetch), ['yandex' => new PlaywrightPageFetcher($node)]);
$assembler = new FindCompetitorsAssembler(
new AggregatorFilter($this->app->make(AitunnelAggregatorClassifier::class)),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($this->app->make(AitunnelEmbedder::class)),
);
return new LiveFindCompetitors(
new AitunnelQueryAnalyzer($http),
// 2ГИС: 4 страницы на рубрику (5-я — «хвост» низкоранговых, шум). Кол-во режем на старте.
new CategoryScraper($catPages, $reader, maxPages: 4),
new PlaywrightYandexDirectory($this->yandexListSource()),
new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser),
new ExaSiteFinder($http),
$assembler,
enricher: new CompetitorCardEnricher($enrichPages, new DirectoryParser, $reader),
// Канал прогресса (синглтон): джоба привяжет его к прогону, движок отметит этапы.
progress: $this->app->make(RunProgressChannel::class),
);
}
}
+9 -6
View File
@@ -13,8 +13,11 @@ use InvalidArgumentException;
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
* (App\Console\Commands\AuditRebuildChain).
*
* ADR-018: per-tenant via RLS scope for tenant tables,
* global for BYPASSRLS tables.
* ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц.
* Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая
* balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную
* цепочку (RLS не скоупит его SELECT) per-tenant изоляция ADR-018 никогда не
* была реализована. Verify/rebuild приведены к реальности (partition => '').
*
* columns: list in ordinal_position order from db/schema.sql.
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
@@ -43,7 +46,7 @@ final class AuditChainConfig
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'tenant_operations_log' => [
'columns' => [
@@ -51,7 +54,7 @@ final class AuditChainConfig
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'balance_transactions' => [
'columns' => [
@@ -60,7 +63,7 @@ final class AuditChainConfig
'related_type', 'related_id', 'user_id', 'admin_user_id',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'pd_processing_log' => [
'columns' => [
@@ -68,7 +71,7 @@ final class AuditChainConfig
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'saas_admin_audit_log' => [
'columns' => [
@@ -35,7 +35,7 @@ class RegistrationService
private const MAX_FAILED_ATTEMPTS = 5;
private const START_BALANCE_RUB = '300.00';
private const START_BALANCE_RUB = '1000.00';
public function __construct(private readonly CaptchaVerifier $captcha) {}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
/**
* Граница «это поставщик услуги или площадка-агрегатор?» (§12.6 движка v4). За ней LLM-рассуждение
* (sonar-reasoning-pro и т.п.), а НЕ статический список площадок (он мёртв: все агрегаторы не знаем,
* плодятся). Позволяет фильтровать агрегаторов офлайн на фейке.
*/
interface AggregatorClassifier
{
/**
* true площадка-агрегатор (Авито/Юла/Zoon/Банки.ру…); false поставщик услуги;
* null модель не смогла решить (тогда конкурента НЕ выкидываем, см. {@see AggregatorFilter}).
*/
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool;
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
/**
* Отсев площадок-агрегаторов из кандидатов (§12.6): спрашивает {@see AggregatorClassifier} по каждому.
* Консервативно: выкидываем ТОЛЬКО когда классификатор уверенно сказал «агрегатор» (true). При
* неуверенности (null) или «поставщик» (false) конкурента оставляем, чтобы не потерять настоящего.
*/
final class AggregatorFilter
{
public function __construct(private readonly AggregatorClassifier $classifier) {}
/**
* @param array<int, array{name?:string,site_url?:?string,description?:?string}> $candidates
* @return array<int, array>
*/
public function filter(array $candidates): array
{
$candidates = array_values($candidates);
// Классификатор умеет батч → один запрос на весь список (иначе ~90 запросов по одному, «умирает»).
$verdicts = $this->classifier instanceof BatchAggregatorClassifier && $candidates !== []
? $this->classifier->classifyManyAggregators(array_map(fn (array $c): array => [
'name' => (string) ($c['name'] ?? ''),
'site_url' => $c['site_url'] ?? null,
'description' => $c['description'] ?? null,
], $candidates))
: null;
$out = [];
foreach ($candidates as $i => $c) {
$verdict = $verdicts !== null
? ($verdicts[$i] ?? null)
: $this->classifier->isAggregator(
(string) ($c['name'] ?? ''),
$c['site_url'] ?? null,
$c['description'] ?? null,
);
if ($verdict === true) {
continue; // площадка — не конкурент
}
$out[] = $c;
}
return array_values($out);
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see AggregatorClassifier} через AITUNNEL (OpenAI-совместимый chat, §12.6): спрашивает
* у модели, поставщик ли это услуги или площадка-агрегатор. POST {base}/chat/completions
* {choices:[{message:{content}}]}. Ответ AGGREGATOR true, SUPPLIER false, иначе/без ключа/
* при ошибке null (тогда конкурента НЕ выкидываем консервативно).
*/
final class AitunnelAggregatorClassifier implements AggregatorClassifier, BatchAggregatorClassifier
{
public function __construct(private readonly HttpFactory $http) {}
/**
* Батч отсева агрегаторов: список режем на ПОРЦИИ по aggregator_batch_size (иначе один запрос
* на ~100 фирм долгий и рискует не успеть в таймаут). Каждая порция один запрос с длинным
* таймаутом aggregator_timeout_sec. Пропущенные/непонятные/сбой порции null (консервативно).
*
* @param list<array{name:string,site_url:?string,description:?string}> $companies
* @return list<?bool>
*/
public function classifyManyAggregators(array $companies): array
{
$companies = array_values($companies);
$n = count($companies);
$none = array_fill(0, $n, null);
if ($n === 0) {
return [];
}
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return $none;
}
$batchSize = max(1, (int) ($cfg['aggregator_batch_size'] ?? 40));
$out = $none;
$offset = 0;
foreach (array_chunk($companies, $batchSize) as $chunk) {
foreach ($this->classifyChunk($chunk, $key, $cfg) as $i => $verdict) {
$out[$offset + $i] = $verdict;
}
$offset += count($chunk);
}
return $out;
}
/**
* Одна порция: нумеруем 1..m, один запрос к модели с длинным таймаутом. Ответ вердикты порции.
*
* @param list<array{name:string,site_url:?string,description:?string}> $chunk
* @return list<?bool>
*/
private function classifyChunk(array $chunk, string $key, array $cfg): array
{
$none = array_fill(0, count($chunk), null);
$lines = [];
foreach ($chunk as $i => $c) {
$lines[] = ($i + 1).'. «'.($c['name'] ?? '').'» сайт: '.($c['site_url'] ?: '—').
' описание: '.($c['description'] ?: '—');
}
$prompt = "Список компаний:\n".implode("\n", $lines)."\n\n".
'Для КАЖДОЙ определи: это сам поставщик услуги/товара (SUPPLIER) или площадка-агрегатор/'
.'каталог/маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями '
.'(AGGREGATOR)? Ответ — СТРОГО JSON-массив по номерам, без текста вне него: '
.'[{"n":1,"v":"SUPPLIER"},{"n":2,"v":"AGGREGATOR"}]';
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['aggregator_timeout_sec'] ?? 90))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $this->model($cfg),
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0,
]);
if (! $resp->successful()) {
return $none;
}
return $this->parseVerdicts((string) $resp->json('choices.0.message.content'), $none);
} catch (\Throwable) {
return $none;
}
}
/**
* JSON-массив [{n,v}] из ответа вердикты по индексам (n 1). Пропущенные остаются null.
*
* @param list<?bool> $none болванка нужной длины
* @return list<?bool>
*/
private function parseVerdicts(string $content, array $none): array
{
$start = strpos($content, '[');
$end = strrpos($content, ']');
if ($start === false || $end === false || $end < $start) {
return $none;
}
$arr = json_decode(substr($content, $start, $end - $start + 1), true);
if (! is_array($arr)) {
return $none;
}
$out = $none;
$count = count($none);
foreach ($arr as $row) {
if (! is_array($row) || ! isset($row['n'])) {
continue;
}
$idx = ((int) $row['n']) - 1;
if ($idx < 0 || $idx >= $count) {
continue;
}
$v = mb_strtolower((string) ($row['v'] ?? ''));
if (str_contains($v, 'aggregator')) {
$out[$idx] = true;
} elseif (str_contains($v, 'supplier')) {
$out[$idx] = false;
}
}
return $out;
}
/**
* Модель для отсева агрегаторов: aggregator_model (посильнее, вариант А) фолбэк на общий
* chat_model gpt-4o-mini. Отдельный ключ, чтобы усилить точность классификации, не трогая
* остальные ИИ-вызовы.
*
* @param array<string,mixed> $cfg
*/
private function model(array $cfg): string
{
$m = (string) ($cfg['aggregator_model'] ?? '');
return $m !== '' ? $m : (string) ($cfg['chat_model'] ?? 'gpt-4o-mini');
}
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
{
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return null;
}
$prompt = "Компания: «{$name}». Сайт: ".($siteUrl ?: '—').'. Описание: '.($description ?: '—').'. '
.'Это сам поставщик услуги/товара (ответь SUPPLIER) или площадка-агрегатор/каталог/'
.'маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями '
.'(ответь AGGREGATOR)? Ответь РОВНО одним словом: SUPPLIER или AGGREGATOR.';
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $this->model($cfg),
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0,
]);
if (! $resp->successful()) {
return null;
}
$content = mb_strtolower((string) $resp->json('choices.0.message.content'));
if (str_contains($content, 'aggregator')) {
return true;
}
if (str_contains($content, 'supplier')) {
return false;
}
return null;
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Aggregator;
/**
* Батч-версия {@see AggregatorClassifier}: классифицирует ВЕСЬ список за один запрос к модели,
* а не по одному (иначе на ~90 фирм 90 последовательных запросов, «умирает»). Классификаторы,
* умеющие батч, реализуют этот интерфейс; {@see AggregatorFilter} использует его, если он есть.
*/
interface BatchAggregatorClassifier
{
/**
* @param list<array{name:string,site_url:?string,description:?string}> $companies
* @return list<?bool> вердикты по индексам входа: true агрегатор, false поставщик, null не решил
*/
public function classifyManyAggregators(array $companies): array;
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see QueryAnalyzer} через AITUNNEL: мелкая chat-модель разбивает описание клиента на короткие
* запросы-рубрики для скрейпа категории справочников (шаг АНАЛИЗ канала А).
*
* ⚠️ Промт ВОССТАНОВЛЕН (оригинал жил в самоочистившемся collectA.js) на живом прогоне точим.
* Нет ключа / ошибка / пустой разбор fallback: один запрос = само описание (канал А не мёртв).
*/
final class AitunnelQueryAnalyzer implements QueryAnalyzer
{
private const MAX_QUERIES = 8;
public function __construct(private readonly HttpFactory $http) {}
public function analyze(string $description, string $region, string $selfSite = '', array $competitorExamples = []): array
{
$description = trim($description);
$fallback = $description !== '' ? [$description] : [];
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '' || $description === '') {
return $fallback;
}
// Контекст для модели: наш сайт + сайты/карточки конкурентов из запроса (условно — если переданы).
$context = $this->contextClause($selfSite, $competitorExamples);
// Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой
// сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента. Требуем УЗКИЕ категории и
// запрещаем зонтичные слова («финансовые услуги» и т.п.) — иначе канал А тащит не тех (юрфирмы, банки).
$prompt = "Описание деятельности клиента: «{$description}». {$context}Регион: {$region}.\n".
'Дай от 3 до 6 КОРОТКИХ запросов-рубрик, по которым в справочниках 2ГИС и Яндекс.Карты '.
"ищут именно ПРЯМЫХ конкурентов клиента — как называется его УЗКАЯ категория.\n".
'Правило: рубрики только КОНКРЕТНЫЕ и узкие. ЗАПРЕЩЕНЫ общие «зонтичные» слова, которые '.
'тащат кого попало: «услуги», «финансовые услуги», «финансы», «медицина», «товары», «магазин», '.
"«сервис», «компания». Каждая рубрика — точная специализация, а не сфера целиком.\n".
'Примеры (узкое, НЕ зонтичное): стоматология → «стоматология», «имплантация зубов» (НЕ «медицина»); '.
'автосервис → «шиномонтаж», «автосервис» (НЕ «услуги»); займы под залог авто → «автоломбард», '.
'«займ под ПТС» (НЕ «финансовые услуги»). Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '.
'Только сами запросы, без пояснений, без названий фирм и без города. '.
'Ответ — строго JSON-массив строк: ["...","..."]';
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $cfg['chat_model'] ?? 'gpt-4o-mini',
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0,
]);
if (! $resp->successful()) {
return $fallback;
}
$queries = $this->parseQueries((string) $resp->json('choices.0.message.content'));
return $queries !== [] ? $queries : $fallback;
} catch (\Throwable) {
return $fallback;
}
}
/**
* Контекстная фраза «посмотри наш сайт X, а также наших главных конкурентов условно:
* каждая часть добавляется, только если данные есть. Пусто ''.
*
* @param list<string> $competitorExamples
*/
private function contextClause(string $selfSite, array $competitorExamples): string
{
$selfSite = trim($selfSite);
$examples = array_values(array_filter(array_map(
static fn ($v): string => trim((string) $v),
$competitorExamples,
), static fn (string $v): bool => $v !== ''));
$parts = [];
if ($selfSite !== '') {
$parts[] = "посмотри наш сайт {$selfSite}";
}
if ($examples !== []) {
$parts[] = 'а также наших главных конкурентов '.implode(', ', $examples);
}
return $parts !== [] ? implode(', ', $parts).' ' : '';
}
/**
* JSON-массив строк из сырого ответа: фрагмент `[``]`, дедуп (без регистра), отсев пустых, лимит.
*
* @return list<string>
*/
private function parseQueries(string $raw): array
{
$start = strpos($raw, '[');
$end = strrpos($raw, ']');
if ($start === false || $end === false || $end < $start) {
return [];
}
$decoded = json_decode(substr($raw, $start, $end - $start + 1), true);
if (! is_array($decoded)) {
return [];
}
$out = [];
$seen = [];
foreach ($decoded as $q) {
if (! is_string($q)) {
continue;
}
$q = trim($q);
$k = mb_strtolower($q, 'UTF-8');
if ($q === '' || isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = $q;
if (count($out) >= self::MAX_QUERIES) {
break;
}
}
return $out;
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Лёгкий разбор страницы категории 2ГИС (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка, БЕЗ захода в карточку).
* Из списка по каждой фирме берём: ИМЯ + ссылку на карточку (firm/<id>) + САЙТ.
*
* Сайт лежит в редиректе «Перейти на сайт»: `…/<firmId>/null/<hash>?http://site.ru…` id в пути = id
* фирмы, настоящий адрес после `?`. Мусор (агрегаторы/мессенджеры/трекеры/иностранные TLD) отсеиваем
* это не сайт фирмы. Телефоны/описание тут НЕ трогаем (это шаг 2). Чистый: на вход HTML, наружу не ходит.
*/
final class CategoryListingParser
{
/** Не сайт фирмы: агрегаторы/каталоги/соцсети/мессенджеры/трекеры. */
private const BLACKLIST = [
'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', 'flamp.ru', 'orgpage.ru',
'max.ru', 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'wa.me', 'facebook.com', 'instagram.com',
'youtube.com', 'dzen.ru', 'rambler.ru', 'mail.ru', 'tns-counter.ru', 'top100.ru',
'serving-sys.ru', 'otello.ru', 'russpass.ru', 'gosuslugi.ru',
];
private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md', '.com.tr'];
/**
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function parse(string $html): array
{
$sites = $this->siteMap($html);
$out = [];
$seen = [];
$re = '#<a href="(/[a-z0-9_-]+/firm/(\d+))[^"]*"\s+class="_1rehek"><span class="_lvwrwt"><span>([^<]+)</span>#u';
if (preg_match_all($re, $html, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$id = $hit[2];
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$name = trim(html_entity_decode($hit[3], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($name === '') {
continue;
}
$out[] = [
'name' => $name,
'card_url' => 'https://2gis.ru'.$hit[1],
'site' => $sites[$id] ?? null,
];
}
}
return $out;
}
/**
* firmId домен сайта из редиректов «Перейти на сайт». Первый «настоящий» на фирму.
*
* @return array<string,string>
*/
private function siteMap(string $html): array
{
$sites = [];
if (preg_match_all('#/(\d{10,})/null/[^"?]*\?(https?://[^"\\\\?]+)#', $html, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$id = $hit[1];
if (isset($sites[$id])) {
continue;
}
$domain = $this->domain($hit[2]);
if ($domain !== null && $this->isRealSite($domain)) {
$sites[$id] = $domain;
}
}
}
return $sites;
}
private function domain(string $url): ?string
{
$url = mb_strtolower(trim($url), 'UTF-8');
$url = preg_replace('#^https?://#', '', $url);
$url = preg_replace('#^www\.#', '', $url);
$url = explode('/', $url)[0];
$url = explode('?', $url)[0];
return $url !== '' ? $url : null;
}
private function isRealSite(string $domain): bool
{
if (in_array($domain, self::BLACKLIST, true)) {
return false;
}
foreach (self::FOREIGN_TLD as $tld) {
if (str_ends_with($domain, $tld)) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
/**
* Канал А (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка БЕЗ захода в карточку): по каждому запросу-рубрике
* из шага АНАЛИЗ скрейпим категорию 2ГИС со СКВОЗНОЙ ПАГИНАЦИЕЙ всех страниц и берём из СПИСКА по
* фирме имя + ссылку на карточку + сайт (через {@see CategoryListingParser}). Карточки не открываем
* это резко быстрее. Дедуп между страницами и запросами по ссылке карточки. Остановка: пустая
* страница / страница без новых фирм / лимит maxPages.
*
* Загрузка страниц ПАРАЛЛЕЛЬНАЯ ПОПЕРЁК РУБРИК (страница P всех активных рубрик за один
* {@see BatchPageFetcher::htmlBatch()} вызов). Параллельность живёт внутри htmlBatch (режет на
* порции по services.xfetch.concurrency). Если передан простой PageFetcher (тестовый стаб без
* batch) деградирует на одиночные html() вызовы, не ломая существующие тесты.
*/
final class CategoryScraper
{
public function __construct(
private readonly PageFetcher $pages,
// Разбор списка 2ГИС: HtmlTwoGisReader (xf4) ИЛИ TwoGisJsonReader (self-render) — пара к фетчеру.
private readonly TwoGisReader $reader,
private readonly int $maxPages = 5,
) {}
/**
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function collectTwoGis(string $slug, array $queries): array
{
$seen = [];
$out = [];
// Нормализуем и фильтруем пустые рубрики
$activeQueries = [];
foreach ($queries as $query) {
$query = trim((string) $query);
if ($query !== '') {
$activeQueries[] = $query;
}
}
if ($activeQueries === []) {
return [];
}
// Базовые URL для каждой рубрики (страница 1)
$baseUrls = [];
foreach ($activeQueries as $query) {
$baseUrls[$query] = "https://2gis.ru/{$slug}/search/".rawurlencode($query);
}
// active — список рубрик, которые ещё «живы» (дали новые фирмы на предыдущей странице)
$active = $activeQueries;
for ($page = 1; $page <= max(1, $this->maxPages); $page++) {
if ($active === []) {
break;
}
// Строим URL текущей страницы для каждой активной рубрики
$urlByQuery = [];
foreach ($active as $query) {
$base = $baseUrls[$query];
$urlByQuery[$query] = $page === 1 ? $base : $base."/page/{$page}";
}
// Загружаем пачку: BatchPageFetcher — параллельно; простой PageFetcher — по одному
$htmlByUrl = $this->fetchBatch(array_values($urlByQuery));
// Разбираем каждую рубрику; те, что дали 0 новых — выбывают
$stillActive = [];
foreach ($active as $query) {
$url = $urlByQuery[$query];
$html = $htmlByUrl[$url] ?? '';
$rows = $this->reader->listing($html);
if ($rows === []) {
// Пустая страница — рубрика выбывает
continue;
}
$added = 0;
foreach ($rows as $row) {
$key = $row['card_url'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
$added++;
}
if ($added > 0) {
$stillActive[] = $query; // есть новые — рубрика живёт
}
// $added === 0: страница без новых фирм → рубрика тоже выбывает
}
$active = $stillActive;
}
return $out;
}
/**
* Загружает HTML нескольких URL. Если fetcher реализует BatchPageFetcher делегирует
* в htmlBatch() (параллельно, порциями по лимиту сервиса). Иначе цикл html() по одному
* (сохраняет совместимость с простыми PageFetcher-стабами в тестах).
*
* @param list<string> $urls
* @return array<string, string> url => html
*/
private function fetchBatch(array $urls): array
{
if ($this->pages instanceof BatchPageFetcher) {
return $this->pages->htmlBatch($urls);
}
$result = [];
foreach ($urls as $url) {
$result[$url] = $this->pages->html($url);
}
return $result;
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Fetch\LocalYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
/**
* Живой {@see YandexDirectory}: по каждой рубрике рендерит ленту `yandex.ru/maps/?text=<запрос> <город>`,
* скроллит и берёт организации (имя + карточка). Сам рендер за {@see YandexListSource}: локальный
* Playwright (xf4-путь) ИЛИ виртуалка+прокси (self-render). Дедуп по SLUG в {@see YandexListParser}.
*
* Источник за интерфейсом оркестрация (URL по рубрике, потолок, слияние) тестируется офлайн фейком.
*/
final class PlaywrightYandexDirectory implements YandexDirectory
{
public function __construct(
private readonly YandexListSource $source = new LocalYandexListSource,
// Берём ВСЕ рубрики (аналитик отдаёт до 8): каждая рубрика первой даёт самые релевантные фирмы.
private readonly int $maxQueries = 8,
// Сколько раз прокрутить ленту в каждой рубрике. Мало = быстро (верх выдачи = самые релевантные).
private readonly int $scrolls = 6,
private readonly YandexListParser $parser = new YandexListParser,
) {}
public function collect(string $city, array $queries): array
{
// Сырьё по всем рубрикам; дедуп/опознание фирмы по SLUG — в парсере («Корунд ×15 → один»).
$orgs = [];
foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) {
$query = trim((string) $query);
if ($query === '') {
continue;
}
$url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city));
foreach ($this->source->fetch($url, $this->scrolls) as $org) {
$orgs[] = $org;
}
}
return $this->parser->parse($orgs);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Шаг АНАЛИЗ канала А: из описания деятельности клиента НЕСКОЛЬКО коротких запросов-рубрик для
* скрейпа категории 2ГИС/Яндекса (ZAFIKSIROVANO §0-БИС / §12.7 в тесте Омеги было ~6 запросов).
* Реализация решает, какой моделью (по умолчанию мелкая chat-модель). За границей для офлайн-теста.
*/
interface QueryAnalyzer
{
/**
* @param string $selfSite сайт клиента из запроса на поиск (пусто не добавляем в промт)
* @param list<string> $competitorExamples сайты/карточки конкурентов из запроса (пусто не добавляем)
* @return list<string> короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»).
*/
public function analyze(string $description, string $region, string $selfSite = '', array $competitorExamples = []): array;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Второй справочник канала А Яндекс.Карты (ZAFIKSIROVANO: Яндекс = имя + карточка, БЕЗ сайта;
* сайт в списке Яндекса не отдаётся, только на карточке не открываем). Собирает по запросам-рубрикам
* организации: имя + ссылка на карточку. За границей для офлайн-теста оркестратора через фейк.
*/
interface YandexDirectory
{
/**
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
* @return list<array{name:string,card_url:string}>
*/
public function collect(string $city, array $queries): array;
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Разбор списка организаций Яндекс.Карт (канал А). Дедуп по SLUG коду фирмы от самого Яндекса
* из адреса страницы `/maps/org/<slug>/<id>`, а НЕ по id филиала и НЕ по тексту имени.
*
* Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой
* поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 один» и склеивает разные написания
* одной фирмы («КрасЛомбард»/«Красломбард» slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим
* именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника,
* а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит.
*/
final class YandexListParser
{
/**
* @param list<array<string,mixed>> $orgs сырые организации из render-скрипта (name, href, category, ...)
* @return list<array{name:string,card_url:string,slug:string,description:?string}>
*/
public function parse(array $orgs): array
{
$seen = [];
$out = [];
foreach ($orgs as $org) {
$href = trim((string) ($org['href'] ?? ''));
$name = trim((string) ($org['name'] ?? ''));
if ($name === '' || ! preg_match('#^/maps/org/([a-z0-9_-]+)/(\d+)#i', $href, $m)) {
continue;
}
$slug = mb_strtolower($m[1], 'UTF-8');
if (isset($seen[$slug])) {
continue; // одна фирма (slug) = одна строка; филиалы схлопнуты
}
$seen[$slug] = true;
// Рубрика из списка Яндекса («Ломбард, автоломбард», «Микрофинансовая организация») = описание.
$category = trim((string) ($org['category'] ?? ''));
$out[] = [
'name' => $name,
'card_url' => 'https://yandex.ru'.$href,
'slug' => $slug,
'description' => $category !== '' ? $category : null,
];
}
return $out;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see ResearcherClient} через AITUNNEL (OpenAI-совместимый chat). ФИНАЛ (ZAFIKSIROVANO §0-БИС):
* ОДНА рассуждающая модель (`research_model`, по умолчанию sonar-reasoning-pro) с веб-поиском, temperature:0.
* POST {base}/chat/completions [system, user] {choices:[{message:{content}}]}.
* Нет ключа / ошибка сети возвращаем '[]' (движок деградирует, не падает; канал В даст 0 имён).
*/
final class AitunnelResearcher implements ResearcherClient
{
public function __construct(private readonly HttpFactory $http) {}
public function research(string $system, string $user): string
{
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return '[]';
}
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['research_timeout_sec'] ?? 120))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => $cfg['research_model'] ?? 'sonar-reasoning-pro',
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
'temperature' => 0,
]);
if (! $resp->successful()) {
return '[]';
}
$content = (string) $resp->json('choices.0.message.content');
return $content !== '' ? $content : '[]';
} catch (\Throwable) {
return '[]';
}
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Канал В (федералы/онлайн) генератор ИМЁН моделью, ФИНАЛ владельца (ZAFIKSIROVANO §0-БИС + §11.3):
* На вход ИИ даём СПИСОК ИЗ КАНАЛА А (фирмы, уже найденные в справочниках) + примеры клиента
* как «уже известных, не повторять».
* ИИ выдаёт ТОЛЬКО НАЗВАНИЯ новых конкурентов (+ тип), которых в списке нет. Без сайтов/карточек.
* ОДНА модель × 2 прохода: проход 2 получает стоп-лист = известные + найденное в проходе 1.
* Сайт каждого имени добывает ПОТОМ EXA (нет сайта нет якоря имя выкидывается).
*
* Дедуп по имени (ё→е, без не-букв/цифр). Модель за границей {@see ResearcherClient} (офлайн-тест).
*/
final class ChannelBSearch
{
/** Промт канала В: ТОЛЬКО НАЗВАНИЯ+тип федералов, ведущих деятельность в регионе. Сайт находит EXA. */
public const SYSTEM_PROMPT = <<<'TXT'
Ты поисковик конкурентов для нашей компании. Используй интернет и справочники. Дай список НАЗВАНИЙ реальных компаний-конкурентов ведущих свою деятельность в указанном регионе федеральных игроков в этой сфере чем больше реальных, тем лучше.
Дай ТОЛЬКО НАЗВАНИЕ и ТИП каждой фирмы. Сайты и ссылки НЕ нужны их мы найдём сами. Не выдумывай фирмы ради объёма; лучше меньше, но реальные.
В запросе дан список уже известных НЕ повторяй их.
ФОРМА строго JSON-массив, без текста вне него: [{"name":"Название","type":"федеральная"}]
TXT;
public function __construct(
private readonly ResearcherClient $client,
private readonly ResearcherParser $parser,
) {}
/**
* @param list<string> $known имена, уже известные (список из канала А + примеры клиента)
* @return list<array{name:string,type:?string}> только НОВЫЕ фирмы (имя + тип; сайт найдёт EXA)
*/
public function harvest(string $profile, string $region, string $clientSite, array $known, int $passes = 2): array
{
$accumulated = [];
$seen = [];
$stop = [];
foreach ($known as $name) {
$name = trim((string) $name);
if ($name === '') {
continue;
}
$k = $this->key($name);
if (isset($seen[$k])) {
continue; // чистим стоп-лист от дублей-филиалов: «Корунд» ×27 → один
}
$seen[$k] = true;
$stop[] = $name;
}
for ($pass = 1; $pass <= max(1, $passes); $pass++) {
$user = $this->userPrompt($profile, $region, $clientSite, $stop);
$raw = $this->client->research(self::SYSTEM_PROMPT, $user);
foreach ($this->parser->parse($raw) as $cand) {
$k = $this->key($cand['name']);
if (isset($seen[$k])) {
continue; // уже известен (канал А, пример или прошлый проход)
}
$seen[$k] = true;
$accumulated[] = $cand;
$stop[] = $cand['name']; // стоп-лист растёт к следующему проходу
}
}
return $accumulated;
}
/** Пользовательский промт §11.3: профиль/регион/сайт клиента + список известных имён. */
private function userPrompt(string $profile, string $region, string $clientSite, array $stop): string
{
$list = $stop === []
? '(пока пусто)'
: implode("\n", array_map(static fn (string $n): string => '- '.$n, $stop));
return "Наша компания: {$profile} в {$region}. Наш сайт: {$clientSite}.\n".
"Уже известные конкуренты — НЕ выводить их повторно:\n".
"{$list}\n".
"Дай ТОЛЬКО НОВЫЕ фирмы-конкуренты в {$region}, которых нет в списке — только название и тип. Ответ — строго в формате из системного промта.";
}
/** Ключ дедупа по имени: ё→е, нижний регистр, только буквы/цифры. */
private function key(string $name): string
{
$name = str_replace('ё', 'е', mb_strtolower($name, 'UTF-8'));
return (string) preg_replace('/[^\p{L}\p{N}]+/u', '', $name);
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\Response;
/**
* Нормализация канала В (ZAFIKSIROVANO / §11.411.5): у федерала НЕТ карточки в 2ГИС/Яндексе на регион,
* поэтому его САЙТ ищем по имени через EXA. Берём домен первого «настоящего» результата, отсеивая
* каталоги/агрегаторы/реестры/отзовики и иностранные TLD (это НЕ сайт самой фирмы).
*
* POST {base}/search {query, numResults} (header x-api-key) {results:[{url}]}. Нет ключа/ошибка/
* нет подходящих null (тогда у имени из В сайта не будет; резолвер решит «нет филиала»).
*/
final class ExaSiteFinder
{
/** Домены-каталоги/агрегаторы/реестры/карты/отзовики — НЕ сайт фирмы (§11.4). */
private const BLACKLIST = [
'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', '1000bankov.ru',
'yandex.ru', 'ya.ru', '2gis.ru', 'flamp.ru', 'orgpage.ru', 'rusprofile.ru',
'list-org.com', 'sbis.ru', 'spark-interfax.ru', 'otzovik.com', 'irecommend.ru',
'vk.com', 'ok.ru', 'instagram.com', 'facebook.com', 't.me', 'telegram.me',
'wikipedia.org', 'youtube.com', 'dzen.ru', 'zen.yandex.ru',
];
/** Иностранные TLD — не сайт российской фирмы (§11.4). */
private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md'];
public function __construct(private readonly HttpFactory $http) {}
public function findSite(string $name, string $region): ?string
{
$cfg = (array) config('services.exa');
$key = (string) ($cfg['key'] ?? '');
if ($key === '' || trim($name) === '') {
return null;
}
try {
$resp = $this->http
->withHeaders(['x-api-key' => $key])
->withOptions($this->proxyOptions())
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', $this->body($name));
return $this->pickDomain($resp);
} catch (\Throwable) {
return null;
}
}
/**
* Пакетный поиск сайтов ПАРАЛЛЕЛЬНО пулом Laravel Http, порциями по services.exa.concurrency
* (внутри порции одновременно, порции по очереди). Лимит EXA на КЛЮЧ (один на всех клиентов),
* поэтому не «все разом», а бережно; глобальный потолок между клиентами держит очередь.
* Сбой/429 по имени его сайт null (не роняет пул и не теряет остальных).
*
* @param list<string> $names
* @return array<string, ?string> имя домен (или null)
*/
public function findSites(array $names, string $region): array
{
$names = array_values(array_unique(array_filter(
array_map(static fn ($n): string => trim((string) $n), $names),
static fn (string $n): bool => $n !== '',
)));
$out = [];
foreach ($names as $n) {
$out[$n] = null;
}
if ($names === []) {
return $out;
}
$cfg = (array) config('services.exa');
$key = (string) ($cfg['key'] ?? '');
if ($key === '') {
return $out;
}
$url = rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search';
$timeout = (int) ($cfg['timeout_sec'] ?? 30);
$concurrency = max(1, (int) ($cfg['concurrency'] ?? 5));
foreach (array_chunk($names, $concurrency) as $chunk) {
try {
$responses = $this->http->pool(fn ($pool) => array_map(
fn (string $n) => $pool->as($n)
->withHeaders(['x-api-key' => $key])
->withOptions($this->proxyOptions())
->timeout($timeout)
->post($url, $this->body($n)),
$chunk,
));
} catch (\Throwable) {
continue; // весь чанк не удался — имена остаются null, идём к следующему
}
foreach ($chunk as $n) {
$out[$n] = $this->pickDomain($responses[$n] ?? null);
}
}
return $out;
}
/** Тело запроса EXA по имени. */
private function body(string $name): array
{
return ['query' => $name.' официальный сайт', 'numResults' => 10, 'type' => 'auto'];
}
/**
* Опции Guzzle для запроса EXA: прокси (services.exa.proxy), если задан. EXA за Cloudflare
* режет российские IP, поэтому на боевом канал В ходит через зарубежный выход. Пусто напрямую.
*
* @return array<string, mixed>
*/
private function proxyOptions(): array
{
$proxy = trim((string) (config('services.exa.proxy') ?? ''));
return $proxy !== '' ? ['proxy' => $proxy] : [];
}
/** Первый «настоящий» домен из ответа EXA (не каталог/агрегатор/иностранный TLD), иначе null. */
private function pickDomain(mixed $resp): ?string
{
if (! $resp instanceof Response || ! $resp->successful()) {
return null;
}
foreach ((array) $resp->json('results') as $r) {
$url = is_array($r) ? (string) ($r['url'] ?? '') : '';
$domain = $this->domain($url);
if ($domain !== null && $this->isRealSite($domain)) {
return $domain;
}
}
return null;
}
/** Голый домен из URL: без схемы/www/пути, нижний регистр. */
private function domain(string $url): ?string
{
$url = mb_strtolower(trim($url), 'UTF-8');
if ($url === '') {
return null;
}
$url = preg_replace('#^https?://#', '', $url);
$url = preg_replace('#^www\.#', '', $url);
$url = explode('/', $url)[0];
$url = explode('?', $url)[0];
return $url !== '' ? $url : null;
}
/** Настоящий сайт фирмы — не из чёрного списка и не иностранный TLD. */
private function isRealSite(string $domain): bool
{
if (in_array($domain, self::BLACKLIST, true)) {
return false;
}
foreach (self::FOREIGN_TLD as $tld) {
if (str_ends_with($domain, $tld)) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Граница живого канала В: один вызов модели-исследователя (system+user промт сырой текст ответа).
* Финал движка (см. ZAFIKSIROVANO §0-БИС) ОДНА модель, гоняется 2 прохода; реализация решает,
* какую модель шлёт (по умолчанию sonar-reasoning-pro). Логика проходов/стоп-листа за границей,
* в {@see ChannelBSearch}, чтобы тестироваться офлайн через фейк-клиент.
*/
interface ResearcherClient
{
public function research(string $system, string $user): string;
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Канал В (§11.3 + ZAFIKSIROVANO §0-БИС): модель-исследователь даёт ТОЛЬКО НАЗВАНИЯ конкурентов
* (+ тип регион/федерал), без сайтов/карточек/телефонов их добывает ПОТОМ Firecrawl/резолвер.
* Парсер вытаскивает JSON-массив имён из сырого ответа (часто в markdown-обёртке или с текстом вокруг).
* Чистый: на вход строка, наружу не ходит.
*/
final class ResearcherParser
{
/**
* @return list<array{name:string,type:?string}>
*/
public function parse(string $raw): array
{
$out = [];
foreach ($this->decodeArray($raw) as $row) {
if (! is_array($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
continue; // без имени — мусор
}
// ТОЛЬКО имя+тип. Если модель по инерции положит site/card — игнорируем: сайт ищет EXA.
$out[] = [
'name' => $name,
'type' => $this->str($row['type'] ?? null),
];
}
return $out;
}
/**
* Достаёт JSON-массив: фрагмент от первой `[` до последней `]`. Не распарсилось пустой массив.
*
* @return array<int, mixed>
*/
private function decodeArray(string $raw): array
{
$start = strpos($raw, '[');
$end = strrpos($raw, ']');
if ($start === false || $end === false || $end < $start) {
return [];
}
$decoded = json_decode(substr($raw, $start, $end - $start + 1), true);
return is_array($decoded) ? $decoded : [];
}
private function str(mixed $v): ?string
{
if (! is_string($v)) {
return null;
}
$v = trim($v);
return $v !== '' ? $v : null;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
interface CompetitorAgent
{
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
/**
* «Вскрыть всё» на этапе сбора: открываем карточку справочника у КАЖДОГО кандидата с карточкой и без
* сайта, и с сайтом (максимум качества). Из вскрытой карточки берём телефоны + описание всегда, а сайт
* только если его ещё нет (свой сайт из листинга надёжнее, его не затираем). Это нужно для чистой склейки
* дублей (Автодом24 без сайта AutoMoney) и богатого описания. БЕЗ лимита на число карточек открываем
* ВСЕ ПАЧКОЙ (параллельно), если фетчер умеет ({@see BatchPageFetcher}); каждый запрос с таймаутом, поэтому
* зависнуть не может. Кол-во режем на старте (страницы/рубрики), а не гейтом на вскрытии. Единственное
* реальное ограничение частотный лимит самого 2ГИС (карточка вернёт пусто тогда просто пропускаем).
*
* Остальные дубли (по вскрывшемуся при ИЗУЧЕНИИ телефону/адресу) добирает кнопка «Найти дубли».
* Фетчер за границей {@see PageFetcher} тестируется офлайн.
*/
final class CompetitorCardEnricher
{
public function __construct(
private readonly PageFetcher $pages,
private readonly DirectoryParser $parser,
// Разбор карточек 2ГИС: HtmlTwoGisReader (xf4) ИЛИ TwoGisJsonReader (self-render) — пара к
// фетчеру. Яндекс-карточки остаются на $parser->parseYandexCard (свой формат, не 2ГИС).
private readonly TwoGisReader $twoGis = new HtmlTwoGisReader,
) {}
/**
* @param array<int, array> $candidates
* @return array<int, array>
*/
public function enrich(array $candidates): array
{
// Цели = ВСЕ кандидаты, у которых есть карточка справочника (гейта «сайт есть → пропустить»
// больше нет — максимум качества: вскрываем всё, кол-во режем на старте страницами/рубриками).
// idx => первая карточка. Без лимита.
$targets = [];
foreach ($candidates as $idx => $c) {
foreach ($c['directory_urls'] ?? [] as $u) {
$u = trim((string) $u);
if ($u !== '') {
$targets[$idx] = $u;
break;
}
}
}
if ($targets === []) {
return $candidates;
}
// Пачкой (параллельно) если умеет, иначе по одной.
$urls = array_values(array_unique(array_values($targets)));
$htmls = $this->pages instanceof BatchPageFetcher
? $this->pages->htmlBatch($urls)
: $this->fetchSequential($urls);
foreach ($targets as $idx => $url) {
$html = $htmls[$url] ?? '';
if ($html === '') {
continue;
}
$c = $candidates[$idx];
foreach ($this->parseCards($html, $url) as $card) {
if (empty($c['site_url']) && $card->siteUrl !== null && $card->siteUrl !== '') {
$c['site_url'] = $card->siteUrl;
}
if (empty($c['description']) && $card->description !== null && $card->description !== '') {
$c['description'] = $card->description;
}
if ($card->number !== '') {
$c['phones'][] = $card->number;
}
}
$c['phones'] = array_values(array_unique($c['phones'] ?? []));
$candidates[$idx] = $c;
}
return $candidates;
}
/**
* @param list<string> $urls
* @return array<string, string>
*/
private function fetchSequential(array $urls): array
{
$out = [];
foreach ($urls as $u) {
$out[$u] = $this->pages->html($u);
}
return $out;
}
/**
* Разбор карточки по типу справочника: Яндекс parseYandexCard, 2ГИС parse2gisCard.
*
* @return list<DirectoryCard>
*/
private function parseCards(string $html, string $url): array
{
if (str_contains($url, 'yandex')) {
return $this->parser->parseYandexCard($html, $url);
}
if (str_contains($url, '2gis')) {
return $this->twoGis->card($html, $url);
}
return [];
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Dto;
final class CollectedSource
{
/** @param array<int,array{label:string,url:?string}> $sources */
public function __construct(
public readonly string $signalType, // call | site
public readonly string $identifier, // 7XXXXXXXXXX | домен
public readonly ?string $phoneKind, // real | substitute | null
public readonly ?string $phoneType, // city | mobile | tollfree | null
public readonly ?string $office, // подпись филиала | null
public readonly array $sources, // «где нашли»
) {}
public function confirmations(): int
{
return count($this->sources);
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsRequest
{
public function __construct(
public readonly int $regionCode,
public readonly array $examples,
public readonly array $aboutSelf,
public readonly bool $includeFederal,
public readonly int $maxCompetitors,
) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsResult
{
/**
* @param array<int,array{name:string,description?:?string,is_federal?:bool,relevance_pct?:?int,site_url?:?string,directory_urls?:array,provenance?:array}> $competitors
*/
public function __construct(public readonly array $competitors) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class ResolveByNameRequest
{
public function __construct(
public readonly string $name,
public readonly int $regionCode,
) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class ResolveByNameResult
{
/**
* @param array<int,array{name:string,description?:?string,site_url?:?string,directory_urls?:array,provenance?:array}> $candidates
*/
public function __construct(public readonly array $candidates) {}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class StudyCompetitorRequest
{
/**
* @param array{name:string,site_url?:?string,directory_urls?:array} $competitor
*/
public function __construct(
public readonly array $competitor,
public readonly int $regionCode,
) {}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class StudyCompetitorResult
{
/**
* @param array<int,array{signal_type:string,identifier:string,phone_kind?:?string,phone_type?:?string,provenance_url?:?string,provenance_label?:?string,where_found?:array<int,array{label:string,url:?string}>,office?:?string,confirmations?:int}> $sources
*/
public function __construct(public readonly array $sources) {}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class CalltrackingDetector
{
private const PROVIDERS = ['roistat', 'calltouch', 'comagic', 'uiscom', 'mango-office', 'callibri', 'ringostat', 'phonet'];
/** @return list<string> */
public function detect(string $html): array
{
$found = [];
foreach (self::PROVIDERS as $p) {
if (stripos($html, $p) !== false) {
$found[] = $p;
}
}
return array_values(array_unique($found));
}
}
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
use App\Services\Autopodbor\AutopodborNormalizer;
final class CandidateBuilder
{
public function __construct(
private HtmlPhoneScanner $scanner = new HtmlPhoneScanner,
private CalltrackingDetector $detector = new CalltrackingDetector,
private AutopodborNormalizer $norm = new AutopodborNormalizer,
) {}
/**
* @param list<FetchedSite> $sites
* @param list<DirectoryCard> $cards
* @param ?string $defaultAreaCode запасной код города (по региону конкурента)
* для достройки коротких номеров, если на странице нет полных
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
*/
public function build(array $sites, array $cards, ?string $defaultAreaCode = null): array
{
$out = [];
foreach ($sites as $site) {
$scan = $this->scanner->scan($site->rawHtml, $defaultAreaCode);
$hasTracker = $this->detector->detect($site->rawHtml) !== [];
foreach (array_keys($scan['code']) as $number) {
$out[] = [
'number' => (string) $number,
'kind' => 'code',
'label' => 'в коде сайта',
'url' => $site->url,
'office' => null,
'tracker' => $hasTracker,
];
}
// короткие локальные номера, код города которых не удалось определить —
// НЕ теряем, отдаём клиенту с пометкой «требует проверки»
foreach ($scan['uncertain'] ?? [] as $short) {
$out[] = [
'number' => (string) $short,
'kind' => 'uncertain',
'label' => 'локальный номер на сайте — код города не определён, требует проверки',
'url' => $site->url,
'office' => null,
'tracker' => $hasTracker,
];
}
$codeNumbers = array_map('strval', array_keys($scan['code']));
// видимые отрендеренные номера → displayed (подменный), если их нет в коде
$visible = [];
foreach ($site->visiblePhones as $raw) {
$n = $this->normalize($raw);
if ($n === null) {
continue;
}
$visible[] = $n;
if (! in_array($n, $codeNumbers, true)) {
$out[] = [
'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)',
'url' => $site->url, 'office' => null, 'tracker' => $hasTracker,
];
}
}
// пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере
if ($hasTracker) {
foreach ($scan['body'] as $number => $count) {
$number = (string) $number;
if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) {
$out[] = [
'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены',
'url' => $site->url, 'office' => null, 'tracker' => true,
];
}
}
} else {
// без коллтрекинга: номера, написанные на сайте ПРОСТЫМ ТЕКСТОМ (не tel:/schema,
// не отрендеренные) — не теряем, а отдаём клиенту с пометкой «проверьте». На
// верифицированном сайте фирмы это чаще её линии, но без tel:-ссылки за 100%
// настоящие не выдаём. (Число вхождений не важно — номер уже в видимом тексте.)
foreach ($scan['body'] as $number => $count) {
$number = (string) $number;
if (in_array($number, $codeNumbers, true) || in_array($number, $visible, true)) {
continue;
}
$out[] = [
'number' => $number, 'kind' => 'text', 'label' => 'в тексте сайта — проверьте',
'url' => $site->url, 'office' => null, 'tracker' => false,
];
}
}
// номера филиалов со страницы «Контакты» (рендер)
foreach ($site->contactsNumbers as $row) {
$n = $this->normalize($row['number']);
if ($n === null) {
continue;
}
$out[] = [
'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»',
'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker,
];
}
}
foreach ($cards as $card) {
$n = $this->normalize($card->number);
if ($n === null) {
continue;
}
$out[] = [
'number' => $n, 'kind' => 'directory', 'label' => $card->source,
'url' => $card->url, 'office' => $card->office, 'tracker' => false,
];
}
return $out;
}
private function normalize(string $raw): ?string
{
$n = $this->norm->phone($raw);
return (strlen($n) === 11 && $n[0] === '7') ? $n : null;
}
}
@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* Разбор страниц справочников (2ГИС/Яндекс): список филиалов и карточка филиала.
* Чистый: на вход уже отрендеренный HTML, на выход ссылки/карточки.
*/
final class DirectoryParser
{
/**
* Ссылки на карточки филиалов со страницы списка (2ГИС: /city/firm/<id>).
*
* @return list<string>
*/
public function parseBranchList(string $html): array
{
// Путь карточки филиала /<город>/firm/<id> — 2ГИС отдаёт его то как href,
// то внутри JSON-состояния страницы. Берём отовсюду (с дедупом) — иначе на части
// прорисовок филиалы теряются (поймано на живом 2ГИС).
$out = [];
if (preg_match_all('#/[a-z0-9_-]+/firm/\d+#i', $html, $m)) {
foreach ($m[0] as $href) {
if (! in_array($href, $out, true)) {
$out[] = $href;
}
}
}
return $out;
}
/**
* Карточка филиала: телефоны из tel:-ссылок + адрес из заголовка страницы.
*
* @return list<DirectoryCard>
*/
public function parseFirmCard(string $html, string $url, string $source): array
{
$office = $this->officeFromTitle($html);
$out = [];
if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) {
$seen = [];
foreach ($m[1] as $raw) {
$num = trim($raw);
if (isset($seen[$num])) {
continue;
}
$seen[$num] = true;
$out[] = new DirectoryCard(number: $num, office: $office, url: $url, source: $source);
}
}
return $out;
}
/**
* Карточка организации Яндекс.Карт: телефоны + сайт + адрес из заголовка.
*
* Приоритеты парсинга:
* 1. Телефоны из <div class="orgpage-phones-view__phone-number">
* 2. Резервные номера из wa.me-ссылок (если не совпадают по цифрам с уже найденными)
* 3. Сайт из <a class="business-urls-view__link" href="">
* 4. Адрес офиса через officeFromTitle()
*
* Если телефонов нет, но сайт есть одна карточка с number=''.
* Если нет ни телефона, ни сайта [].
*
* @return list<DirectoryCard>
*/
public function parseYandexCard(string $html, string $url): array
{
$office = $this->officeFromTitle($html);
// «Чем занимается» — рубрика из заголовка карточки Яндекса («Имя, РУБРИКА, адрес…, Город»).
$description = $this->categoryFromTitle($html);
// Телефоны из <div class="orgpage-phones-view__phone-number"…>ТЕЛЕФОН</div>
$phones = [];
if (preg_match_all(
'/class="orgpage-phones-view__phone-number"[^>]*>\s*([^<]+?)\s*</',
$html,
$m,
)) {
foreach ($m[1] as $raw) {
$raw = trim($raw);
if ($raw !== '') {
$phones[] = $raw;
}
}
}
// Телефоны из скрытого JSON (Яндекс прячет 2-й и последующие за раскрывашкой,
// но все объекты {"number":"...","type":"phone","value":"..."} лежат в HTML).
// Берём поле value — это чистый номер в международном формате.
if (preg_match_all(
'/"number":"[^"]+","type":"phone","value":"(\+?\d{10,15})"/u',
$html,
$jm,
)) {
foreach ($jm[1] as $jsonNum) {
$jsonDigits = preg_replace('/\D/', '', $jsonNum);
$alreadyHave = false;
foreach ($phones as $existing) {
if (preg_replace('/\D/', '', $existing) === $jsonDigits) {
$alreadyHave = true;
break;
}
}
if (! $alreadyHave) {
$phones[] = $jsonNum;
}
}
}
// Резервные: wa.me/7XXXXXXXXXX — добавляем только если цифры нового
if (preg_match_all('/wa\.me\/(\d{10,15})/i', $html, $wm)) {
foreach ($wm[1] as $digits) {
$alreadyHave = false;
foreach ($phones as $existing) {
if (preg_replace('/\D/', '', $existing) === $digits) {
$alreadyHave = true;
break;
}
}
if (! $alreadyHave) {
$phones[] = '+'.$digits;
}
}
}
// Сайт
$siteUrl = null;
if (preg_match('/class="business-urls-view__link"\s+href="([^"]+)"/i', $html, $sm)) {
$siteUrl = $sm[1];
}
if ($phones === [] && $siteUrl === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(
number: '',
office: $office,
url: $url,
source: 'Яндекс.Карты',
siteUrl: $siteUrl,
description: $description,
)];
}
$out = [];
$seenDigits = [];
foreach ($phones as $raw) {
$digits = preg_replace('/\D/', '', $raw);
if (isset($seenDigits[$digits])) {
continue;
}
$seenDigits[$digits] = true;
$out[] = new DirectoryCard(
number: $raw,
office: $office,
url: $url,
source: 'Яндекс.Карты',
siteUrl: $siteUrl,
description: $description,
);
}
return $out;
}
/**
* Карточка фирмы 2ГИС «вскрыть всё»: сайт + ВСЕ телефоны + описание из карточки.
*
* 2ГИС хранит контакты JSON-массивом объектов:
* - сайт объект {"url":"","type":"website",} берём поле `url` (НЕ `value`:
* там редирект link.2gis.ru, а не настоящий сайт);
* - телефоны объекты {"type":"phone","value":"+7…"} берём `value`;
* - описание «чем занимается» best-effort из og:description (стабильнее хэш-классов 2ГИС).
*
* Мусор-домены (счётчики/трекеры в <script src>) сюда не попадают: сайт берём ТОЛЬКО из
* объекта-контакта website, а не «первый домен на странице».
*
* Если телефонов нет, но сайт есть одна карточка с number=''. Нет ни того, ни другого [].
*
* @return list<DirectoryCard>
*/
public function parse2gisCard(string $html, string $url): array
{
$office = $this->officeFromTitle($html);
// «Чем занимается»: сначала своё описание (og), если оно не заглушка; иначе — рубрика из
// заголовка карточки («Имя, РУБРИКА, адрес…, Город»). Рекламный текст 2ГИС грузится отдельно
// (не в HTML) — его тут нет, рубрика — надёжный минимум.
$description = $this->descriptionFromMeta($html) ?? $this->categoryFromTitle($html);
// Сайт: в объекте-контакте url идёт ДО "type":"website" (порядок ключей url,text,code,type).
// Берём первый такой объект; [^}]*? не даёт «перепрыгнуть» в соседний объект.
$siteUrl = null;
if (preg_match('/"url":"([^"]+)"[^}]*?"type":"website"/u', $html, $sm)) {
$siteUrl = $sm[1];
}
// Телефоны: value у объектов {"type":"phone","value":"+7…"}; дедуп по цифрам.
// ВАЖНО: разбираем ПООБЪЕКТНО (не сквозной регуляркой по всей странице), чтобы отсеять
// РЕКЛАМНЫЕ кнопки-действия. 2ГИС вшивает в карточку рекламу чужих фирм («Связаться с
// менеджером») — это объект с caption/platforms и БЕЗ code/print_text; его value — телефон
// другой компании. Если его взять, чужой номер попадает в контакты фирмы и даёт ложную
// склейку/«объединить?» (живой случай: телефон Айклауда прилетал в Ломбард Бодрый).
$phones = [];
$seenDigits = [];
if (preg_match_all('/\{[^{}]*?"type":"phone"[^{}]*?\}/u', $html, $om)) {
foreach ($om[0] as $obj) {
// Рекламная кнопка-действие, а не контакт фирмы → пропускаем.
if (str_contains($obj, '"platforms"') || str_contains($obj, '"caption"')) {
continue;
}
if (! preg_match('/"value":"(\+?\d{10,15})"/u', $obj, $vm)) {
continue;
}
$digits = preg_replace('/\D/', '', $vm[1]);
if ($digits === '' || isset($seenDigits[$digits])) {
continue;
}
$seenDigits[$digits] = true;
$phones[] = $vm[1];
}
}
if ($phones === [] && $siteUrl === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(
number: '',
office: $office,
url: $url,
source: '2ГИС',
siteUrl: $siteUrl,
description: $description,
)];
}
$out = [];
foreach ($phones as $raw) {
$out[] = new DirectoryCard(
number: $raw,
office: $office,
url: $url,
source: '2ГИС',
siteUrl: $siteUrl,
description: $description,
);
}
return $out;
}
/**
* Описание «чем занимается» из og:description / meta description (best-effort, может быть null).
*/
private function descriptionFromMeta(string $html): ?string
{
if (preg_match('/<meta[^>]+property="og:description"[^>]+content="([^"]+)"/i', $html, $m)
|| preg_match('/<meta[^>]+name="description"[^>]+content="([^"]+)"/i', $html, $m)) {
$d = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return ($d !== '' && ! $this->isJunkDescription($d)) ? $d : null;
}
return null;
}
/**
* Служебная заглушка справочника (не «чем занимается»): «Оценка N, N фото, N отзывов. Вся
* информация об этом месте есть в 2ГИС» и т.п. Такое в описание не пишем это мусор для
* релевантности и отсева агрегаторов.
*/
private function isJunkDescription(string $d): bool
{
$low = mb_strtolower($d, 'UTF-8');
// Упоминает сам справочник (2ГИС/Яндекс) или это блок «оценка … фото … отзыв».
return str_contains($low, '2гис')
|| str_contains($low, '2gis')
|| (str_contains($low, 'фото') && str_contains($low, 'отзыв'))
|| (bool) preg_match('/^оценка\s+\d/u', $low);
}
/**
* Рубрика «чем занимается» из <title> вида «Имя, РУБРИКА, адрес…, Город 2ГИС/Яндекс».
* Рубрика 2-й кусок, НО только если он не похож на адрес (иначе это «Имя, адрес…, Город» без рубрики).
*/
private function categoryFromTitle(string $html): ?string
{
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
return null;
}
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
$title = trim($title);
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
$parts = array_map('trim', explode(',', $title));
if (count($parts) < 3) {
return null; // «Имя, Город» — отдельной рубрики нет
}
$cand = $parts[1] ?? '';
return ($cand !== '' && ! $this->looksLikeAddress($cand)) ? $cand : null;
}
/** Кусок заголовка похож на адрес (улица/проспект/дом), а не на рубрику. */
private function looksLikeAddress(string $s): bool
{
$low = mb_strtolower($s, 'UTF-8');
return (bool) preg_match('/улиц|ул\.|проспект|пр-т|пр\.|переул|пер\.|бульвар|шоссе|набереж|площад|проезд|тракт|\d/u', $low);
}
/**
* Адрес офиса из <title> вида «Имя, адрес…, Город 2ГИС».
* Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем).
*/
private function officeFromTitle(string $html): ?string
{
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
return null;
}
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
// нормализуем пробелы (в т.ч. неразрывные U+00A0) → одиночные
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
$title = trim($title);
// отрезаем хвост « — 2ГИС» / « — Яндекс Карты»
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
$parts = array_map('trim', explode(',', $title));
if (count($parts) < 3) {
return null; // нет адреса между именем и городом
}
// имя фирмы — первый кусок, город — последний; адрес — середина
$address = array_slice($parts, 1, count($parts) - 2);
return implode(', ', $address) ?: null;
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class HtmlPhoneScanner
{
/**
* @param ?string $defaultAreaCode код города из запроса (напр. «391») запасной для
* достройки коротких локальных номеров, если на странице нет полных
* @return array{code: array<string,list<string>>, body: array<string,int>, emails: list<string>, uncertain: list<string>}
*/
public function scan(string $html, ?string $defaultAreaCode = null): array
{
$code = [];
$uncertain = [];
// 1. Собираем сырые значения из ЯВНЫХ телефонных контекстов (tel/schema/microdata).
// Короткие локальные формы (без кода города) здесь безопасны — это точно телефоны.
$rawCandidates = [];
$patterns = [
['/tel:([+0-9()\s-]{6,})/i', 'tel'],
['/"telephone"\s*:\s*"([^"]+)"/i', 'schema'],
['/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', 'microdata'],
];
foreach ($patterns as [$re, $slot]) {
if (preg_match_all($re, $html, $m)) {
foreach ($m[1] as $x) {
$rawCandidates[] = ['raw' => $x, 'slot' => $slot];
}
}
}
// 2. Из полных номеров берём национальные части (10 цифр) — по ним определяем код города.
$national = [];
foreach ($rawCandidates as $c) {
$n = $this->normalizeMaybe($c['raw']);
if ($n !== null) {
$national[] = substr($n, 1);
}
}
$add = function (string $n, string $slot) use (&$code): void {
$code[$n] ??= [];
if (! in_array($slot, $code[$n], true)) {
$code[$n][] = $slot;
}
};
// 3. Разносим: полные — как есть; короткие локальные — достраиваем кодом города.
foreach ($rawCandidates as $c) {
$n = $this->normalizeMaybe($c['raw']);
if ($n !== null) {
$add($n, $c['slot']);
continue;
}
[$status, $value] = $this->classifyShort($c['raw'], $national, $defaultAreaCode);
if ($status === 'built') {
$add((string) $value, $c['slot']);
} elseif ($status === 'uncertain' && ! in_array($value, $uncertain, true)) {
$uncertain[] = (string) $value; // код города не определить — не теряем, к проверке
}
// 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем
}
// Номера в теле берём из ВИДИМОГО текста: вырезаем <script>/<style>, чтобы не тянуть
// технический мусор из конфигов/аналитики. Реальные номера из JSON-LD уже пойманы выше (schema).
$bodyHtml = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $bodyHtml, $m)) {
foreach ($m[0] as $x) {
$n = $this->normalizeMaybe($x);
if ($n !== null) {
$body[$n] = ($body[$n] ?? 0) + 1;
}
}
}
$emails = [];
if (preg_match_all('/([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}/i', $html, $m)) {
foreach ($m[1] as $local) {
$d = preg_replace('/\D+/', '', $local) ?? '';
if (strlen($d) >= 7) {
$emails[] = $d;
}
}
}
return ['code' => $code, 'body' => $body, 'emails' => $emails, 'uncertain' => $uncertain];
}
private function normalizeMaybe(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
return '7'.substr($digits, 1);
}
if (strlen($digits) === 10) {
return '7'.$digits;
}
return null;
}
/**
* Классифицирует короткую (67 цифр) форму номера из явного телефонного контекста.
* Возвращает [статус, значение]:
* - 'built' + 7XXXXXXXXXX локальный номер достроен кодом города;
* - 'fragment' + null обрезок полного номера (страна+код), выкидываем;
* - 'uncertain' + цифры код города не определить, не теряем, помечаем к проверке;
* - 'skip' + null не 67-значная форма, не наше.
* Пример: «271-33-33» при «+7 (391) …» ['built','73912713333'];
* обрезок «+7 (391) 271» (7391271) ['fragment', null].
*
* @param list<string> $national 10-значные национальные части полных номеров страницы
* @param ?string $defaultAreaCode запасной код города из запроса (если полных нет)
* @return array{0:string,1:?string}
*/
private function classifyShort(string $raw, array $national, ?string $defaultAreaCode): array
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
$len = strlen($digits);
if ($len < 6 || $len > 7) {
return ['skip', null];
}
$area = $this->areaCode($national, 10 - $len, $defaultAreaCode);
if ($area === null) {
return ['uncertain', $digits]; // код города не определить
}
// Обрезок полного номера: уже содержит код страны 7/8 + код города (напр. 7391271 = 7·391·271).
// Настоящий локальный (2713333, или московский 7712233 при коде 495) этим не задевается.
if (str_starts_with($digits, '7'.$area) || str_starts_with($digits, '8'.$area)) {
return ['fragment', null];
}
return ['built', '7'.$area.$digits];
}
/**
* Код города нужной длины: самый частый префикс среди полных номеров страницы,
* иначе запасной из запроса. null если не определить.
*
* @param list<string> $national
*/
private function areaCode(array $national, int $prefixLen, ?string $defaultAreaCode): ?string
{
if ($national !== []) {
$counts = [];
foreach ($national as $nat) {
$p = substr($nat, 0, $prefixLen);
$counts[$p] = ($counts[$p] ?? 0) + 1;
}
arsort($counts);
$prefix = (string) array_key_first($counts);
if (strlen($prefix) === $prefixLen) {
return $prefix;
}
}
if ($defaultAreaCode !== null && strlen($defaultAreaCode) === $prefixLen) {
return $defaultAreaCode;
}
return null;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* xf4-путь шва {@see TwoGisReader}: тонкий делегат к нынешним HTML-парсерам. Поведение 1:1 с прямым
* вызовом {@see CategoryListingParser}/{@see DirectoryParser} старые парсеры не трогаются, xf4-путь
* остаётся целым. Развилка HTML↔JSON на уровне провайдера (какой ридер инжектится), не внутри парсеров.
*/
final class HtmlTwoGisReader implements TwoGisReader
{
public function __construct(
private readonly CategoryListingParser $listing = new CategoryListingParser,
private readonly DirectoryParser $directory = new DirectoryParser,
) {}
public function listing(string $payload): array
{
return $this->listing->parse($payload);
}
/** @return list<DirectoryCard> */
public function card(string $payload, string $url): array
{
return $this->directory->parse2gisCard($payload, $url);
}
/** @return list<string> */
public function branchList(string $payload): array
{
return $this->directory->parseBranchList($payload);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class PhoneType
{
/** @param string $p номер в виде 7XXXXXXXXXX */
public static function of(string $p): string
{
$code = substr($p, 1, 3);
if ($code === '800') {
return 'tollfree';
}
if (isset($code[0]) && $code[0] === '9') {
return 'mobile';
}
return 'city';
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
final class SourceAggregator
{
private const TRUSTED = ['code', 'contacts', 'directory', 'email'];
/**
* @param array<int,array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
* @return list<CollectedSource>
*/
public function aggregate(array $candidates): array
{
/** @var array<string,array{sources:array<string,array{label:string,url:?string}>,kinds:list<string>,office:?string,tracker:bool}> $by */
$by = [];
foreach ($candidates as $c) {
$n = $c['number'];
$by[$n] ??= ['sources' => [], 'kinds' => [], 'office' => null, 'tracker' => false];
$by[$n]['kinds'][] = $c['kind'];
$by[$n]['tracker'] = $by[$n]['tracker'] || $c['tracker'];
$by[$n]['office'] ??= $c['office'];
if ($c['kind'] !== 'pool') {
$key = $c['label'];
if (! isset($by[$n]['sources'][$key])) {
$by[$n]['sources'][$key] = ['label' => $c['label'], 'url' => $c['url']];
}
}
}
$out = [];
foreach ($by as $n => $info) {
$n = (string) $n; // PHP приводит числовые ключи массива к int — возвращаем строку
$kinds = $info['kinds'];
$hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds);
$hasDisplayed = in_array('displayed', $kinds, true);
$hasText = in_array('text', $kinds, true);
// короткий локальный номер без определимого кода города — «требует проверки»
$isUncertain = ! $hasTrusted && ! $hasDisplayed && ! $hasText && in_array('uncertain', $kinds, true);
// номер только из текста сайта (не подтверждён tel:/справочником) — показываем «проверьте»
$isTextOnly = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && $hasText;
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && ! $isTextOnly; // только pool-свалка
if ($onlyHidden) {
continue; // пул-свалка — не выводим
}
if ($isUncertain) {
$phoneKind = 'uncertain';
$phoneType = null; // тип не выдумываем — номер ещё не достроен
} elseif ($isTextOnly) {
$phoneKind = 'unverified'; // «на сайте — проверьте»
$phoneType = PhoneType::of($n); // номер полный — тип знаем
} else {
$phoneKind = $hasTrusted ? 'real' : 'substitute';
$phoneType = PhoneType::of($n);
}
$out[] = new CollectedSource(
signalType: 'call',
identifier: $n,
phoneKind: $phoneKind,
phoneType: $phoneType,
office: $info['office'],
sources: array_values($info['sources']),
);
}
usort($out, function (CollectedSource $a, CollectedSource $b): int {
$sa = $a->phoneKind === 'substitute' ? 1 : 0;
$sb = $b->phoneKind === 'substitute' ? 1 : 0;
if ($sa !== $sb) {
return $sa <=> $sb; // подменные — вниз
}
if ($a->confirmations() !== $b->confirmations()) {
return $b->confirmations() <=> $a->confirmations(); // больше подтверждений — выше
}
return strcmp($a->identifier, $b->identifier);
});
return $out;
}
}
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* self-render путь шва {@see TwoGisReader}: разбирает встроенное состояние 2ГИС (`window.initialState`,
* приходит строкой-JSON от рендер-сервиса) в ТОТ ЖЕ контракт, что HTML-парсеры. Карта JSON-путей и
* рецепт рендера docs/superpowers/findings/2026-07-05-2gis-initialstate-spike.md.
*
* Структура: упорядоченный список результатов `data.search.pagination.{searchId}.{page}.data`
* (id фирм по рангу) профили `data.entity.profile.{id}.data` (имя/город/рубрика/сайт/телефоны).
* 🔴 Ходим ТОЛЬКО по pagination-списку, НЕ по ключам profile (там бывают посторонние сущности).
*/
final class TwoGisJsonReader implements TwoGisReader
{
/** Тип «фирма» в 2ГИС — branch (филиал организации). Здания/улицы/маршруты = не фирма, отсекаем. */
private const FIRM_TYPE = 'branch';
/**
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function listing(string $payload): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
$out = [];
foreach ($this->resultIds($state) as $id) {
$d = $this->profileData($state, $id);
if ($d === null || ($d['type'] ?? null) !== self::FIRM_TYPE) {
continue; // не фирма (здание/улица/маршрут) или нет профиля — пропускаем
}
$name = trim((string) ($d['name'] ?? ''));
if ($name === '') {
continue;
}
$out[] = [
'name' => $name,
'card_url' => $this->cardUrl($d, $id),
'site' => $this->siteOf($d),
];
}
return $out;
}
/**
* @return list<DirectoryCard>
*/
public function card(string $payload, string $url): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
// Целевая фирма — по id из URL /firm/{id}; если не нашли — единственный профиль страницы.
$id = null;
if (preg_match('#/firm/(\d+)#', $url, $m)) {
$id = $m[1];
}
$d = $id !== null ? $this->profileData($state, $id) : null;
if ($d === null) {
$profiles = $this->arr($state, ['data', 'entity', 'profile']);
$first = $profiles !== [] ? array_key_first($profiles) : null;
$d = $first !== null ? ($profiles[$first]['data'] ?? null) : null;
}
if (! is_array($d)) {
return [];
}
$site = $this->siteOf($d);
$office = isset($d['address_name']) ? (string) $d['address_name'] : null;
$description = $this->firstRubric($d);
// Телефоны из contact_groups → contacts type=phone → поле value (чистый +7…). Дедуп по цифрам.
$phones = [];
$seen = [];
foreach ($this->contacts($d) as $c) {
if (($c['type'] ?? null) !== 'phone') {
continue;
}
$value = (string) ($c['value'] ?? $c['text'] ?? '');
$digits = preg_replace('/\D/', '', $value) ?? '';
if ($digits === '' || isset($seen[$digits])) {
continue;
}
$seen[$digits] = true;
$phones[] = $value;
}
// Семантика 1:1 с parse2gisCard: нет ни телефона, ни сайта → []; нет телефонов, есть сайт →
// одна карточка с number=''; иначе карточка на каждый телефон (общий сайт/описание/офис).
if ($phones === [] && $site === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(number: '', office: $office, url: $url, source: '2ГИС', siteUrl: $site, description: $description)];
}
$out = [];
foreach ($phones as $raw) {
$out[] = new DirectoryCard(number: $raw, office: $office, url: $url, source: '2ГИС', siteUrl: $site, description: $description);
}
return $out;
}
/**
* @return list<string>
*/
public function branchList(string $payload): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
$out = [];
foreach ($this->resultIds($state) as $id) {
$d = $this->profileData($state, $id);
if ($d === null || ($d['type'] ?? null) !== self::FIRM_TYPE) {
continue;
}
$path = '/'.($d['city_alias'] ?? '').'/firm/'.$id;
if (! in_array($path, $out, true)) {
$out[] = $path;
}
}
return $out;
}
// --- helpers ---
/** @return array<mixed>|null */
private function decode(string $payload): ?array
{
$payload = trim($payload);
if ($payload === '' || $payload[0] !== '{') {
return null;
}
$state = json_decode($payload, true);
return is_array($state) ? $state : null;
}
/**
* Упорядоченный список id результатов: первый searchId все страницы по числовому ключу data.
*
* @param array<mixed> $state
* @return list<string>
*/
private function resultIds(array $state): array
{
$pagination = $this->arr($state, ['data', 'search', 'pagination']);
if ($pagination === []) {
return [];
}
$searchId = array_key_first($pagination);
$pages = is_array($pagination[$searchId] ?? null) ? $pagination[$searchId] : [];
// Страницы по возрастанию номера (payload обычно = одна страница, но подстрахуемся).
$keys = array_keys($pages);
sort($keys, SORT_NATURAL);
$ids = [];
$seen = [];
foreach ($keys as $k) {
foreach (($pages[$k]['data'] ?? []) as $id) {
$id = (string) $id;
if ($id !== '' && ! isset($seen[$id])) {
$seen[$id] = true;
$ids[] = $id;
}
}
}
return $ids;
}
/**
* @param array<mixed> $state
* @return array<mixed>|null
*/
private function profileData(array $state, string $id): ?array
{
$profiles = $this->arr($state, ['data', 'entity', 'profile']);
$d = $profiles[$id]['data'] ?? null;
return is_array($d) ? $d : null;
}
/** @param array<mixed> $d */
private function cardUrl(array $d, string $id): string
{
return 'https://2gis.ru/'.($d['city_alias'] ?? '').'/firm/'.$id;
}
/**
* Сайт из contact_groups contact type=website поле url (НЕ value: там редирект link.2gis.ru).
*
* @param array<mixed> $d
*/
private function siteOf(array $d): ?string
{
foreach ($this->contacts($d) as $c) {
if (($c['type'] ?? null) === 'website') {
$url = (string) ($c['url'] ?? '');
if ($url !== '') {
return $url;
}
}
}
return null;
}
/** @param array<mixed> $d @return list<array<mixed>> */
private function contacts(array $d): array
{
$out = [];
foreach (($d['contact_groups'] ?? []) as $group) {
foreach (($group['contacts'] ?? []) as $c) {
if (is_array($c)) {
$out[] = $c;
}
}
}
return $out;
}
/** @param array<mixed> $d */
private function firstRubric(array $d): ?string
{
foreach (($d['rubrics'] ?? []) as $r) {
$name = trim((string) ($r['name'] ?? ''));
if ($name !== '') {
return $name;
}
}
return null;
}
/**
* @param array<mixed> $state
* @param list<string> $path
* @return array<mixed>
*/
private function arr(array $state, array $path): array
{
$node = $state;
foreach ($path as $key) {
if (! is_array($node) || ! array_key_exists($key, $node)) {
return [];
}
$node = $node[$key];
}
return is_array($node) ? $node : [];
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* Шов разбора 2ГИС: единый контракт поверх «сырья» страницы (HTML от xf4 ИЛИ initialState-JSON
* от своего рендера). Реализации: {@see HtmlTwoGisReader} (xf4-путь, делегирует старым парсерам) и
* {@see TwoGisJsonReader} (self-render, разбирает initialState). Выход по форме идентичен нынешним
* парсерам, поэтому потребители (шаг 1/2, разворот) не замечают разницы источника.
*/
interface TwoGisReader
{
/**
* Список фирм со страницы поиска-рубрики (как {@see CategoryListingParser::parse}).
*
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function listing(string $payload): array;
/**
* Карточка фирмы: телефоны+сайт+описание (как {@see DirectoryParser::parse2gisCard}).
*
* @return list<DirectoryCard>
*/
public function card(string $payload, string $url): array;
/**
* Пути карточек филиалов со страницы списка/поиска (как {@see DirectoryParser::parseBranchList}).
*
* @return list<string>
*/
public function branchList(string $payload): array;
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
final class FakeCompetitorAgent implements CompetitorAgent
{
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
{
return new FindCompetitorsResult([
['name' => 'Окна Комфорт', 'description' => 'Пластиковые окна и остекление балконов под ключ.', 'is_federal' => false, 'relevance_pct' => 100, 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'similar-pages']],
['name' => 'Пластика Окон', 'description' => 'Окна ПВХ, лоджии, входные группы.', 'is_federal' => false, 'relevance_pct' => 96, 'site_url' => 'plastika-okon-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/2'], 'provenance' => ['via' => 'similar-pages']],
['name' => 'Фабрика Окон', 'description' => 'Федеральная сеть окон ПВХ, филиал в регионе.', 'is_federal' => true, 'relevance_pct' => 84, 'site_url' => 'fabrika-okon.ru', 'directory_urls' => ['https://2gis.ru/firm/3'], 'provenance' => ['via' => 'similar-pages']],
['name' => 'Балкон-Сервис 16', 'description' => 'Остекление балконов; окна частично.', 'is_federal' => false, 'relevance_pct' => 61, 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/4', 'https://2gis.ru/firm/4'], 'provenance' => ['via' => 'similar-pages']],
]);
}
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
{
return new StudyCompetitorResult([
['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'],
['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'phone_type' => 'city', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'],
['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'phone_type' => 'mobile', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'],
['signal_type' => 'call', 'identifier' => '88002001122', 'phone_kind' => 'real', 'phone_type' => 'tollfree', 'provenance_url' => 'https://okna-komfort-kzn.ru/contacts', 'provenance_label' => 'бесплатная линия 8-800 на сайте'],
]);
}
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
{
return new ResolveByNameResult([
['name' => $r->name, 'description' => 'Найдено по названию (заглушка).', 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'name-search']],
]);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
/**
* Загрузчик, умеющий тянуть НЕСКОЛЬКО страниц пачкой (параллельно, порциями по лимиту сервиса).
* Отдельный интерфейс поверх {@see PageFetcher}, чтобы обходчик справочников мог опционально
* ускорять сбор карточек (до 25 филиалов) а простые/тестовые загрузчики его не реализуют
* и продолжают работать по одному через {@see PageFetcher::html()}.
*/
interface BatchPageFetcher extends PageFetcher
{
/**
* Достать HTML нескольких страниц. Реализация сама режет на порции по лимиту конкурентности
* и параллелит внутри порции. Порядок не важен возвращается карта url => html.
*
* @param list<string> $urls
* @return array<string, string> url => html (пустая строка при неудаче исключений не кидаем)
*/
public function htmlBatch(array $urls): array;
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Illuminate\Support\Facades\Cache;
/**
* Декоратор над реальным загрузчиком карточек ({@see SelfRenderClient} / {@see XfetchClient}).
* Кэширует ОТРИСОВАННЫЕ карточки в общем хранилище (Redis) по ключу URL на N дней чтобы не
* дёргать 2ГИС/Яндекс/прокси повторно за одним и тем же. Кэш общий для шага 1 (поиск) и шага 2
* (изучение) и для повторных прогонов: ключ по URL в Redis любые экземпляры делят один кэш.
*
* 🔴 Кэшируем ТОЛЬКО карточки (2ГИС `…/firm/…`, Яндекс `…/maps/org/…`). Поиск (`…/search/…`) и
* ленты всегда мимо кэша (passthrough): добывание новых фирм должно оставаться свежим, иначе
* повторный сбор не найдёт новые фирмы. Свежесть ограничивается ТОЛЬКО у уже найденного контента.
*
* 🔴 Пустой ответ НЕ кэшируем разовый сбой (прокси/антибот) не должен отравить кэш на N дней.
*/
final class CachingPageFetcher implements BatchPageFetcher
{
public function __construct(
private BatchPageFetcher $inner,
private int $ttlDays = 5,
) {}
public function html(string $url): string
{
if (! $this->cacheable($url)) {
return $this->inner->html($url);
}
$hit = Cache::get($this->key($url));
if (is_string($hit) && $hit !== '') {
return $hit;
}
$value = $this->inner->html($url);
$this->remember($url, $value);
return $value;
}
/**
* @param list<string> $urls
* @return array<string, string>
*/
public function htmlBatch(array $urls): array
{
$result = [];
$misses = [];
foreach (array_values(array_unique($urls)) as $url) {
if ($this->cacheable($url)) {
$hit = Cache::get($this->key($url));
if (is_string($hit) && $hit !== '') {
$result[$url] = $hit;
continue;
}
}
$misses[] = $url;
}
if ($misses !== []) {
foreach ($this->inner->htmlBatch($misses) as $url => $value) {
$result[$url] = $value;
$this->remember($url, $value);
}
}
return $result;
}
/** Класть в кэш только карточку с НЕПУСТЫМ содержимым. */
private function remember(string $url, string $value): void
{
if ($value !== '' && $this->cacheable($url)) {
Cache::put($this->key($url), $value, now()->addDays($this->ttlDays));
}
}
/** Кэшируем только карточки: 2ГИС `/firm/`, Яндекс `/maps/org/`. Поиск и ленты — нет. */
private function cacheable(string $url): bool
{
return str_contains($url, '/firm/') || str_contains($url, '/maps/org/');
}
private function key(string $url): string
{
return 'autopodbor:render:'.sha1($url);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
/**
* Разводит добычу по двум путям: сайт конкурента через богатый загрузчик
* (curl + локальный Playwright, бесплатно, с видимыми/пул/контактами), справочники
* (2ГИС/Яндекс) через антибот-загрузчик (xfetch). Для движка один {@see Fetcher}.
*/
final class CompositeFetcher implements Fetcher
{
public function __construct(
private Fetcher $siteFetcher,
private Fetcher $directoryFetcher,
) {}
public function site(string $url): FetchedSite
{
return $this->siteFetcher->site($url);
}
public function directory(string $url): array
{
return $this->directoryFetcher->directory($url);
}
}
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use Symfony\Component\Process\Process;
final class CurlPlaywrightFetcher implements Fetcher
{
public function __construct(
private string $nodeBin = 'node',
private string $renderScript = '', // путь к render-page.cjs; по умолчанию — base_path
private int $timeout = 45,
private string $firmScript = '', // путь к render-firm.cjs (карточка справочника с кликом)
private int $maxBranches = 25, // предел обхода филиалов за один сбор
private DirectoryParser $dirParser = new DirectoryParser,
// Сиды (seams) для тестов: подмена curl и запуска node-рендера, без реальной сети/подпроцесса.
// null → боевое поведение. fn(string $url): string и fn(string $script, string $url): ?array.
private ?\Closure $curlRunner = null,
private ?\Closure $nodeRunner = null,
) {
if ($this->renderScript === '') {
$this->renderScript = base_path('scripts/render-page.cjs');
}
if ($this->firmScript === '') {
$this->firmScript = base_path('scripts/render-firm.cjs');
}
}
public function site(string $url): FetchedSite
{
if (! $this->isSafeUrl($url)) {
return new FetchedSite(url: $url, rawHtml: '');
}
$raw = $this->curl($url);
$visible = [];
$contacts = [];
$rendered = $this->render($url);
if ($rendered !== null) {
$visible = $rendered['visiblePhones'] ?? [];
// номера со страницы /contacts добираем отдельным рендером, если есть такая ссылка
$contactsUrl = $this->guessContactsUrl($url, $raw);
if ($contactsUrl !== null && $this->isSafeUrl($contactsUrl)) {
$rc = $this->render($contactsUrl);
if ($rc !== null) {
foreach (($rc['visiblePhones'] ?? []) as $p) {
$contacts[] = ['number' => $p, 'office' => null];
}
}
}
}
return new FetchedSite(url: $url, rawHtml: $raw, visiblePhones: $visible, contactsNumbers: $contacts);
}
public function directory(string $url): array
{
if (! $this->isSafeUrl($url)) {
return [];
}
// 1. Рендерим страницу списка филиалов → ссылки на карточки.
$list = $this->render($url);
if ($list === null) {
return [];
}
$links = $this->dirParser->parseBranchList($list['html'] ?? '');
if ($links === []) {
return [];
}
$origin = parse_url($url);
$base = ($origin['scheme'] ?? 'https').'://'.($origin['host'] ?? '');
$source = stripos((string) ($origin['host'] ?? ''), 'yandex') !== false ? 'Яндекс.Карты' : '2ГИС';
// 2. Обходим карточки филиалов (с пределом), на каждой жмём «показать телефон».
$cards = [];
foreach (array_slice($links, 0, $this->maxBranches) as $href) {
$firmUrl = str_starts_with($href, 'http') ? $href : $base.$href;
if (! $this->isSafeUrl($firmUrl)) {
continue;
}
$firm = $this->renderFirm($firmUrl);
if ($firm === null) {
continue;
}
foreach ($this->dirParser->parseFirmCard($firm['html'] ?? '', $firmUrl, $source) as $card) {
$cards[] = $card;
}
}
return $cards;
}
private function curl(string $url): string
{
if ($this->curlRunner !== null) {
return (string) ($this->curlRunner)($url);
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // защита от SSRF через редирект на внутренний адрес
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiderraBot/1.0)',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
curl_close($ch);
return is_string($body) ? $body : '';
}
/**
* Пускаем только публичные http/https адреса; блокируем loopback, приватные
* и служебные диапазоны (защита от SSRF заход на внутренние сервисы).
*/
private function isSafeUrl(string $url): bool
{
$parts = parse_url($url);
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
return false;
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = $parts['host'];
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
if ($ips === []) {
return false;
}
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false; // приватный/служебный/loopback — не ходим
}
}
return true;
}
/** @return array{html:string,visiblePhones:list<string>}|null */
private function render(string $url): ?array
{
// Рендер — НЕ фатальный: если node висит/падает по таймауту (Playwright ждёт networkidle
// на «шумном» сайте), Symfony Process бросает ProcessTimedOutException. Гасим его здесь,
// чтобы уже добытый curl-HTML (с номерами «в коде сайта») не потерялся. Возврат null =
// «видимых/рендер-номеров нет», обычные номера из curl остаются.
try {
return $this->runNode($this->renderScript, $url);
} catch (\Throwable) {
return null;
}
}
/** @return array{html:string}|null */
private function renderFirm(string $url): ?array
{
try {
return $this->runNode($this->firmScript, $url);
} catch (\Throwable) {
return null;
}
}
/**
* Запуск node-скрипта рендера. Реальный Process при висе (Playwright ждёт networkidle на
* «шумном» сайте) бросает ProcessTimedOutException.
*
* @return array<string,mixed>|null
*/
private function runNode(string $script, string $url): ?array
{
if ($this->nodeRunner !== null) {
return ($this->nodeRunner)($script, $url);
}
$p = new Process([$this->nodeBin, $script, $url]);
$p->setTimeout($this->timeout);
$p->run();
if (! $p->isSuccessful()) {
return null;
}
$json = json_decode($p->getOutput(), true);
return is_array($json) ? $json : null;
}
private function guessContactsUrl(string $base, string $raw): ?string
{
if (preg_match('#href=["\']([^"\']*(?:contacts?|kontakty)[^"\']*)["\']#i', $raw, $m)) {
$href = $m[1];
if (preg_match('#^https?://#i', $href)) {
return $href;
}
return rtrim($base, '/').'/'.ltrim($href, '/');
}
return null;
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
final class DirectoryCard
{
public function __construct(
public readonly string $number, // сырой номер
public readonly ?string $office, // подпись филиала
public readonly string $url, // ссылка на карточку
public readonly string $source, // '2ГИС' | 'Яндекс.Карты'
public readonly ?string $siteUrl = null, // сайт, указанный в карточке
public readonly ?string $description = null, // «чем занимается» (из карточки справочника)
) {}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
final class FetchedSite
{
/**
* @param list<string> $visiblePhones видимые посетителю (отрендеренные) номера, сырые строки
* @param list<array{number:string,office:?string}> $contactsNumbers номера со страницы «Контакты» с привязкой к офису
*/
public function __construct(
public readonly string $url,
public readonly string $rawHtml,
public readonly array $visiblePhones = [],
public readonly array $contactsNumbers = [],
) {}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
interface Fetcher
{
public function site(string $url): FetchedSite;
/** @return list<DirectoryCard> */
public function directory(string $url): array;
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
/**
* Живая добыча страниц для поиска конкурентов (§12.2 движка v4): 2ГИС через xfetch (обход
* антибота); Яндекс тоже через xfetch, а при пустом ответе fallback на ЛОКАЛЬНЫЙ Playwright
* (бесплатный, проверенный рендер) через scripts/render-page.cjs. Прочие домены не грузим
* (поиск конкурентов ходит только в справочники). Любая ошибка '' (как контракт PageFetcher).
*/
final class LivePageFetcher implements PageFetcher
{
public function __construct(
private readonly XfetchClient $xfetch,
private readonly string $nodeBin = 'node',
private readonly string $renderScript = 'scripts/render-page.cjs',
private readonly int $renderTimeoutSec = 90,
) {}
public function html(string $url): string
{
if (str_contains($url, '2gis.ru')) {
return $this->xfetch->html($url);
}
if (str_contains($url, 'yandex.')) {
$html = $this->xfetch->html($url);
return $html !== '' ? $html : $this->renderLocally($url);
}
return '';
}
/** Локальный Playwright-рендер (бесплатный запас для Яндекса). */
private function renderLocally(string $url): string
{
try {
$process = new Process([$this->nodeBin, base_path($this->renderScript), $url]);
$process->setTimeout($this->renderTimeoutSec);
$process->run();
if (! $process->isSuccessful()) {
return '';
}
$decoded = json_decode($process->getOutput(), true);
return is_array($decoded) && isset($decoded['html']) ? (string) $decoded['html'] : '';
} catch (ProcessTimedOutException|\Throwable) {
return '';
}
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Symfony\Component\Process\Process;
/**
* xf4-путь {@see YandexListSource}: локальный Playwright через `scripts/render-yandex-list.cjs`
* (Firecrawl Яндекс.Карты не рендерит). Печатает JSON `{orgs:[...]}`. Сеть/браузер за Process
* офлайн не юнит-тестим (как раньше PlaywrightYandexDirectory).
*/
final class LocalYandexListSource implements YandexListSource
{
private string $script;
public function __construct(
private readonly string $nodeBin = 'node',
string $script = '',
private readonly int $timeout = 120,
) {
$this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs');
}
public function fetch(string $url, int $scrolls): array
{
try {
$p = new Process([$this->nodeBin, $this->script, $url, (string) $scrolls]);
$p->setTimeout($this->timeout);
$p->run();
if (! $p->isSuccessful()) {
return [];
}
$json = json_decode($p->getOutput(), true);
return is_array($json['orgs'] ?? null) ? $json['orgs'] : [];
} catch (\Throwable) {
return [];
}
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
/**
* Тонкая граница «достать HTML страницы» за ней может стоять xfetch, локальный
* Playwright и т.п. Позволяет тестировать обход справочников без сети.
*/
interface PageFetcher
{
/** Вернуть HTML страницы (пустую строку при неудаче — не кидаем исключений). */
public function html(string $url): string;
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use App\Services\Autopodbor\Agent\ChannelA\PlaywrightYandexDirectory;
use Symfony\Component\Process\Process;
/**
* Загрузка страниц ЛОКАЛЬНЫМ Playwright (scripts/render-page.cjs JSON {html}). Для карточек Яндекса:
* xfetch под нагрузкой их режет (пустой ответ терялся сайт), а Playwright рендерит надёжно и бесплатно.
*
* ПАРАЛЛЕЛЬНО: порциями по $concurrency стартуем несколько node-процессов разом (Process::start),
* затем ждём каждый (wait) так пачка карточек грузится параллельно, а не по одной. Каждый рендер
* с таймаутом, ошибка/таймаут пустая строка (контракт PageFetcher, исключений не кидаем).
*
* Сеть/браузер за Process офлайн не юнит-тестим (как {@see PlaywrightYandexDirectory}).
*/
final class PlaywrightPageFetcher implements BatchPageFetcher
{
public function __construct(
private readonly string $nodeBin = 'node',
// render-card.cjs: domcontentloaded + пауза (Яндекс.Карты не доходят до networkidle).
private readonly string $script = 'scripts/render-card.cjs',
private readonly int $timeoutSec = 90,
// Сколько браузеров держать одновременно (каждый рендер — headless Chromium, тяжёлый по RAM).
private readonly int $concurrency = 4,
) {}
public function html(string $url): string
{
return $this->htmlBatch([$url])[$url] ?? '';
}
/**
* @param list<string> $urls
* @return array<string, string>
*/
public function htmlBatch(array $urls): array
{
$urls = array_values(array_unique($urls));
$out = array_fill_keys($urls, '');
foreach (array_chunk($urls, max(1, $this->concurrency)) as $chunk) {
/** @var array<string, Process> $procs */
$procs = [];
foreach ($chunk as $u) {
try {
$p = new Process([$this->nodeBin, base_path($this->script), $u]);
$p->setTimeout($this->timeoutSec);
$p->start();
$procs[$u] = $p;
} catch (\Throwable) {
// не стартовал — оставляем пустым
}
}
foreach ($procs as $u => $p) {
try {
$p->wait();
if ($p->isSuccessful()) {
$out[$u] = $this->decode($p->getOutput());
}
} catch (\Throwable) {
// таймаут/ошибка — оставляем пустым
}
}
}
return $out;
}
private function decode(string $json): string
{
$d = json_decode($json, true);
return is_array($d) && isset($d['html']) ? (string) $d['html'] : '';
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Illuminate\Support\Facades\Http;
/**
* self-render путь {@see YandexListSource}: просит виртуалку отрисовать ленту Яндекс.Карт (со скроллом)
* через прокси и вернуть организации. Контракт: `POST {endpoint} {url,type:'yandex-list',scrolls}` с
* заголовком `X-Render-Key` `{ "data": "<JSON {orgs:[...]}>" }`. Без endpoint/key пусто (деградация).
*/
final class RenderServiceYandexListSource implements YandexListSource
{
public function __construct(
private readonly string $endpoint,
private readonly string $key,
private readonly int $httpTimeout = 120,
) {}
public function fetch(string $url, int $scrolls): array
{
if ($this->endpoint === '' || $this->key === '') {
return [];
}
$resp = Http::timeout($this->httpTimeout)
->withHeaders(['X-Render-Key' => $this->key])
->asJson()
->post($this->endpoint, ['url' => $url, 'type' => 'yandex-list', 'scrolls' => $scrolls]);
if (! $resp->successful()) {
return [];
}
$data = $resp->json('data');
if (! is_string($data) || $data === '') {
return [];
}
$json = json_decode($data, true);
return is_array($json['orgs'] ?? null) ? $json['orgs'] : [];
}
}

Some files were not shown because too many files have changed in this diff Show More