Commit Graph

112 Commits

Author SHA1 Message Date
Дмитрий dee2ebbcf8 feat(автоподбор): второй справочник канала А — Яндекс.Карты через локальный Playwright + слияние
Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright.
render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС).
YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка
(сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс,
слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну
карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml →
общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:06:10 +03:00
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 3c8886c97f feat(автоподбор): stripBadge — чистое имя конкурента без значка
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:33:11 +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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 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
Дмитрий 63a2d53255 fix/projects: смена лимита-региона-дней на защищённом проекте больше не блокируется ложно как смена источника
Симптом: на проекте, по которому уже идут лиды от поставщика, правка только лимита, региона или дней отдавала 422 «Изменить источник можно будет после N» — хотя источник не менялся. Найдено приёмкой 25.06.2026 глазами через Playwright. Дефект на main, то есть живой на боевом liderra.ru.

Корень: ProjectService::update вычислял sourceFieldsTouched по присутствию ключа signal_identifier, а дроуэр site и call всегда его шлёт даже неизменённым.

Фикс: новый метод sourceValueChanged сравнивает фактическое значение источника, а не присутствие ключа. Guard срабатывает только на реальную смену источника.

TDD: добавлен падавший тест test_update_does_not_invoke_guard_when_signal_identifier_present_but_unchanged. Larastan чист, phpstan-baseline обновлён под Mockery-шум. Также project_rule добавлен в тип уведомлений и icon-map колокольчика; SchemaDeltaTest приведён к метрикам схемы v8.55 после 2 новых таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:34:55 +03:00
Дмитрий 91690c4fd9 fix/test: ProjectServiceGuardWiring — мок ждёт isProtected (Эпик 6.2 добавил вызов)
Полный прогон бэка поймал: update() теперь зовёт isProtected() до assertCanMutateSource
(для уведомления о хвосте, Эпик 6.2), а wiring-мок этого не ждал → Mockery error.
Добавлено shouldReceive('isProtected'). 3/3. Единственная регрессия из полного прогона (883 теста).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:53:16 +03:00
Дмитрий ca923e4da4 fix/ui: объявления о датах — актуальны по времени суток (правило 18:00/21:00 МСК)
Найдено проверкой глазами в 19:27 МСК (после 18:00):
1. Баннер правок количества/региона/дней говорил «вступят со следующего дня» — врал
   после 18:00 (правка не попадает в сегодняшний слепок → реально послезавтра). Теперь
   показывает АКТУАЛЬНУЮ дату через firstLeadDate (до 18:00 → завтра, после → послезавтра):
   «…вступят в силу с 27 июня». Дроуэр + окно «Редактировать».
2. Сообщение блокировки удаления/смены источника в SupplierSnapshotGuard было захардкожено
   «мы увидим это сегодня в 18:00 … можно будет послезавтра» — после 18:00 «сегодня в 18:00»
   уже прошло. Теперь time-aware через computeGraceUntil: «…лиды придут до 26 июня … можно
   будет после 26 июня».

Проверено глазами: баннер лимита (27 июня), подтверждение источника (до 26 июня),
блок удаления (после 26 июня) — все согласованы и меняются по времени суток. Тесты:
guard 30/30, фронт 38/38, leadDate (18:00 порог) зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:40:04 +03:00
Дмитрий be08239634 feat/projects: единый источник текстов правил сбора для клиента (Эпик 6.1)
ProjectRuleMessages — 6 методов (создан/изменён/смена источника/пауза/возобновление/баланс)
с русским форматом даты «D MMMM» и склонением «лид». Единственный источник текстов:
in-app уведомления (6.2) и баннеры (6.3) тянут отсюда, не дублируют строки. 6/6 тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:56:47 +03:00