Экстрактор кода страницы ловил любые 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>
Серверный 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>
Старая линия main сохранена: тег main-legacy-20260709 + off-site бэкап. Дерево этого
коммита побайтово равно боевому серверу (495/495 бэкенд-файлов сверены).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Задача 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().
Переиспользует BalancePreflightService.evaluate() для проверки, что после
списания за пакет ещё хватает баланса на дневной лимит активных проектов.
Бинарный поиск максимально доступного количества единиц пакета (maxAffordable)
и расчёт недостающей суммы (topupRub) при отказе.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Боевой в 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>
Блок 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)
Убраны письма 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)
Отметка в ПИЛОТ.md обоих моих выкатов: (1) SyncSupplierProjectsJob deactivateOrphanedOrders
(пауза гасит заказ у поставщика в 18:00 до снимка 21:00; бэкап .bak-20260709-023707);
(2) AuditChainConfig global (verify-chains EXIT 0, 78 партиций; бэкап .bak-20260709-060558).
NB про параллельный CSV-reconcile money-фикс (91c5a119) — уже в gitea main, дрейфа нет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Причина фантомных сделок kdv1 (09.07.2026): CsvReconcileJob брал отчёт «Запрос
номеров» = ПУЛ собранных номеров (phones_cnt), а не ОТДАННОЕ (crms_cnt), и лепил
из пула сделки клиенту + выедал дневной лимит. Пример: пул 157, отдано 15,
у клиента 15 фантомов, а 15 реально отданных выбило лимитом.
Фикс: SupplierPortalClient::fetchDeliveredLeads() читает журнал «Мои сделки»
(index-visit, по vid) — только реально отгруженное. CsvReconcileJob сверяет по vid,
добор несёт настоящий vid (idx_supplier_leads_vid_unique -> идемпотентно с webhook).
Пул не трогается -> фантомы невозможны, лимит не забивается мусором.
Проверено: 12 новых тестов + 294 supplier зелёные, Pint/Larastan чисто. Выкачено и
проверено на проде (reconcile читает отданное, 0 фантомов за прогон).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Боевой в 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>
Портал выкатывается полной заменой public/build; сборка из устаревшей ветки
молча сносила чужую работу (инцидент 08.07: Метрика снесла колонки «Сделок»).
deploy-build.sh перед подменой сверяет маркеры критичных фич (по активным
чанкам manifest.json, не по orphan-осколкам) — теряет живую фичу → ОТКАЗ.
Осознанный откат — FORCE=1, только проверка — DRY_RUN=1. Установлен на боевом.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Широкая таблица (7 колонок) не влезала в узкую колонку планшета рядом с
rail-меню — правые колонки «Комментарий»/«Поставлен» обрезались на 600–1279px.
ResponsiveTable теперь показывает карточки на телефоне И планшете, таблицу —
только на десктопе (>=1280). Тесты табличного слоя зафиксированы на десктоп-ширину.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Имя конкурента хранится как «Бренд, описание» — в колонке «Конкурент»
показываем часть до первой запятой, полное имя — в title при наведении.
Применено к десктопной таблице и мобильной карточке. +тест на обрезку.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cherry-pick b35d3888 поверх боевой ветки feat/portal-mobile-adaptive для
выката ТОЛЬКО правки тарифов на прод. Логика денег не менялась.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Влитый из автоподбора deep-link «Сделки → Автоподбор» читал route.query
в onMounted без защиты. В приложении роутер всегда есть, но при монтировании
в юнит-тесте без роутера route === undefined → unhandled error (2 ошибки,
vitest exit 1). Добавил route?.query ?? {} — поведение в бою не меняется,
полный прогон снова зелёный (1184 ✅, exit 0). Правка нужна для чистого
CI-гейта выката мобильной ветки.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
После вливания переделки сделок (колонки Конкурент/Источник со ссылками
в автоподбор) эти поля были только в таблице на компьютере — в карточке
телефона их не было. Добавил в мобильную карточку:
- «Конкурент» — имя конкурента зелёной ссылкой, ведёт в автоподбор
(/autopodbor?competitor=ID), рядом город;
- «Источник» — отдельной строкой домен/идентификатор ссылкой в автоподбор
(+ «· +N» если источников несколько) и канал (Сайт/Звонки/СМС).
Для сделок без источника автоподбора карточка выглядит как раньше
(проект · канал · город). @click.stop — ссылки не открывают деталь сделки.
Проверено на 390px: конкурент+источник видны, вбок не едет, переход
по ссылке в автоподбор работает; тесты DealsTable/mapper/ResponsiveTable
зелёные (23).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Прошёл все экраны портала на 390px, нашёл и починил переполнения:
1. NewProjectDialog: кнопки дней недели (ПН…ВС) в окне редактирования
проекта не влезали — СБ и ВС уезжали за правый край и были не видны.
На телефоне делим ряд поровну между 7 кнопками.
2. DaysBulkDialog (массовое «Дни сбора»): та же беда с двумя рядами дней —
тот же приём (flex 1/1/0 на телефоне).
3. ApiTab: пары кнопок «Копировать+Перегенерировать» и
«Сохранить+Тестовый webhook» не помещались в строку — длинная вторая
кнопка обрезалась. Разрешаем перенос на телефоне (flex-wrap).
4. NotificationsTab: таблица «События × каналы» была шире экрана —
колонка Email уезжала, а названия событий крошились по одному слову.
На телефоне ужимаем колонки-галочки и уменьшаем отступы, событию
достаётся почти вся ширина; заголовки каналов мельче.
Все правки — только в phone-media (<600px), десктоп не затронут.
Проверено замерами DOM + скриншотами на 390px; тесты затронутых
компонентов зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
На узком экране заголовок «Проекты» и кнопка «Создать проект» стояли
вплотную в одну строку. На телефоне (<600px) кнопка теперь под заголовком
во всю ширину-строку, слева, с воздухом между ними; заголовок прижат влево
через align-self (перебивает utility .align-center с !important). Десктоп
не затронут — правка только в phone-media.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Карточка источника (.ld-srccard) — flex-ряд: иконка | данные | блок кнопок
(.ld-srcctl, flex-shrink:0, max-width:320) справа. На телефоне кнопки
(Создать проект/Приостановить/Настройки проекта/В источники/Возобновить)
уезжали за край карточки. На телефоне (media <600px) карточка переносит
блок кнопок на строку ниже во всю ширину. Затрагивает обе под-вкладки
(Источники в работе + Предложения). Проверено сканом: 0 заездов. Тесты 71/71.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Строка счёта была гридом из 4 колонок (дата | номер | сумма | кнопки).
На телефоне при двух кнопках («Счёт» + «Акт» после оплаты) номер крошился,
а кнопки уезжали за край. На телефоне (media <600px) строка в 3 яруса:
номер+сумма, дата+статус, ниже кнопки «Счёт»/«Акт» во всю ширину. Проверено
на засеянном оплаченном счёте с актом. Десктоп (грид 4 колонки) без изменений.
Тесты 39/39 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Лента этапов на телефоне скроллилась вбок (overflow-x), из-за чего после
«Просмотрено» остальные этапы (В работе/Сделка/Не реализовано) уходили за
экран и были не видны, третий чип обрезался. Заменил горизонтальную прокрутку
на flex-wrap — все 5 чипсов-этапов переносятся на строки и видны сразу.
Тесты Канбана 23/23 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Предсуществующий баг BalanceCard: на узких экранах (<960px) ряд трёх плиток
становится столбцом, но flex:1 1 0 (flex-basis:0) схлопывал высоту плиток до
~35px, а overflow:hidden обрезал содержимое — цифры (49 050 ₽, 98 лидов, 4 дн.)
и кнопки «Пополнить» пропадали, оставались пустые заголовки. В столбце плитки
теперь flex:0 0 auto (по высоте контента). Затрагивает телефон и планшет;
десктоп-ряд (>=960px) без изменений. Тесты 52/52 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Строка услуги (название+описание | «при успешном…» 140px | цена) на телефоне
сдавливала название до одной колонки — текст ломался по слову («Сбор конкуре
нтов»). На телефоне (media <600px) раскладываем: название на всю ширину сверху,
ниже строкой «когда списываем» + цена справа. Десктоп/планшет без изменений.
Тесты 113/113 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Диалог массовой правки лимита теперь на телефоне открывается на весь экран
(:fullscreen), единообразно с «Дни оптом»/«Регионы оптом» из той же панели
множественного выбора. Десктоп/планшет без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Панель деталей сделки была фикс. ширины (inline 400px в «Сделках», drawer 480px
в Канбане) — на экране 390 она была шире экрана: в «Сделках» давала горизонтальный
скролл страницы и крошила карточки списка, в Канбане левый край уезжал за экран.
На телефоне:
- «Сделки» — inline-панель стала полноэкранным оверлеем (position:fixed inset:0).
- Канбан — ширина drawer = ширине экрана (useDisplay).
Десктоп/планшет — прежний master-detail 400px / drawer 480px, без изменений.
Тесты 103/103 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Узкие центрированные модалки на телефоне резали содержимое (ряды кнопок,
вкладки, дни недели, сетку регионов — контент вылезал за края, текст обрезался
слева и справа). Диалоги с богатым содержимым теперь на телефоне открываются
на весь экран (:fullscreen по useLayoutTier + scrollable):
- Проект (создание/редактирование) — NewProjectDialog
- Новая сделка — NewDealDialog
- Пополнение баланса — TopupDialog
- Дни оптом — DaysBulkDialog (те же кнопки дней)
- Регионы оптом — RegionsBulkDialog
- Импорт: сопоставление статусов — UnknownStatusesDialog
Десктоп/планшет — прежний центрированный модал, без изменений.
Тесты диалогов 59/59 (+2 skip) зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
В карточках «На актуализацию» три кнопки (Добавить/Заменить/Отказаться)
были шире контента и заезжали за правый край карточки (на ~18px, «Отказаться»
упиралась в бортик). На телефоне (media <600px) кнопки теперь во всю ширину
карточки поровну (flex:1) — держатся внутри паддинга. Десктоп без изменений.
Проверено сканом всех 39 карточек: заездов за паддинг больше нет.
Тесты 71/71 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
На телефоне (media <600px):
- Плашка «Лидерра подготовила N предложений… Разобрать предложения →»
раскладывается в столбик: текст переставал крошиться по слову, а ссылка
«Разобрать предложения →» больше не вылезает за край (была nowrap).
- Строка «Выбрать всех/все предложения» — flex-wrap: подсказка падает на
новую строку во всю ширину, не жмётся в узкую колонку.
Правки в общем field-shared.css — покрывают и «В поле», и «Предложения».
Тесты «Поля» 71/71 зелёные. Десктоп/планшет без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
При длинном заголовке («Конкурентное поле») кнопка «Поиск» стояла вплотную
к тексту (зазор 0). Добавлен margin-left 10px на телефоне (media <600px) —
теперь есть воздух. Десктоп/планшет без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Списания type=autopodbor_charge шли без описания и показывались как «—»
и в таблице (десктоп), и в карточках (телефон). Добавлена метка
«Списание за автоподбор» в общий словарь операций TransactionsTable.
Тесты транзакций 9/9 зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Сегмент-контрол «Тип лица» (Физическое лицо/ИП/Юридическое лицо) на телефоне
не влезал в строку и наезжал; теперь раскладывается в столбик во всю ширину
(media <600px, скруглены верх/низ группы). Остальные поля реквизитов уже
адаптивны. Десктоп/планшет — горизонтальный переключатель, без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Баг 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>
На телефоне фильтры (Тип/Статус/Регион/День/Сортировка/Поиск) перестали
обрезаться («Р…», «Д…») — раскладываются в столбик во всю ширину
(перебит inline max-width через media <600px). Десктоп/планшет без изменений;
карточки проектов уже адаптивны.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
На телефоне v-btn-toggle (Сегодня/7 дней/30 дней/Период) растянут на всю
ширину с равными кнопками — убрана внутренняя горизонтальная прокрутка.
Только media-запрос <600px; десктоп/планшет без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
На планшете (600–1279px) сайдбар сжимается в rail: только иконки пунктов,
подписи/поиск/бренд-текст скрыты, контент шире. Иконки берутся из mdi,
уже заложенных в данные пунктов; на десктопе иконки скрыты — текстовый
сайдбар остаётся прежним. Отступы топбара и контента разведены по ярусу
(десктоп 232/240px, планшет 64/72px, телефон 0). Гамбургер топбара теперь
показывается только на телефоне (d-sm-none) — на планшете/десктопе он не нужен.
Тесты каркаса 24/24 зелёные. Проверено на 820 и 1440px; десктоп не изменился.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
На телефоне 5 колонок больше не листаются вбок: сверху горизонтальный
переключатель этапов (чипсы со счётчиками), ниже — выбранная колонка на всю
ширину. Смена этапа сделки на телефоне — тапом по карточке через drawer
(drag вбок на узком экране неудобен). Планшет/десктоп — прежняя доска со всеми
колонками и перетаскиванием, без изменений.
Тесты Канбана 23/23 зелёные. Проверено на 390 и 1440px (+ переключение этапов).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Второй пробел после фикса таймаута рендера: номера, написанные на сайте простым
текстом (не кликабельные 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>
Блок 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)
Убраны письма 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)
История транзакций обёрнута в ResponsiveTable: на десктопе/планшете —
v-data-table-server как прежде, на телефоне — карточки (операция, сумма
цветом, дата, #ID) + компактный пейджер по 20. Серверная пагинация сохранена.
Тесты биллинга 40/40 зелёные. Проверено на 390 и 1440px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Обёртка ResponsiveTable (таблица на десктопе/планшете, карточки на телефоне
по ярусу useLayoutTier). Применена к реестру лидов «Сделки»: на телефоне —
карточки лида (чекбокс выбора, телефон, статус-плашка, источник·город,
комментарий, дата), тап открывает карточку сделки; на десктопе прежняя
v-data-table без изменений.
Тесты: ResponsiveTable.spec (2), «Сделки» 58/58 зелёные. Проверено на 390 и 1440px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AppLayout: ярус-отступ (десктоп/планшет 232px, телефон 0 + низ под панель), рендер
AppBottomNav/AppMoreDrawer на телефоне, гамбургер открывает «Ещё». AppSidebar:
проп tier + скрытие на телефоне. Десктоп/планшет без изменений.
Хендоф называл три фичи «невыкаченными, за тумблерами» — по факту (проверено
командой на боевом) все три на проде и флаги ON: AUTOPODBOR_DEEP_STUDY,
AUTOPODBOR_RENDER_CACHE, EXA_PROXY (тоннель отдаёт зарубежный IP). Уехали 06.07.
Следствие: баг «шаг 2 AutoMoney» — в уже-живом deep-study. Добавлена сегодняшняя
сортировка регионов (REGIONS_ALPHA), gitea-ссылка обновлена на 94cfc7ce.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Те же выпадашки региона (новый проект, фильтр списка, массовое
add/remove регионов, карточка проекта) переведены на REGIONS_ALPHA —
единый порядок с автоподбором, республики по своему имени, а не кучей.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Регионы выводились в конституционном порядке (республики → края →
области), из-за чего список выглядел кашей (Дагестан → Донецкая
Народная → Ингушетия → Кабардино-Балкарская). Добавлен REGIONS_ALPHA:
сортировка по «настоящему» имени, слова-типы (Республика/область/край/
автономный округ) отбрасываются — Татарстан на «Т», Адыгея на «А».
Подставлен во все 7 выпадашек автоподбора. Тест regionsAlpha.spec.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Кнопка на актуализации переименована «Удалить»→«Отказаться» (чтобы клиент не путал с удалением конкурента).
- 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>
Кнопка «Удалить» на карточках «На актуализацию» рядом с Добавить/Заменить.
Отклоняет ТОЛЬКО находку (новый сайт/адрес): фирма поля остаётся, находка → архив,
её новые ключи запоминаются у фирмы поля (новая колонка 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>
- «Списания» (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>
Баг: при 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>
Джобы 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>
ПИЛОТ: укрепление тоннеля (sshd ClientAlive drop-in + сторож чистит порт на проде).
Добавлен NEXT-SESSION снимок: автоподбор live, EXA через тоннель+роботы, бэкап,
ротация пароля; открыт один пункт — робастный серверный прокси для EXA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Флаги ВСЕ ВКЛ, 4 таблицы autopodbor_* в кластере, воркеры find/study,
autopodbor_enabled=1, приёмка вживую пройдена. Ключи движка перенесены
в боевой .env. Известная дыра: liderra-backup.sh дампит мёртвую копию.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Групповое и одиночное создание теперь ТРЕБУЮТ регион (vsyaRf=false по умолчанию;
«Вся РФ» — явная галочка, дефолта больше нет — подтверждено владельцем). Тесты
были под старый дефолт «Вся РФ» → doCreate бэйлился на гейте региона. Добавлен
явный выбор «Вся РФ» перед doCreate. Плюс requisites_required теперь открывает
форму реквизитов (reqGate), а не тост — тест переведён на новое поведение,
reqGate добавлен в defineExpose для наблюдаемости. Спек 6/6 зелёный.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Стиль-only (braces_position, ordered_imports, fully_qualified_strict_types и т.п.),
поведение не меняется. Autopodbor 476/476 зелёный после причёсывания.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Скриншот-скрипт-времянка, случайно закоммичен ранее; при overlay-деплое
попал бы в app/scripts на прод. Мусор — удалён до выката.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
План релиза всей системы автоподбора (190 коммитов, greenfield на проде, миграции
на денежную базу) с гейтами, откатом, находками prod-deploy-validator (ловушка
локального сокета vs Managed PG, диск) и rls-reviewer (grants после migrate, canon-sync).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rls-reviewer при подготовке боевого выката нашёл: миграция 2026_06_28_110100
добавляет тип 'autopodbor_charge' в balance_transactions_type_check, но в тело
schema.sql это не было свёрнуто (CHECK заканчивался на 'migration'). Денежная
таблица — §5 п.8. Свёрнуто + запись в CHANGELOG. Структурно только значение CHECK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Тест «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>
Клик «Удалить» у предложения открывает окно со всеми предложениями,
найденными в том же месте (совпал url карточки/сайта в «где нашли»), с
галочками. По умолчанию отмечена только нажатая строка; клиент выбирает,
какие связанные строки удалить. «В работе» — прежнее окно. Массовое
удаление предложений — подтверждение со списком выбранных.
Только показ и порядок «сначала выбери, потом удали»; БД/бэкенд не тронуты.
Тесты: пул по url, строгое совпадение, дефолт-галочка, удаление отмеченных.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Решение владельца: общий Redis-кэш карточек 2ГИС/Яндекс на 5 дней, единый для шага 1
(поиск) и шага 2 (изучение) и повторных прогонов. Кэшируем ТОЛЬКО карточки (/firm/,
/maps/org/); поиск (/search/) и ленты Яндекса — всегда свежие (добывание новых фирм не
ограничиваем). Кэш ограничивает свежесть только у уже найденных карточек — редко меняются.
Замер на САПС (счётчик скачиваний): 12 повторных открытий / ~54с внутри одного сбора
(карточка Метрополиса тянулась 3× — ingest+поиск+разворот); повторный сбор в течение 5 дней
даёт ~200с+ и резкое падение нагрузки на 2ГИС/Яндекс/прокси.
План реализации — через TDD за флагом AUTOPODBOR_RENDER_CACHE (ВЫКЛ по умолчанию), TTL
AUTOPODBOR_RENDER_CACHE_TTL_DAYS=5. Плюс промт-хендофф для следующей сессии.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Качество. Привязанные клиентом карточки 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>
Дописан блок «УСКОРЕНИЕ 06.07» в ранбук фазы 8: числа, что откатано и почему, где узкое место шага 2, следующий рычаг. Ссылка на коммит 9022b15b.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Весь код канала готов (459/459 тестов), рендер-сервис проверен на живом 2ГИС,
мобильный прокси Proxy.Market куплен (Россия/Москва/Tele2, безлимит, до 04.08).
Секреты прокси + RENDER_KEY — в gitignored render-service/.env (в гит не идут).
Осталась фаза 8: поднять виртуалку + включить флаг + ручная сверка владельцем.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Формат PROXIES под Proxy.Market: только HTTP-эндпоинт (Playwright не умеет
авторизацию SOCKS5). Один мобильный порт = один IP; для нашего объёма хватает.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрыт хвост §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>
План: таблица статусов фаз с коммитами (1-7 ✅, 8 и приёмка — за владельцем).
Спека §5.3: помечен follow-up — node-скриптовые точки Яндекса (листинг шаг 1,
разворот) пока на локальном Playwright под self_render (не xf4, без прокси).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Флаг 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>
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>
Фазы 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>
Разведка живого 2ГИС (worktree, НЕ прод): из window.initialState чисто
достаётся список фирм-в-фирму для всех 3 типов URL (листинг/карточка/
поиск-по-телефону). Главный риск проекта снят.
- Фикстуры (реальные initialState+HTML): tests/fixtures/autopodbor/2gis/
listing (12 ломбардов), card (Ломбардико: сайт+телефон), phone (холдинг).
- Карта JSON-путей: pagination-список результатов + entity.profile (имя/
город/рубрика/сайт/телефоны). Идти ТОЛЬКО по pagination, не по ключам.
- Вскрыт рецепт рендера museum double-tap: гейт «обновите браузер» дважды
кодирует return_url (%20→«20», отсюда старый мусор). Обход — двойной заход.
- Findings: docs/superpowers/findings/2026-07-05-2gis-initialstate-spike.md
- План реализации (8 фаз): docs/superpowers/plans/2026-07-05-self-render-*.md
- Спека v4 обновлена: риск initialState снят, double-tap задокументирован.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Третий проход поднял межсистемные стыки: деньги списываются ВНУТРИ задания
(chargeForRun → balance_transactions), значит вариант A сажал биллинг/RLS/
очередь/оркестрацию на рендер-машину. Переход на вариант Б: виртуалка = тупой
Node-сервис рендера («свой xf4»), портал зовёт по HTTP на том же шве ->html().
Деньги/база/RLS/админка/проекты/другие очереди — ОСТАЮТСЯ на портале, бит-в-бит.
Добавлено §4.2 таблица неприкосновенности остальной системы; §5.6 рендер-сервис
(не app-узел, без БД/Redis); §12 SelfRenderClient как HTTP-клиент к виртуалке;
§8 фаза 5 без роли БД. Вариант Б снимает риски биллинга/RLS/очереди варианта A.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Второй построчный проход + живая разведка 2ГИС вскрыли главное: допущение
«JSON = чисто и просто» НЕ подтверждено. Источник = window.initialState
(356КБ), но извлечь список фирм нетривиально (наивный обход берёт города-
спутники), а телефонов/сайтов в листинге НЕТ (только в карточках) — вскрытие
карточек остаётся. Правки: §5.2 реальный источник+риск; §8 фаза 1 = СПАЙК
с GO/NO-GO до стройки; §12 точки подмены (DeepStudyCollector жёстко завязан
на XfetchClient — развязать на интерфейс; шаг 2 последователен — reuse, но
не кратно; гранулярность флага); §13 риски+план Б (initialState неподъёмна →
план Б, xf4 не рискуем зря).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Инвентаризация кода вскрыла пропуски: 2ГИС/Яндекс живут не только в шаге 1.
Добавлено: §4.1 полная карта 7 точек добычи (шаг 1 листинг/карточки, шаг 2
изучение, разворот по телефонам 2ГИС+Яндекс, резолв) + 3 типа URL 2ГИС;
§5.2 явный стык JSON↔HTML через TwoGisJsonReader с инвариантом контракта;
§5.3 прокси на ВСЕХ 4 Яндекс-точках; §5.4 трафик прокси = деньги + отсечка
картинок; §5.6 роль БД с виртуалки; §5.7 гарантия «шаг 2 и разворот не
теряются, xf4 не остаётся нигде при флаге ВКЛ»; §7 тесты на 3 типа URL +
контракт-идентичность + регресс шага 2. Недельная работа сохраняется 1:1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Дизайн утверждён владельцем в брейншторме: рендер своим браузером с
маскировкой + ротация RU-прокси вместо платного xf4, чистый разбор 2ГИС
через перехват встроенного JSON (убирает трэш), постоянный браузер,
всё на выделенной виртуалке (не на боевом портале), за флагом self_render
с откатом на xf4. Планка приёмки — фирма-в-фирму с живым 2ГИС.
UI/кнопки/функции не трогаются. Замер: 25 мин xf4 → 9 мин свой рендер.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Разворот по телефонам 2ГИС и Яндекс больше не уходит цепочкой «до талого»:
по умолчанию берём только фирмы с ЛИЧНЫМ номером фирмы — глубина 1. Именно
неограниченная цепочка уводила в чужие фирмы, давая мусорные сайты в
предложениях холдинга. Многошаговый разворот сохранён за параметром maxDepth,
по умолчанию 1. Изолировано: работает только под флагом phone_reverse, основной
пул сбора не затрагивается. Бэкенд автоподбора 430 из 430.
Фронт: на вкладке «Предложения» добавлена кнопка массового удаления выбранных
источников — bulkDeleteProps через removeSource, покрыта тестами.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Раньше поиск, резолв и изучение делили одну очередь 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>
Сбор 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>
Слияние конкурентов больше не «чёрная дыра» (баг «САПС/Метрополис»: карточка
исчезла без следа, вернуть было нечем). Теперь каждое слияние пишет событие в
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>
Баг «САПС/Метрополис»: клиент переименовал свежую карточку и слил — а выжила
ДРУГАЯ (с проектами/в поле), имя переименованной затёрлось вслепую, карточка
пропала. Теперь окно «Найти дубли» помечает карточку, которая ОСТАНЕТСЯ
(«· останется»), и берёт ЕЁ имя как имя объединённого по умолчанию — клиент
видит исход до подтверждения и не теряет карточку случайно.
Бэкенд (survivor-метка в duplicate-groups + AutopodborCompetitorMerger::survivorId)
уже в 928561bd; здесь фронт: api-тип, окно, метка, тест. Фронт 11/11.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Две починки в «Конкурентном поле» + превью выжившего в окне склейки:
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>
Кейс «Образцово»: у застройщика (СМ.Сити) один номер отдела продаж легитимно
висит на всех его ЖК (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>
Кнопка «Найти и объединить дубли» на экране поля теперь ловит и предложения,
дублирующие карточки поля (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>
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>
Для застройщиков, которые сидят только в Яндексе. По каждому номеру ищем в
Яндекс.Картах, но список рыхлый — поэтому КАЖДУЮ карточку открываем и оставляем
только те, где искомый номер реально есть. Это отсекает «Похожие места рядом»,
рекламу и рыхлые совпадения по бренду. Оба справочника объединяются
(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>
Ошибка создания проекта показывается заметно прямо в окне (красный блок), а не тихим
тостом внизу. При дубле источника — подсказка «проверьте на дубли» и кнопка, которая
перекидывает в «Конкурентное поле» и сразу открывает окно объединения дублей.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Инвариант «один проект = один источник»: при переносе проекта на новый источник
снимаем привязку со всех прочих источников — проект больше не может висеть в двух
конкурентах сразу (жёсткий баг с дублями). Плюс разбор ошибки в автоподборе
показывает конкретную причину бэкенда вместо общей фразы «не удалось».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
По личным телефонам фирмы обратным поиском в 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>
Застройщик = холдинг из проектов-ЖК, каждый со своим сайтом; шаг 2 берёт только
главную карточку. Решение: проекты = элементы конкурента, авто-открытие из 2ГИС/
сайта (нужен го владельца), не переобрабатывать уже собранную главную. Эталон —
ДСК (tenant 38, competitor 720). Открытые вопросы О-1..О-4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Клиент ушёл со страницы во время сбора и вернулся — индикатор пропадал (жил только
на опросе в памяти браузера), казалось, что процесс исчез.
- competitor-эндпоинт отдаёт active_run (идущий по конкуренту сбор) — тест
- loadCompetitor подхватывает active_run в currentRun (не затирая итог завершённого)
- onMounted возобновляет опрос, если сбор ещё идёт → индикатор оживает, дожидаемся итога
- тесты: active_run (бэк), loadCompetitor подхват/сохранение, подхват опроса на экране
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
У клиента включено «уменьшить движение» (Windows/браузер) — глобальное правило
motion.css гасило ВСЕ анимации (animation-iteration-count: 1 !important), поэтому
любой кружок замирал после одного оборота и казался зависшим.
Спиннеры-загрузчики (.ld-spin, .ld-collecting__spin) — законное исключение (WCAG
допускает индикатор загрузки): возвращаем медленное непрерывное вращение. Чинит
все кружки в приложении разом, не только автоподбор.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Клиент жаловался: кружок «завис» и прогресс не видно, когда окно сбора закрыл.
- CollectingIndicator: собственный гарантированно крутящийся кружок (старый ld-spin
из @import в scoped не анимировался — известный баг Vue) + текст очередь/этап.
- Индикатор ставится прямо на экран (конкурент и «поле») — виден, даже если окно закрыли:
«вы в очереди №N» / «этап N из M» / «идёт сбор…».
- В окнах сбора заменил статичный кружок на этот индикатор.
- 4 теста компонента + тест экрана.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Имя проекта следует за конкурентом: после склейки дублей все проекты объединённого
получают имя выжившего конкурента, при совпадении различаем источником, а не цифрой.
Проекты не удаляются и не рвутся — меняется только имя.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кнопка «🔄 Собрать источники ещё раз» вместо мёртвой «✓ Источники собраны».
Окно предупреждает, что повтор платный (даже если нового нет). Честный итог
после сбора (N новых / M ранее удалённых). Блок «ранее удалённые — вернуть?»
с кнопкой «Вернуть» (box archived→proposal).
- RunDto.result + collectResultMessage (чистый хелпер) + тесты
- store.restoreSource + тест
- компонент RefoundDeletedSources + тест
- FieldCompetitorScreen: кнопка-повтор, честный итог, блок «вернуть», текст окна
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Клиент может собрать источники по конкуренту ещё раз. Новые → в предложения,
ранее удалённые (найдены снова) → помечаются для возврата, уже известные не
задваиваются и не трогаются.
- удаление источника стало мягким (box=archived) — помним, чтобы показать «ранее удалён»
- снят жёсткий стоп «уже изучали» в startStudy (повтор разрешён; от нахлёста — assertNoInFlight)
- колонка result (jsonb) на прогоне + cast + RunResource
- джоба изучения классифицирует источники и пишет итог {new_sources, refound_deleted, ids}
- тесты: мягкое удаление, повтор, классификация
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Имя проекта = имя конкурента; при совпадении различаем источником сайт/номер,
а не хвостом «2». Один проект у конкурента — просто «Сибиряк», несколько —
«Сибиряк · сибиряк.рф». При переносе источника к другому конкуренту проект
переименовывается на нового конкурента.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1 requisites_required — показываем короткую форму реквизитов прямо в экране
Конкурентного поля и повторяем создание проекта после сохранения.
#4 дубль источника — при добавлении проверяем, нет ли такого же источника у другого
конкурента или уже созданного по нему проекта. Отдаём клиенту выбор: убрать дубль или
перенести проект к новому источнику по идентификатору. Проект не пересоздаётся.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Форма шлёт пустую строку в незаполненной строке сайта/карточки, middleware
ConvertEmptyStringsToNull делает из неё null — валидация elements.*.sites.*/
cards.* теперь допускает nullable (иначе 422). Ручной источник заводится сразу
в «работу» (box=field). Найдено незакоммиченным в воркстри (доделка #2 «элементы»),
тесты зелёные — коммичу отдельно, чтобы не потерять и не смешать с прогрессом.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Компонент RunCollectStatus: «вы в очереди №N» (пока прогон ждёт) или «этап N
из M: подпись» (пока идёт). Встроен в оба окна сбора (конкуренты/источники) и
экран загрузки ручного изучения. RunDto получил queue_position и progress.
Никаких фейковых процентов — только реальные этапы движка. 4 теста Vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Клиент во время сбора видит место в общей очереди (пока ждёт) и текущий
этап (пока идёт). Очередь — длина общей 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>
Диалог дублей: селект имени среди выбранных к слиянию, по умолчанию первое.
Merger и ручка принимают опциональное имя. Обе экрана.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
В группе из 3+ клиент отмечает, кого объединить. По умолчанию выбраны все
(группа из 2 - как раньше). Слить можно любое подмножество >=2. На обоих экранах.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Глубокое изучение строит отпечаток на КАЖДЫЙ элемент холдинга - имя элемента плюс
его первый сайт - и сводит телефоны со всех. Без элементов - как раньше.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Бэк: хелпер CompetitorElements нормализует элементы и выводит легаси site_url/directory_urls
из ВСЕХ карточек. Ручки manual/update принимают elements.
Фронт: компонент CompetitorElementsEditor встроен в формы создания и правки на экранах
Поле и Предложения. Прежний баг - пересбор directory_urls из двух полей - закрыт.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Конкурент хранит список элементов {name,sites,cards} - подкомпании/ЖК холдинга.
Слияние складывает элементы всех объединяемых, ничего не роняет.
Миграция бэкфилла: один элемент из текущих site_url + directory_urls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- стор removeSource чистит store.sources - карточка конкурента обновляется без reload
- 8 оверлеев на экранах Поле/Предложения переведены на useOverlayDismiss
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FieldCompetitorScreen:
1) метки ✓ настоящий номер / 🎭 рекламный подменный + плейн-язык «где нашли»
+ легенда «как читать» (данные в БД и сверка с ЭТАЛОНом НЕ тронуты — перевод только в показе);
2) регион «впечатывается» — свой тайп-ахед в стиле экрана (без Vuetify, чтобы не ломать тесты)
в окнах «Создать проект» и «Настройки проекта»; чипы для мультивыбора, подпись округа;
3) галка «Вся РФ (все регионы)» даёт баннер-объявление;
4) групповое создание проектов из всех выбранных источников без проекта (source_ids пачкой),
заголовок/текст под «несколько». +тест на групповое создание. Фронт-спеки 24/24 зелёные.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 задач по TDD: тумблер+is_federal, CompetitorGeo, DomainKinship (близнецы
НЕ сливать), PlatformClassifier, отпечаток+запросы EXA, AnchorGate,
FootprintSearch, SiteOpener, DeepStudyCollector (маппинг 1:1 со словарём
чипов песочницы), развилка по тумблеру, DI, приёмка на 5 против ЭТАЛОНа.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Точный чек-лист «СТАНЕТ» по всем 5 (385/412/414/416/420), извлечён из
вмороженных артефактов bylo-stanet (коммит 2b18922d): сайты (оба близнеца
порознь), настоящие номера, пометки, «где нашли», справочники. Отклонение
недопустимо ни на миллиметр; единственное вне контроля — ротация самих
подменных коллтрекинг-номеров (🎭) провайдером.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закон: результат боевого шага 2 строго 1:1 с песочницей. Полный движок за
тумблером autopodbor.deep_study, старый путь — запасной. Явная НЕ-цель:
сайты-близнецы НЕ сливать (красломбард.рф и kraslombard24 — две отдельные
записи). Хранение/биллинг/идемпотентность не трогаем. Приёмка — те же 5
конкурентов через боевой шаг 2 = одобренные артефакты bylo-stanet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Перегнал было/станет для 385 Автозайм124, 412 Драйв займ, 414 Банк ДОМ.РФ,
416 Ваш Инвестор, 420 КрасЛомбард на одном движке шага 2: 2ГИС ищется
поиском по имени фирмы (карточка находится, даже если её не было в выдаче),
Яндекс.Карты вскрываются Playwright, склейка кир/лат доменов, подменные
коллтрекинг-номера помечены отдельно от настоящих.
Артефакты — только dev, боевой шаг 2 не изменён.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Прототип нового шага 2 «Собрать источники» — отдельный скрипт scripts/sandbox-step2.php,
боевой шаг 2 НЕ тронут. Отпечаток фирмы (домен+имя+тип+город) → поиск по отпечатку через EXA
(гео-скоуп по типу фед/рег) → фильтр «точно она» по якорному телефону → глубокое вскрытие
сайтов (код/скрытые/подменный коллтрекинг/Контакты) + 2ГИС(поиск по имени, JSON) + Яндекс(Playwright)
→ все номера с пометками. Генерит HTML «было/станет» на конкурента (было из БД, станет из прогона),
ссылки кликабельные. scripts/_shot.cjs — скриншот HTML в PNG.
Артефакты bylo-stanet/ на 5 конкурентов с «Предложениями» (tenant 36). Свежие со всеми фиксами —
КрасЛомбард и Банк ДОМ.РФ; остальным нужен финальный перегон. Детали, дизайн и 4 фикса
(схема сайта, Яндекс→Playwright, 2ГИС по имени/JSON/ретрай, близнецы кир/лат) — в файле находок.
НЕ в портале, НЕ на проде. Прототип для оценки.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Шаг 1 «Конкурентное поле» — чекпоинт:
- Финальный проход «Найти и объединить дубли» на поле и предложениях; клиент решает по каждой группе, ничего не склеиваем молча.
- Тихая склейка только по сайту и коду справочника; телефон и людный номер у более чем 4 фирм — на решение клиента.
- Колонка phones jsonb; телефон-дубли видны уже на «Предложениях».
- Sonar даёт только имя и тип, сайт всегда через EXA; вскрываем все карточки без гейта «есть сайт».
- Яндекс-карточки через локальный Playwright параллельно; рубрика из заголовка идёт в описание.
- Фикс рекламных номеров: 2ГИС-парсер разбирает телефоны пообъектно и выкидывает рекламные кнопки с platforms/caption чужих фирм.
Тесты: автоподбор 224/224 unit, 105/105 feature; фронт-спеки зелёные. Времянки app/scripts/diag-*/probe-* в гит не входят.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Консолидированное состояние двух треков (launch-gate выкачен на liderra.ru core-only;
автоподбор работает только в воркстри) + квирки окружения/тестов + промт для
восстановления контекста после компакта.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ядро фичи для обычных проектов (без автоподбора): единый LaunchBalanceGate под
lockForUpdate(Tenant), ProjectService::create($launch)+setActive, bulk resume
«сколько влезло», ProjectController store/update/toggle, config/billing.php,
ProjectResource.preflight_blocked_at + фронт экранов проектов (сообщения в рублях,
метка «не запущен», группы). Схема БД не менялась (preflight_blocked_at уже был).
Cherry-pick ТОЛЬКО ядра поверх main 609c7ae9; автоподбор НЕ выкатывается.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
По итогам финального ревью: путь повышения дневного лимита в ProjectController@update
прогонял балансовый гейт и писал новый лимит БЕЗ замка на тенанта (в отличие от
create/setActive). Две параллельные правки лимита одного тенанта могли обе пройти
гейт и суммарно превысить ёмкость баланса (TOCTOU). Обёрнуто в DB::transaction +
Tenant::lockForUpdate() при повышении лимита — evaluate и запись под одним локом.
UpdateLimitGateTest 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Фронт для правила «баланс блокирует запуск, а не создание»:
- ProjectLimitOverloadDialog переформулирован в «Проект не запущен — пополните
~X ₽ или уменьшите объём» (сумма в рублях topup_rub).
- NewProjectDialog: создание всегда успешно; при launch.deferred показывает
сообщение (не блок). Update-лимита 409 → тот же диалог.
- Возобновление (toggle-active) ловит 409 balance_insufficient (стор не
переключает is_active), показывает диалог; постоянная метка «Не запущен —
не хватает баланса» на карточке/в панели по preflight_blocked_at/balance_blocked.
- BulkActionsBar: сводка «Запущено N, отложено M — не хватает баланса» на resume.
- Автоподбор: CreateScreen показывает сводку запуска (рубли); FieldCompetitor
массовые pause/resume через общий POST /api/projects/bulk (наследует BULK_MAX,
слепок-защиту, балансовый гейт).
- ProjectResource отдаёт preflight_blocked_at.
Новые/обновлённые фронт-тесты зелёные. Пред-существующий красный baseline
фронт-набора (ErrorView/Settings/Legal/… + ProjectsView v-show) не трогался.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Создавать проекты можно всегда; баланс проверяется в момент ЗАПУСКА
(создать-и-запустить / запустить / возобновить одиночно и пачкой / автоподбор)
под замком на клиента (без гонок). Не хватает — проект остаётся на паузе с
меткой preflight_blocked_at, клиенту сообщение в рублях (сколько пополнить).
Групповой запуск «сколько влезло». Нет активного тарифа на дату → запуск
запрещён (fail-closed). Гейт реквизитов первого проекта добавлен и в автоподбор.
- LaunchBalanceGate — единый гейт вместо 3 копий preflight (ProjectController
store/update, AutopodborController), под DB::transaction + lockForUpdate(Tenant).
- ProjectService::create($launch) + новый setActive(); bulk resume «сколько влезло».
- AutopodborProjectCreator: пачка в транзакции через общий ProjectService::create.
- Идемпотентность box/phone_type миграций автоподбора (Schema::hasColumn guard).
- Тест-инфра: afterRefreshingDatabase восстанавливает месячные партиции.
Тесты фичи 40/40 зелёные. Спека и план — docs/superpowers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Мини-плашка теперь показывает деньги (или —) И слово статуса (жив/выключено/ok)
рядом, как в детализации. Убрана мёртвая serviceValue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Баланс/статус различаем по типу сервиса (LIVENESS_ONLY_KEYS), не по null-балансу
(денежный Поставщик с сорванным автологином больше не показывается «выключено»).
Мини-плитка переименована «Балансы сервисов»→«Внешние сервисы».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Статус-текст для сервисов без денежного баланса (жив/не отвечает/выключено),
метки и иконки почты/ЮKassa/Jivo/капчи, обновлённые подписи плитки и дрилла.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Edge-trigger: одно письмо на ops-адрес при первом покраснении (баланс на
исходе или сервис упал); повторное красное не спамит. Mailable + blade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Стабы fakeProvider/fakeProbe вынесены в tests/Pest.php (нужны в двух файлах).
Старые балансовые тесты отключают пробы живости (useLivenessProbes([])).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ускорение шага 1: CategoryScraper грузит страницы категории 2ГИС не по одной, а пачками поперёк рубрик
через BatchPageFetcher. На остывшем xfetch 2ГИС упал с ~23 мин до ~5 мин и собрал 139 фирм вместо 12.
Общий лимит одновременных xfetch снижен 4→3 (безопаснее под оконный rate-limit сервиса).
Таймауты ИИ/EXA подняты с запасом (латентность моделей плавает, сбор асинхронный, клиент ждёт):
sonar 120→300с, отсев агрегаторов 90→240с, общий AITUNNEL 30→120с, EXA 30→90с. Джоба: один прогон
без авто-ретраев (tries 3→1, чтобы таймаут не перезапускал платный движок трижды) и общий таймаут 900→1800с.
Фиксы, найденные на живом прогоне:
- Письмо «готово» больше не роняет прогон при недоступной почте (тесты на сбой SMTP при status done/empty).
- Яндекс-загрузчик берёт путь к node из config (autopodbor.node_bin, дефолт node) — из-под PHP node
теперь находится; на живом прогоне Яндекс дал 78 фирм вместо 0.
По TDD. Бэкенд автоподбора 262/262, Pint чисто. Живой прогон end-to-end: ~8 мин, 14 новых конкурентов,
списание 300 ₽ корректно.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Замер боевого лимита xf4.ru показал безопасный потолок 4 одновременных запроса — выше идут 429 Too Many Requests, плюс есть лимит за окно времени. Сбор до 25 карточек филиалов в XfetchDirectoryFetcher грузился по одной секунды на каждую — узкое место шага 2.
Новый интерфейс BatchPageFetcher + XfetchClient::htmlBatch грузит страницы порциями по services.xfetch.concurrency дефолт 4, параллелит внутри порции через Http::pool, ретраит пустые и 429. XfetchDirectoryFetcher берёт карточки одной пачкой если загрузчик batch-совместимый, иначе по одной. Два справочника шага 2 оставлены последовательно намеренно — иначе 2x4=8 за лимитом. Число выносится в XFETCH_CONCURRENCY без деплоя.
По TDD. Бэкенд автоподбора 258/258, Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Секция «На актуализацию»: дельта-карточка показывает текущие сайты и карточки фирмы поля + новый сайт кликабельными ссылками, кнопки Добавить и Заменить ведут через подтверждение «Создать проект?» в существующую форму CreateScreen с подставленным новым источником. Имя фирмы берётся из поля, не перезатирается.
Бэкенд: endpoint POST /competitors/{id}/actualize action=add|replace new_site. add заводит сайт-источник рядом со старым, replace архивирует старые сайт-источники и заводит новый, при активном проекте по старому сайту возвращает 409 manage_via_project. /proposals обогащён полем matched с текущими сайтами и карточками совпавшей фирмы.
По TDD. Бэкенд автоподбора 254/254, фронт автоподбора 64/64, ESLint чисто, Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1: опознавалка фирмы по сайту/карточкам, ProposalClassifier new/actualize/archived/hidden, box=archived, bulk удалить всех ранее удалённых. Backend 249/249, front autopodbor 61/61.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Отсев агрегаторов шёл по СЫРЬЮ до склейки — gpt-4o-mini залетало 227 позиций
с дублями (Ломбардико ×6). Порядок в FindCompetitorsAssembler: сначала
склейка/дедуп, потом отсев — классифицируем ~90 уникальных, а не 227.
Плюс файл состояния сессии (что сделано/осталось) + промт восстановления
контекста для компакта. TDD, 234/234 зелёные. НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Масштаб (лимиты внешних сервисов — на КЛЮЧ, общий на всех клиентов):
- EXA: параллельный пул findSites (concurrency=5) вместо 40 запросов по одному.
- Агрегатор: батч вместо ~90 запросов по одному + чанкинг по 40 и длинный
таймаут 90с (большой список gpt-4o-mini не успевал в 30с).
- Очередь autopodbor + WithoutOverlapping на всех 3 джобах — глобальный
потолок = число воркеров; assertNoInFlight держит один подбор на клиента.
Чистота выдачи (универсально, без зашитых ниш):
- Анализатор: узкие рубрики, запрет зонтичных слов («финансовые услуги» тащила
юрфирмы/банки).
- Дедуп: ключ имени минус слова из СЛОВАРЯ рубрик прогона («Яричъ Ломбард»→«Яричъ»);
слово-категория из данных, не из списка. Гард: склейка только если остаётся
ровно один слово-токен (генерики вроде «займы под залог» не трогаем).
TDD, 233/233 (unit+feature) зелёные. НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Playwright тянет рубрику фирмы прямо из СПИСКА Яндекса («Ломбард, автоломбард»,
«Микрофинансовая организация», «Банк») без захода в карточку — быстро и бесплатно.
Рубрика идёт в описание конкурента → в похожесть-эмбеддинги (раньше меряли по
голому имени, хлам и целевое сбивались). Промпт анализатора обезличен: примеры
из РАЗНЫХ отраслей (стоматология/автосервис/доставка) + «подбери под описание
клиента» — движок универсален, не подточен под нишу займов Омеги. TDD 216/216, НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Яндекс отдавал по строке на КАЖДЫЙ филиал (дедуп по id точки) — 214 строк,
из них хлам-повторы одной сети (Корунд ×15). Новый YandexListParser дедупит
по SLUG (код фирмы от источника /maps/org/<slug>/id): одна фирма = одна строка,
разные написания одной фирмы склеены, разные фирмы с разным кодом раздельны.
Код справочника (ya:slug) уходит в union-find склейку как опознавательный
признак. Живьём 214 -> 94. TDD, 215/215 (unit+feature) зелёные. НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Стоп-лист известных для ИИ канала В дедуплицировался только в проверке
видели/не видели, а в промпт уходил с повторами (Корунд ×27, КрасЛомбард ×19).
Теперь чистим и сам список по ключу имени — ИИ видит каждое имя один раз:
короче, дешевле по токенам, точнее. TDD, 137/137 unit зелёные.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Единая точка: что сделано (движок шага 1 пересобран — АНАЛИЗ→канал А 2ГИС(xfetch)+Яндекс
(Playwright)→слияние→канал В федералы(EXA)→сборка; 136 unit+74 feature зелёные, HEAD dee2ebbc),
что осталось (полный живой прогон+сверка на 72, калибровка, параллельность), ключи/окружение,
карта кода, промт для восстановления после компакта.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright.
render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС).
YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка
(сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс,
слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну
карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml →
общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Шаг 1 = только имя+сайт+ссылка на справочник (телефоны/описание = шаг 2). Убраны медленные
per-card открытия: со страницы категории 2ГИС берём по фирме имя + ссылку карточки + САЙТ сразу
(CategoryListingParser: сайт из редиректа «Перейти на сайт», firmId в пути = id фирмы; мусор
avito/max.ru/трекеры/иностр.TLD отсеиваем). CategoryScraper отдаёт строки {name,card_url,site}
с пагинацией. LiveFindCompetitors упрощён: канал А из списка (без резолва), канал В — имена →
сайт EXA, без сайта (нет якоря) выкидываем. Живой канал А на новом ключе xfetch: 48 реальных
фирм Красноярска, 17 с сайтом, ~4.5 мин, без per-card. Филиалы схлопывает merge (union-find).
Модуль 135 unit + 74 feature зелёные. За флагом autopodbor.real_find; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Замена вырожденного «одна фраза → одна страница» на §12/§11.3 финал:
- Шаг АНАЛИЗ (ChannelA\AitunnelQueryAnalyzer): описание → запросы-рубрики (мелкая модель).
- Канал А (ChannelA\CategoryScraper): скрейп категории 2ГИС с пагинацией → резолв карточек.
- Канал В (ChannelB\*): ОДНА модель sonar-reasoning-pro × 2 прохода → ТОЛЬКО имена
федералов; стоп-лист = имена из А + примеры; сайт федерала через EXA (ExaSiteFinder),
т.к. у федерала нет карточки в 2ГИС/Яндексе на регион.
- Оркестратор LiveFindCompetitors переписан: АНАЛИЗ→А→В→слияние→отсев→дедуп→похожесть→DTO.
- Провайдер перепрошит; config services.php +research_model/exa.
Похожесть — эмбеддер-модель (математически), резолвер/дедуп — без изменений.
Всё за тонкими границами, офлайн-тесты на фикстурах: модуль 130 unit + 74 feature зелёные.
Провайдер за флагом autopodbor.real_find; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Свод воедино: методика §12 (3 канала А/В/0 → резолвер → DaData → дедуп →
эмбеддинги → DTO) + все дословные промпты (5 моделей-исследователей,
нормализатор, фильтр агрегаторов). Честная сверка: PHP LiveFindCompetitors
собрал только нижнюю половину (резолвер/дедуп/похожесть/телефоны) и берёт
1 фразу/1 страницу → 3 находки; верхняя половина (АНАЛИЗ+мульти-запрос+
пагинация канала А, канал В 5-моделей) не перенесена. Эталон работы движка —
прогон 29.06 (72 конкурента). План переделки PHP под §12 один-в-один.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RunAutopodborSearchJob по завершении (done/empty) шлёт AutopodborReadyMail на contact_email тенанта
(с числом найденных + ссылкой на портал). Письмо не роняет подбор при недоступной почте (try/catch).
Клиент ставит задачу, работает дальше, получает «готово» письмом.
Тесты: SearchJob 4/4 (вкл. Mail::assertSent); Автоподбор unit+feature 183/183; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AitunnelEmbedder (text-embedding-3-small) → косинус-похожесть конкурента на профиль клиента (%).
AitunnelAggregatorClassifier (chat) → «поставщик или площадка?» — авто-отсев Авито/Zoon/Банки.ру.
Оба деградируют сами при пустом ключе (пустые векторы → 0%, null → не выкидываем), поэтому
подключены всегда. Ключ — только в .env (services.aitunnel.key), в гит не попадает.
Тесты: AITUNNEL клиенты 9/9; Автоподбор unit+feature 182/182 (флаг real_find в тестах
принудительно ВЫКЛ через phpunit.xml — иначе findCompetitors ходил бы в сеть); Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RunAutopodborSearchJob.timeout=900 — живой поиск (2ГИС+Яндекс через антибот + обход карточек)
идёт ~4-5 мин; дефолтные 60с убивали задание на середине. Очередь поднимать с --timeout>=900.
Найдено живым прогоном по нише «ломбард/Красноярск»:
1) Яндекс-заголовок «Имя, рубрика, ГОРОД, улица, дом» — город НЕ последний сегмент; брал «дом»
как город → все карточки отбраковывались. Теперь YandexResolver: имя=первый сегмент, город —
по НАЛИЧИЮ в заголовке (DirectoryFields::titleName/titleHasCity). 2ГИС (город последний) — без изменений.
2) ResolvingAgent (ручной resolveByName) теперь берёт ГОРОД центра субъекта (RegionCity), не имя региона.
3) Страница выдачи 2ГИС/Яндекс флакует (капча/пустой layout) → LiveFindCompetitors повторяет поиск,
пока не появятся фирмы (searchRetries).
Тесты: модуль Автоподбора unit 99/99 + live 4/4; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LivePageFetcher (2ГИС→xfetch, Яндекс→xfetch+fallback локальный Playwright). Провайдер при
config('autopodbor.real_find')=true собирает LiveFindCompetitors (без ИИ-ключа: null-классификатор
+ нулевой эмбеддер → сырой список без отсева площадок и без %). RealCompetitorAgent.findCompetitors
использует живой движок, если подключён, иначе заглушку. Флаг по умолчанию ВЫКЛ — на проде без изменений.
Тесты: Автоподбор unit+feature 172/172 (флаг выкл — биндинг цел); Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LiveFindCompetitors: ниша из формы → поиск 2ГИС (по слагу города) + Яндекс (по «ниша город») →
парсер выдачи → резолв каждой карточки напрямую → FindCompetitorsAssembler (фильтр/слияние/
похожесть → DTO §7.2). Добыча за PageFetcher — обход тестируется офлайн на фикстурах.
RegionCity — слаг/имя города центра субъекта (как RegionAreaCode, только уверенные).
Тесты: live 3/3 (склейка 2ГИС+Яндекс одной фирмы, вычет своего сайта, пустая выдача);
модуль Автоподбора unit 99/99; Pint чисто. Провайдер ещё не флипнут — следующий шаг.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AggregatorFilter + граница AggregatorClassifier (§12.6): «поставщик услуги или площадка?» —
LLM-рассуждением (за границей), а не мёртвым статик-списком. Консервативно: выкидываем только
при уверенном «агрегатор»; при неуверенности (null) конкурента не теряем.
Тесты: aggregator 3/3; Pint чисто. (живой LLM-классификатор подключается на H/живом прогоне.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CompetitorPhoneEnricher: телефоны карточки → тип (city/mobile/tollfree) + регион + годность (qc=0)
через существующий DaDataPhoneClient (тот же, что резолв региона лида — §12.4, не дубль).
DaData недоступна → деградация: тип по префиксу (PhoneType), регион пуст, негоден — подбор не падает.
Номера не синтезируются, только классифицируются (берём опубликованные фирмой).
Ключ DaData — в config services.dadata (env, прод). Тест офлайн через Http::fake (реальная форма ответа).
Тесты: phone 4/4; модуль Автоподбора unit 91/91; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AutopodborDedup::mergeCompetitors — склейка одного конкурента из 3 каналов под разными
написаниями, доменом-vs-именем или общим телефоном (union-find по корню имени/домена/номеру).
Объединяет ссылки справочников и телефоны, is_federal — местная карточка перевешивает.
Вычитает самого клиента (его имя/сайт не попадают в конкурентов). Усиливает §12.11.
Размещено в существующем backend-дедупе (§7.2 «дедуп на стороне бэкенда»), а не дублем
в движке. Старый dedupCompetitors и его тесты не тронуты.
Нормалайзер: nameKey/domainRoot (alnum-ключ, ё→е) — имя «Драйв займ» сцепляется с «драйвзайм.рф».
Тесты: merge 6/6; модуль Автоподбора unit 81/81; feature dedup 3/3; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A4 CompetitorResolver (§12.3): имя+город → 2ГИС-в-городе (прямая ссылка канала А) →
иначе Яндекс (поиск имя+город → первый org → проверка) → иначе local=false.
is_federal ПО ФАКТУ: нет местной карточки + есть сайт = федерал; есть карточка = местный.
Транспорт за PageFetcher — вся логика офлайн на фикстурах.
A5 ResolvingAgent (§7.2 resolveByName): имя+region_code → кандидат
{name,description,site_url,directory_urls,provenance} из настоящей карточки; нет филиала
→ честный кандидат-заглушка. Свободное сравнение город↔субъект (DirectoryFields::localeMatches).
Хелперы тестов (stubPages/autopodborFixture) вынесены в tests/Pest.php.
Провайдер НЕ флипнут (findCompetitors всё ещё заглушка до под-блока H).
Тесты: Resolve 17/17; модуль Автоподбора unit 75/75; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Разбор реальной org-карточки Яндекс.Карт (/maps/org/<seo>/<id>) в ResolvedCompetitor:
имя/город из <title>, чистый сайт из business-urls (itemprop=url, без utm),
телефоны type=phone (→7XXXXXXXXXX), описание из categories[].name. Отбраковка
чужой фирмы того же имени: имя и город обязаны совпасть с ожидаемыми, иначе null.
Рефактор: общие извлекатели имя+город и телефоны вынесены в DirectoryFields
(используют и 2ГИС, и Яндекс) — без дублирования.
Фикстура — реальная карточка КрасЛомбарда, снята локальным Playwright (render-page.cjs).
Тесты: модуль Автоподбора unit 68/68; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Разбор реальной карточки филиала 2ГИС (/<город>/firm/<id>) в ResolvedCompetitor:
имя из <title>, чистый сайт из contact_groups (url, не редирект link.2gis),
телефоны type=phone (нормализованы к 7XXXXXXXXXX), город из title, описание из
rubrics. Терпим к хвосту ?stat=. Пустая оболочка 2ГИС → null.
Фикстура — реальная карточка КрасЛомбарда из живого прогона шага 2 (публичный
бизнес-телефон конкурента, не клиентские ПДн); allowlist gitleaks для app/tests/fixtures/.
Тесты: A2 3/3; модуль Автоподбора unit 64/64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Резюме для подхвата после компакта: брать ПОСЛЕДНИЙ вариант (§12 v4), ранние
не воскрешать (ИНН выкинут, похожесть=эмбеддинги, агрегаторы=LLM, is_federal по факту).
Следующая задача A2 TwoGisResolver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Карточка резолва имени: name/site/phones/directoryUrl/source/region/description/
isFederal + isLocal(). Без ИНН (решение владельца). Офлайн-тест 3/3.
Фундамент под-блока A плана шага 1.
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Обновлён GOTOVNOST: интеграция шага 2 (провод реального агента, где-нашли→БД→API,
код города по региону, ретрай 2ГИС) сделана и проверена вживую на kraslombard24.ru.
Следующий блок — шаг 1 (реальный findCompetitors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Живой прогон вскрыл: рендер списка 2ГИС иногда отдаёт непустую «оболочку»
без ссылок на филиалы — ретрай в XfetchClient ловит только пустой ответ,
а тут карточки терялись. XfetchDirectoryFetcher теперь повторяет загрузку
списка, пока не появятся ссылки на филиалы, до 3 раз.
Проверено вживую на kraslombard24.ru: центральный 8(391)292-00-00
подтверждён И кодом сайта, И 2ГИС с адресом «улица Ладо Кецховели, 30»;
where_found/office/confirmations доходят до БД и API. 0 фальшивок.
- тест повтора списка при флаке; бэкенд автоподбора 131/131
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Если на сайте конкурента короткий локальный номер без кода города И на странице
нет ни одного полного номера, движок берёт код по региону конкурента. Основной
путь прежний — код со страницы; региональный код лишь запасной.
- RegionAreaCode: субъект РФ → телефонный код адм. центра, только уверенные
3-значные коды миллионников, ключ по имени из RussianRegions, неизвестный → null
- RealCompetitorAgent резолвит regionCode и пробрасывает в CandidateBuilder→HtmlPhoneScanner
- ограничение задокументировано: код центра, не выдумываем при отсутствии в карте
- тесты карты и сквозной достройки; бэкенд автоподбора 130/130
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Шаг 2 «Конкурентного поля»: один номер встречается в нескольких местах —
код сайта плюс карточки 2ГИС/Яндекс с разными адресами. Раньше хранилось
одно provenance_url/label — список терялся. Теперь сквозной провод
движок→контракт→джоб→БД→API; фронт уже умел показывать кликабельным
списком с подтверждениями.
- autopodbor_sources +3 колонки where_found/office/confirmations
миграция 2026_06_30_120000, идемпотентная, RLS-review APPROVE 7/7
- canon-sync schema.sql v8.59 плюс CHANGELOG, вкл. catch-up phone_type/box 29.06
- тесты бэкенда автоподбора 122/122
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- GOTOVNOST-status: к внедрению НЕ готов; шаг 2 собран по частям, не встроен;
шаг 1 — реальной логики никогда не писали (всегда заглушка), из репо ничего
не терялось (проверено git); собрать шаг 1 = написать одну findCompetitors.
- KONTEKST-prodolzhenie: промпт для корректного подхвата контекста + план.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Экран FieldCompetitorScreen:
- «где нашли» теперь кликабельные ссылки на источники (where_found из DTO),
адреса филиалов 2ГИС видны и кликабельны под номером;
- сортировка источников: больше подтверждений — выше, подменные — вниз;
- строка адреса офиса, счётчик подтверждений.
DTO SourceDto расширен полями where_found/confirmations/office (опциональны —
обратная совместимость: без них падаем на старое provenance_label).
Histoire-стори с живыми данными КрасЛомбарда (рендер настоящего компонента).
TDD: +2 теста, autopodbor-экраны 24/24. Бэкенд-провод where_found — отдельно (Plan C).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Два бага, вскрытых живым прогоном по КрасЛомбару (оба в обработке, не в xfetch):
1. Достройка коротких номеров лепила фальшивку из обрезка полного номера:
сайт делит номер на tel:+7 (391) 271 и tel:271-33-33. Обрезок 7391271
(страна+код города) ошибочно достраивался в 73917391271. Теперь обрезок
«7/8 + код города» распознаётся и выкидывается; настоящие локальные
(включая московские 771-..) — целы. Логику вынес в classifyShort/areaCode.
2. parseBranchList брал ссылку филиала только из href — на части прорисовок
2ГИС ссылка лежит в JSON-данных, филиалы терялись. Берём путь /city/firm/<id>
откуда угодно (с дедупом).
TDD: Autopodbor 49/49.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Антибот 2ГИС бьём сервисом xfetch.ru (render:true, timeout:20 —
без timeout страница не дорисовывается). Доказано на живом КрасЛомбаре:
поиск → 12 филиалов → телефон + адрес каждой карточки.
- PageFetcher — граница «достать HTML» (тестируется без сети)
- XfetchClient — POST к xfetch, декод base64; без ключа молча пусто
- XfetchDirectoryFetcher — список→филиалы→карточки через DirectoryParser
- DirectoryParser — чтение списка и карточки 2ГИС (был в хвостах)
- config services.xfetch + .env.example; ключ только в .env (gitignored)
Яндекс.Карты — отдельно (другой формат URL карточек). TDD: Autopodbor 46/46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Москва 495, Питер 812, Екатеринбург 343 — код города берётся со страницы
конкурента или из города запроса, никакой привязки к Красноярску. Защита
от регрессии. Autopodbor 41/41.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сканер кода сайта выкидывал короткие локальные номера (271-33-33,
2-828-828 и т.п.) как мусор — терялись реальные телефоны филиалов.
Теперь короткий номер из tel:/schema/microdata достраивается кодом
города: сперва по преобладающему коду полных номеров той же страницы,
иначе по коду региона запроса; если код не определить — номер не
теряется, а помечается «требует проверки» (phoneKind=uncertain).
Из тела текста короткие формы не достраиваются (защита от ложных).
TDD: 6 новых тестов, весь Autopodbor 40/40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CURLOPT_SSL_VERIFYPEER/VERIFYHOST включены
- isSafeUrl: только http/https, блок loopback/приватных/служебных IP
- FOLLOWLOCATION выключен, протоколы ограничены HTTP/HTTPS
- render-page.cjs валидирует схему URL перед навигацией
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт):
- F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» —
карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт +
Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка
«Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform.
- F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен
доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита,
сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню.
- F3: колонка «когда списывается» в панели доп.услуг биллинга.
- M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не
было переходов; ручное добавление живёт окном на «Поле».
Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build ✅.
НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HANDOFF (состояние/решения/окружение), impl-plan (фазы+догрузки), кликабельный прототип
2026-06-29-konkurentnoe-pole-proto.html (визуал-эталон), omega-visual-check (живая сверка
рабочего места с реальными конкурентами Омеги, скрины FIELD-*/PROTO-*).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.
Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.
API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).
Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».
Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Настройки→Реквизиты и диалог проекта: «Физлицо»→«Физическое лицо», «Юрлицо»→«Юридическое лицо» (ключи value не тронуты)
- У физлица скрыт блок «Реквизиты для оплаты» (банковских реквизитов нет; оплата по счёту — только для юр/ИП)
- Диалог проекта: выбранные дни недели залиты зелёным #0f6e56 как в ProjectDetailsDrawer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Прод приведён к gitea-main 84dfbc85 один-в-один (rsync-checksum, потерь нет);
бэкап цел; экран Тенанты на серверную пагинацию выкачен; 992 фронт-теста зелёные;
деньги t2=1 839 405₽ целы. Остатки не-git на проде объяснены (.bak-precutover до 03.07).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AutopodborChargeService::chargeForRun — DB::transaction + lockForUpdate
на AutopodborRun (guard идемпотентности по balance_transaction_id) и Tenant;
bcmath (bcsub/bccomp/bcmul), никаких float; throw InsufficientBalanceException
до любых изменений баланса при нехватке средств.
- Миграция 2026_06_28_110100: расширяет CHECK constraint
balance_transactions_type_check — добавляет 'autopodbor_charge'.
- Тест: 2 money-инварианта (идемпотентность + noop при нехватке).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AutopodborNormalizer: domainHead (схема/www/путь/порт → голова),
phone (через PhoneNormalizer::normalize → 7xxxxxxxxxx без плюса),
sourceKey и competitorKey для дедупликации конкурентов и источников.
4 теста, 9 assertions, все GREEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Политики tenant_isolation приведены к каноничной харднинг-форме
NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
(после переноса на gitea/main с v8.57). Запись CHANGELOG для всех 3 таблиц.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Дизайн и пошаговый план фичи «Автоподбор конкурентов» (ИИ-агент находит
конкурентов и их источники). Движок — отдельной сессией, здесь розетка+заглушка.
План сверен с кодом: RLS app.current_tenant_id, tenant-контекст SET LOCAL,
тестовая БД liderra_testing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.
Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Свод за заход «закрывай хвосты»: разбор и фикс preflight-sweep/reminder (no-op с 26.06
из-за RLS-роли очереди), self-heal 4 проектов на проде, деньги t2 целы, playwright в deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Корень: после переезда на Managed PG очередь ходит под ролью crm_app_user (RLS),
и Tenant::query() в BalancePreflightSweepJob/BalanceFrozenReminderJob отдавал 0 строк
без app.current_tenant_id — биллинг-преflight молча стал no-op с 26.06 (ни заморозок,
ни снятия проектных блоков). Перечень тенантов теперь берётся через pgsql_supplier
(BYPASSRLS), модель грузится внутри per-tenant SET LOCAL контекста. Логика проверена
на боевых данных: t25/t26 снимутся, t27/t30 заморозятся.
Playwright рантайма supplier-портала объявлен в dependencies ровно 1.59.0 под
chromium-1217 + package-lock синхронизирован; деплой ставит его npm ci --omit=dev,
durable к чистке node_modules.
Тесты Billing 18/18, pint/phpstan чисто.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Над «Откуда собирать заявки» добавлена строка-подсказка с tooltip:
лимит распределяется по поставщикам равномерно; даже если не выбирается
полностью — просто увеличить лимит, и сделок придёт больше.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:35:29 +03:00
638 changed files with 66149 additions and 914 deletions
->selectRaw("id, charged_at, 'lead' as kind, deal_id, tier_no, charge_source, price_per_lead_kopecks, cast(null as text) as label");
$this->applyPeriodTo($lead,'charged_at',$period);
if(is_string($source)&&$source!==''){
$lead->where('charge_source',$source);
}
$this->applyFilters($query,$request);
// Списания автоподбора (balance_transactions) — тоже показываем в «Списаниях», иначе клиент
// видит их только в «Истории транзакций». Фильтр charge_source (prepaid/rub) к автоподбору
// неприменим → при выбранном источнике автоподбор не подмешиваем.
if(!is_string($source)||$source===''){
$auto=DB::table('balance_transactions')
->where('tenant_id',$tenantId)
->where('type','autopodbor_charge')
->selectRaw("id, created_at as charged_at, 'autopodbor' as kind, cast(null as bigint) as deal_id, cast(null as integer) as tier_no, cast(null as varchar) as charge_source, cast(round(abs(amount_rub) * 100) as bigint) as price_per_lead_kopecks, cast('Автоподбор конкурентов' as text) as label");
$this->applyPeriodTo($auto,'created_at',$period);
$rows=$lead->unionAll($auto);
}else{
$rows=$lead;
}
$page=$query->paginate(20);
$page=DB::query()->fromSub($rows,'c')
->orderByDesc('charged_at')
->orderByDesc('id')
->paginate(20);
returnresponse()->json([
'data'=>$page->items(),
@@ -141,6 +161,29 @@ class TenantChargesController extends Controller
}
});
// Списания автоподбора — тоже в CSV (после лид-строк). charge_source к ним неприменим,
// поэтому при выбранном источнике их не выгружаем (как и в списке).
['name'=>'Окна Комфорт','description'=>'Пластиковые окна и остекление балконов под ключ.','is_federal'=>false,'relevance_pct'=>100,'site_url'=>'okna-komfort-kzn.ru','directory_urls'=>['https://2gis.ru/firm/1'],'provenance'=>['via'=>'similar-pages']],
['name'=>'Фабрика Окон','description'=>'Федеральная сеть окон ПВХ, филиал в регионе.','is_federal'=>true,'relevance_pct'=>84,'site_url'=>'fabrika-okon.ru','directory_urls'=>['https://2gis.ru/firm/3'],'provenance'=>['via'=>'similar-pages']],
['signal_type'=>'site','identifier'=>'okna-komfort.pro','phone_kind'=>null,'phone_type'=>null,'provenance_url'=>'https://yandex.ru/maps/1','provenance_label'=>'Яндекс.Карты — сайт в контактах'],
['signal_type'=>'call','identifier'=>'78432009988','phone_kind'=>'substitute','phone_type'=>'city','provenance_url'=>'https://okna-komfort-kzn.ru','provenance_label'=>'номер в шапке (коллтрекинг)'],
['signal_type'=>'call','identifier'=>'88002001122','phone_kind'=>'real','phone_type'=>'tollfree','provenance_url'=>'https://okna-komfort-kzn.ru/contacts','provenance_label'=>'бесплатная линия 8-800 на сайте'],
['name'=>$r->name,'description'=>'Найдено по названию (заглушка).','site_url'=>'okna-komfort-kzn.ru','directory_urls'=>['https://2gis.ru/firm/1'],'provenance'=>['via'=>'name-search']],
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.