Commit Graph

569 Commits

Author SHA1 Message Date
Дмитрий 7d0bfcd758 style(bot): pint — неиспользуемые импорты в трёх bot-тестах
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:48:51 +03:00
Дмитрий 84cd60aa00 test(bot): бюджет скорости — p95 нашей части тракта < 500мс 2026-07-10 08:48:50 +03:00
Дмитрий 94e6c060d2 feat(bot): JivoBotController — webhook /api/webhook/jivo/{secret}, мгновенный ack 2026-07-10 08:48:50 +03:00
Дмитрий ea1ef833a1 feat(bot): ProcessJivoMessageJob — оркестратор, очередь bot, журнал+latency 2026-07-10 08:48:49 +03:00
Дмитрий 27b93dec35 feat(bot): BotAnswerService — стоп-темы, строгий промпт, tour-ссылка под флагом 2026-07-10 08:48:49 +03:00
Дмитрий fac46eff55 feat(bot): KnowledgeSearch — FTS russian top-N с ранжированием 2026-07-10 08:48:47 +03:00
Дмитрий 1c01fdc29e feat(bot): help:rebuild-knowledge — индексация статей в knowledge_chunks + schedule 04:30 2026-07-10 08:48:45 +03:00
Дмитрий d3b8013769 test(bot): SchemaDelta ожидания v8.58 — 76 таблиц / 130 индексов (+knowledge_chunks, +bot_dialogs)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:47:45 +03:00
Дмитрий f5ca0d52b1 feat(bot): таблица bot_dialogs — журнал диалогов бота 2026-07-10 08:46:47 +03:00
Дмитрий 8773f6ba0e feat(bot): таблица knowledge_chunks — база знаний бота (FTS russian + GIN) 2026-07-10 08:46:46 +03:00
Дмитрий 46a89537eb feat(privacy): срезать канальный префикс B<N>_ в API сделок и CSV-экспорте
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Серверный 3-й слой защиты (SupplierProjectName::strip) в DealController,
V1/DealsController, DealExportController — паритет с фронтом stripChannelPrefix.
На входе префикс и так срезается (RouteSupplierLeadJob/CsvLeadsParser), это
belt-and-suspenders на случай будущих правок парсера. Тесты: API+CSV не палят B<N>_.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:21:59 +03:00
Дмитрий 23da9550f3 feat(autopodbor): endpoint POST /api/autopodbor/study/batch 2026-07-09 09:45:11 +03:00
Дмитрий f667a1079d feat(autopodbor): джоба зовёт распорядителя после завершения + крон autopodbor:study-tick
Задача 5: RunAutopodborStudyJob::handle() оборачивает существующее тело в
try/finally — после ЛЮБОГО завершения прогона (done/empty/failed, включая
проброшенное исключение) распорядитель AutopodborStudyScheduler::tick()
берёт следующего кандидата с освободившейся дорожки шага 2.

Задача 6: artisan autopodbor:study-tick — крон-страховка раз в минуту на
случай, если дорожка освободилась не через завершение джобы. Без
withoutOverlapping() — требует таблицу cache_locks, которой нет в схеме
(см. комментарий в routes/console.php); overlap уже защищён внутренним
Cache::lock в AutopodborStudyScheduler::tick().
2026-07-09 09:45:10 +03:00
Дмитрий 6c0ed2d300 feat(autopodbor): startStudyBatch + перевод одиночных сборов на распорядителя 2026-07-09 09:45:10 +03:00
Дмитрий 9229323af5 fix(supplier): пауза доходит до поставщика в batch — ночной джоб 18:00 гасит осиротевшие заказы
Боевой в batch-режиме: пауза проекта не доходила до поставщика — заказ висел активным
(галочка вкл, лимит>0), поставщик мог лить лиды и списывать за остановленный проект
(подтв. живой базой: omega, 57 заказов, 0 команд после паузы 08.07).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:35:22 +03:00
Дмитрий a4c9200261 feat(сделки): колонки «Конкурент» и «Источник» со ссылками в автоподбор
Раздел «Сделки»: колонка «Источник» разбита на две — «Конкурент» (ссылка на
экран конкурента) и «Источник» (ссылка на «Настройки проекта»). В модалку
настроек добавлена кнопка пуск/стоп (срабатывает не закрывая окно).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:28:51 +03:00
Дмитрий 7d6516e6b2 feat(autopodbor): журнал слияний + возврат поглощённой карточки
Слияние конкурентов больше не «чёрная дыра» (баг «САПС/Метрополис»: карточка
исчезла без следа, вернуть было нечем). Теперь каждое слияние пишет событие в
autopodbor_merge_events со СНИМКОМ поглощённых карточек (имя/сайт/телефоны/
справочники), кто и когда слил.

- Таблица autopodbor_merge_events (+RLS tenant_isolation, паттерн 1:1 с sources,
  rls-ревью OK; nullOnDelete на survivor_id/user_id — история не рушится).
- AutopodborCompetitorMerger::merge($tenantId,$ids,$name,$userId) пишет событие.
- GET /field/merge-events — история; POST /field/merge-events/{id}/restore —
  возврат поглощённых как предложения (защита от повторного возврата).
- Фронт: панель «🕘 История слияний» + «↩ Вернуть карточку».

Бэк 428/428, фронт 12/12, Pint чистый, build ок. schema.sql канон-синк всех
таблиц автоподбора — предвыкатный TODO (вся фича migration-only в этой ветке).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:07:59 +03:00