Compare commits

..

153 Commits

Author SHA1 Message Date
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 8e864bf96f merge: фиксы достоверности дашборда в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-27 18:58:33 +03:00
Дмитрий f30c6612c0 fix дашборд: достоверность метрик (здоровье/лиды/заказ) + периоды 60/90д
По сверке прод-данных с реальностью (часть чисел вводила в заблуждение):
- Финансы: +периоды 60 и 90 дней (крупные пополнения старше 30д теперь видны).
- Здоровье: «инциденты» больше не считают авто-лог ошибок джоб (summary
  'Автоматически:%') — раньше копилось 975 и держало красный ложно. Теперь:
  open_incidents = только реальные; добавлен job_errors_24h (повторяющиеся
  ошибки джоб за сутки) в подсистему queues.
- Лиды: убраны обманчивый «% доставки» (это было «обработано», не доставлено)
  и «нераспределённые по менеджерам» (менеджеры не используются). Добавлено
  «получено от поставщика сегодня»; доставлено = реально созданные сегодня сделки.
- Заказ: показаны дата снимка и полная картина (всего активных заказов /
  Σ лимита у поставщика) — сверка по снимку больше не выглядит занижено.

Тесты: admin-срез 87 зелёных, unit 3/3, фронт 10/10. stan 0, pint/eslint/
type-check/build чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:57:35 +03:00
Дмитрий 2ecc1d6115 merge: дашборд Командный центр Этапы 1+2 в main для выката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-06-27 14:49:49 +03:00
Дмитрий 02a8a90e4d feat дашборд: Этап 2 — живые плитки Лиды и Заказ у поставщика
Backend: AdminDashboardController +leads/+supply эндпоинты, summary дополнен
плитками leads/supply; сверка заказа вынесена в чистый сервис
SupplyReconciliation (спрос → формула computeOrder=max(max,⌈Σ/3⌉) → факт →
рассинхрон). Лиды: доставлено сегодня / зависшие 4ч+ / нераспределённые /
% доставки — cross-tenant под pgsql_admin.

Frontend: плитки Лиды и Заказ оживлены (убраны заглушки «Этап 2»), drill
с KPI и таблицей групп спрос→формула→факт→совпадает.

Тесты: SupplyReconciliation unit 3/3, Leads/Supply/Summary feature,
admin-срез 87 зелёных, фронт 10/10. stan 0, pint/eslint/type-check/build чисто.
phpstan-baseline перегенерирован (getJson false-positive на новых тестах).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:32:31 +03:00
Дмитрий 67ea5d32b4 feat дашборд-fe: экран Командного центра + API-клиент + роут /admin/dashboard
Этап 1 фронтенда дашборда «Командный центр»: плитки Финансы и Здоровье
с живыми данными, заглушки Лиды и Заказ у поставщика на Этап 2,
drill-детали, клик по клиенту ведёт в карточку тенанта.
Редирект /admin теперь на /admin/dashboard.

Тесты: AdminDashboardView 8/8, router.spec обновлён под новый редирект.
type-check / vite build / eslint — чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:43:10 +03:00
Дмитрий fa7361364d feat: подсказка «Как увеличить количество сделок» в диалоге проекта
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip:
лимит распределяется по поставщикам равномерно; даже если не выбирается
полностью — просто увеличить лимит, и сделок придёт больше.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:35:29 +03:00
Дмитрий 69f8614abe feat: подсказка «Как увеличить количество сделок» в диалоге проекта
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip:
лимит распределяется по поставщикам равномерно; даже если не выбирается
полностью — просто увеличить лимит, и сделок придёт больше.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:32:09 +03:00
Дмитрий 9eaa9322dc feat(дашборд): backend Командного центра — summary/finance/health (Этап 1)
3 read-only эндпоинта под группой [saas-admin,admin-db] (cross-tenant через
pgsql_admin): L1 сводка (Финансы+Здоровье), L2 Финансы (KPI+внимание+топ),
L2 Здоровье (6 подсистем+светофор). TDD, 83 admin-теста зелёные. baseline:
+3 Pest getJson false-positive. Без маржи, без новых таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:58:58 +03:00
Дмитрий 1a92b70223 docs(дашборд): план реализации Этапа 1 (Командный центр + Финансы + Здоровье)
10 задач по TDD: 3 backend-эндпоинта (summary/finance/health) под admin-db
группой, Vue-экран AdminDashboardView + роут /admin/dashboard, тесты, выкат.
Без новых таблиц; переиспуют существующие detail-экраны как Уровень 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:49:34 +03:00
Дмитрий 7ac9af7c79 feat: убрать лимит по числу проектов — ограничение только по балансу/лидам
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Правило продукта: ограничений по количеству проектов нет, лимит только
по балансу и заказанным лидам. Убран гейт tenants.limits.max_projects
в ProjectService::create и показ лимита проектов на дашборде. Поле limits
оставлено как резерв; max_users и api_rps в коде не используются.

Заодно фикс типа в EditProjectDialog.spec: sampleProject типизирован
настоящим Project, source_locked больше не краснит vue-tsc.

Тесты: ProjectsStore 13/13, DashboardSummary 11/11, DashboardView 8/8,
EditProjectDialog 7/7; vue-tsc чисто; pint чисто; vite build ок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:47:49 +03:00
Дмитрий 1fd56e205b docs(админка): спецификация + кликабельный макет «Командного центра»
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Иерархический дашборд (3 уровня, drill-down). Этап 1: Командный центр +
Финансы + Здоровье (переиспользуют существующие экраны как L3). Этап 2: Лиды +
Заказ у поставщика. Механизм заказа задокументирован по коду (формула
SupplierQuotaAllocator: max(max_спрос, ceil(Σ/3))), без маржи (по решению владельца).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:37:28 +03:00
Дмитрий c7e015a9ac refactor(fe): убрать мёртвый repositionMenuAfterOpen - ядро внутреннее
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Старый per-instance экспорт больше не используется (заменён глобальным
installMenuRepositionFix). Старый тест-файл удалён - механизм покрыт
installMenuRepositionFix.spec.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:30:06 +03:00
Дмитрий 11dcd04173 refactor(fe): снять ручные обходы меню - заменены глобальным установщиком
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:27:46 +03:00
Дмитрий c78b69fcaf feat(fe): подключить installMenuRepositionFix при запуске SPA
Также: привести resizeSpy в тесте к EventListener (тип-чистота vue-tsc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:23:55 +03:00
Дмитрий 9f013ec591 feat(fe): глобальный installMenuRepositionFix + тест механизма
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:20:08 +03:00
Дмитрий 4fd4e390af docs(план): реализация глобального фикса позиционирования меню - 4 TDD-задачи
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:15:09 +03:00
Дмитрий 4044885c3e docs(спека): глобальный фикс позиционирования выпадающих меню - корневой обход вместо ручных пометок
Корень дефекта живого клиента 27.06: список Тип лица в окне создания
проекта уезжал за экран, реквизиты не сохранялись 422. Обход вешался
вручную на каждый список и забыт в 3 окнах. Решение - включать обход
автоматически глобально через MutationObserver, убрать ручные пометки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:09:50 +03:00
Дмитрий 9d0999d49a style(админка): pint — new UseAdminConnection без скобок в тесте
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:17:39 +03:00
Дмитрий b38fe0c875 feat(админка): admin-db middleware в группе saas-admin + SharesAdminPdo для тестов
bootstrap: alias admin-db=UseAdminConnection; web.php: группа saas-admin теперь
['saas-admin','admin-db'] (swap default→pgsql_admin после гейта). Тест: admin-db
в пайплайне /api/admin/tenants, saas-admin не потерян.

SharesAdminPdo (зеркало SharesSupplierPdo) применён глобально к Feature suite
(Pest.php): admin-db висит на всей группе → admin-эндпоинты в тестах читают
через pgsql_admin (separate PDO) и не видели бы засеянные в транзакции данные;
sharing PDO даёт cross-connection visibility. baseline: +trait.unused
(Pest применяет трейт в рантайме, phpstan не видит uses() из Pest.php).
261 supplier+admin тестов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:54:23 +03:00
Дмитрий 1c72f6dec2 feat(админка): middleware UseAdminConnection — swap default на pgsql_admin
Меняет default-подключение на pgsql_admin на время admin-запроса и
восстанавливает прежнее в finally (важно для Pest: несколько запросов в
одном процессе). Ставится после saas-admin. Tests: swap+restore и
restore при исключении downstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:39:27 +03:00
Дмитрий d5c972c3f2 feat(админка): connection pgsql_admin под ролью crm_admin_user (Путь А)
AdminTenantsController/AdminBillingController ходят под default-подключением;
новое pgsql_admin (crm_admin_user, srv_bypass) даст им cross-tenant доступ
через middleware-переключатель (следующий коммит). На dev fallback на
DB_USERNAME. Test: pgsql_admin делит базовый pgsql-конфиг, роль из DB_ADMIN_*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:37:42 +03:00
Дмитрий 819d74292f fix(phpstan): resync larastan baseline drift (pre-existing, не от admin-правок)
Дрейф выше старого baseline: счётчики ignore Pest-хелперов (postJson/actingAs/
$tenant на PendingCalls\TestCall) выросли в тест-файлах + 2 PaymentGateway
'strict comparison int/null always false' (PHPDoc-certainty). Все pre-existing,
ни одного в admin-правках. Регенерация по quirk 25 (2 шага). NB deferred-проверка:
PaymentGateway.php:38 и AdminPaymentGatewayController.php:35 — глянуть отдельно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:37:15 +03:00
Дмитрий 2c876162d5 fix(deptrac): baseline ProjectResource→ProjectRuleMessages (Эпик 6, ADR-005)
Pre-existing нарушение: ProjectRuleMessages (Service) — read-only текст правил
сбора для UI-баннеров, тот же класс что уже принятый SupplierSnapshotGuard.
По ADR-005 такие read-only UI-вычисления принимаются в baseline (перенос в
контроллер усложнил бы коллекции без выигрыша). Не от текущих admin-правок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:32:24 +03:00
Дмитрий 737d2e192b docs(админка): уточнённая спецификация + план фикса доступа через crm_admin_user
Поправка по факту кода: реально сломаны только AdminTenantsController и
AdminBillingController (ходят под default crm_app_user); Incidents/Pd/
SupplierIntegration/Impersonation уже используют pgsql_supplier и работают.
План: connection pgsql_admin + middleware UseAdminConnection (admin-db).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:21:00 +03:00
Дмитрий 1b3158dd45 docs(админка): спецификация фикса доступа к данным через crm_admin_user (Путь А)
Корень: после переезда на Managed PG админка ходит под crm_app_user без
cross-tenant доступа; штатная роль crm_admin_user готова, но не подключена.
Способ A: pgsql_admin connection + middleware-переключатель на админ-группе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:14:04 +03:00
Дмитрий a8aa79e75f chore(safety): гард от сноса боевой базы + указатель на живую БД (защита от параллельных сессий)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Повод: 26.06.2026 параллельная сессия выполнила yc managed-postgresql database
delete liderra + recreate на боевом кластере → переналила схему со старыми
небезопасными RLS-политиками → вход в портал лёг (см. db/CHANGELOG_schema.md v8.57).

- .claude/hooks/prod-db-guard.mjs (PreToolUse Bash|PowerShell): блокирует ТОЛЬКО
  снос/пересоздание боевой базы/кластера (yc database/cluster delete, DROP DATABASE
  liderra). Обычную работу (чтение, запросы, тесты на liderra_testing, migrate)
  НЕ трогает. Override владельца: маркер PROD-DESTROY-OK или env ALLOW_PROD_DB_DESTROY=1.
  Проверено 7 сценариями + живым запуском (echo с паттерном заблокирован).
- .claude/hooks/prod-db-pointer.mjs (SessionStart): инжектит указатель «живая база =
  кластер c9q2cvtjpq3hgq6l0r96, старая копия на VM не трогать, тесты на liderra_testing»
  — чтобы сессия не путала актуальную БД со stale-копией и не «пересобирала».
- .claude/settings.json: deny-паттерны (yc database/cluster delete) + оба хука.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:40:23 +03:00
Дмитрий a17e72a52e fix(billing): ЮKassa — формируем чек 54-ФЗ при онлайн-пополнении (фикс 400 Receipt is missing)
Магазин ЮKassa (1392092) с включённой фискализацией требует секцию receipt на
каждом платеже. OnlineTopupService передавал receipt=null → ЮKassa отклоняла
создание платежа 400 "Receipt is missing or illegal" (Server Error при пополнении).

- OnlineTopupService::start теперь формирует receipt: customer.email (почта
  пользователя, fallback на mail.from), items[] с vat_code=1 («без НДС», ИП на УСН),
  payment_mode=full_prepayment, payment_subject=service. Передаём всегда (магазин
  требует чек безусловно). Формат проверен живым запросом к боевому API → HTTP 200.
- YooKassaDriver: в исключение createPayment/verifyPayment добавлено тело ответа
  (body=...), чтобы причина 4xx была видна в логе сразу.
- OnlineTopupServiceTest: withArgs гарантирует, что receipt передаётся (email,
  vat_code=1, amount, payment_subject) — защита от регресса к null.

Проверено: Pest passed, Pint clean, формат чека → HTTP 200 на api.yookassa.ru.
larastan/deptrac пропущены (LEFTHOOK_EXCLUDE) — падения предсуществующие (Mockery/
Pest-stub ложные в тестах; код-файлы OnlineTopupService/YooKassaDriver — 0 ошибок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:39:38 +03:00
Дмитрий 08558df8ee fix(rls): NULLIF-хардненинг GUC во всех 44 политиках tenant_isolation — фикс входа на Managed PG
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Инцидент 26.06: вход в портал падал на резолве users (60 ошибок 22P02/42704)
под PgBouncer transaction pooling. current_setting('app.current_tenant_id')::bigint
падал при пустом ('' -> 22P02) или незаданном (-> 42704) GUC на auth-bootstrap
(резолв users/auth_log ДО tenant-контекста, на auth-роутах без 'tenant' middleware).

- все 44 политики -> NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
  (флаг ,true убирает 42704; NULLIF(...,'') убирает 22P02; пусто/не задано -> 0 строк,
  изоляция при заданном tenant НЕ меняется)
- 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes,
  user_sessions) получили ветку "NULLIF(...) IS NULL OR ..." — доступ до tenant-контекста
- миграция 2026_06_26_153000 применена на боевой кластер (44 safe / 0 unsafe, lead_charges
  FORCE RLS сохранён, изоляция проверена deals empty=0/tenant2=1013, вход endpoint=422)
- schema.sql v8.57 + CHANGELOG_schema.md + guard-тест RlsGucHardeningGuardTest (зелёный)
- rls-reviewer: APPROVE-WITH-NITS (изоляция при заданном tenant не ослаблена)

Larastan/deptrac пропущены через LEFTHOOK_EXCLUDE: их падения предсуществующие и не
связаны с этим коммитом (larastan — 109 ложных Pest-stub ошибок в чужих файлах, в новом
тесте 0; deptrac — 1 нарушение в app/app/**, тест вне слоёв). Проверено прямым прогоном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:15:22 +03:00
Дмитрий d6ffa0a6d0 docs: ЮKassa go-live шаги 1-4а — обновлены ПИЛОТ.md и ранбук
Договор ЮKassa подписан, магазин 1392092, на проде в кластерной базе заведены legal_entities ИП id=1 + payment_gateways yookassa id=2 active + webhook payment.succeeded. Осталось включить флаг billing_yookassa_enabled + живой тест 100р. Поправка к ранбуку: после переезда на Managed PG данные приложения заводить через app/Eloquent, не через peer-psql на VM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:59:37 +03:00
Дмитрий 1b809d6abc docs(ПИЛОТ): снимок 26.06 — боевая БД переехала на Managed PG (переезд + хвосты + откат)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:47:49 +03:00
Дмитрий 662ebd6e8b feat/db-path-a: прод переключён на Managed PG + verify-full SSL + хвосты закрыты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
- config/database.php: добавлен sslrootcert (env DB_SSLROOTCERT) для sslmode=verify-full
- ПИЛОТ.md §3: боевая БД = Yandex Managed PG; старая локальная БД = откат >=7 дней
- etap3-prod-cutover-DONE: отчёт переезда (деньги ДО==ПОСЛЕ, HTTP200, изоляция, откат)
- cspell-words: +рус. жаргон из снимков

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:22:06 +03:00
Дмитрий 1b5316b2c8 feat/db-path-a: anon(152-ФЗ)+схема+изоляция проверены на боевом Managed PG; 02_grants портирован под управляемую базу
- anon 1.3.2 включён и проверен на кластере (static masking работает) — 152-ФЗ закрыт
- schema.sql v8.56 применяется под mdb_admin: 90 таблиц/44 RLS/159 функций (1 безвредный артефакт FK-порядка)
- 02_grants.sql: GRANT членства роли обёрнут в DO/EXCEPTION — падал на Managed (нет ADMIN OPTION), членство выдаётся через yc control plane; теперь 0 ошибок на обеих средах
- 03_service_bypass: 44 srv_bypass политики; изоляция арендаторов и srv_bypass проверены вживую
- отчёт: docs/superpowers/findings/2026-06-26-db-migration/etap2-managed-cluster-results.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:24:30 +03:00
Дмитрий 7b23118856 fix(db): шов E — убраны мёртвые ссылки в 02_grants.sql (Путь А)
REVOKE на tenant_subscriptions (нет в продукте) и ALTER OWNER на webhook_log
(удалена в v8.35 legacy-webhook removal) вызывали ошибки при провижене ролей.
Убраны. Проверено: повторный прогон 02_grants.sql на полигоне — без ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:46:36 +03:00
Дмитрий 347bc3a13b feat(db): Путь А — пересчёт аудита через GUC + политики srv_bypass вместо BYPASSRLS
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.

Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).

Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.

LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:39:19 +03:00
Дмитрий 7efe9e3e83 fix/tests: idempotency 2 auth-тестов — SharesSupplierPdo против утечки регистрации мимо отката
Accessibility (Pa11y live) / a11y (push) Has been cancelled
AuthFlowIntegrationTest и AuthLogCoverageTest писали регистрацию через BYPASSRLS pgsql_supplier без SharesSupplierPdo. Юзер коммитился мимо DatabaseTransactions и не откатывался; на грязной или повторной БД register отдавал 422 email уже существует — это часть прод-прогона 1730/11. Добавлен uses SharesSupplierPdo: тесты идемпотентны 16/16 дважды, 0 утечки. На свежей migrate-БД весь набор 1757 прошло 0 упало 1 skip. Разбор 11 в findings tails-doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:43:30 +03:00
Дмитрий 77107c9cb8 docs/source-edit: пост-выкатная сверка байт-в-байт + полный прогон тестов на проде (1730/11)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Сверка прод===gitea===локалка (1105 файлов, 0 расхождений). Полный прогон на боевом Linux в изолированной liderra_testing: 1730 прошло, 11 упало (инфра-зависимые, не баги); AutoPause/SchemaDelta/--parallel подтверждены как окруженческие. Рецепт безопасного прода-прогона + грабли зафиксированы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:53:14 +03:00
Дмитрий fbf982e12c docs: обновление состояния — фича на проде, флаг ВКЛ, тумблер; ПИЛОТ снимок 26.06; CLAUDE §6
Accessibility (Pa11y live) / a11y (push) Has been cancelled
ПИЛОТ.md — снимки выката source-edit + включения флага и тумблера. findings tails-doc — статус ВЫКАЧЕНО НА БОЕВОЙ. CLAUDE.md §6 последняя продуктовая фича обновлена, снята устаревшая ремарка про синк квинтета (закрыто в PSR/Tooling), плюс досессионная правка Б-1 ИП/ЮKassa. Нормативный квинтет Pravila/PSR/Tooling без изменений (агент normative-sync подтвердил).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:38:22 +03:00
Дмитрий f9f86ca05f feat/admin: тумблер разблокировки смены источника на экране интеграции с поставщиком
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Дружелюбный переключатель ВКЛ/ВЫКЛ флага routing_match_by_snapshot для владельца — без правки БД и без 30-символьного основания общего edit-flow. GET/POST source-edit-flag в AdminSupplierIntegrationController пишут в system_settings type=bool + audit-журнал. На экране карточка с VSwitch и диалогом подтверждения, бамп ключа возвращает тумблер к факту при отмене. TDD: 5 эндпоинт-тестов + фронт-спек. Larastan чист, baseline дополнен Pest-шумом. Проверено глазами через Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:27:32 +03:00
Дмитрий f82596c527 docs/pilot: снимок выката source-edit-snapshot-routing на боевой + пометка ИП/ЮKassa
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:02:38 +03:00
350 changed files with 34564 additions and 710 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// PreToolUse guard (Bash|PowerShell): блокирует ТОЛЬКО удаление/пересоздание
// БОЕВОЙ базы/кластера Лидерры. Обычную работу (чтение, запросы, тесты на
// отдельной базе, правки через приложение) НЕ трогает.
//
// Повод: 26.06.2026 параллельная сессия выполнила `yc managed-postgresql
// database delete liderra` + recreate на боевом кластере → переналила схему со
// старыми небезопасными RLS-политиками → вход в портал лёг. См. db/CHANGELOG_schema.md v8.57.
//
// Боевая база = Managed PG кластер c9q2cvtjpq3hgq6l0r96 (rw-endpoint *.mdb.yandexcloud.net).
// Тест-база = отдельная liderra_testing (её сносить можно).
//
// Override владельца: маркер `PROD-DESTROY-OK` в самой команде ИЛИ env ALLOW_PROD_DB_DESTROY=1.
import { readFileSync } from 'node:fs';
let raw = '';
try { raw = readFileSync(0, 'utf8'); } catch { /* нет stdin — пропускаем */ }
let cmd = '';
try {
const j = JSON.parse(raw || '{}');
cmd = (j.tool_input && (j.tool_input.command ?? j.tool_input.script)) || '';
} catch { /* не JSON — нечего проверять */ }
cmd = String(cmd);
// Явный override владельца — пропускаем.
if (process.env.ALLOW_PROD_DB_DESTROY === '1' || /PROD-DESTROY-OK/.test(cmd)) {
process.exit(0);
}
const PROD_CLUSTER = 'c9q2cvtjpq3hgq6l0r96';
// Цель — именно ПРОД (а не liderra_testing): по cluster-id, по rw/managed-хосту,
// либо по имени базы `liderra` как отдельному слову (не liderra_testing).
const targetsProd =
new RegExp(PROD_CLUSTER, 'i').test(cmd) ||
/\bc-[a-z0-9]+\.(rw|ro)\.mdb\.yandexcloud\.net/i.test(cmd) ||
/\bliderra\b(?!_)/i.test(cmd);
// Деструктив над управляемой БД/кластером.
const clusterDelete = /managed-postgresql\s+cluster\s+delete/i.test(cmd); // снос кластера — всегда катастрофа
const databaseDelete = /managed-postgresql\s+database\s+delete/i.test(cmd); // снос управляемой БД
const dropDatabase = /\bdrop\s+database\b/i.test(cmd); // SQL DROP DATABASE
const destructive = clusterDelete || databaseDelete || dropDatabase;
// Снос кластера блокируем всегда; остальное — только если цель = прод.
if (destructive && (clusterDelete || targetsProd)) {
const reason =
'ЗАБЛОКИРОВАНО (prod-db-guard): попытка удалить/пересоздать БОЕВУЮ базу/кластер Лидерры. ' +
'Это снесёт портал (инцидент 26.06.2026). Боевая база = Managed PG кластер ' + PROD_CLUSTER + '. ' +
'Для тестов используй ОТДЕЛЬНУЮ базу liderra_testing, не прод. ' +
'Если это осознанное действие ВЛАДЕЛЬЦА — добавь в команду маркер PROD-DESTROY-OK ' +
'или запусти с env ALLOW_PROD_DB_DESTROY=1.';
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
},
systemMessage: reason,
}));
process.exit(0);
}
process.exit(0);
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// SessionStart: указатель «где сейчас живая боевая база» — чтобы любая сессия
// не путала актуальный кластер со старой rollback-копией на VM и не пыталась
// её «пересобирать». Только инъекция контекста, ничего не блокирует.
const context = [
'ОРИЕНТИР ПО БАЗЕ ЛИДЕРРЫ (важно перед любой работой с БД):',
'- ЖИВАЯ боевая база = Yandex Managed PG, кластер c9q2cvtjpq3hgq6l0r96',
' (rw-endpoint *.rw.mdb.yandexcloud.net:6432). Доступ — через app/.env',
' (роли crm_app_user / crm_supplier_worker). Это ЕДИНСТВЕННЫЙ источник',
' актуальных данных портала.',
'- На прод-VM (127.0.0.1:5432) лежит СТАРАЯ rollback-копия (до переезда 26.06).',
' НЕ путать с живой, НЕ менять там данные. `sudo -u postgres psql` на VM = старая копия.',
'- Для тестов — ОТДЕЛЬНАЯ база liderra_testing (через php artisan migrate),',
' НИКОГДА не прод `liderra`.',
'- НИКОГДА не удалять/пересоздавать боевую базу/кластер',
' (yc managed-postgresql database/cluster delete, DROP DATABASE liderra) —',
' это снесёт портал (инцидент 26.06, см. db/CHANGELOG_schema.md v8.57).',
' Хук prod-db-guard это блокирует; осознанный снос владельцем — маркер PROD-DESTROY-OK.',
].join('\n');
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: context,
},
}));
+21 -266
View File
@@ -32,283 +32,38 @@
"Bash(git push --force:*)",
"Bash(git reset --hard:*)",
"Bash(npm publish:*)",
"Bash(yc managed-postgresql database delete:*)",
"Bash(yc managed-postgresql cluster delete:*)",
"PowerShell(Remove-Item:*-Recurse*)",
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)"
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)",
"PowerShell(yc managed-postgresql database delete:*)",
"PowerShell(yc managed-postgresql cluster delete:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/router-tool-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-memory-coverage.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-tdd-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-branch-switch.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-verify-before-push.mjs",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-router-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "PowerShell",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-powershell-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-normative-content-rules.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-tdd-real-test-verifier.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-self-debrief-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-mcp-classification.mjs",
"timeout": 5
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-read-path-deny.mjs",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-verify-record.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-subagent-return-scanner.mjs",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node tools/observer-stop-hook.mjs",
"timeout": 60
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/router-stop-gate.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-coverage-verify.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-todowrite-skill-verifier.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/cost-stop-hook.mjs",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-prehook.mjs",
"timeout": 60
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-prompt-injection.mjs",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-embedding-warmup.mjs",
"timeout": 30
"command": "node .claude/hooks/prod-db-pointer.mjs",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/prod-db-guard.mjs",
"timeout": 10,
"statusMessage": "prod-db-guard"
}
]
}
]
}
}
}
+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 — генераторы тестовых фикстур (фейковые телефоны/ИНН,
+3 -3
View File
@@ -13,7 +13,7 @@
# CLAUDE.md — техконтекст Лидерры
**Версия:** 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. **NB:** cross-ref версии CLAUDE.md в Pravila/PSR/Tooling указывают 2.46 — синхронизация квинтета на 2.47 — отдельный follow-up.
**Версия:** 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
@@ -241,11 +241,11 @@ trivy image liderra:latest
- `ЭТАЛОН.md` (корень репо) — локальная dev-версия (git/окружение/временное/демо).
- `ПИЛОТ.md` (корень репо) — боевая интернет-версия liderra.ru (доступ/HTTPS/сервер/БД/безопасность/YC Lockbox).
**Последняя продуктовая фича:** определение региона лида по телефону + каскадная маршрутизация (DaData → реестр Россвязи → tag-fallback) — на проде, включена на 100%.
**Последняя продуктовая фича:** разблокировка смены источника проекта без потери лидов — матч поставщиковых лидов по слепку `project_routing_snapshots` (флаг `routing_match_by_snapshot`), Эпик 4 онлайн-заморозка 18:00→00:00 + `FlushDeferredOnlineSyncJob` (00:05 МСК), экран «Вечерняя заливка» (`supplier_sync_runs`) и дружелюбный тумблер управления флагом в админке «Интеграция с поставщиком». На проде liderra.ru (26.06.2026), флаг **ВКЛЮЧЁН**, идёт суточное наблюдение. Откат — тумблер в ВЫКЛ.
**Полный журнал фаз и работ** (что и когда делалось, включая историю «мозга») — в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md).
**P0-блокер:** **Б-1** (реквизиты юр. лица, ждут регистрации ООО). От него зависят Диз-3, DO-2, DO-4.
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО), договор с **ЮKassa** готов — осталось только подписать; после подписи включается онлайн-оплата (флаг `billing_yookassa_enabled`). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источник истины — память `project-legal-entity-ip-yookassa-2026-06-25` (25.06.2026).
---
+3
View File
@@ -96,3 +96,6 @@ VITE_APP_NAME="${APP_NAME}"
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
XFETCH_API_KEY=
+12 -12
View File
@@ -101,13 +101,15 @@ final class AuditRebuildChain extends Command
return self::FAILURE;
}
// Disable BEFORE triggers (audit_block_mutation blocks UPDATE).
// Use session-level SET so it works even inside a wrapping transaction
// (e.g. DatabaseTransactions in tests). Reset in finally.
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'replica'");
try {
$totalUpdated = 0;
// Пересчёт цепочки = UPDATE по append-only таблицам. Вместо superuser-параметра
// session_replication_role (недоступен в Managed PG — Путь А) используем метку
// app.audit_rebuild='on', которую чтит триггер audit_block_mutation. SET LOCAL
// внутри транзакции — Odyssey-safe: метка живёт ровно на время пересчёта и
// сбрасывается на commit. В тестах (DatabaseTransactions + SharesSupplierPdo)
// это savepoint внутри внешней транзакции — метка применяется ко всем UPDATE.
$totalUpdated = 0;
DB::connection('pgsql_supplier')->transaction(function () use ($partition, $partitionClause, $rowExpr, $fromId, &$totalUpdated) {
DB::connection('pgsql_supplier')->statement("SET LOCAL app.audit_rebuild = 'on'");
if ($partitionClause === 'PARTITION BY tenant_id') {
// Per-tenant rebuild — separate scope iteration per tenant.
@@ -128,14 +130,12 @@ final class AuditRebuildChain extends Command
);
}
} else {
// BYPASSRLS-таблицы (auth_log, saas_admin_audit_log) — global scope.
// global scope (auth_log, saas_admin_audit_log).
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
}
});
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
} finally {
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'origin'");
}
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
class RunInFlightException extends \RuntimeException {}
@@ -0,0 +1,580 @@
<?php
declare(strict_types=1);
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;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin «Командный центр» read-only агрегаты для дашборда.
* Под группой ['saas-admin','admin-db'] cross-tenant через pgsql_admin.
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
*/
class AdminDashboardController extends Controller
{
/**
* Диапазон периода из 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
{
$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 (все плитки). */
public function summary(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'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, Carbon $to): array
{
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$charges = (float) DB::table('balance_transactions')
->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')->whereBetween('created_at', [$from, $to])->whereNull('deleted_at')->count();
$negative = DB::table('tenants')->whereNull('deleted_at')->where('balance_rub', '<', 0)->count();
return [
'topups_rub' => (string) $topups,
'charges_rub' => (string) abs($charges),
'active_clients' => $active,
'new_clients' => $newClients,
'negative_balance_count' => $negative,
'light' => $negative > 0 ? 'red' : 'green',
];
}
/** GET /api/admin/dashboard/finance — детали Финансов (L2). */
public function finance(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$topups = (float) DB::table('balance_transactions')
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
$charges = abs((float) DB::table('balance_transactions')
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub'));
// «Требуют внимания»: баланс < 0 (по возрастанию — самые глубокие минусы сверху).
$attention = DB::table('tenants')->whereNull('deleted_at')
->where('balance_rub', '<', 0)
->orderBy('balance_rub')
->limit(20)
->get(['id', 'subdomain', 'organization_name', 'balance_rub', 'balance_leads'])
->map(fn ($t) => [
'id' => (int) $t->id,
'subdomain' => $t->subdomain,
'organization_name' => $t->organization_name,
'balance_rub' => (string) $t->balance_rub,
'state' => 'negative',
]);
// Топ по обороту: сумма пополнений за период.
$top = DB::table('balance_transactions')
->join('tenants', 'tenants.id', '=', 'balance_transactions.tenant_id')
->where('balance_transactions.type', 'topup')
->whereBetween('balance_transactions.created_at', [$from, $to])
->whereNull('tenants.deleted_at')
->groupBy('tenants.id', 'tenants.organization_name')
->orderByRaw('SUM(balance_transactions.amount_rub) DESC')
->limit(10)
->get([
'tenants.id',
'tenants.organization_name',
DB::raw('SUM(balance_transactions.amount_rub) AS topped_rub'),
])
->map(fn ($r) => [
'id' => (int) $r->id,
'organization_name' => $r->organization_name,
'topped_rub' => (string) $r->topped_rub,
]);
return response()->json([
'period' => (string) $request->query('period', '7d'),
'kpi' => [
'topups_rub' => (string) $topups,
'charges_rub' => (string) $charges,
'net_inflow_rub' => (string) ($topups - $charges),
'negative_balance_count' => $attention->count(),
],
'attention' => $attention,
'top_by_turnover' => $top,
]);
}
/** GET /api/admin/dashboard/health — 6 подсистем эксплуатации (L2). */
public function health(): JsonResponse
{
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
$lastReconcile = DB::table('supplier_csv_reconcile_log')->orderByDesc('id')->first();
$unresolvedWebhooks = DB::table('failed_webhook_jobs')->whereNull('resolved_at')->count();
$inc = $this->incidentCounts();
$staleHeartbeat = DB::table('scheduler_heartbeats')->where('consecutive_failures', '>', 0)->count();
$jobsLight = ($failedJobs > 0 || $inc['auto_job_24h'] > 0) ? 'red' : 'green';
$jobsDetail = $inc['auto_job_24h'] > 0
? $inc['auto_job_24h'].' повторяющихся ошибок джоб за сутки'
: $failedJobs.' упавших за сутки';
$subsystems = [
['key' => 'queues', 'light' => $jobsLight, 'detail' => $jobsDetail],
['key' => 'scheduler', 'light' => $staleHeartbeat > 0 ? 'red' : 'green',
'detail' => $staleHeartbeat > 0 ? $staleHeartbeat.' задач с пропусками' : 'по расписанию'],
['key' => 'supplier_sync', 'light' => ($lastSync && in_array($lastSync->status, ['failed', 'aborted'], true)) ? 'red' : 'green',
'detail' => 'последний: '.($lastSync->status ?? 'нет')],
['key' => 'csv_drift', 'light' => ($lastReconcile && $lastReconcile->status === 'drift_alert') ? 'red' : 'green',
'detail' => 'статус: '.($lastReconcile->status ?? 'нет')],
['key' => 'webhooks', 'light' => $unresolvedWebhooks > 0 ? 'amber' : 'green',
'detail' => $unresolvedWebhooks.' неразобранных'],
['key' => 'incidents', 'light' => $inc['real'] > 0 ? 'red' : 'green',
'detail' => $inc['real'].' открытых (реальных)'],
];
$order = ['green' => 0, 'amber' => 1, 'red' => 2];
$overall = collect($subsystems)->sortByDesc(fn ($s) => $order[$s['light']])->first()['light'];
return response()->json(['subsystems' => $subsystems, 'overall_light' => $overall]);
}
/**
* Счётчики инцидентов с разделением: РЕАЛЬНЫЕ (заведённые человеком/РКН) vs
* АВТО-ошибки джоб ('Автоматически: persistent exception job=…'), которые
* копятся и сами не закрываются. Для здоровья считаем реальные + свежие авто.
*
* @return array{real:int,auto_job_24h:int}
*/
private function incidentCounts(): array
{
$real = DB::table('incidents_log')->whereNull('resolved_at')
->where(function ($q) {
$q->whereNull('summary')->orWhere('summary', 'not like', 'Автоматически:%');
})
->count();
$autoJob24h = DB::table('incidents_log')->whereNull('resolved_at')
->where('summary', 'like', 'Автоматически:%')
->where('detected_at', '>=', now()->subDay())
->count();
return ['real' => $real, 'auto_job_24h' => $autoJob24h];
}
/** @return array<string,mixed> */
private function healthTile(): array
{
$inc = $this->incidentCounts();
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
$light = 'green';
if ($inc['real'] > 0 || $failedJobs > 0 || $inc['auto_job_24h'] > 0
|| ($lastSync !== null && in_array($lastSync->status, ['failed', 'aborted'], true))) {
$light = 'red';
}
return [
'light' => $light,
'open_incidents' => $inc['real'],
'job_errors_24h' => $inc['auto_job_24h'],
'failed_jobs_24h' => $failedJobs,
'last_sync_status' => $lastSync->status ?? 'none',
'last_sync_at' => $lastSync->finished_at ?? null,
];
}
// === Этап 2: Лиды ===
/** @return array<string,mixed> */
private function leadsMetrics(): array
{
$todayStart = now('Europe/Moscow')->startOfDay();
// Доставлено = реально созданные сегодня сделки у клиентов (не тест, не удал.).
$deliveredToday = DB::table('deals')
->where('received_at', '>=', $todayStart)
->where('is_test', false)
->whereNull('deleted_at')
->count();
// Получено от поставщика сегодня.
$receivedToday = DB::table('supplier_leads')->where('received_at', '>=', $todayStart)->count();
// В очереди на распределение прямо сейчас.
$unrouted = DB::table('supplier_leads')->whereNull('processed_at')->count();
// Зависшие = не распределены дольше 4 часов (порог cron leads:escalate-stale).
$stuck = DB::table('supplier_leads')
->whereNull('processed_at')
->where('received_at', '<', now()->subHours(4))
->count();
$light = 'green';
if ($stuck > 0) {
$light = 'red';
} elseif ($unrouted > 0) {
$light = 'amber';
}
return [
'light' => $light,
'delivered_today' => $deliveredToday,
'received_today' => $receivedToday,
'stuck' => $stuck,
'unrouted' => $unrouted,
];
}
/** @return array<string,mixed> */
private function leadsTile(): array
{
$m = $this->leadsMetrics();
return [
'light' => $m['light'],
'delivered_today' => $m['delivered_today'],
'received_today' => $m['received_today'],
'stuck' => $m['stuck'],
'unrouted' => $m['unrouted'],
];
}
/** 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' => [
'delivered_today' => $m['delivered_today'],
'received_today' => $m['received_today'],
'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: Заказ у поставщика ===
/**
* Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects).
* Плюс ПОЛНАЯ картина у поставщика (все активные заказы), чтобы не выглядело
* занижено: сверка идёт только по группам последнего снимка, а заказов больше.
*
* @return array{snapshot_date:?string,total_orders:int,total_limit:int,result:array{groups:list<array<string,mixed>>,totals:array<string,int>}}
*/
private function supplyReconciliation(): array
{
/** @var string|null $latest */
$latest = DB::table('project_routing_snapshots')->max('snapshot_date');
$demand = [];
if ($latest !== null) {
$rows = DB::table('project_routing_snapshots')
->where('snapshot_date', $latest)
->groupBy('signal_type', 'signal_identifier')
->select(
'signal_type',
'signal_identifier',
DB::raw('SUM(daily_limit) AS demand'),
DB::raw('MAX(daily_limit) AS max_limit'),
)
->get();
foreach ($rows as $r) {
$demand[] = [
'signal_type' => (string) $r->signal_type,
'identifier' => (string) $r->signal_identifier,
'demand' => (int) $r->demand,
'max_limit' => (int) $r->max_limit,
];
}
}
/** @var array<string,int> $orderedByKey */
$orderedByKey = DB::table('supplier_projects')
->groupBy('signal_type', 'unique_key')
->select('signal_type', 'unique_key', DB::raw('SUM(current_limit) AS ordered'))
->get()
->mapWithKeys(fn ($r) => [$r->signal_type.'|'.$r->unique_key => (int) $r->ordered])
->all();
return [
'snapshot_date' => $latest,
'total_orders' => (int) DB::table('supplier_projects')->where('current_limit', '>', 0)->count(),
'total_limit' => (int) DB::table('supplier_projects')->sum('current_limit'),
'result' => SupplyReconciliation::build($demand, $orderedByKey),
];
}
/** @return array<string,mixed> */
private function supplyTile(): array
{
$rec = $this->supplyReconciliation();
$totals = $rec['result']['totals'];
return [
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
'demand' => $totals['demand'],
'formula' => $totals['formula'],
'ordered' => $totals['ordered'],
'mismatches' => $totals['mismatches'],
'total_orders' => $rec['total_orders'],
'total_limit' => $rec['total_limit'],
'snapshot_date' => $rec['snapshot_date'],
];
}
// === Балансы внешних сервисов (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(),
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
{
$rec = $this->supplyReconciliation();
$totals = $rec['result']['totals'];
return response()->json([
'snapshot_date' => $rec['snapshot_date'],
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
'totals' => $totals,
'total_orders' => $rec['total_orders'],
'total_limit' => $rec['total_limit'],
'groups' => $rec['result']['groups'],
]);
}
}
@@ -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,
]);
}
}
@@ -15,6 +15,7 @@ use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\SupplierExportMode;
use App\Services\Supplier\SupplierPortalClient;
use App\Support\RussianRegions;
use App\Support\SystemSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -225,6 +226,49 @@ final class AdminSupplierIntegrationController extends Controller
return response()->json(['mode' => $data['mode']]);
}
/**
* Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
* GET текущее состояние ВКЛ/ВЫКЛ для переключателя в админке.
*/
public function getSourceEditFlag(): JsonResponse
{
return response()->json(['enabled' => SystemSettings::bool('routing_match_by_snapshot', false)]);
}
/**
* POST включить/выключить разблокировку смены источника (матч по слепку).
* Пишет в system_settings (type=bool) + audit-журнал; основание не требуется
* (дружелюбный тумблер для владельца, в отличие от общего edit-flow §settings).
*/
public function setSourceEditFlag(Request $request): JsonResponse
{
$data = $request->validate([
'enabled' => ['required', 'boolean'],
]);
$enabled = (bool) $data['enabled'];
$prev = DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value');
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => $enabled ? 'true' : 'false', 'type' => 'bool', 'updated_at' => now()],
);
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
'action' => 'supplier_integration.source_edit_flag_set',
'target_type' => 'system_setting',
'target_id' => null,
'payload_before' => $prev !== null ? ['enabled' => $prev] : null,
'payload_after' => ['enabled' => $enabled ? 'true' : 'false'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
'requires_approval' => false,
]);
return response()->json(['enabled' => $enabled]);
}
/**
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot
* projects tenants) + дата последней поставки лида.
@@ -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,581 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Http\Resources\Autopodbor\CompetitorResource;
use App\Http\Resources\Autopodbor\RunResource;
use App\Http\Resources\Autopodbor\SourceResource;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\AutopodborProjectCreator;
use App\Services\Autopodbor\AutopodborRunService;
use App\Services\Billing\BalancePreflightService;
use App\Support\SystemSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Клиентский API автоподбора конкурентов.
*
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
* Все выборки дополнительно скоупятся по tenant_id (пояс+подтяжки к RLS).
*/
class AutopodborController extends Controller
{
/** GET /api/autopodbor/state */
public function state(Request $request): JsonResponse
{
$tenantId = $request->user()->tenant_id;
$runs = AutopodborRun::where('tenant_id', $tenantId)
->orderByDesc('id')
->limit(20)
->get();
return response()->json([
'enabled' => SystemSettings::bool('autopodbor_enabled'),
'runs' => RunResource::collection($runs),
'prices' => [
'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'),
'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'),
],
]);
}
/** GET /api/autopodbor/runs/{run} */
public function run(Request $request, int $run): JsonResponse
{
$r = AutopodborRun::where('tenant_id', $request->user()->tenant_id)
->findOrFail($run);
return response()->json(['data' => new RunResource($r)]);
}
/** GET /api/autopodbor/competitors/{competitor} */
public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse
{
$tenantId = $request->user()->tenant_id;
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)
->with('sources.project')
->findOrFail($competitor);
$sources = $comp->sources->map(function (AutopodborSource $s) use ($dedup) {
$existingProjectId = $s->created_project_id
?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier);
return array_merge(
(new SourceResource($s))->resolve(),
[
'existing_project_id' => $existingProjectId,
'project' => $this->projectStatus($s->project),
]
);
});
return response()->json([
'data' => new CompetitorResource($comp),
'sources' => $sources,
]);
}
/** GET /api/autopodbor/runs/{run}/competitors */
public function runCompetitors(Request $request, int $run): JsonResponse
{
$tenantId = $request->user()->tenant_id;
// убедимся, что прогон принадлежит tenant (404 если чужой)
AutopodborRun::where('tenant_id', $tenantId)->findOrFail($run);
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
->where('search_run_id', $run)
->orderByDesc('relevance_pct')
->orderBy('id')
->get();
return response()->json(['data' => CompetitorResource::collection($competitors)]);
}
/** POST /api/autopodbor/search */
public function search(Request $request, AutopodborRunService $svc): JsonResponse
{
$v = $request->validate([
'region_code' => 'required|integer',
'examples' => 'array',
'about_self' => 'array',
'include_federal' => 'boolean',
]);
try {
$run = $svc->startSearch(
$request->user()->tenant_id,
(int) $v['region_code'],
$v['examples'] ?? [],
$v['about_self'] ?? [],
(bool) ($v['include_federal'] ?? false),
);
return response()->json(['data' => new RunResource($run)], 201);
} catch (RunInFlightException) {
return response()->json(['error' => 'run_in_flight'], 409);
} catch (InsufficientBalanceException) {
return response()->json(['error' => 'balance_insufficient'], 409);
}
}
/** POST /api/autopodbor/study */
public function study(Request $request, AutopodborRunService $svc): JsonResponse
{
$v = $request->validate([
'competitor_id' => 'required|integer',
]);
try {
$run = $svc->startStudy(
$request->user()->tenant_id,
(int) $v['competitor_id'],
);
return response()->json(['data' => new RunResource($run)], 201);
} catch (RunInFlightException) {
return response()->json(['error' => 'run_in_flight'], 409);
} catch (InsufficientBalanceException) {
return response()->json(['error' => 'balance_insufficient'], 409);
}
}
/** POST /api/autopodbor/resolve */
public function resolve(Request $request, AutopodborRunService $svc): JsonResponse
{
$v = $request->validate([
'name' => 'required|string',
'region_code' => 'required|integer',
]);
try {
$run = $svc->startResolve(
$request->user()->tenant_id,
$v['name'],
(int) $v['region_code'],
);
return response()->json(['data' => new RunResource($run)], 201);
} catch (RunInFlightException) {
return response()->json(['error' => 'run_in_flight'], 409);
}
}
/** POST /api/autopodbor/manual-study */
public function manualStudy(Request $request, AutopodborRunService $svc, AutopodborNormalizer $norm): JsonResponse
{
$v = $request->validate([
'competitor_id' => ['nullable', 'integer'],
'name' => ['nullable', 'string', 'max:255'],
'site_url' => ['nullable', 'string', 'max:500'],
'directory' => ['nullable', 'string', 'max:500'],
'region_code' => ['required', 'integer'],
]);
$uid = $request->user()->tenant_id;
try {
if (! empty($v['competitor_id'])) {
$run = $svc->startStudy($uid, (int) $v['competitor_id']);
} else {
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
$name = ! empty($v['name']) ? $v['name'] : ($site ?? 'Конкурент');
if (empty($v['name']) && $site === null) {
return response()->json(['error' => 'name_or_site_required'], 422);
}
$run = $svc->startManualStudy($uid, [
'name' => $name,
'site_url' => $site,
'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [],
], (int) $v['region_code']);
}
} catch (RunInFlightException) {
return response()->json(['error' => 'run_in_flight'], 409);
} catch (InsufficientBalanceException) {
return response()->json(['error' => 'balance_insufficient'], 409);
}
return response()->json(['data' => new RunResource($run)], 201);
}
/**
* GET /api/autopodbor/field рабочее место «Конкурентное поле».
* Конкуренты в ящике «поле» с их источниками в поле, статусом проекта по каждому
* источнику и счётчиками (источников / создано проектов / в работе).
*/
public function field(Request $request): JsonResponse
{
$tenantId = $request->user()->tenant_id;
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
->where('box', 'field')
->with(['sources' => function ($q) {
$q->where('box', 'field')->with('project');
}])
->orderByDesc('relevance_pct')
->orderBy('id')
->get();
$payload = $competitors->map(function (AutopodborCompetitor $comp) {
$sources = $comp->sources->map(fn (AutopodborSource $s) => array_merge(
(new SourceResource($s))->resolve(),
['project' => $this->projectStatus($s->project)],
));
$created = $comp->sources->filter(fn ($s) => $s->project !== null);
$inWork = $created->filter(
fn ($s) => $s->project->is_active && $s->project->preflight_blocked_at === null
);
return array_merge(
(new CompetitorResource($comp))->resolve(),
[
'counters' => [
'sources' => $comp->sources->count(),
'projects_created' => $created->count(),
'projects_in_work' => $inWork->count(),
],
'sources' => $sources,
],
);
});
return response()->json(['competitors' => $payload]);
}
/**
* POST /api/autopodbor/competitors/manual завести конкурента вручную сразу В ПОЛЕ,
* без запуска изучения (§14.2 «+ Добавить вручную»). Изучение источников отдельно, по кнопке.
*/
public function manualCompetitor(Request $request, AutopodborNormalizer $norm): JsonResponse
{
$v = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'is_federal' => ['boolean'],
'relevance_pct' => ['nullable', 'integer', 'min:0', 'max:100'],
'site_url' => ['nullable', 'string', 'max:500'],
'directory' => ['nullable', 'string', 'max:500'],
'directory_urls' => ['nullable', 'array'],
'directory_urls.*' => ['string', 'max:500'],
]);
$uid = $request->user()->tenant_id;
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
$dirs = $v['directory_urls'] ?? (! empty($v['directory']) ? [$v['directory']] : []);
$dirs = array_values(array_filter(array_map('trim', $dirs)));
$comp = AutopodborCompetitor::create([
'tenant_id' => $uid,
'search_run_id' => null,
'name' => $v['name'],
'description' => $v['description'] ?? null,
'is_federal' => (bool) ($v['is_federal'] ?? false),
'relevance_pct' => $v['relevance_pct'] ?? null,
'origin' => 'manual',
'box' => 'field',
'site_url' => $site,
'directory_urls' => $dirs,
'dedup_key' => $norm->competitorKey($v['name'], $site),
]);
return response()->json(['data' => new CompetitorResource($comp)], 201);
}
/** PATCH /api/autopodbor/competitors/{id} — правка полей карточки конкурента */
public function updateCompetitor(Request $request, int $competitor): JsonResponse
{
$v = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
'is_federal' => ['sometimes', 'boolean'],
'relevance_pct' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'],
'site_url' => ['sometimes', 'nullable', 'string', 'max:500'],
'directory_urls' => ['sometimes', 'array'],
'directory_urls.*' => ['string', 'max:500'],
'box' => ['sometimes', 'string', 'in:proposal,field'],
]);
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
->findOrFail($competitor);
$comp->update($v);
return response()->json(['data' => new CompetitorResource($comp)]);
}
/**
* DELETE /api/autopodbor/competitors/{id} удаление конкурента и его источников.
* Блокируется, если у любого источника есть активный созданный проект
* (управлять проектом нужно через раздел проектов §14.10).
*/
public function destroyCompetitor(Request $request, int $competitor): JsonResponse
{
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
->with('sources.project')
->findOrFail($competitor);
$hasActive = $comp->sources->contains(
fn (AutopodborSource $s) => $s->project && $s->project->is_active
);
if ($hasActive) {
return response()->json(['error' => 'has_active_projects'], 409);
}
$comp->sources()->delete();
$comp->delete();
return response()->json(null, 204);
}
/** GET /api/autopodbor/proposals — конкуренты в ящике «предложения», сорт по похожести. */
public function proposals(Request $request): JsonResponse
{
$competitors = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
->where('box', 'proposal')
->orderByDesc('relevance_pct')
->orderBy('id')
->get();
return response()->json(['data' => CompetitorResource::collection($competitors)]);
}
/** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */
public function competitorBox(Request $request, int $competitor): JsonResponse
{
$v = $request->validate([
'box' => ['required', 'string', 'in:proposal,field'],
]);
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
->findOrFail($competitor);
$comp->update(['box' => $v['box']]);
return response()->json(['data' => new CompetitorResource($comp)]);
}
/**
* PATCH /api/autopodbor/sources/{id} правка значения/провенанса/ящика источника.
* Тип источника (signal_type) НЕИЗМЕНЯЕМ (как в ProjectService молча игнорируем).
* Смена самого значения (identifier) у источника с активным проектом запрещена
* это смена источника проекта, делается через раздел проектов (§14.10).
*/
public function updateSource(Request $request, int $source): JsonResponse
{
$v = $request->validate([
'identifier' => ['sometimes', 'string', 'max:500'],
'phone_kind' => ['sometimes', 'nullable', 'string', 'in:real,substitute'],
'phone_type' => ['sometimes', 'nullable', 'string', 'in:city,mobile,tollfree'],
'provenance_url' => ['sometimes', 'nullable', 'string', 'max:500'],
'provenance_label' => ['sometimes', 'nullable', 'string', 'max:255'],
'box' => ['sometimes', 'string', 'in:proposal,field'],
]);
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
->with('project')
->findOrFail($source);
$changesIdentifier = array_key_exists('identifier', $v) && $v['identifier'] !== $src->identifier;
if ($changesIdentifier && $src->project && $src->project->is_active) {
return response()->json(['error' => 'manage_via_project'], 409);
}
$src->update($v);
return response()->json(['data' => new SourceResource($src)]);
}
/**
* DELETE /api/autopodbor/sources/{id} удаление источника.
* Блокируется, если у источника есть активный созданный проект (§14.10).
*/
public function destroySource(Request $request, int $source): JsonResponse
{
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
->with('project')
->findOrFail($source);
if ($src->project && $src->project->is_active) {
return response()->json(['error' => 'has_active_project'], 409);
}
$src->delete();
return response()->json(null, 204);
}
/** PATCH /api/autopodbor/sources/{id}/box — перенос источника предложение↔в работу */
public function sourceBox(Request $request, int $source): JsonResponse
{
$v = $request->validate([
'box' => ['required', 'string', 'in:proposal,field'],
]);
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
->findOrFail($source);
$src->update(['box' => $v['box']]);
return response()->json(['data' => new SourceResource($src)]);
}
/** POST /api/autopodbor/sources/manual */
public function addManualSource(Request $request, AutopodborNormalizer $norm): JsonResponse
{
$v = $request->validate([
'competitor_id' => ['required', 'integer'],
'raw' => ['required', 'string', 'max:500'],
]);
$uid = $request->user()->tenant_id;
$comp = AutopodborCompetitor::where('tenant_id', $uid)->findOrFail((int) $v['competitor_id']);
if ($comp->study_run_id === null) {
return response()->json(['error' => 'not_studied'], 422);
}
$raw = trim($v['raw']);
$digits = preg_replace('/\D+/', '', $raw) ?? '';
$isCall = strlen($digits) >= 10;
$signalType = $isCall ? 'call' : 'site';
$identifier = $isCall ? $norm->phone($raw) : $norm->domainHead($raw);
$source = AutopodborSource::updateOrCreate(
['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)],
[
'tenant_id' => $uid,
'study_run_id' => $comp->study_run_id,
'signal_type' => $signalType,
'identifier' => $identifier,
'phone_kind' => $isCall ? 'real' : null,
'provenance_url' => null,
'provenance_label' => 'Добавлено вручную',
],
);
return response()->json(['data' => new SourceResource($source)], 201);
}
/**
* Статус проекта источника для UI (пауза/работа/блок). null проекта нет.
*
* @return array{id: int, name: string, is_active: bool, paused_at: ?string, preflight_blocked_at: ?string}|null
*/
private function projectStatus(?Project $project): ?array
{
if ($project === null) {
return null;
}
return [
'id' => $project->id,
'name' => $project->name,
'signal_identifier' => $project->signal_identifier,
'is_active' => (bool) $project->is_active,
'paused_at' => $project->paused_at?->toIso8601String(),
'preflight_blocked_at' => $project->preflight_blocked_at?->toIso8601String(),
'daily_limit_target' => (int) $project->daily_limit_target,
'delivered_in_month' => (int) $project->delivered_in_month,
'delivery_days_mask' => (int) $project->delivery_days_mask,
'regions' => $project->regions ?? [],
];
}
/** POST /api/autopodbor/projects */
public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse
{
$v = $request->validate([
'source_ids' => 'required|array',
'source_ids.*' => 'integer',
'regions' => 'array',
'regions.*' => 'integer',
'daily_limit_target' => 'required|integer',
'delivery_days_mask' => 'required|integer',
'launch' => 'boolean',
]);
$tenant = $request->user()->tenant;
$launch = (bool) ($v['launch'] ?? false);
// Балансовый preflight при launch=true
if ($launch) {
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target'];
$preflight = $this->runPreflight($tenant, $wouldBe);
if (! $preflight['passes']) {
return response()->json([
'error' => 'balance_insufficient',
'current_balance_rub' => (string) $tenant->balance_rub,
'current_capacity_leads' => $preflight['capacity_leads'],
'would_be_required_leads' => $wouldBe,
'deficit_leads' => $preflight['deficit_leads'],
], 409);
}
}
$projects = $creator->createFromSources(
$tenant->id,
$v['source_ids'],
[
'regions' => $v['regions'] ?? [],
'daily_limit_target' => (int) $v['daily_limit_target'],
'delivery_days_mask' => (int) $v['delivery_days_mask'],
],
$launch,
);
return response()->json([
'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(),
], 201);
}
/**
* Копия helper'а из ProjectController балансовый preflight.
*
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// preflight пропускаем (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,
];
}
}
@@ -79,7 +79,6 @@ class DashboardController extends Controller
->where('tenant_id', $tenantId)
->where('is_active', true)
->count();
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
// --- activity: 7 daily-бакетов по received_at (MSK) ---
$activityStart = $now->subDays(6)->startOfDay();
@@ -141,7 +140,7 @@ class DashboardController extends Controller
'range' => $range,
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
'active_projects' => ['active' => $activeProjects],
'balance' => [
'amount_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Переключает активное подключение к БД на pgsql_admin (роль crm_admin_user)
* на время обработки SaaS-admin запроса и восстанавливает прежнее в finally.
*
* Зачем: после переезда на Managed PG (Путь А) AdminTenantsController и
* AdminBillingController ходят под default-ролью crm_app_user, у которой нет
* cross-tenant доступа (RLS tenants_self_isolation) пустые «Тенанты»/«Биллинг».
* crm_admin_user имеет политику srv_bypass + GRANT на админ-таблицы.
*
* Ставится ПОСЛЕ saas-admin (EnsureSaasAdmin), чтобы гейт и проверка
* impersonation прошли под исходным подключением. Контроллеры, явно прибитые к
* pgsql_supplier, не затрагиваются меняется только default.
*
* См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md
*/
class UseAdminConnection
{
public function handle(Request $request, Closure $next): Response
{
$previous = DB::getDefaultConnection();
DB::setDefaultConnection('pgsql_admin');
try {
return $next($request);
} finally {
DB::setDefaultConnection($previous);
}
}
}
@@ -0,0 +1,29 @@
<?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,
'studied_at' => $this->studied_at?->toIso8601String(),
'study_run_id' => $this->study_run_id,
'search_run_id' => $this->search_run_id,
];
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
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,
'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,
];
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
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\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 function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
{
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно
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,140 @@
<?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\Support\SystemSettings;
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;
class RunAutopodborSearchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [15, 60, 300];
// Живой поиск (2ГИС+Яндекс через антибот, обход карточек) идёт минутами — даём запас,
// иначе фоновое задание убьётся дефолтным 60-сек таймаутом на середине прогона.
public int $timeout = 900;
public function __construct(public int $runId) {}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge): void
{
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
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;
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
$res = $agent->findCompetitors(new FindCompetitorsRequest(
regionCode: (int) $run->region_code,
examples: $p['examples'] ?? [],
aboutSelf: $p['about_self'] ?? [],
includeFederal: (bool) ($p['include_federal'] ?? false),
maxCompetitors: $max,
));
$unique = $dedup->dedupCompetitors($res->competitors);
// Сквозной дедуп: убираем конкурентов, уже известных тенанту (в поле или предложениях
// из прошлых прогонов) — иначе повторный подбор плодит дубли карточек. Если после
// фильтра ничего нового не осталось — прогон пустой и НЕ списывается (как и обычное «пусто»).
// Исключаем конкурентов ЭТОГО же прогона (иначе ретрай упавшего прогона схлопнул бы
// собственные результаты в «пусто»). Фильтруем только чужие прогоны и ручных.
$existingKeys = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->pluck('dedup_key')
->all();
$unique = array_values(array_filter(
$unique,
fn (array $c) => ! in_array($c['dedup_key'], $existingKeys, true),
));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
$this->notifyReady($run, 0);
return;
}
$saved = array_slice($unique, 0, $max);
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'] ?? [],
'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,112 @@
<?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\Support\SystemSettings;
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;
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 function handle(
CompetitorAgent $agent,
AutopodborDedup $dedup,
AutopodborChargeService $charge,
AutopodborNormalizer $norm,
): void {
$run = AutopodborRun::findOrFail($this->runId);
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
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 {
$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 ?? [],
],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
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,
]
);
}
$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()]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}
@@ -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(
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Jobs\External;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
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;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в 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,
];
}
public function handle(): void
{
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(),
],
);
}
}
/** @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),
],
default => [0.0, 0.0],
};
}
}
+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',
],
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?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',
'provenance',
'dedup_key',
'study_run_id',
'studied_at',
'box',
];
protected $casts = [
'is_federal' => 'bool',
'directory_urls' => 'array',
'provenance' => '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');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?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',
'price_rub_charged',
'balance_transaction_id',
'error_code',
'started_at',
'finished_at',
];
protected $casts = [
'params' => '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 = [
+3 -1
View File
@@ -58,7 +58,9 @@ class Tenant extends Model
'desired_daily_numbers' => 'integer',
'delivered_in_month' => 'integer',
'api_key_limit' => 'integer',
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
// JSONB-резерв тарифных ограничений. Ключ max_projects убран —
// лимита по числу проектов нет (ограничение только по балансу/лидам).
// max_users / api_rps в коде не используются (зарезервированы).
'limits' => 'array',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
@@ -0,0 +1,89 @@
<?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\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\FakeCompetitorAgent;
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\XfetchClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver;
use App\Services\Autopodbor\Agent\Search\SearchResultsParser;
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider
{
public function register(): void
{
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl +
// локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru.
//
// Шаг 1 (поиск конкурентов): при включённом флаге autopodbor.real_find подключается ЖИВОЙ
// движок (ниша → поиск 2ГИС/Яндекс → резолв → сборка). Без ИИ-ключа отсев агрегаторов и
// похожесть-% отключены (null-классификатор + нулевой эмбеддер) — выдаётся сырой список.
// Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку (как раньше). resolveByName — заглушка.
$this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent {
$xfetch = new XfetchClient(
apiKey: config('services.xfetch.key'),
endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'),
);
$fetcher = new CompositeFetcher(
siteFetcher: new CurlPlaywrightFetcher,
directoryFetcher: new XfetchDirectoryFetcher($xfetch),
);
$liveFind = config('autopodbor.real_find')
? $this->buildLiveFind($xfetch)
: null;
return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent, liveFind: $liveFind);
});
}
/**
* Живой движок поиска шага 1 ФИНАЛ v4 (ZAFIKSIROVANO §0-БИС): шаг АНАЛИЗ (мелкая модель
* запросы-рубрики) КАНАЛ А (скрейп категории 2ГИС с пагинацией через xfetch резолв карточек)
* КАНАЛ В (одна модель sonar-reasoning-pro × 2 прохода ИМЕНА федералов; их САЙТ через EXA)
* сборка (отсев агрегаторов + дедуп + похожесть-эмбеддинги + DTO). Все ИИ/exa/xfetch клиенты
* деградируют при пустом ключе (без падения), поэтому подключаем их всегда.
*/
private function buildLiveFind(XfetchClient $xfetch): LiveFindCompetitors
{
$pages = new LivePageFetcher($xfetch);
$http = $this->app->make(HttpFactory::class);
$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),
new CategoryScraper($pages, new SearchResultsParser),
new CompetitorResolver($pages),
new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser),
new ExaSiteFinder($http),
$assembler,
);
}
}
@@ -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,37 @@
<?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
{
$out = [];
foreach ($candidates as $c) {
$verdict = $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,59 @@
<?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
{
public function __construct(private readonly HttpFactory $http) {}
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' => $cfg['chat_model'] ?? 'gpt-4o-mini',
'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,98 @@
<?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): array
{
$description = trim($description);
$fallback = $description !== '' ? [$description] : [];
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '' || $description === '') {
return $fallback;
}
$prompt = "Описание деятельности клиента: «{$description}». Регион: {$region}.\n".
'Дай от 3 до 6 КОРОТКИХ поисковых запросов (рубрик), по которым в справочниках 2ГИС и '.
'Яндекс.Карты ищут его КОНКУРЕНТОВ — так, как ищут категорию (напр. «автоломбард», '.
'«займ под залог авто», «МФО», «ломбард»). Только сами запросы, без пояснений, '.
'без названий конкретных фирм и без города. Ответ — строго 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;
}
}
/**
* 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,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
use App\Services\Autopodbor\Agent\Search\SearchResultsParser;
/**
* Канал А (§12.7): по каждому запросу-рубрике из шага АНАЛИЗ скрейпим категорию 2ГИС со СКВОЗНОЙ
* ПАГИНАЦИЕЙ всех страниц (`/search/<q>` `/search/<q>/page/2`), собираем ссылки на карточки фирм.
* Дедуп между страницами и между запросами. Остановка: пустая страница / страница без новых фирм /
* лимит maxPages. Сами карточки (имя/сайт/телефоны) добывает дальше резолвер.
*
* Чистый над {@see PageFetcher} (живой xfetch/firecrawl; в тестах стаб), без прямой сети.
*/
final class CategoryScraper
{
public function __construct(
private readonly PageFetcher $pages,
private readonly SearchResultsParser $parser,
private readonly int $maxPages = 5,
) {}
/**
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
* @return list<string> полные ссылки на карточки фирм 2ГИС
*/
public function collectTwoGis(string $slug, array $queries): array
{
$seen = [];
$urls = [];
foreach ($queries as $query) {
$query = trim((string) $query);
if ($query === '') {
continue;
}
$base = "https://2gis.ru/{$slug}/search/".rawurlencode($query);
for ($page = 1; $page <= max(1, $this->maxPages); $page++) {
$url = $page === 1 ? $base : $base."/page/{$page}";
$firms = $this->parser->twoGis($this->pages->html($url));
if ($firms === []) {
break; // выдача кончилась
}
$added = 0;
foreach ($firms as $firm) {
$path = $firm['path'];
if (isset($seen[$path])) {
continue;
}
$seen[$path] = true;
$urls[] = 'https://2gis.ru'.$path;
$added++;
}
if ($added === 0) {
break; // страница не дала ничего нового — дальше листать смысла нет
}
}
}
return $urls;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Шаг АНАЛИЗ канала А: из описания деятельности клиента НЕСКОЛЬКО коротких запросов-рубрик для
* скрейпа категории 2ГИС/Яндекса (ZAFIKSIROVANO §0-БИС / §12.7 в тесте Омеги было ~6 запросов).
* Реализация решает, какой моделью (по умолчанию мелкая chat-модель). За границей для офлайн-теста.
*/
interface QueryAnalyzer
{
/** @return list<string> короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»). */
public function analyze(string $description, string $region): array;
}
@@ -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,89 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
/**
* Канал В (федералы/онлайн) генератор ИМЁН моделью, ФИНАЛ владельца (ZAFIKSIROVANO §0-БИС + §11.3):
* На вход ИИ даём СПИСОК ИЗ КАНАЛА А (фирмы, уже найденные в справочниках) + примеры клиента
* как «уже известных, не повторять».
* ИИ выдаёт ТОЛЬКО НАЗВАНИЯ новых конкурентов (+ тип), которых в списке нет. Без сайтов/карточек.
* ОДНА модель × 2 прохода: проход 2 получает стоп-лист = известные + найденное в проходе 1.
* Реальные сайты/карточки/телефоны по этим именам добывает ПОТОМ Firecrawl/резолвер (чистильщик).
*
* Дедуп по имени (ё→е, без не-букв/цифр). Модель за границей {@see ResearcherClient} (офлайн-тест).
*/
final class ChannelBSearch
{
/** §11.3 — финальный промт «только имена». */
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}> только НОВЫЕ имена
*/
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;
}
$seen[$this->key($name)] = 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,98 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelB;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Нормализация канала В (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])
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', [
'query' => $name.' официальный сайт',
'numResults' => 10,
'type' => 'auto',
]);
if (! $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;
} catch (\Throwable) {
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,61 @@
<?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; // без имени — мусор
}
$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,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,121 @@
<?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,
];
}
}
}
// номера филиалов со страницы «Контакты» (рендер)
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,87 @@
<?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;
}
/**
* Адрес офиса из <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,167 @@
<?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' (не телефон) — игнорируем
}
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $html, $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,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,79 @@
<?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);
// короткий локальный номер без определимого кода города — «требует проверки»
$isUncertain = ! $hasTrusted && ! $hasDisplayed && in_array('uncertain', $kinds, true);
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain; // только pool-свалка
if ($onlyHidden) {
continue; // пул-свалка — не выводим
}
if ($isUncertain) {
$phoneKind = 'uncertain';
$phoneType = null; // тип не выдумываем — номер ещё не достроен
} 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,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,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,182 @@
<?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,
) {
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
{
$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
{
$p = new Process([$this->nodeBin, $this->renderScript, $url]);
$p->setTimeout($this->timeout);
$p->run();
if (! $p->isSuccessful()) {
return null;
}
$json = json_decode($p->getOutput(), true);
return is_array($json) ? $json : null;
}
/** @return array{html:string}|null */
private function renderFirm(string $url): ?array
{
$p = new Process([$this->nodeBin, $this->firmScript, $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,16 @@
<?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, // сайт, указанный в карточке
) {}
}
@@ -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,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,65 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Illuminate\Support\Facades\Http;
/**
* Загрузка страниц через сервис xfetch.ru (обход антибота 2ГИС/Яндекс/Cloudflare).
* POST {url, api_key, render, timeout} {response_body_base64}. Ключ из конфига,
* НИКОГДА не в коде/гите. Без ключа клиент молча возвращает пусто (не падает).
*/
final class XfetchClient implements PageFetcher
{
public function __construct(
private ?string $apiKey,
private string $endpoint = 'https://xf4.ru/fetch',
private bool $render = true,
private int $renderTimeout = 20,
private int $httpTimeout = 120,
private int $retries = 3,
) {}
public function html(string $url): string
{
if ($this->apiKey === null || $this->apiKey === '') {
return '';
}
// Рендер 2ГИС/Яндекс флакует: иногда возвращается пустой/ошибочный ответ.
// Повторяем до $retries раз — берём первый непустой результат.
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
$html = $this->fetchOnce($url);
if ($html !== '') {
return $html;
}
}
return '';
}
private function fetchOnce(string $url): string
{
$resp = Http::timeout($this->httpTimeout)->asJson()->post($this->endpoint, [
'url' => $url,
'api_key' => $this->apiKey,
'render' => $this->render,
'timeout' => $this->renderTimeout,
]);
if (! $resp->successful()) {
return '';
}
$b64 = $resp->json('response_body_base64');
if (! is_string($b64) || $b64 === '') {
return '';
}
$html = base64_decode($b64, true);
return is_string($html) ? $html : '';
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
/**
* Загрузчик справочников (2ГИС/Яндекс.Карты) через {@see PageFetcher} (обычно xfetch).
* Список филиалов ссылки на карточки телефон+адрес каждой карточки.
* Парсинг в {@see DirectoryParser}; добыча HTML за антибот-границей PageFetcher.
*/
final class XfetchDirectoryFetcher implements Fetcher
{
public function __construct(
private PageFetcher $pages,
private DirectoryParser $parser = new DirectoryParser,
private int $maxBranches = 25,
private int $listRetries = 3,
) {}
public function site(string $url): FetchedSite
{
return new FetchedSite(url: $url, rawHtml: $this->pages->html($url));
}
public function directory(string $url): array
{
// Рендер списка филиалов 2ГИС флакует: иногда отдаётся «оболочка» БЕЗ ссылок на
// карточки. Повторяем загрузку списка, пока ссылки не появятся (до $listRetries раз).
$links = [];
for ($attempt = 1; $attempt <= max(1, $this->listRetries); $attempt++) {
$listHtml = $this->pages->html($url);
$links = $this->parser->parseBranchList($listHtml);
if ($links !== []) {
break;
}
}
if ($links === []) {
return [];
}
$origin = parse_url($url);
$base = ($origin['scheme'] ?? 'https').'://'.($origin['host'] ?? '');
$source = stripos((string) ($origin['host'] ?? ''), 'yandex') !== false ? 'Яндекс.Карты' : '2ГИС';
$cards = [];
foreach (array_slice($links, 0, $this->maxBranches) as $href) {
$firmUrl = str_starts_with($href, 'http') ? $href : $base.$href;
$firmHtml = $this->pages->html($firmUrl);
if ($firmHtml === '') {
continue;
}
foreach ($this->parser->parseFirmCard($firmHtml, $firmUrl, $source) as $card) {
$cards[] = $card;
}
}
return $cards;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\AutopodborDedup;
/**
* Сборка ядра шага 1 (§12.1, хвост движка v4): резолвленные кандидаты каналов (A/B/0)
* отсев агрегаторов (§12.6) слияние+дедуп+вычет клиента (E, §12) отсев федералов (если не нужны)
* похожесть-эмбеддинги (F, §12.5) срез top-N DTO {@see FindCompetitorsResult} (§7.2).
*
* Это ЧИСТАЯ сборка: добыча страниц/имён (каналы) и резолв выше по течению, за своими границами.
* Поэтому всё ядро тестируется офлайн. Провайдер на боевой движок флипается отдельно (за флагом).
*/
final class FindCompetitorsAssembler
{
public function __construct(
private readonly AggregatorFilter $aggregatorFilter,
private readonly AutopodborDedup $dedup,
private readonly EmbeddingRelevance $relevance,
) {}
/**
* @param array<int, array> $candidates резолвленные кандидаты (имя/сайт/описание/телефоны/ссылки/is_federal)
* @param list<string> $clientExamples тексты-примеры клиента (для похожести)
* @param list<string> $clientKeys имя/сайт клиента (для вычета себя)
*/
public function assemble(
array $candidates,
array $clientExamples,
array $clientKeys,
bool $includeFederal,
int $maxCompetitors,
): FindCompetitorsResult {
$filtered = $this->aggregatorFilter->filter($candidates);
$merged = $this->dedup->mergeCompetitors($filtered, $clientKeys);
if (! $includeFederal) {
$merged = array_values(array_filter($merged, fn (array $c): bool => empty($c['is_federal'])));
}
$ranked = $this->relevance->rank($clientExamples, $merged);
if ($maxCompetitors > 0) {
$ranked = array_slice($ranked, 0, $maxCompetitors);
}
return new FindCompetitorsResult(array_map(fn (array $c): array => $this->toCompetitor($c), $ranked));
}
/**
* Карточка ядра конкурент §7.2 {name, description, is_federal, relevance_pct, site_url, directory_urls, provenance}.
*/
private function toCompetitor(array $c): array
{
return [
'name' => (string) ($c['name'] ?? ''),
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => $c['relevance_pct'] ?? null,
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
'provenance' => $c['provenance'] ?? ['via' => 'engine'],
];
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer;
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver;
use App\Services\Autopodbor\Agent\Resolve\ResolvedCompetitor;
use App\Support\RegionCity;
/**
* Живой findCompetitors ФИНАЛ движка v4 (ZAFIKSIROVANO §0-БИС, §11–§12). Порядок:
* 1. Шаг АНАЛИЗ ({@see QueryAnalyzer}): описание клиента запросы-рубрики.
* 2. КАНАЛ А ({@see CategoryScraper}): Firecrawl/xfetch скрейп категории 2ГИС с пагинацией ссылки
* фирм {@see CompetitorResolver} реальные карточки (имя/сайт/телефоны/2ГИС).
* 3. КАНАЛ В ({@see ChannelBSearch}): ОДНА модель × 2 прохода ТОЛЬКО ИМЕНА федералов/онлайн
* (стоп-лист = имена из А + примеры). У федерала нет карточки на регион его САЙТ ищем EXA
* ({@see ExaSiteFinder}) резолвер помечает федералом (нет местной карточки + есть сайт).
* 4. Слияние А+В {@see FindCompetitorsAssembler}: отсев агрегаторов дедуп+вычет клиента
* федерал-фильтр похожесть-эмбеддинги DTO §7.2.
*
* Все внешние вызовы (модели/скрейп/exa/резолв) за тонкими границами, логика тестируема офлайн.
*/
final class LiveFindCompetitors
{
public function __construct(
private readonly QueryAnalyzer $analyzer,
private readonly CategoryScraper $scraper,
private readonly CompetitorResolver $resolver,
private readonly ChannelBSearch $channelB,
private readonly ExaSiteFinder $exa,
private readonly FindCompetitorsAssembler $assembler,
// Потолок имён канала В на резолв (ограничивает живые exa/резолв-вызовы за подбор).
private readonly int $channelBCap = 40,
private readonly int $passes = 2,
) {}
public function find(FindCompetitorsRequest $r): FindCompetitorsResult
{
$profile = $this->profile($r->aboutSelf);
$clientSite = $this->clientSite($r->aboutSelf);
$city = RegionCity::name($r->regionCode) ?? '';
$slug = RegionCity::slug($r->regionCode);
// 1+2. АНАЛИЗ → канал А (справочники, пагинация) → резолв карточек.
$queries = $this->analyzer->analyze($profile, $city);
$aCards = [];
if ($slug !== null && $queries !== []) {
foreach ($this->scraper->collectTwoGis($slug, $queries) as $firmUrl) {
$card = $this->resolver->resolve('', $firmUrl, $city);
if ($card->name !== '') {
$aCards[] = $card;
}
}
}
// 3. Канал В: стоп-лист = имена из А + примеры клиента → имена → EXA-сайт → резолв.
$bCards = [];
if ($r->includeFederal) {
$known = array_merge(
array_map(static fn (ResolvedCompetitor $c): string => $c->name, $aCards),
$this->stringList($r->examples),
);
$names = $this->channelB->harvest($profile, $city, $clientSite, $known, $this->passes);
foreach (array_slice($names, 0, max(0, $this->channelBCap)) as $cand) {
$site = $this->exa->findSite($cand['name'], $city);
$bCards[] = $this->resolver->resolve($cand['name'], null, $city, $site);
}
}
$candidates = array_map(
fn (ResolvedCompetitor $c): array => $this->toArray($c),
array_merge($aCards, $bCards),
);
$clientKeys = $this->stringList($r->aboutSelf);
$examples = $this->stringList($r->examples);
return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors);
}
/** Профиль (ниша) = первая непустая строка «о себе». */
private function profile(array $aboutSelf): string
{
foreach ($aboutSelf as $v) {
$v = trim((string) $v);
if ($v !== '') {
return $v;
}
}
return '';
}
/** Сайт клиента = первая «о себе»-строка, похожая на домен (есть точка, нет пробела). */
private function clientSite(array $aboutSelf): string
{
foreach ($aboutSelf as $v) {
$v = trim((string) $v);
if ($v !== '' && ! str_contains($v, ' ') && str_contains($v, '.')) {
return $v;
}
}
return '';
}
/** @return list<string> */
private function stringList(array $items): array
{
return array_values(array_filter(
array_map(static fn ($v): string => trim((string) $v), $items),
static fn (string $v): bool => $v !== '',
));
}
private function toArray(ResolvedCompetitor $c): array
{
return [
'name' => $c->name,
'site_url' => $c->siteUrl,
'description' => $c->description,
'is_federal' => $c->isFederal,
'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [],
'phones' => $c->phones,
'provenance' => ['via' => 'engine', 'source' => $c->source],
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Phone;
use App\Services\Autopodbor\Agent\Extract\PhoneType;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
/**
* Обогащение телефонов конкурента через DaData clean/phone (§12.4 движка v4): тип (городской/
* мобильный/8-800), регион, годность (qc=0). Переиспользует ЖИВОЙ {@see DaDataPhoneClient}
* (тот же, что резолв региона лида) не дублирует клиент. Если DaData недоступна, подбор не
* падает: тип берётся по префиксу номера ({@see PhoneType}), регион пуст, номер помечается негодным.
*
* Берём ТОЛЬКО опубликованные фирмой номера (§12.4) здесь они не синтезируются, только классифицируются.
*/
final class CompetitorPhoneEnricher
{
public function __construct(private readonly DaDataPhoneClient $client) {}
/**
* @param list<string> $phones номера 7XXXXXXXXXX (из карточки конкурента)
* @return list<array{phone:string,type:string,region:?string,valid:bool}>
*/
public function enrich(array $phones): array
{
$out = [];
foreach ($phones as $phone) {
try {
$r = $this->client->cleanPhone($phone);
$out[] = [
'phone' => $phone,
'type' => $this->slug($r->type, $phone),
'region' => $r->region,
'valid' => $r->qc === 0,
];
} catch (DaDataException) {
// DaData недоступна/ошибка — деградируем, не роняя весь подбор.
$out[] = [
'phone' => $phone,
'type' => PhoneType::of($phone),
'region' => null,
'valid' => false,
];
}
}
return $out;
}
/** Тип DaData (рус.) → slug city/mobile/tollfree; неизвестный — по префиксу номера. */
private function slug(?string $type, string $phone): string
{
$t = mb_strtolower(trim((string) $type));
if ($t === '') {
return PhoneType::of($phone);
}
if (str_contains($t, 'мобильн')) {
return 'mobile';
}
if (str_contains($t, 'стационар') || str_contains($t, 'городск')) {
return 'city';
}
if (str_contains($t, 'бесплатн') || str_contains($t, 'колл')) {
return 'tollfree';
}
return PhoneType::of($phone);
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
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;
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Support\RegionAreaCode;
final class RealCompetitorAgent implements CompetitorAgent
{
public function __construct(
private Fetcher $fetcher,
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
private CandidateBuilder $builder = new CandidateBuilder,
private SourceAggregator $aggregator = new SourceAggregator,
private AutopodborNormalizer $norm = new AutopodborNormalizer,
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
) {}
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
{
// Подключён живой движок поиска — используем его; иначе заглушка (демо-данные).
return $this->liveFind?->find($r) ?? $this->fallback->findCompetitors($r);
}
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
{
return $this->fallback->resolveByName($r);
}
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
{
$competitor = $r->competitor;
$siteUrl = $competitor['site_url'] ?? null;
$directoryUrls = $competitor['directory_urls'] ?? [];
// 1. Грузим сайт(ы) и карточки справочников
$sites = [];
if (is_string($siteUrl) && $siteUrl !== '') {
$sites[] = $this->fetcher->site($this->ensureScheme($siteUrl));
}
$cards = [];
foreach ($directoryUrls as $du) {
foreach ($this->fetcher->directory($du) as $card) {
$cards[] = $card;
}
}
// 2. Размечаем кандидатов и сводим ядром. Запасной код города — по региону
// конкурента (только если на странице нет полных номеров; см. RegionAreaCode).
$areaCode = RegionAreaCode::forSubject($r->regionCode);
$candidates = $this->builder->build($sites, $cards, $areaCode);
$collected = $this->aggregator->aggregate($candidates);
// 3. Маппинг в существующий контракт
$rows = [];
// 3a. Сайты: сайт конкурента + сайты из карточек
$siteIds = [];
if (is_string($siteUrl) && $siteUrl !== '') {
$siteIds[$this->norm->domainHead($siteUrl)] = ['url' => $this->ensureScheme($siteUrl), 'label' => 'сайт конкурента'];
}
foreach ($cards as $card) {
if ($card->siteUrl !== null && $card->siteUrl !== '') {
$siteIds[$this->norm->domainHead($card->siteUrl)] ??= ['url' => $card->url, 'label' => $card->source.' — сайт в карточке'];
}
}
foreach ($siteIds as $domain => $meta) {
$rows[] = [
'signal_type' => 'site',
'identifier' => $domain,
'phone_kind' => null,
'phone_type' => null,
'provenance_url' => $meta['url'],
'provenance_label' => $meta['label'],
'where_found' => [['label' => $meta['label'], 'url' => $meta['url']]],
'office' => null,
'confirmations' => 1,
];
}
// 3b. Телефоны
foreach ($collected as $c) {
$rows[] = $this->callRow($c);
}
return new StudyCompetitorResult($rows);
}
/** @return 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} */
private function callRow(CollectedSource $c): array
{
$top = $c->sources[0] ?? ['label' => null, 'url' => null];
return [
'signal_type' => 'call',
'identifier' => $c->identifier,
'phone_kind' => $c->phoneKind,
'phone_type' => $c->phoneType,
'provenance_url' => $top['url'] ?? null,
'provenance_label' => $top['label'] ?? null,
'where_found' => $c->sources,
'office' => $c->office,
'confirmations' => $c->confirmations(),
];
}
private function ensureScheme(string $url): string
{
return preg_match('#^[a-z]+://#i', $url) ? $url : 'https://'.$url;
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Resolve;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
/**
* Единый резолвер имени конкурента в настоящую карточку справочника (§12.3 движка v4).
* Порядок: 2ГИС-в-городе (по прямой ссылке из канала А) иначе Яндекс (поиск по имени+городе
* первый org проверка имя/город) иначе local=false. `is_federal` ПО ФАКТУ: нет местной
* карточки + есть сайт = федерал; есть карточка = местный (модели не верим, §12.3).
*
* Транспорт за {@see PageFetcher} (html('') при неудаче), поэтому вся логика тестируема офлайн.
*/
final class CompetitorResolver
{
public function __construct(
private readonly PageFetcher $pages,
private readonly TwoGisResolver $twoGis = new TwoGisResolver,
private readonly YandexResolver $yandex = new YandexResolver,
) {}
/**
* @param string $name имя конкурента (из канала 0/А/В)
* @param ?string $twoGisUrl прямая ссылка карточки 2ГИС firm/<id> из канала А, если есть
* @param string $city город клиента (регион поиска)
* @param ?string $knownSite сайт кандидата, если известен из канала (для пометки федерала)
*/
public function resolve(string $name, ?string $twoGisUrl, string $city, ?string $knownSite = null): ResolvedCompetitor
{
// 1) 2ГИС по прямой ссылке из канала А — самый чистый сигнал.
if ($twoGisUrl !== null && $twoGisUrl !== '') {
$card = $this->twoGis->parse($this->pages->html($twoGisUrl), $twoGisUrl);
if ($card !== null && $this->inCity($card->region, $city)) {
return $card; // местная карточка 2ГИС
}
}
// 2) Яндекс: поиск по имени+городе → первый org → проверка имя/город внутри YandexResolver.
$ya = $this->resolveYandex($name, $city);
if ($ya !== null) {
return $ya;
}
// 3) Местной карточки нет. Есть сайт → федерал/онлайн; иначе — «нет филиала в регионе».
if ($knownSite !== null && $knownSite !== '') {
return new ResolvedCompetitor(name: $name, siteUrl: $knownSite, region: $city, isFederal: true);
}
return new ResolvedCompetitor(name: $name, region: $city, isFederal: false);
}
/** Поиск в Яндекс.Картах по «имя город» → первый org → проверка имя/город в YandexResolver. */
private function resolveYandex(string $name, string $city): ?ResolvedCompetitor
{
$searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode($name.' '.$city);
$searchHtml = $this->pages->html($searchUrl);
if (! preg_match('#/maps/org/[a-z0-9_-]+/\d+#i', $searchHtml, $m)) {
return null; // в выдаче нет ни одной организации
}
$orgUrl = 'https://yandex.ru'.$m[0];
return $this->yandex->parse($this->pages->html($orgUrl), $orgUrl, $name, $city);
}
/** Карточка считается местной, если её город совпал с городом/регионом клиента. */
private function inCity(?string $cardCity, string $city): bool
{
return $cardCity !== null && DirectoryFields::localeMatches($cardCity, $city);
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Resolve;
/**
* Общие извлекатели полей из карточек справочников (2ГИС/Яндекс) то, что у резолверов
* совпадает байт-в-байт: имя+город из <title> и телефоны из contact_groups/phones.
* Чистые статические функции, без состояния.
*/
final class DirectoryFields
{
/**
* Имя (первый сегмент) и город (последний сегмент) из <title> карточки.
* Заголовок справочников: «Имя, …адрес/рубрика…, Город 2ГИС|Яндекс Карты».
*
* @return array{0: ?string, 1: ?string} [имя, город]; имя=null если title не похож на карточку
*/
public static function nameAndCity(string $html): array
{
$parts = self::titleParts($html);
if (count($parts) < 2) {
return [null, null];
}
return [$parts[0], $parts[count($parts) - 1]];
}
/** Имя фирмы — первый сегмент <title> (для справочников, где город в заголовке не на фикс. месте). */
public static function titleName(string $html): ?string
{
$parts = self::titleParts($html);
return $parts === [] ? null : $parts[0];
}
/** Содержит ли заголовок карточки указанный город — устойчиво к позиции города в заголовке. */
public static function titleHasCity(string $html, string $city): bool
{
$city = mb_strtolower(trim($city));
if ($city === '') {
return false;
}
$title = mb_strtolower(implode(', ', self::titleParts($html)));
return $title !== '' && self::localeMatches($title, $city);
}
/**
* Сегменты <title> карточки: декодированы, схлопнуты пробелы, отрезан хвост « 2ГИС/Яндекс».
*
* @return list<string>
*/
private static function titleParts(string $html): array
{
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
return [];
}
$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);
// отрезаем хвост « — 2ГИС» / « — Яндекс Карты»
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
return array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== ''));
}
/**
* Телефоны из встроенного JSON карточки (объекты type=phone, поле value),
* нормализованы к 7XXXXXXXXXX (8→7, без +/скобок/пробелов).
*
* @return list<string>
*/
public static function phones(string $html): array
{
$out = [];
if (preg_match_all('/"type":"phone","value":"([+0-9]+)"/i', $html, $m)) {
foreach ($m[1] as $raw) {
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && $digits[0] === '8') {
$digits = '7'.substr($digits, 1);
}
if ($digits !== '' && ! in_array($digits, $out, true)) {
$out[] = $digits;
}
}
}
return $out;
}
/**
* Свободное сравнение локаций (город субъект): без регистра, по вхождению в любую сторону.
* Так город карточки «Красноярск» совпадает с регионом «Красноярский край» (ручной резолв
* по region_code), но «Красноярск» НЕ совпадает с «Москва». Консервативно: при сомнении мимо.
*/
public static function localeMatches(string $a, string $b): bool
{
$a = mb_strtolower(trim($a));
$b = mb_strtolower(trim($b));
return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a));
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Resolve;
/**
* Результат резолва одного имени конкурента в настоящую карточку справочника (§12.3).
* Если местной карточки нет (ни 2ГИС-в-городе, ни Яндекс-совпал) directoryUrl/source = null,
* {@see isLocal()} = false (UI «нет филиала в регионе»).
*
* ИНН-полей НЕТ намеренно (решение владельца 29.06): карточка = имя/сайт/телефоны/справочник.
*/
final class ResolvedCompetitor
{
/** @param list<string> $phones номера в формате 7XXXXXXXXXX */
public function __construct(
public readonly string $name,
public readonly ?string $siteUrl = null,
public readonly array $phones = [],
public readonly ?string $directoryUrl = null, // прямая ссылка 2ГИС firm/<id> или Яндекс org/<seo>/<id>
public readonly ?string $source = null, // 2ГИС | Яндекс.Карты | null
public readonly ?string $region = null, // город из адреса карточки
public readonly ?string $description = null, // рубрики/категории — для эмбеддинг-похожести
public readonly bool $isFederal = false, // нет местной карточки + есть сайт = федерал/онлайн
) {}
/** Есть ли настоящая местная карточка в справочнике (иначе — «нет филиала в регионе»). */
public function isLocal(): bool
{
return $this->directoryUrl !== null;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Resolve;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Support\RegionCity;
/**
* Ручной резолв по названию (контракт §7.2 `resolveByName`): имя + регион кандидат(ы)
* из настоящей карточки справочника через {@see CompetitorResolver}. Манульный путь не несёт
* прямой ссылки 2ГИС (её даёт канал А), поэтому идёт через поиск Яндекса по «имя + регион»;
* если местной карточки нет честный кандидат-заглушка «нет филиала в регионе».
*/
final class ResolvingAgent
{
public function __construct(private readonly CompetitorResolver $resolver) {}
public function resolve(ResolveByNameRequest $r): ResolveByNameResult
{
// Город центра субъекта (для поиска/проверки карточки); запасной — имя субъекта.
$city = RegionCity::name($r->regionCode) ?? '';
$card = $this->resolver->resolve($r->name, twoGisUrl: null, city: $city);
return new ResolveByNameResult([$this->toCandidate($card)]);
}
/**
* Карточка резолвера кандидат §7.2 {name, description, site_url, directory_urls[], provenance}.
*
* @return array{name:string,description:?string,site_url:?string,directory_urls:list<string>,is_federal:bool,provenance:array{via:string,source:?string}}
*/
private function toCandidate(ResolvedCompetitor $c): array
{
return [
'name' => $c->name,
'description' => $c->description,
'site_url' => $c->siteUrl,
'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [],
'is_federal' => $c->isFederal,
'provenance' => ['via' => 'name-search', 'source' => $c->source],
];
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Resolve;
/**
* Разбор карточки филиала 2ГИС (/<город>/firm/<id>) в {@see ResolvedCompetitor}.
* Чистый: на вход уже отрендеренный HTML карточки, на выход карточка конкурента
* (имя/сайт/телефоны/город/рубрики) либо null, если данных карточки нет (пустая «оболочка» 2ГИС).
*
* Источники полей в HTML 2ГИС:
* - имя/город <title> вида «Имя, адрес…, Город 2ГИС» (первый/последний сегмент);
* - сайт contact_groups, объект type=website, поле url (ЧИСТЫЙ адрес, НЕ редирект link.2gis в value);
* - телефоны contact_groups, объекты type=phone, поле value (нормализуем к 7XXXXXXXXXX);
* - описание rubrics[].name (для эмбеддинг-похожести на следующих под-блоках).
*/
final class TwoGisResolver
{
public function parse(string $html, string $url): ?ResolvedCompetitor
{
[$name, $city] = DirectoryFields::nameAndCity($html);
if ($name === null) {
return null; // нет имени фирмы в title — это не карточка филиала
}
return new ResolvedCompetitor(
name: $name,
siteUrl: $this->website($html),
phones: DirectoryFields::phones($html),
directoryUrl: $this->cleanUrl($url),
source: '2ГИС',
region: $city,
description: $this->description($html),
isFederal: false, // найдена местная карточка справочника
);
}
/** Чистый сайт из contact_groups (url перед type=website), а НЕ редирект link.2gis из value. */
private function website(string $html): ?string
{
if (preg_match('/"url":"(https?:\/\/[^"]+)"[^{}]*?"type":"website"/i', $html, $m)) {
return $m[1];
}
return null;
}
/** Описание = названия рубрик карточки (rubrics[].name). */
private function description(string $html): ?string
{
if (! preg_match('/"rubrics":\s*\[(.*?)\]/s', $html, $rm)) {
return null;
}
if (! preg_match_all('/"name":"([^"]+)"/', $rm[1], $nm)) {
return null;
}
$names = [];
foreach ($nm[1] as $n) {
$n = trim($n);
if ($n !== '' && ! in_array($n, $names, true)) {
$names[] = $n;
}
}
return $names === [] ? null : implode(', ', $names);
}
/** Прямая ссылка карточки без хвоста ?stat=… (2ГИС дописывает к firm/<id>). */
private function cleanUrl(string $url): string
{
$q = strpos($url, '?');
return $q === false ? $url : substr($url, 0, $q);
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Resolve;
/**
* Разбор карточки организации Яндекс.Карт (/maps/org/<seo>/<id>) в {@see ResolvedCompetitor}.
* Чистый: на вход отрендеренный HTML карточки + ожидаемое имя и город (для отбраковки
* чужой фирмы того же имени из другого города/профиля). Возвращает null, если это не наша
* карточка (имя/город не совпали) или данных карточки нет.
*
* Источники полей:
* - имя/город <title> «Имя, рубрика, адрес…, Город Яндекс Карты» (первый/последний сегмент);
* - сайт business-urls, тег itemprop="url" (ЧИСТЫЙ адрес, без utm-хвоста action-кнопки);
* - телефоны phones[].value (нормализуем к 7XXXXXXXXXX);
* - описание categories[].name.
*/
final class YandexResolver
{
public function parse(string $html, string $url, string $expectName, string $city): ?ResolvedCompetitor
{
$name = DirectoryFields::titleName($html);
if ($name === null) {
return null; // не карточка организации
}
// Отбраковка чужой фирмы того же имени: имя должно совпасть, и заголовок карточки
// должен содержать ожидаемый город. Город у Яндекса в заголовке НЕ на фикс. месте
// («Имя, рубрика, Город, улица, дом»), поэтому проверяем по наличию, а не по сегменту.
if (! $this->namesMatch($name, $expectName)) {
return null;
}
if (! DirectoryFields::titleHasCity($html, $city)) {
return null;
}
return new ResolvedCompetitor(
name: $name,
siteUrl: $this->website($html),
phones: DirectoryFields::phones($html),
directoryUrl: $this->cleanUrl($url),
source: 'Яндекс.Карты',
region: $city, // искомый город (подтверждён в заголовке карточки)
description: $this->description($html),
isFederal: false,
);
}
private function namesMatch(string $cardName, string $expect): bool
{
$a = mb_strtolower(trim($cardName));
$b = mb_strtolower(trim($expect));
return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a));
}
/** Чистый сайт из business-urls (itemprop=url), без utm-хвоста action-кнопки. */
private function website(string $html): ?string
{
if (preg_match('/itemprop="url"[^>]*href="(https?:\/\/[^"?]+)/i', $html, $m)) {
return rtrim($m[1], '/');
}
return null;
}
/** Описание = названия рубрик карточки (categories[].name). */
private function description(string $html): ?string
{
if (! preg_match('/"categories":\s*\[(.*?)\]/s', $html, $cm)) {
return null;
}
if (! preg_match_all('/"name":"([^"]+)"/', $cm[1], $nm)) {
return null;
}
$names = [];
foreach ($nm[1] as $n) {
$n = trim($n);
if ($n !== '' && ! in_array($n, $names, true)) {
$names[] = $n;
}
}
return $names === [] ? null : implode(', ', $names);
}
/** Прямая ссылка /maps/org/<seo>/<id> без хвоста ?ll=… и завершающего слеша. */
private function cleanUrl(string $url): string
{
$q = strpos($url, '?');
if ($q !== false) {
$url = substr($url, 0, $q);
}
return rtrim($url, '/');
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Search;
/**
* Разбор страниц ВЫДАЧИ справочников (канал А, §12.1): категория-поиск список ссылок на
* фирмы/организации + имя-подсказка. Имя лишь подсказка; авторитетное имя/поля даёт резолвер
* (под-блок A), который открывает каждую карточку. Чистый: на вход отрендеренный HTML.
*/
final class SearchResultsParser
{
/**
* Фирмы из выдачи 2ГИС: ссылка-путь /<город>/firm/<id> + имя из вложенного <span>.
* Дедуп по пути (одна фирма один раз).
*
* @return list<array{path:string,name:?string}>
*/
public function twoGis(string $html): array
{
$out = [];
$seen = [];
if (preg_match_all('#<a href="(/[a-z0-9_-]+/firm/\d+)"[^>]*>(.*?)</a>#is', $html, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$path = $hit[1];
if (isset($seen[$path])) {
continue;
}
$seen[$path] = true;
$name = preg_match('#<span>([^<]{2,})</span>#u', $hit[2], $nm)
? trim(html_entity_decode($nm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'))
: null;
$out[] = ['path' => $path, 'name' => $name !== '' ? $name : null];
}
}
return $out;
}
/**
* Организации из выдачи Яндекс.Карт: прямая ссылка /maps/org/<seo>/<id> + имя из aria-label.
* Дедуп по ссылке.
*
* @return list<array{url:string,name:string}>
*/
public function yandex(string $html): array
{
$out = [];
$seen = [];
if (preg_match_all('#class="link-overlay" href="(/maps/org/[a-z0-9_-]+/\d+)/?"[^>]*aria-label="([^"]+)"#i', $html, $m, PREG_SET_ORDER)) {
foreach ($m as $hit) {
$url = $hit[1];
if (isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$out[] = ['url' => $url, 'name' => trim(html_entity_decode($hit[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'))];
}
}
return $out;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Similarity;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* Живой {@see Embedder} через AITUNNEL (OpenAI-совместимый, text-embedding-3-small, §12.5/§12.9).
* POST {base}/embeddings {model, input:[...]} {data:[{index, embedding:[...]}]}.
* Ключ из конфига (.env), НИКОГДА в коде/гите. Без ключа/при ошибке возвращает пустые векторы
* (движок тогда даёт 0% похожести, но не падает).
*/
final class AitunnelEmbedder implements Embedder
{
public function __construct(private readonly HttpFactory $http) {}
public function embed(array $texts): array
{
$texts = array_values($texts);
if ($texts === []) {
return [];
}
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
$empty = array_map(static fn (): array => [], $texts);
if ($key === '') {
return $empty;
}
try {
$resp = $this->http
->withToken($key)
->timeout((int) ($cfg['timeout_sec'] ?? 30))
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/embeddings', [
'model' => $cfg['embed_model'] ?? 'text-embedding-3-small',
'input' => $texts,
]);
if (! $resp->successful()) {
return $empty;
}
$out = $empty;
foreach ((array) $resp->json('data') as $row) {
$i = $row['index'] ?? null;
if (is_int($i) && isset($out[$i]) && is_array($row['embedding'] ?? null)) {
$out[$i] = array_map('floatval', $row['embedding']);
}
}
return $out;
} catch (\Throwable) {
return $empty;
}
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Similarity;
/**
* Тонкая граница «получить эмбеддинги текстов» за ней живой AITUNNEL
* (text-embedding-3-small, §12.5/§12.9). Позволяет считать похожесть офлайн на фикстурах.
*/
interface Embedder
{
/**
* Векторные представления для каждого текста (порядок сохраняется).
*
* @param list<string> $texts
* @return list<list<float>>
*/
public function embed(array $texts): array;
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Similarity;
/**
* Похожесть кандидата на профиль клиента ЭМБЕДДИНГАМИ, а не «мнением модели» (§12.5 движка v4).
* Профиль клиента (примеры: имя+описание) центроид; каждый кандидат (имя+описание) косинус
* к центроиду relevance_pct [0..100]; сортировка по убыванию. Описание важно иначе меряется
* «красота имени», а не суть.
*
* Векторы берутся через {@see Embedder} (живой AITUNNEL за границей) логика тестируема офлайн.
*/
final class EmbeddingRelevance
{
public function __construct(private readonly Embedder $embedder) {}
/**
* @param list<string> $clientExamples тексты-примеры клиента (имя+описание)
* @param array<int, array{name?:string,description?:?string}> $candidates
* @return array<int, array> кандидаты с relevance_pct, отсортированы по убыванию
*/
public function rank(array $clientExamples, array $candidates): array
{
if ($candidates === []) {
return [];
}
$centroid = $clientExamples === []
? []
: $this->centroid($this->embedder->embed(array_values($clientExamples)));
$candTexts = array_map(fn (array $c): string => $this->text($c), $candidates);
$candVecs = $this->embedder->embed(array_values($candTexts));
$scored = [];
foreach (array_values($candidates) as $i => $c) {
$cos = $centroid === [] ? 0.0 : $this->cosine($candVecs[$i], $centroid);
$c['relevance_pct'] = (int) round(max(0.0, min(1.0, $cos)) * 100);
$scored[] = $c;
}
// стабильная сортировка по убыванию похожести (исходный порядок при равенстве)
usort($scored, fn (array $a, array $b): int => $b['relevance_pct'] <=> $a['relevance_pct']);
return $scored;
}
/** Текст кандидата для эмбеддинга: имя + описание. */
private function text(array $c): string
{
return trim((string) ($c['name'] ?? '').' '.(string) ($c['description'] ?? ''));
}
/**
* Покомпонентный центроид (среднее) набора векторов.
*
* @param list<list<float>> $vectors
* @return list<float>
*/
private function centroid(array $vectors): array
{
$vectors = array_values(array_filter($vectors, fn (array $v): bool => $v !== []));
if ($vectors === []) {
return [];
}
$dim = count($vectors[0]);
$sum = array_fill(0, $dim, 0.0);
foreach ($vectors as $v) {
for ($j = 0; $j < $dim; $j++) {
$sum[$j] += (float) ($v[$j] ?? 0.0);
}
}
$n = count($vectors);
return array_map(fn (float $x): float => $x / $n, $sum);
}
/**
* Косинусная близость двух векторов (0, если любой нулевой/пустой).
*
* @param list<float> $a
* @param list<float> $b
*/
private function cosine(array $a, array $b): float
{
$dim = min(count($a), count($b));
if ($dim === 0) {
return 0.0;
}
$dot = 0.0;
$na = 0.0;
$nb = 0.0;
for ($i = 0; $i < $dim; $i++) {
$x = (float) $a[$i];
$y = (float) $b[$i];
$dot += $x * $y;
$na += $x * $x;
$nb += $y * $y;
}
if ($na <= 0.0 || $nb <= 0.0) {
return 0.0;
}
return $dot / (sqrt($na) * sqrt($nb));
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\AutopodborRun;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* Сервис списания за прогон автоподбора конкурентов.
*
* Контракт:
* - Списание только при готовом результате (by-success).
* - Атомарное: весь flow в одной DB-транзакции.
* - Идемпотентное: повторный вызов с тем же run не изменяет баланс
* (guard по balance_transaction_id).
* - bcmath: никаких float-арифметик.
*
* @throws InsufficientBalanceException если balance_rub < priceRub.
* До throw баланс и транзакции не меняются.
*/
final class AutopodborChargeService
{
public function chargeForRun(AutopodborRun $run, string $priceRub): void
{
DB::transaction(function () use ($run, $priceRub): void {
// Блокируем run первым — guard идемпотентности
/** @var AutopodborRun $locked */
$locked = AutopodborRun::whereKey($run->id)->lockForUpdate()->firstOrFail();
if ($locked->balance_transaction_id !== null) {
// Уже списано — идемпотентный возврат без второго списания
return;
}
if (bccomp($priceRub, '0', 2) === 0) {
// Бесплатный прогон — без ledger-строки; фиксируем факт нулевой стоимости.
if ($locked->price_rub_charged === null) {
$locked->price_rub_charged = '0.00';
$locked->save();
}
return;
}
// Блокируем tenant для атомарного изменения баланса
/** @var Tenant $tenant */
$tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail();
// bcmath: сравниваем с точностью 2 знака
if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) {
throw new InsufficientBalanceException(
priceKopecks: (int) bcmul($priceRub, '100', 0),
balanceRub: (string) $tenant->balance_rub,
);
}
$newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2);
// Обновляем баланс через DB::table (как в LedgerService) — надёжнее при decimal
DB::table('tenants')
->where('id', $tenant->id)
->update(['balance_rub' => $newBalance]);
// Записываем транзакцию
$tx = BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE,
'amount_rub' => '-'.$priceRub,
'amount_leads' => null,
'balance_rub_after' => $newBalance,
'balance_leads_after' => null,
'related_type' => AutopodborRun::class,
'related_id' => $locked->id,
'created_at' => now(),
]);
// Фиксируем на run идемпотентный маркер
$locked->balance_transaction_id = $tx->id;
$locked->price_rub_charged = $priceRub;
$locked->save();
});
}
}
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Models\Project;
final class AutopodborDedup
{
public function __construct(private AutopodborNormalizer $norm) {}
/**
* Ищет существующий проект арендатора с тем же типом и нормализованным идентификатором.
* Возвращает id найденного проекта или null.
*/
public function existingProjectId(int $tenantId, string $signalType, string $identifier): ?int
{
$needle = $signalType === 'call'
? $this->norm->phone($identifier)
: $this->norm->domainHead($identifier);
return Project::query()
->where('tenant_id', $tenantId)
->where('signal_type', $signalType)
->where('signal_identifier', $needle)
->value('id');
}
/**
* Дедупликация источников внутри переданного списка по нормализованному ключу.
* Возвращает уникальные элементы с добавленным полем dedup_key.
*
* @param array<int, array{signal_type: string, identifier: string}> $sources
* @return array<int, array>
*/
public function dedupSources(array $sources): array
{
$seen = [];
$out = [];
foreach ($sources as $s) {
$key = $this->norm->sourceKey($s['signal_type'], $s['identifier']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$s['dedup_key'] = $key;
$out[] = $s;
}
return $out;
}
/**
* Дедупликация конкурентов внутри переданного списка по нормализованному ключу.
* Возвращает уникальные элементы с добавленным полем dedup_key.
*
* @param array<int, array{name: string, site_url?: string|null}> $competitors
* @return array<int, array>
*/
public function dedupCompetitors(array $competitors): array
{
$seen = [];
$out = [];
foreach ($competitors as $c) {
$key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$c['dedup_key'] = $key;
$out[] = $c;
}
return $out;
}
/**
* Сильное слияние конкурентов из 3 каналов (§12 движка v4): union-find по ЛЮБОМУ общему
* ключу корню имени / корню домена / телефону. Так один конкурент под разными
* написаниями, доменом-vs-именем или общим номером схлопывается в одну карточку.
* Дополнительно вычитает самого клиента (его имя/сайт не должны попасть в конкуренты).
*
* Сильнее {@see dedupCompetitors} (одиночный ключ) для финальной сборки findCompetitors.
*
* @param array<int, array{name?:string,site_url?:?string,description?:?string,is_federal?:bool,directory_urls?:array<int,string>,phones?:array<int,string>}> $candidates
* @param list<string> $clientKeys сырые идентификаторы клиента (имя и/или сайт) для вычета себя
* @return array<int, array>
*/
public function mergeCompetitors(array $candidates, array $clientKeys = []): array
{
$candidates = array_values($candidates);
$n = count($candidates);
if ($n === 0) {
return [];
}
$keysOf = [];
foreach ($candidates as $i => $c) {
$keysOf[$i] = $this->candidateKeys($c);
}
// union-find: общий ключ → одна группа
$parent = range(0, $n - 1);
$find = function (int $x) use (&$parent): int {
while ($parent[$x] !== $x) {
$parent[$x] = $parent[$parent[$x]];
$x = $parent[$x];
}
return $x;
};
$keyToIdx = [];
foreach ($keysOf as $i => $keys) {
foreach ($keys as $k) {
if (isset($keyToIdx[$k])) {
$a = $find($i);
$b = $find($keyToIdx[$k]);
if ($a !== $b) {
$parent[$a] = $b;
}
} else {
$keyToIdx[$k] = $i;
}
}
}
// ключи клиента (для вычета самого себя)
$client = [];
foreach ($clientKeys as $ck) {
foreach ($this->candidateKeys(['name' => $ck, 'site_url' => $ck]) as $k) {
$client[$k] = true;
}
}
$groups = [];
foreach ($candidates as $i => $c) {
$groups[$find($i)][] = $i;
}
$out = [];
foreach ($groups as $members) {
// если любая часть группы — это сам клиент, выкидываем всю группу
$isClient = false;
foreach ($members as $i) {
foreach ($keysOf[$i] as $k) {
if (isset($client[$k])) {
$isClient = true;
break 2;
}
}
}
if ($isClient) {
continue;
}
$out[] = $this->mergeGroup(array_map(fn ($i) => $candidates[$i], $members));
}
return array_values($out);
}
/**
* Ключи кандидата для union-find: корень имени и корень домена под общим префиксом
* (чтобы имя сцеплялось с доменом), телефоны под отдельным.
*
* @param array{name?:string,site_url?:?string,phones?:array<int,string>} $c
* @return list<string>
*/
private function candidateKeys(array $c): array
{
$keys = [];
$name = isset($c['name']) ? (string) $c['name'] : '';
if ($name !== '') {
$nk = $this->norm->nameKey($name);
if ($nk !== '') {
$keys[] = 'k:'.$nk;
}
}
if (! empty($c['site_url'])) {
$dr = $this->norm->domainRoot((string) $c['site_url']);
if ($dr !== '') {
$keys[] = 'k:'.$dr;
}
}
foreach ($c['phones'] ?? [] as $p) {
$pp = $this->norm->phone((string) $p);
if ($pp !== '') {
$keys[] = 'p:'.$pp;
}
}
return array_values(array_unique($keys));
}
/**
* Сливает группу совпавших кандидатов в одну карточку: имя/сайт/описание первое непустое,
* ссылки справочников и телефоны объединением, is_federal местная карточка перевешивает.
*
* @param array<int, array> $group
*/
private function mergeGroup(array $group): array
{
$name = null;
$site = null;
$desc = null;
$isFederal = true;
$hasFederalFlag = false;
$dirs = [];
$phones = [];
foreach ($group as $c) {
if ($name === null && ! empty($c['name'])) {
$name = $c['name'];
}
if ($site === null && ! empty($c['site_url'])) {
$site = $c['site_url'];
}
if ($desc === null && ! empty($c['description'])) {
$desc = $c['description'];
}
if (array_key_exists('is_federal', $c)) {
$hasFederalFlag = true;
if (! $c['is_federal']) {
$isFederal = false; // нашлась местная карточка — группа местная
}
}
foreach ($c['directory_urls'] ?? [] as $d) {
if (! in_array($d, $dirs, true)) {
$dirs[] = $d;
}
}
foreach ($c['phones'] ?? [] as $p) {
if (! in_array($p, $phones, true)) {
$phones[] = $p;
}
}
}
$merged = [
'name' => $name ?? '',
'site_url' => $site,
'directory_urls' => $dirs,
'phones' => $phones,
];
if ($desc !== null) {
$merged['description'] = $desc;
}
if ($hasFederalFlag) {
$merged['is_federal'] = $isFederal;
}
return $merged;
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Support\PhoneNormalizer;
/**
* Нормализует домены и телефоны для дедупликации конкурентов и источников.
*/
final class AutopodborNormalizer
{
/**
* Возвращает «голову» домена: без схемы, www, пути, порта, нижний регистр.
* Примеры:
* https://www.Okna-Komfort.RU/contacts okna-komfort.ru
* http://site.ru:8080/path?x=1 site.ru
*/
public function domainHead(string $raw): string
{
$s = trim(mb_strtolower($raw));
// Убираем схему (http://, https://, ftp:// и т.п.)
$s = preg_replace('#^[a-z]+://#', '', $s);
// Убираем www.
$s = preg_replace('#^www\.#', '', $s);
// Берём только host часть (до первого /)
$s = explode('/', $s)[0];
// Убираем query string если вдруг осталась
$s = explode('?', $s)[0];
// Убираем порт
$s = explode(':', $s)[0];
return $s;
}
/**
* Нормализует телефон к виду 7xxxxxxxxxx (11 цифр, без плюса).
* Использует существующий PhoneNormalizer::normalize, который возвращает +7XXXXXXXXXX.
*/
public function phone(string $raw): string
{
$normalized = PhoneNormalizer::normalize($raw);
if ($normalized === null) {
// Fallback: оставить только цифры и привести к 7xxxxxxxxxx
$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 $digits;
}
// PhoneNormalizer возвращает +7XXXXXXXXXX — срезаем ведущий '+'
return ltrim($normalized, '+');
}
/**
* Строит dedup-ключ для источника (сайт или звонок).
* Формат: «type:нормализованный_идентификатор»
*/
public function sourceKey(string $type, string $identifier): string
{
$id = $type === 'call'
? $this->phone($identifier)
: $this->domainHead($identifier);
return $type.':'.$id;
}
/**
* Срезает хвостовой значок ( или 🎭) вместе с пробелами перед ним.
* Если значка нет строка возвращается без изменений.
* Примеры:
* 'Окна Комфорт ✓' 'Окна Комфорт'
* 'Окна Комфорт 🎭' 'Окна Комфорт'
* 'Окна Комфорт' 'Окна Комфорт'
* 'Балкон-Сервис 16' 'Балкон-Сервис 16'
*/
public function stripBadge(string $name): string
{
// Срезаем ровно один хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
// Используем mb-безопасный regex с флагом u (эмодзи 🎭 — 4-байтный).
return preg_replace('/\s*(?:\x{2713}|\x{1F3AD})\s*$/u', '', $name) ?? $name;
}
/**
* Строит dedup-ключ для конкурента.
* Если есть сайт «site:домен», иначе «name:имя_в_нижнем_регистре».
*/
public function competitorKey(string $name, ?string $siteUrl): string
{
if ($siteUrl !== null) {
return 'site:'.$this->domainHead($siteUrl);
}
// Нижний регистр + схлопываем пробелы
$normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name)));
return 'name:'.$normalized;
}
/**
* Сжатый ключ имени для union-find слияния: нижний регистр, ё→е, только буквы/цифры.
* Намеренно совпадает по форме с {@see domainRoot}, чтобы «Драйв займ» (имя) сцепился
* с «драйвзайм.рф» (корень домена). Примеры: «Драйв займ» «драйвзайм»; «ОКНА-КОМФОРТ» «окнакомфорт».
*/
public function nameKey(string $name): string
{
return $this->alnumKey($name);
}
/**
* Корень домена для union-find: «голова» домена без TLD, только буквы/цифры.
* Примеры: «драйвзайм.рф» «драйвзайм»; «https://okna-komfort.ru/contacts» «окнакомфорт» (лат.).
*/
public function domainRoot(string $site): string
{
$host = $this->domainHead($site);
$parts = explode('.', $host);
if (count($parts) > 1) {
array_pop($parts); // срезаем зону (.ru/.рф/...)
}
return $this->alnumKey(implode('.', $parts));
}
/** Нижний регистр + ё→е + только буквы/цифры (Unicode). */
private function alnumKey(string $s): string
{
$s = str_replace('ё', 'е', mb_strtolower(trim($s)));
return preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '';
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Models\AutopodborSource;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
final class AutopodborProjectCreator
{
public function __construct(private ProjectService $projects) {}
/**
* @param int[] $sourceIds
* @param array{regions:int[],daily_limit_target:int,delivery_days_mask:int} $common
* @return Project[]
*/
public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array
{
$tenant = Tenant::findOrFail($tenantId);
$sources = AutopodborSource::where('tenant_id', $tenantId)
->whereIn('id', $sourceIds)->with('competitor')->get();
$created = [];
foreach ($sources as $src) {
$name = $this->uniqueName($tenantId, $this->displayName($src));
$project = $this->projects->create($tenant, [
'name' => $name,
'signal_type' => $src->signal_type,
'signal_identifier' => $src->identifier,
'daily_limit_target' => $common['daily_limit_target'],
'regions' => $common['regions'],
'delivery_days_mask' => $common['delivery_days_mask'],
]);
if (! $launch) {
$project->update(['is_active' => false, 'paused_at' => now()]);
$project = $project->fresh();
}
$src->update(['created_project_id' => $project->id]);
$created[] = $project;
}
return $created;
}
private function displayName(AutopodborSource $s): string
{
$n = $s->competitor->name;
if ($s->signal_type === 'call' && $s->phone_kind === 'real') {
return $n.' ✓';
}
if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') {
return $n.' 🎭';
}
return $n;
}
private function uniqueName(int $tenantId, string $base): string
{
$name = $base;
$i = 1;
while (Project::where('tenant_id', $tenantId)->where('name', $name)->exists()) {
$i++;
$name = $base.' '.$i;
}
return $name;
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
use App\Models\AutopodborRun;
use App\Models\AutopodborCompetitor;
use App\Models\Tenant;
use App\Support\SystemSettings;
final class AutopodborRunService
{
public function __construct(
private AutopodborNormalizer $normalizer = new AutopodborNormalizer(),
) {}
private function assertNoInFlight(int $tenantId, string $kind): void
{
$exists = AutopodborRun::where('tenant_id', $tenantId)
->where('kind', $kind)
->whereIn('status', ['queued', 'running'])
->exists();
if ($exists) {
throw new RunInFlightException();
}
}
private function priceGate(int $tenantId, string $key): string
{
$price = (string) (SystemSettings::get($key) ?? '0');
$balance = (string) Tenant::whereKey($tenantId)->value('balance_rub');
if (bccomp($balance, $price, 2) < 0) {
throw new InsufficientBalanceException(
priceKopecks: (int) bcmul($price, '100', 0),
balanceRub: $balance,
);
}
return $price;
}
public function startSearch(
int $tenantId,
int $regionCode,
array $examples,
array $aboutSelf,
bool $includeFederal,
): AutopodborRun {
$this->assertNoInFlight($tenantId, 'search');
$this->priceGate($tenantId, 'autopodbor_price_search_rub');
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'search',
'status' => 'queued',
'region_code' => $regionCode,
'params' => [
'examples' => $examples,
'about_self' => $aboutSelf,
'include_federal' => $includeFederal,
],
]);
RunAutopodborSearchJob::dispatch($run->id);
return $run;
}
public function startStudy(int $tenantId, int $competitorId): AutopodborRun
{
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
if ($comp->studied_at !== null) {
return $comp->studyRun;
}
$this->assertNoInFlight($tenantId, 'study');
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'study',
'status' => 'queued',
'region_code' => $comp->searchRun?->region_code,
'competitor_id' => $comp->id,
'params' => [],
]);
RunAutopodborStudyJob::dispatch($run->id);
return $run;
}
/**
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
*
* @param array{name:string, site_url:?string, directory_urls:array} $competitorData
*/
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
{
$this->assertNoInFlight($tenantId, 'study');
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
$comp = AutopodborCompetitor::create([
'tenant_id' => $tenantId,
'search_run_id' => null,
'name' => $competitorData['name'],
'origin' => 'manual',
'relevance_pct' => null,
'site_url' => $competitorData['site_url'] ?? null,
'directory_urls' => $competitorData['directory_urls'] ?? [],
'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null),
]);
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'study',
'status' => 'queued',
'region_code' => $regionCode,
'competitor_id' => $comp->id,
'params' => [],
]);
RunAutopodborStudyJob::dispatch($run->id);
return $run;
}
public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun
{
$this->assertNoInFlight($tenantId, 'resolve');
// resolve бесплатный — без priceGate
$run = AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'resolve',
'status' => 'queued',
'region_code' => $regionCode,
'params' => ['name' => $name],
]);
RunAutopodborResolveJob::dispatch($run->id);
return $run;
}
}
@@ -42,7 +42,7 @@ final class YooKassaDriver implements PaymentGatewayDriver
->post(self::BASE.'/payments', $payload);
if (! $resp->successful()) {
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status());
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status().' body='.$resp->body());
}
$id = (string) $resp->json('id');
@@ -63,7 +63,7 @@ final class YooKassaDriver implements PaymentGatewayDriver
->get(self::BASE.'/payments/'.$gatewayPaymentId);
if (! $resp->successful()) {
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status());
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status().' body='.$resp->body());
}
return new WebhookVerifyResult(
@@ -6,6 +6,7 @@ namespace App\Services\Billing;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Models\User;
use App\Services\Billing\Gateway\CreatePaymentResult;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use Illuminate\Support\Str;
@@ -41,7 +42,25 @@ final class OnlineTopupService
'created_at' => now(),
]);
$result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, null);
// Чек 54-ФЗ обязателен на стороне магазина ЮKassa (фискализация включена) —
// без секции receipt платёж отклоняется 400 "Receipt is missing". Формируем
// всегда. vat_code=1 = «без НДС» (ИП на УСН; проверено живым запросом 26.06.2026).
$email = $userId !== null ? User::query()->whereKey($userId)->value('email') : null;
$email = is_string($email) && $email !== '' ? $email : (string) config('mail.from.address', 'info@liderra.ru');
$receipt = [
'customer' => ['email' => $email],
'items' => [[
'description' => 'Пополнение баланса Лидерра',
'quantity' => '1.00',
'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
'vat_code' => 1,
'payment_mode' => 'full_prepayment',
'payment_subject' => 'service',
]],
];
$result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, $receipt);
$tx->gateway_payment_id = $result->gatewayPaymentId;
$tx->save();
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Services\Dashboard;
/**
* Чистая логика светофора балансов внешних сервисов: «хватит на N дней» + цвет.
* Без БД/сети unit-тестируема. Светофор по ДВУМ правилам (решение владельца 28.06):
* 🔴 баланс < red_floor ИЛИ дней_осталось < 3
* 🟡 баланс < amber_floor ИЛИ дней_осталось < 7
* 🟢 иначе
*
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
*/
class BalanceHealth
{
/**
* @return array{days_left:?int,light:string}
*/
public static function evaluate(
float $balance,
?float $dailySpend,
float $redFloor,
float $amberFloor,
): array {
// Отрицательный/нулевой баланс → денег уже нет: 0 дней (не отрицательное «−1 дн.»).
$days = ($dailySpend !== null && $dailySpend > 0)
? max(0, (int) floor($balance / $dailySpend))
: null;
$light = 'green';
if ($balance < $amberFloor || ($days !== null && $days < 7)) {
$light = 'amber';
}
if ($balance < $redFloor || ($days !== null && $days < 3)) {
$light = 'red';
}
return ['days_left' => $days, 'light' => $light];
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Services\Dashboard;
/**
* Сверка заказа у поставщика для дашборда: спрос клиентов надо по формуле
* заказали по факту совпадает ли. Чистая логика (без БД), тестируема.
*
* Формула = SupplierQuotaAllocator::computeOrder = max(max(лимитов), ceil(сумма/3)).
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
*/
class SupplyReconciliation
{
/**
* @param list<array{signal_type:string,identifier:string,demand:int,max_limit:int}> $demand
* @param array<string,int> $orderedByKey ключ "signal_type|identifier" => SUM(current_limit)
* @return array{groups:list<array{signal_type:string,identifier:string,demand:int,formula:int,ordered:int,in_sync:bool}>,totals:array{demand:int,formula:int,ordered:int,mismatches:int}}
*/
public static function build(array $demand, array $orderedByKey): array
{
$groups = [];
$sumDemand = 0;
$sumFormula = 0;
$sumOrdered = 0;
$mismatches = 0;
foreach ($demand as $d) {
$formula = max((int) $d['max_limit'], (int) ceil($d['demand'] / 3));
$key = $d['signal_type'].'|'.$d['identifier'];
$ordered = (int) ($orderedByKey[$key] ?? 0);
$inSync = $formula === $ordered;
$groups[] = [
'signal_type' => (string) $d['signal_type'],
'identifier' => (string) $d['identifier'],
'demand' => (int) $d['demand'],
'formula' => $formula,
'ordered' => $ordered,
'in_sync' => $inSync,
];
$sumDemand += (int) $d['demand'];
$sumFormula += $formula;
$sumOrdered += $ordered;
if (! $inSync) {
$mismatches++;
}
}
return [
'groups' => $groups,
'totals' => [
'demand' => $sumDemand,
'formula' => $sumFormula,
'ordered' => $sumOrdered,
'mismatches' => $mismatches,
],
];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Переходник на один внешний платный сервис: читает его баланс.
* Изоляция: fetch() НЕ бросает любую ошибку (сеть/доступ/парсинг) заворачивает
* в BalanceReading::fail(), чтобы падение одного сервиса не роняло плитку.
*/
interface BalanceProvider
{
/** dadata | supplier | yandex_cloud */
public function serviceKey(): string;
public function fetch(): BalanceReading;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Carbon;
/**
* Снимок баланса одного внешнего сервиса. Иммутабельный DTO результат провайдера.
* Провайдер НЕ бросает исключения наружу: ошибку заворачивает в self::fail().
*/
final class BalanceReading
{
public function __construct(
public readonly string $serviceKey,
public readonly ?float $balance,
public readonly string $currency,
public readonly ?float $dailySpend,
public readonly bool $ok,
public readonly ?string $error,
public readonly Carbon $checkedAt,
) {}
public static function ok(string $key, float $balance, string $currency, ?float $dailySpend): self
{
return new self($key, $balance, $currency, $dailySpend, true, null, now());
}
public static function fail(string $key, string $error): self
{
return new self($key, null, 'RUB', null, false, mb_substr($error, 0, 500), now());
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* Баланс профиля DaData (резолв региона/ИНН лида). API: GET profile/balance
* с заголовком Authorization: Token <api_key>.
*/
class DadataBalanceProvider implements BalanceProvider
{
public function serviceKey(): string
{
return 'dadata';
}
public function fetch(): BalanceReading
{
try {
$key = (string) config('services.dadata.api_key');
if ($key === '') {
return BalanceReading::fail('dadata', 'DaData api_key не задан');
}
// Эндпоинт profile/balance требует ОБА ключа: Authorization: Token <api_key>
// И X-Secret: <secret> (иначе HTTP 401). secret — тот же, что для cleaner API.
$headers = ['Authorization' => 'Token '.$key, 'Accept' => 'application/json'];
$secret = (string) config('services.dadata.secret');
if ($secret !== '') {
$headers['X-Secret'] = $secret;
}
$resp = Http::timeout(10)
->withHeaders($headers)
->get((string) config('services.dadata.balance_url'));
if (! $resp->ok()) {
return BalanceReading::fail('dadata', 'HTTP '.$resp->status());
}
$balance = (float) ($resp->json('balance') ?? 0);
return BalanceReading::ok('dadata', $balance, 'RUB', $this->dailySpend());
} catch (\Throwable $e) {
return BalanceReading::fail('dadata', $e->getMessage());
}
}
/**
* Оценка расхода/день: вызовы резолва за 7д × стоимость вызова.
* Best-effort любая ошибка подсчёта НЕ должна ронять чтение баланса.
*/
private function dailySpend(): ?float
{
try {
$costRub = ((int) config('services.dadata.call_cost_kopecks', 60)) / 100;
$calls7d = DB::table('supplier_leads')
->where('region_source', 'dadata')
->where('received_at', '>=', now()->subDays(7))
->count();
if ($calls7d === 0) {
return null;
}
return round(($calls7d / 7) * $costRub, 2);
} catch (\Throwable) {
return null;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use App\Services\Supplier\PlaywrightBridge;
use Illuminate\Support\Facades\DB;
/**
* Баланс кабинета поставщика лидов (crm.bp-gr.ru). У кабинета нет JSON-эндпоинта
* баланса читаем со страницы через headless Playwright (тот же логин-флоу, что
* RefreshSupplierSessionJob). Селектор/URL баланса калибруются разведкой на проде
* (см. план Task 6 Step 1); до калибровки скрипт вернёт exit 2 fail «баланс не найден».
*/
class SupplierBalanceProvider implements BalanceProvider
{
public function __construct(private readonly PlaywrightBridge $bridge) {}
public function serviceKey(): string
{
return 'supplier';
}
public function fetch(): BalanceReading
{
try {
$login = (string) config('services.supplier.login');
$password = (string) config('services.supplier.password');
if ($login === '' || $password === '') {
return BalanceReading::fail('supplier', 'Доступ к кабинету поставщика не настроен');
}
$out = $this->bridge->run([
'script' => 'supplier-balance.js',
'login' => $login,
'password' => $password,
'url' => (string) config('services.supplier.portal_url'),
]);
// У кабинета не деньги, а остаток НОМЕРОВ («Баланс ГЦК»). Деньги = номера × цена.
if (! isset($out['numbers']) || ! is_numeric($out['numbers'])) {
return BalanceReading::fail('supplier', 'Остаток номеров не найден в кабинете');
}
$numbers = (int) $out['numbers'];
$price = (float) config('services.supplier.number_price_rub', 20);
return BalanceReading::ok(
'supplier',
$numbers * $price,
'RUB',
$this->dailySpend(),
);
} catch (\Throwable $e) {
return BalanceReading::fail('supplier', $e->getMessage());
}
}
/**
* Оценка расхода/день: лиды за 7д ÷ 7 × средняя цена лида (из конфига).
* Best-effort нет цены или ошибка подсчёта null (светофор только по порогам).
*/
private function dailySpend(): ?float
{
try {
$price = (float) config('services.supplier.avg_lead_price_rub', 0);
if ($price <= 0) {
return null;
}
$leads7d = DB::table('supplier_leads')
->where('received_at', '>=', now()->subDays(7))
->count();
if ($leads7d === 0) {
return null;
}
return round(($leads7d / 7) * $price, 2);
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;
/**
* Баланс биллинг-аккаунта Yandex Cloud (серверы + Managed PG).
* Поток: OAuth-токен IAM-токен (iam/v1/tokens) billing/v1/billingAccounts/{id}.
* Расход/день оценка из конфига (месячный ÷ 30), уточняется по факту.
*/
class YandexCloudBalanceProvider implements BalanceProvider
{
public function serviceKey(): string
{
return 'yandex_cloud';
}
public function fetch(): BalanceReading
{
try {
$oauth = (string) config('services.yandex_cloud.oauth_token');
$acc = (string) config('services.yandex_cloud.billing_account_id');
if ($oauth === '' || $acc === '') {
return BalanceReading::fail('yandex_cloud', 'YC доступ не настроен');
}
$iam = Http::timeout(10)->post((string) config('services.yandex_cloud.iam_url'), [
'yandexPassportOauthToken' => $oauth,
]);
if (! $iam->ok() || ! $iam->json('iamToken')) {
return BalanceReading::fail('yandex_cloud', 'IAM exchange: HTTP '.$iam->status());
}
$resp = Http::timeout(10)
->withToken((string) $iam->json('iamToken'))
->get((string) config('services.yandex_cloud.billing_url').'/'.$acc);
if (! $resp->ok()) {
return BalanceReading::fail('yandex_cloud', 'Billing: HTTP '.$resp->status());
}
$balance = (float) ($resp->json('balance') ?? 0);
$currency = (string) ($resp->json('currency') ?? 'RUB');
$spend = ((float) config('services.yandex_cloud.daily_spend_rub')) ?: null;
return BalanceReading::ok('yandex_cloud', $balance, $currency, $spend);
} catch (\Throwable $e) {
return BalanceReading::fail('yandex_cloud', $e->getMessage());
}
}
}
+3 -8
View File
@@ -619,14 +619,9 @@ class ProjectService
public function create(Tenant $tenant, array $data): Project
{
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
$current = Project::where('tenant_id', $tenant->id)->count();
if ($current >= $limit) {
throw new HttpResponseException(response()->json([
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
], 403));
}
// Лимита по числу проектов нет — ограничение только по балансу/заказанным
// лидам (балансовый префлайт в ProjectController::store). Прежний гейт
// tenants.limits['max_projects'] убран как противоречащий правилу продукта.
$data['tenant_id'] = $tenant->id;
$data['is_active'] = true;
$data['regions'] = $data['regions'] ?? [];
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Services\Autopodbor\Agent\Extract\HtmlPhoneScanner;
/**
* Запасной телефонный код города по коду субъекта РФ (1..89, см. {@see RussianRegions}).
*
* Назначение: достройка коротких локальных номеров на сайте конкурента, КОГДА на странице
* нет ни одного полного номера, по которому код можно вычислить (основной путь
* «код со страницы» в {@see HtmlPhoneScanner}; этот
* справочник лишь запасной).
*
* ВАЖНО ограничение по точности (решение владельца «по столице региона»):
* Берём телефонный код АДМИНИСТРАТИВНОГО ЦЕНТРА субъекта. Для конкурента из НЕ-столичного
* города региона код может отличаться (Норильск 3919 Красноярск 391) поэтому достройка
* «по региону» срабатывает только при отсутствии кода на самой странице.
* Внесены ТОЛЬКО уверенные 3-значные коды (достраивают 7-значный локальный номер). Регионы
* без однозначного 3-значного кода центра НЕ внесены forSubject() вернёт null, и движок
* не выдумывает код (короткий номер уйдёт в «требует проверки»). Карта расширяема.
*
* Ключ каноничное имя субъекта из RussianRegions::CODE_TO_NAME (а не номер строки),
* чтобы исключить ошибку индекса при сопоставлении кода и города.
*/
final class RegionAreaCode
{
/** @var array<string, string> каноничное имя субъекта => 3-значный код адм. центра */
private const AREA_BY_NAME = [
'Москва' => '495',
'Санкт-Петербург' => '812',
'Красноярский край' => '391', // Красноярск
'Новосибирская область' => '383', // Новосибирск
'Свердловская область' => '343', // Екатеринбург
'Республика Татарстан' => '843', // Казань
'Нижегородская область' => '831', // Нижний Новгород
'Самарская область' => '846', // Самара
'Ростовская область' => '863', // Ростов-на-Дону
'Воронежская область' => '473', // Воронеж
'Пермский край' => '342', // Пермь
'Приморский край' => '423', // Владивосток
'Республика Башкортостан' => '347', // Уфа
'Челябинская область' => '351', // Челябинск
'Краснодарский край' => '861', // Краснодар
];
/** 3-значный телефонный код центра субъекта или null, если уверенного кода нет. */
public static function forSubject(int $subjectCode): ?string
{
$name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null;
if ($name === null) {
return null;
}
return self::AREA_BY_NAME[$name] ?? null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Город (слаг 2ГИС + отображаемое имя) административного центра субъекта РФ (1..89,
* см. {@see RussianRegions}). Нужен живому поиску шага 1: 2ГИС ищет по URL вида
* `2gis.ru/<citySlug>/search/<запрос>`, Яндекс по тексту «<ниша> <город>».
*
* Как и {@see RegionAreaCode}: внесены ТОЛЬКО уверенные центры (ключ каноничное имя
* субъекта из RussianRegions::CODE_TO_NAME); неизвестный субъект null, и движок не
* выдумывает город (для 2ГИС просто пропустит этот источник, останется Яндекс по имени субъекта).
* Карта расширяема.
*/
final class RegionCity
{
/** @var array<string, array{slug:string, name:string}> имя субъекта => {слаг 2ГИС, имя города} */
private const BY_NAME = [
'Москва' => ['slug' => 'moscow', 'name' => 'Москва'],
'Санкт-Петербург' => ['slug' => 'spb', 'name' => 'Санкт-Петербург'],
'Красноярский край' => ['slug' => 'krasnoyarsk', 'name' => 'Красноярск'],
'Новосибирская область' => ['slug' => 'novosibirsk', 'name' => 'Новосибирск'],
'Свердловская область' => ['slug' => 'ekaterinburg', 'name' => 'Екатеринбург'],
'Республика Татарстан' => ['slug' => 'kazan', 'name' => 'Казань'],
'Нижегородская область' => ['slug' => 'n_novgorod', 'name' => 'Нижний Новгород'],
'Самарская область' => ['slug' => 'samara', 'name' => 'Самара'],
'Ростовская область' => ['slug' => 'rostov', 'name' => 'Ростов-на-Дону'],
'Воронежская область' => ['slug' => 'voronezh', 'name' => 'Воронеж'],
'Пермский край' => ['slug' => 'perm', 'name' => 'Пермь'],
'Приморский край' => ['slug' => 'vladivostok', 'name' => 'Владивосток'],
'Республика Башкортостан' => ['slug' => 'ufa', 'name' => 'Уфа'],
'Челябинская область' => ['slug' => 'chelyabinsk', 'name' => 'Челябинск'],
'Краснодарский край' => ['slug' => 'krasnodar', 'name' => 'Краснодар'],
];
/** Слаг города для URL 2ГИС (`krasnoyarsk`) или null, если уверенного нет. */
public static function slug(int $subjectCode): ?string
{
return self::entry($subjectCode)['slug'] ?? null;
}
/** Имя города («Красноярск») или имя субъекта как запасной вариант (для запроса Яндекса). */
public static function name(int $subjectCode): ?string
{
$name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null;
return self::entry($subjectCode)['name'] ?? $name;
}
/** @return array{slug:string, name:string}|array{} */
private static function entry(int $subjectCode): array
{
$name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null;
if ($name === null) {
return [];
}
return self::BY_NAME[$name] ?? [];
}
}
+2
View File
@@ -4,6 +4,7 @@ use App\Http\Middleware\ApiKeyAuth;
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\ImpersonationContext;
use App\Http\Middleware\SetTenantContext;
use App\Http\Middleware\UseAdminConnection;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application;
@@ -27,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
'admin-db' => UseAdminConnection::class,
'apikey' => ApiKeyAuth::class,
]);
+1
View File
@@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
App\Providers\AutopodborServiceProvider::class,
];
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Живой движок поиска конкурентов (шаг 1)
|--------------------------------------------------------------------------
|
| true findCompetitors использует НАСТОЯЩИЙ движок (ниша поиск 2ГИС/Яндекс
| резолв сборка). false демо-заглушка FakeCompetitorAgent. По умолчанию ВЫКЛ
| включается осознанно (локально/за тумблером), т.к. ходит в живые сервисы.
|
*/
'real_find' => env('AUTOPODBOR_REAL_FIND', false),
];

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