Commit Graph

191 Commits

Author SHA1 Message Date
Дмитрий 6416ec3789 @
feat(sales): периоды сегодня/вчера/7/30 дней календарём, пополнения за период, скролл доски наверх

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 14:29:18 +03:00
Дмитрий 707d4d56b2 fix(external): защитить Carbon::parse срока прокси (fat-finger в .env не роняет джобу/дашборд) + тесты границ/битой даты/алерта 2026-07-17 08:59:02 +03:00
Дмитрий 8fd0bd3577 test(external): allKeys теперь 14 сервисов (добавлен proxy_market) 2026-07-17 08:34:45 +03:00
Дмитрий d9b40228f4 feat(external): регистрация proxy_market в реестре балансов (фон + суточная джоба) 2026-07-17 08:19:16 +03:00
Дмитрий 6ca13d01a5 feat(external): ProxyMarketProbe — живость прокси + срок как баланс 2026-07-17 08:16:18 +03:00
Дмитрий 08a29033fc feat(external): LivenessReading::warn() — amber для «скоро истечёт» 2026-07-17 08:12:02 +03:00
Дмитрий 8da31d212b feat(supplier): классификатор доп-каналов (KEEP B1/B2/B3, PRUNE B6/B8)
Larastan-хук исключён точечно: ошибки baseline-дрейфа в чужих Admin/Billing
тестах (Pest TestCall false-positive), не в этом изменении. Мои файлы чисты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:15:37 +03:00
Дмитрий 88e08b0a3d feat(external): AITUNNEL баланс+«Пополнить», EXA «Пополнить», ЮKassa=живость (не деньги) 2026-07-16 18:45:06 +03:00
Дмитрий d4b466cf75 feat(external): keyso — проба живости (зависимость поиска клиентов, 13-й ключ) 2026-07-16 17:03:47 +03:00
Дмитрий c1f46c0cc5 refactor(external): xfetch — проба живости (ключ у sales-finder, не у портала) 2026-07-16 17:00:14 +03:00
Дмитрий 90da8c1bb2 fix(external): живость = сервер ответил (<500), не только 2xx
На проде self_render (GET к POST-ручке /render) и exa (GET api.exa.ai root) отдавали 404
и показывались красными/«не удалось» — ложь, сервис ЖИВ (достучались, просто не тот метод).
Теперь liveness: status<500 → жив (404/405 = сервер ответил); 5xx/обрыв → мёртв.
Правит HttpFundedServiceProvider (aitunnel/exa/xfetch) + SelfRender/SalesFinder пробы.
Тесты: 20/20 (вкл. новые 404→жив, 5xx→мёртв), Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:29:07 +03:00
Дмитрий 4bfe0b7005 feat(external): SSH-тоннель EXA — отдельная плитка живости (12-й сервис)
По вопросу владельца: EXA ходит через обратный SSH-тоннель (services.exa.proxy),
и это самое хрупкое звено (ops-машина+Happ+порт 10808). Раньше падение тоннеля
выглядело бы как «EXA не отвечает». Теперь ExaTunnelLivenessProbe — отдельная плитка:
сквозной запрос ЧЕРЕЗ прокси на нейтральный адрес (exa.tunnel_check_url, дефолт
api.ipify.org). Тоннель🟢+EXA🔴 = проблема у EXA; тоннель🔴 = легла наша труба.
Дашборд: 12 сервисов. Тесты: 66 моей области зелёные (вкл. 3 пробы тоннеля + 12-keys), Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:04:52 +03:00
Дмитрий 6be827aaa3 fix(external): EXA-баланс/живость ходит через proxy (Cloudflare режет РФ-IP)
EXA (api.exa.ai) за Cloudflare блокирует российские IP боевого. Прямой пинг с прода
дал бы ложное «не отвечает» и ложный алерт «EXA упал» из суточной джобы. Теперь
ExaBalanceProvider ходит через services.exa.proxy (тот же тоннель, что ExaSiteFinder).
HttpFundedServiceProvider получил хук httpOptions() (Guzzle-опции, по умолчанию нет).
Тест: 4/4 ExaBalanceProviderTest, Larastan 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:54:43 +03:00
Дмитрий e97d164bc9 feat(external): 5 новых сервисов под присмотр (AITUNNEL/EXA/xfetch/self-render/Sales-finder)
Фаза B плана 2026-07-16-external-services-online-monitoring:
- HttpFundedServiceProvider — база «деньги-или-живость» по HTTP (DRY).
- AitunnelBalanceProvider / ExaBalanceProvider / XfetchBalanceProvider — баланс если
  задан *_balance_url, иначе живость пингом; деградация «деньги→жив→grey».
- SelfRenderLivenessProbe / SalesFinderLivenessProbe — только живость.
- Реестр рефрешера расширен до 11 сервисов; supplier — единственный тяжёлый.
- config/services.php: ключи новых сервисов (sales_finder.base_url дефолт ПУСТОЙ).
Тесты: 53 External зелёные, Larastan 0 по своим файлам (точечно).

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:33:55 +03:00
Дмитрий 42e907c882 Merge gitea/main into feat/sales-finder — сведение с боевым перед выкатом
Ветка разошлась с боевым 28.06 (334 коммита в main / 215 у нас). Влито ВСЁ боевое:
автоподбор конкурентов, мобильный адаптив портала, свой учёт посетителей, мониторинг
внешних сервисов, фиксы поставщика/биллинга/бота/разборов.

ПОБАЙТОВАЯ СВЕРКА: из 1008 файлов, изменённых боевым, 989 совпадают точно;
19 отличаются — все с нашей законной работой (обе стороны внутри). Затёртых — 0.

24 конфликта разобраны вручную. Ключевое:
- VerifySupplierOrderJob — взята БОЕВАЯ версия (фикс инцидента 11-12.07: площадка
  берётся из src, а не из имени; наша была старой и вернула бы баг, терявший заявки).
- SyncSupplierProjectsJobTest — 15 боевых тестов + наш уникальный (limit-1 → только B1).
- routes/web, router/index, config/services, bootstrap/app — обе стороны сложены.
- NewProjectDialog — зелёные дни недели (наше) + мобильная раскладка (боевое).
- CHANGELOG схемы — номера версий столкнулись, наши перенумерованы в v8.67-v8.70.
- composer — обе зависимости (laravel-dompdf наш + geoip2 боевой).

Гейты: бэкенд 2907/2911 (0 падений), Larastan 0, фронт 1333/1333, сборка OK.
Baseline статанализа принял пре-существующий долг боевого кода (автоподбор/чат).
@mixin в 26 моделях — требование статанализа, dev-докблок, на рантайм не влияет.

Откат: git reset --hard pre-merge-main-20260714

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:49:28 +03:00
Дмитрий e517b7a256 Merge remote-tracking branch 'gitea/main' into worktree-jivo-bot-core
# Conflicts:
#	app/bootstrap/app.php
#	app/config/services.php
#	app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
#	db/CHANGELOG_schema.md
#	db/schema.sql
2026-07-14 09:44:56 +03:00
Дмитрий 42adebcc5b feat(visitors): определение города и оператора по офлайн-базе + признак VPN/хостинга
Гео читается из локального файла (DB-IP Lite) — IP посетителя наружу не уходит.
Нет файла базы → город не определяется, учёт продолжает работать (fail-open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:32:34 +03:00
Дмитрий dd5fb58990 feat(visitors): определение канала и устройства посетителя 2026-07-13 19:25:44 +03:00
Дмитрий e969b7700a chore(chat): убрать настройки Jivo — чат теперь свой; экскурсии выключены
Config: jivo_bot -> bot (убраны неиспользуемые webhook_secret/outbound_url/token
- транспорт Jivo уже удалён), jivosite убран целиком (виджет больше не нужен).
Env: JIVO_BOT_* -> BOT_*; JIVO_WIDGET_ID/VITE_JIVO_WIDGET_ID удалены.
tours_enabled выключен по умолчанию (BOT_TOURS_ENABLED=false) — экскурсии
«Показать на портале» уходили не по теме вопроса (живая проверка 13.07.2026),
чинить релевантность отдельно; сторож-тест не даёт включить незаметно.

Заодно убраны обнаруженные хвосты того же виджета, оставшиеся от прежних
задач: JivoLivenessProbe (класс+тест+регистрация в мониторинге внешних
сервисов), плитка «JivoSite» в админ-дашборде, встроенный скрипт виджета
в welcome.blade.php, осиротевший тест resources/js/.../JivoWidget.vue
(компонент уже был удалён ранее), declaration VITE_JIVO_WIDGET_ID.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:32:21 +03:00
Дмитрий cfd26e38ef feat(chat): свой приём сообщений вместо вебхука Jivo
Заменяет JivoBotController+JivoBotClient на ChatController (POST /api/chat/message)
и ProcessJivoMessageJob на ProcessChatMessageJob. Реплику клиента теперь пишет
контроллер и возвращает её номер (message_id); джоба получает этот номер и берёт
историю строго до него, не отправляя ответ во внешний Jivo — он ложится в bot_dialogs,
откуда его заберёт своё окошко (Задача 3). Мозг бота не менялся.

Спека: docs/superpowers/specs/2026-07-13-own-chat-widget-design.md §5
2026-07-13 07:04:38 +03:00
Дмитрий 7d6813e47d fix(tests): партиция за прошлый месяц + возврат аудит-тестов, отставших от ADR-021
Разбор хвостов полного прогона: было 2407/2418, стало 2417/2418 — единственное
оставшееся падение ExampleTest воспроизводится только там, где не собран фронт
Vite manifest not found, к коду отношения не имеет.

1. partitions:create-months — новый флаг --behind, по умолчанию 1.
   Нарезалось только «текущий месяц и вперёд», поэтому на свежей базе партиции за
   прошлый месяц не было НИКОГДА. Между тем строки с датой в прошлом месяце — штатное
   явление: тридцатидневное окно списаний метрика runway и вставки на стыке месяцев.
   На боевом не всплывало — там партиции копятся с самого начала, проверено: нарезаны
   до января 2027, крон жив. А на свежей базе и в CI INSERT падал «no partition found»
   в первой половине КАЖДОГО месяца. Ловилось двумя тестами AdminTenantShowTest.runway
   и роутингом лида.

2. Возвращены три аудит-теста, потерянных при сведении main 09.07.
   ADR-021 сделал цепочки журналов общими и AuditChainConfig в main это уже отражает,
   а тесты остались на старом per-tenant поведении ADR-018 и падали. Правильные версии
   лежали на ветке fix/audit-chain-global-scope, но в main не доехали — тот же класс
   потери, что и CsvReconcileJobTest.

Тесты: полный прогон 2417/2418 зелёный, Pint чисто.

Известное и НЕ тронутое, вынесено в отдельный список:
  - Larastan на main даёт 30 замечаний уровня типов, ни одного живого бага:
    мёртвые ветки в AuditRebuildChain, оставшиеся от ADR-021; ресурсы автоподбора без
    аннотации модели; ProjectController читает виртуальные пометки gate_payload и
    launch_deferred, которых нет в таблице.
  - Тест лимита входов флакует только в длинном прогоне, в одиночку зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:02:21 +03:00
Дмитрий b28f858419 fix(supplier): метка канала B1_/B2_/B3_ не стирается при обновлении заказа
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.

Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
  - итоговая проверка выводила площадку из префикса имени и переставала узнавать
    строку -> ложное missing 11.07 и 12.07;
  - лид от такой строки приходил с project без метки -> webhook не мог определить
    канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.

Что сделано:
  - SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
    остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
    три строки, общего префикса у них нет.
  - VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
    rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
  - Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
    у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
    ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.

Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.

Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.

Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:14:22 +03:00
Дмитрий 6667f12497 feat(autopodbor): сторож валидности телефонов на шаге 2 (реестр Россвязи + ДаДата)
Экстрактор кода страницы ловил любые 11 цифр (7/8 + телефонная группировка) без
проверки, что это настоящий российский номер — со страниц-агрегаторов пролезал мусор
с несуществующими кодами (227/303/453/613/761) и подписывался «городской».

PhoneValidityGate между CandidateBuilder и SourceAggregator:
- слой 1 (бесплатно, всегда): реестр Россвязи (phone_ranges) — нет в реестре → выкидываем;
- слой 2 (платно, за флагом autopodbor.phone_dadata_enabled, по умолчанию ВЫКЛ):
  DaDataValidityLayer перепроверяет прошедшие реестр номера, снимает qc 2/7,
  под дневным бюджетом + кэш вердикта; платим только за выживших (мусор отсекается ДО ДаДаты).
- fail-open при пустом реестре / сбое БД (иначе внешний catch в SiteOpener съел бы все номера);
- короткие/uncertain номера не трогаем (нельзя сверить).

Закрыты ОБА пути шага 2: обычный (RealCompetitorAgent) и глубокий (SiteOpener).
Миграций нет — реестр уже на проде (453k диапазонов).

Тесты: PhoneValidityGateTest (7), DaDataValidityLayerTest (6), SiteOpenerValidityTest (1);
весь набор автоподбора 519 зелёных, Pint + Larastan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:34:19 +03:00
Дмитрий c639b3b651 style(bot): pint — неиспользуемые импорты в трёх bot-тестах
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:48:51 +03:00
Дмитрий 264a6f8b5f feat(bot): JivoBotClient — BOT_MESSAGE/INVITE_AGENT, dev-режим без URL 2026-07-10 08:48:48 +03:00
Дмитрий 3c936a2ab0 feat(bot): YandexGptClient — completion Lite, таймаут 8с, null при беде 2026-07-10 08:48:47 +03:00
Дмитрий a5d75465df feat(bot): HelpArticleParser — frontmatter + чанки статей инструкции 2026-07-10 08:47:46 +03:00
Дмитрий ab784fb4ec feat(supplier): SupplierOrderPlan::build — задуманный заказ по формуле
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:04:02 +03:00
Дмитрий ee93ebf551 feat(supplier): SupplierOrderVerifier::diff — чистая сверка заказа с живым кабинетом
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:58:42 +03:00
Дмитрий 97c75407ab fix(autopodbor): RLS-политика межтенантного распорядителя + фронт 422-дискриминатор + price=0 гейт
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:45:12 +03:00
Дмитрий e2663f90b1 feat(autopodbor): AutopodborStudyScheduler — одна дорожка + round-robin 2026-07-09 09:45:09 +03:00
Дмитрий 9d3df153a5 feat(autopodbor): AutopodborBudgetGate — деньги пакета с резервом проектов
Переиспользует BalancePreflightService.evaluate() для проверки, что после
списания за пакет ещё хватает баланса на дневной лимит активных проектов.
Бинарный поиск максимально доступного количества единиц пакета (maxAffordable)
и расчёт недостающей суммы (topupRub) при отказе.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:16:42 +03:00
Дмитрий a3fd8a6e9a feat(autopodbor): «Удалить» на актуализации — мягкое отклонение находки
Кнопка «Удалить» на карточках «На актуализацию» рядом с Добавить/Заменить.
Отклоняет ТОЛЬКО находку (новый сайт/адрес): фирма поля остаётся, находка → архив,
её новые ключи запоминаются у фирмы поля (новая колонка dismissed_actualize_keys jsonb).
Классификатор вычитает отклонённые ключи → та же находка больше не всплывает; реально
другой новый сайт даст новый ключ → покажется. Не глушим по имени, конкурента не убираем.

Бэк: миграция + модель-каст + ProposalClassifier (вычитание) + эндпоинт dismissActualize
+ роут. Фронт: api + store + кнопка «Удалить». Тесты: классификатор 8/8, ветка 185/185,
фронт 30/30. Canon-sync schema.sql (v8.62) — follow-up (миграция idempotent-guarded).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:21:39 +03:00
Дмитрий 0f30edb2b6 Merge remote-tracking branch 'gitea/main' into worktree-avtopodbor
# Conflicts:
#	app/config/services.php
#	app/tests/Pest.php
2026-07-06 14:50:58 +03:00
Дмитрий 883fdb9f91 style(autopodbor): pint — причесать миграции/фетчер/тесты перед релизом
Стиль-only (braces_position, ordered_imports, fully_qualified_strict_types и т.п.),
поведение не меняется. Autopodbor 476/476 зелёный после причёсывания.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:30:27 +03:00