Commit Graph

568 Commits

Author SHA1 Message Date
Дмитрий ff36e0c10e fix(visitors): партиции нарезает только штатный механизм + сторож схемы на 80 таблиц
Полный прогон вскрыл реальный баг: миграция создавала партицию site_events_2026_07
своими границами (в местном времени), а MonthlyPartitionManager — site_events_y2026_m07
в UTC. Партиции перекрывались (42P17) и роняли 19 ЧУЖИХ тестов. Теперь партиции
нарезает только менеджер (ensureRange), как у всех остальных таблиц.

Плюс сторож схемы обновлён под +2 таблицы учёта (80 таблиц, 141 индекс) и
записаны готовые ссылки с метками для смс/hh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:22:46 +03:00
Дмитрий 12e8ba0cac feat(visitors): чистка данных старше 180 дней (152-ФЗ)
Гостей чистит visitors:prune (воскресенье 03:15 МСК), партиции событий —
существующий partitions:drop-expired по новой настройке retention=6 месяцев.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:06:27 +03:00
Дмитрий f2ab97f9f6 feat(visitors): админ-API воронки, каналов, визитов и активности в кабинете
Воронка — по уникальным живым гостям (is_datacenter=false); гости с хостингов
и VPN считаются отдельным числом, в конверсию не попадают.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:00:00 +03:00
Дмитрий 202a49590a feat(visitors): события регистрации и входа пишет backend, гость связывается с клиентом
Три точки: обычный вход, вход через 2FA (оба пути — код и резервный),
подтверждение почты (именно там клиент реально создаётся, а не на /register).
Учёт в try/catch — ошибка учёта не может уронить вход. Регресс авторизации 114/114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:56:54 +03:00
Дмитрий 50daf92d6d feat(visitors): маячок POST /api/track + кука гостя на .liderra.ru
Task 4 плана 2026-07-13-visitors-analytics.md: VisitTracker (создание/обновление
гостя, запись событий, привязка к клиенту) + публичный TrackController@store,
маршрут POST /api/track (throttle:track 60/мин), лимитер в AppServiceProvider,
CSRF-исключение api/track в bootstrap/app.php.

Отклонения от плана (тест-харнесс, не прод-код):
- getCookie('lid_vid', false) в тесте убрал decrypt=false — с withCookie() это
  давало двойное шифрование (withCookie сам шифрует plain-значение).
- добавлен withCredentials() перед withCookie()+postJson/getJson — Laravel
  тест-клиент по умолчанию не шлёт cookie на JSON-запросы (как XHR
  credentials:'omit'); реальный маячок шлёт fetch с credentials:'include',
  так что прод не затронут.

Pest: 5/5 (TrackEndpointTest). Pint: app/Services/Tracking + TrackController.
2026-07-13 19:46:48 +03:00
Дмитрий bdd81ec8d5 feat(visitors): таблицы site_visitors + site_events (партиции, GRANT'ы, маскирование ПДн) 2026-07-13 19:21:29 +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
Дмитрий 80c5d90873 feat(privacy): срезать канальный префикс B<N>_ в API сделок и CSV-экспорте
Серверный 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
Дмитрий d0d2fe3b11 feat(autopodbor): endpoint POST /api/autopodbor/study/batch 2026-07-09 09:45:11 +03:00
Дмитрий 409c6b0959 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
Дмитрий 9362cda148 feat(autopodbor): startStudyBatch + перевод одиночных сборов на распорядителя 2026-07-09 09:45:10 +03:00
Дмитрий e70bb9eafe 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
Дмитрий 2248feafa8 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 9f0f5cfc59)
2026-07-09 09:44:52 +03:00
Дмитрий 9a1666ad3a 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 46607d702a)
2026-07-09 09:44:51 +03:00
Дмитрий 027071b40f 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
Дмитрий 98614604af 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
Дмитрий 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
Дмитрий fa7b2000e8 chore(autopodbor): кнопка «Отказаться» (было «Удалить») + автоподбор в CSV списаний + canon-sync schema.sql v8.63
- Кнопка на актуализации переименована «Удалить»→«Отказаться» (чтобы клиент не путал с удалением конкурента).
- 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
Дмитрий 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
Дмитрий aa24273355 fix(billing+autopodbor): автоподбор-списания в табе «Списания» + фикс вёрстки плашек «Предложений»
- «Списания» (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
Дмитрий 798b33366f fix(autopodbor): лимит 0 = «не режем» (защита обрезки) + авто-повтор при пустом сборе
Баг: при 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
Дмитрий 9b5e29980a fix(autopodbor): выставлять tenant-контекст ДО чтения прогона в джобах (прод-сбой под RLS)
Джобы 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
Дмитрий 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
Дмитрий 3f1d59852d 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
Дмитрий 428acf5d8e 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
Дмитрий 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
Дмитрий 7eaa7079b4 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
Дмитрий 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
Дмитрий 4e2129227c 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
Дмитрий 2aedb54dc5 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
Дмитрий 853c5a00d5 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
Дмитрий 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
Дмитрий 1258b9bbed 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
Дмитрий 70ca25762a 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
Дмитрий 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
Дмитрий d4a32f16b8 fix(autopodbor): проект принадлежит ровно одному источнику и понятная причина отказа
Инвариант «один проект = один источник»: при переносе проекта на новый источник
снимаем привязку со всех прочих источников — проект больше не может висеть в двух
конкурентах сразу (жёсткий баг с дублями). Плюс разбор ошибки в автоподборе
показывает конкретную причину бэкенда вместо общей фразы «не удалось».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:02:40 +03:00
Дмитрий 44610812a8 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
Дмитрий 8fef74f25e 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
Дмитрий c3d0d24bc4 feat(autopodbor): при слиянии конкурентов проекты переименовываются в имя выжившего
Имя проекта следует за конкурентом: после склейки дублей все проекты объединённого
получают имя выжившего конкурента, при совпадении различаем источником, а не цифрой.
Проекты не удаляются и не рвутся — меняется только имя.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 05:28:24 +03:00
Дмитрий ac27a59baa 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
Дмитрий 4e5ed0995d 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
Дмитрий f807bcd455 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
Дмитрий 1cdaf9cbbb feat(autopodbor): при слиянии выбор имени объединённого из найденных
Диалог дублей: селект имени среди выбранных к слиянию, по умолчанию первое.
Merger и ручка принимают опциональное имя. Обе экрана.

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

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