Compare commits

...

21 commits

Author SHA1 Message Date
d3b0d0bea5 Merge branch 'main' into feat/mera-hero-separate-caveats
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m29s
2026-08-31 09:28:32 +00:00
1b7d10179f Merge pull request 'липкая панель прячется, пока герой в кадре' (#3296) from fix/mera-sticky-after-hero into main
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-08-31 09:28:15 +00:00
e94a9792ce Merge branch 'main' into fix/mera-sticky-after-hero
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
2026-08-31 09:26:41 +00:00
86e6956257 Merge pull request 'лендинг: пятая плитка падала на вторую строку, столбец «Расхождение» читался пустым' (#3290) from fix/mera-kpi-grid-and-table into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-08-31 09:26:34 +00:00
98a1944a86 Merge pull request 'числа лендинга: покрытие 88 % было выше всего диапазона представительных пересборок' (#3295) from fix/mera-honest-coverage-number into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-31 09:26:28 +00:00
b277b1f678 Merge branch 'main' into fix/mera-sticky-after-hero
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
2026-08-31 09:24:43 +00:00
3f57171f63 Merge branch 'main' into fix/mera-kpi-grid-and-table
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
2026-08-31 09:24:37 +00:00
44e7d4b63a Merge branch 'main' into fix/mera-honest-coverage-number
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 16s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m26s
2026-08-31 09:15:06 +00:00
f7d428c0c5 Merge pull request 'витрина: строка без улицы больше не открывает страницу и игру' (#3291) from fix/mera-showcase-order-street into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Trade-In / build-frontend (push) Successful in 2m41s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-31 09:14:48 +00:00
d20b844561 Merge pull request 'бэктест: выборка вразброс — ORDER BY id DESC садится на несколько улиц' (#3289) from feat/backtest-scattered-sampling into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-31 09:14:40 +00:00
078f102841 Merge pull request '«Для бизнеса» вела на форму входа — заведена страница /business' (#3294) from feat/mera-business-page into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy Infra Host / sync-infra-host (push) Successful in 10s
Deploy / changes (push) Successful in 14s
perimeter-smoke-mera / smoke (push) Failing after 16s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 52s
Deploy / build-frontend (push) Successful in 53s
Deploy / build-backend (push) Successful in 54s
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Failing after 13s
2026-08-31 09:14:34 +00:00
47df1a9020 липкая панель прячется, пока герой в кадре
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m37s
Владелец увидел на первом экране, что полоса «Проверить квартиру» лежит поверх
абзаца «Мы называем реальную цену вашей квартиры».

.barSpacer это НЕ лечит и не может: он добавляет высоту в КОНЕЦ документа, а
.barRoot — fixed, то есть накрывает нижние ~67px вьюпорта при ЛЮБОЙ прокрутке,
включая scrollTop=0. Разобравший это агент так и написал и не стал чинить молча.

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

ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА. Скрытие включает только клиентский наблюдатель;
если скрипт не выполнился, поведение остаётся ровно сегодняшним. Обратный
порядок (скрыта, показывает скрипт) в тех же условиях убрал бы призыв со
страницы совсем — то есть чинил бы вид ценой работы. Нет секции героя —
наблюдать нечего, панель остаётся видимой, а не пропадает.

Убирается transform-ом, а не display:none: transform идёт на композиторе и не
вызывает перекладку. Страница и так дорога в отрисовке на мобильном (1824 мс в
Style & Layout), и лечение перекрытия не должно стоить пересчёта макета на
каждом пересечении границы героя.

Убранная панель уходит и из дерева доступности (aria-hidden + inert): иначе Tab
уводит в кнопку, которой на экране нет.

Тест проверяет ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается
пересечение. Фальсификация обеих сторон — панель не прячется («expected false to
be true») и панель скрыта по умолчанию («до срабатывания панель видима»).
150 passed, eslint чисто.
2026-08-31 14:13:36 +05:00
da6a6b6e82 Merge pull request 'P1: лендинг отдавался без витрины — имя сервиса двоится между продуктами' (#3282) from fix/mera-ambiguous-service-names into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 2m29s
Deploy Trade-In / test (push) Has been cancelled
2026-08-31 09:10:09 +00:00
c4beee29ff для бизнеса: убраны обещания, которых нет в контуре; гейт чисел накрыл подстраницы
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
Снимает два замечания ревью.

1. КОПИЯ ОБЕЩАЛА НЕСУЩЕСТВУЮЩЕЕ. «Расчёты по потоку адресов», «не для одной
сделки, а потоком», «по вашему потоку адресов» — всё это подразумевает
массовую загрузку, которой в контуре нет: ни CSV, ни импорта, ни пакетной
ручки. Проверено grep-ом по src/app/v2.

Что там ЕСТЬ на самом деле и теперь названо: учётные записи команды
(app/api/v1/team.py), история своих расчётов (useEstimateHistory, оверлей
«Предыдущие оценки»), месячный лимит на аккаунт (account_quota_overrides,
monthly_limit/unlimited), выдача доступа вручную.

Это ровно тот дефект, который на этом лендинге выкорчёвывали весь день:
величина или возможность, которой никто не мерил и не делал, в продуктовом
голосе. Новая страница не имеет права начинать заново.

2. ГЕЙТ ЧИСЕЛ НЕ ВИДЕЛ НОВУЮ СТРАНИЦУ. Сканер знал два места: корневой
page.tsx и каталог _components/v3. Публичная /business под него не попадала —
правило держалось на памяти автора. Теперь подстраницы находятся ОБХОДОМ
каталога, а не списком: список — снимок, следующую страницу в него снова
забудут вписать.

Юридические страницы (оферта, политика, возврат, документы) исключены явно и
с обоснованием: они состоят из ссылок на законы («Закон РФ от 07.02.1992
№ 2300-1», «152-ФЗ», «ст. 18.1»), а по форме это те же числа, что и замеры.
Отличить регулярным выражением нельзя, и правило гейта к ним не относится —
там числа цитируются из нормативных актов, а не утверждаются о рынке.
Расширение проверено: включение этих страниц давало 4 ложных срабатывания.

Фальсификация: вписанные в /business «42 700 квартир» роняют гейт по значению
с указанием файла. 150 passed, tsc и eslint чисто.
2026-08-31 13:02:45 +05:00
79ae87019a бэктест: подписи окна приведены к замеру, уверенность — к тому же прогону
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m21s
Снимает три блокера ревью на этой же ветке.

1. ПОДПИСЬ ОКНА. Числа перевели на представительную выборку, а подпись
осталась от прежнего прогона: «сделки II квартала 2026 года». Автор честно
записал, что квартальный состав пересборки не смотрел, и оставил критерий
снятия — посчитать deal_date по выборке. Посчитал:

    III кв 2025 — 106 (26,5 %)      I кв 2026 — 88 (22,0 %)
    IV кв 2025  — 110 (27,5 %)     II кв 2026 — 96 (24,0 %)

Пересборка покрывает ЧЕТЫРЕ квартала, II квартал 2026 — четверть её. Прежняя
подпись для этих чисел была бы прямым враньём.

2. ЗНАМЕНАТЕЛЬ. Был 5 954 — сделки только II квартала. Для выборки по четырём
кварталам это завышало долю вчетверо: 5,5 % вместо 1,3 %. Замер тем же
фильтром, что у выборки: в окне 24 333, из них II квартал 2026 — 5 954. Оба
числа одним запросом.

3. УВЕРЕННОСТЬ. Плитка «325 из 327» была из прогона 29.08, а лид секции печатал
«замер 31.08» — старому числу приписывалась новая дата, и 325 в лиде совпадало
с 325 в плитке, читаясь как одна выборка. Заменено на «400 из 400» из ТОЙ ЖЕ
пересборки. Знаменатель 400 — запрошенные сделки, а не 325 сматчившихся:
уверенность считается на каждой оценке.

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

Тесты проверены фальсификацией: возврат знаменатели одного квартала даёт
«expected 5954 to be 24333» и «expected 5.458 to be less than 2»; снятие
оговорки из вывода — «оговорка не выводится». 157 passed.
2026-08-31 12:59:27 +05:00
2864d80854 fix(mera-public): честные числа бэктеста — медиана трёх представительных пересборок
Покрытие 88 % и расхождение 14,5 % получались только на кластеризованной
выборке (ORDER BY id DESC: 20 строк на четыре улицы). Замер 31.08.2026 тремя
представительными пересборками (engine=full, ЕКБ, три соли) дал покрытие
82,77/84,31/86,02 % и MAPE 14,66/15,28/15,66 %: опубликованные 88 % лежат ВЫШЕ
всего диапазона, то есть недостижимы, а 14,5 % — чуть ниже, тот же
благоприятный край.

- BACKTEST.coverage 88 % -> 84,3 %, priceError 14,5 % -> 15,3 % (медианы),
  sampleN 327 -> 325 (медианная пересборка);
- в оговорки добавлен сам диапазон пересборок (82,8-86,0 % и 14,7-15,7 %):
  точечное значение без него обещает точность, которой у замера нет;
- BACKTEST_SOURCE переписан — представительная выборка, а не последние
  загруженные строки; дата замера 29.08 -> 31.08.2026;
- confidenceLow («325 из 327») на пересборках НЕ пересчитывался: у него
  отдельный источник с прежней датой и оговорка про свой знаменатель;
- квартальный состав представительной выборки не проверен — записан
  дословно, с критерием снятия, рядом с BACKTEST_PERIOD_LABEL.

Тест: backtest-freshness проверяет наличие диапазона в оговорках (+контроль на
инструмент) и что AccuracyV3/HeroV3 не содержат самих величин; render-тест
перестал сверяться с вписанным «14,5 %» и берёт значение из landing-facts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:41:32 +05:00
4d51d19f7c feat(mera-public): публичная страница /business вместо ссылки на форму входа
Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале вели на B2B_URL.
Реальная цепочка на проде: 308 → /trade-in → 307 → /trade-in/v2 → клиентский
редирект → /trade-in/login. Посетитель публичного сайта упирался в форму
«Логин / Пароль» от аккаунта, которого у него нет.

Заведена страница /business: кому полезен бизнес-контур, чем он отличается от
бесплатной проверки, и как связаться (почта и телеграм — те же константы, что
в подвале и в документах, не выдуманные для страницы). Ни цен, ни сроков, ни
объёмов: этих величин никто не мерил. Ссылка на вход оставлена внутри страницы
второстепенной — «уже работаете с нами».

Периметр согласован во всех четырёх местах: PUBLIC_ROUTES, три матчера Caddy
(@meraPages, @meraShortSlash, @meraLongPages), PUBLIC_SHORT_PATHS в RouteGuard,
плюс строка в smoke-mera-perimeter.sh. Гейт периметра проверен фальсификацией:
удаление /business из @meraShortSlash роняет public-perimeter.test.ts.

noindex — тем же способом, что у /docs (metadata.robots).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:40:59 +05:00
884a6b9cfe бэктест: режим выборки вразброс — ORDER BY id DESC садится на несколько улиц
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится
пачками по домам: соседние id это один дом и одна улица. Замер на проде
31.08.2026 по Екатеринбургу, выборка 200:

    последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
    случайные 200       : 129 разных улиц, максимум 6 с одной

На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с
двух. Владелец заметил это как «почти все сделки из одного района».

ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок:

    recent            MAPE 15.18%  покрытие 88.75%
    scattered mera    MAPE 14.96%  покрытие 83.13%
    scattered alpha   MAPE 15.97%  покрытие 88.34%
    scattered bravo   MAPE 13.69%  покрытие 83.85%
    scattered charlie MAPE 14.36%  покрытие 86.23%

Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса
представительной выборки. Кластеризация её не раздувала.

А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке
выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон
из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом.

УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово
тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы
сравнимость всей истории замеров. Режим выбирается флагом --spread.

Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе
два прогона отличаются и из-за правки, и из-за состава выборки, и разделить
вклады нечем.

Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием
— падает identity-проверка (сравнение текстов её бы пропустило), заменить md5
на random() — падает проверка воспроизводимости.
2026-08-31 11:28:11 +05:00
28bf3a82d7 fix(mera-public): плитки точности в один ряд, «Расхождение» по одному краю
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m21s
Пятая плитка (тёмная, из /stats) не влезала в грид: контейнер 1168px,
minmax(220px) + gap 20px требуют 1180px — плитка падала на вторую строку
одна и узкая, читалось как обрыв вёрстки. Заменил грид на flex-wrap с общим
базисом 200px: пять плиток встают в ряд, четыре — тоже, а на узких ширинах
остаток растягивается на всю строку вместо огрызка слева.

Значения столбца «Расхождение» стояли по левому краю ячейки, а заголовок —
по правому, из-за чего столбец читался пустым. Выровнял значения вправо,
по краю заголовка; на мобильном брейке text-align по-прежнему сбрасывается
в left (карточки с data-label не тронуты).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:24:11 +05:00
e45b05845e fix(mera-landing): наличие схемы улицы входит в полноту строки витрины
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Первой строкой витрины и первым раундом игры «Проверьте себя» стояла
единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц
рисовался полигон района. Причина — `completeness()` считала район, этаж
и этажность, но не улицу, хотя именно она решает, будет ли у строки карта.

Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем
нарисовать карту, полнее строки с картой быть не может. Признак берётся
из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти);
индекс поднят выше отбора, дорогие пространственные запросы остались в
`_schemes_for` и по-прежнему считаются только для показанных строк.

Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка
с отклонением +75,7% остаётся в витрине, просто больше не открывает её.
Прежнее правило «наличие схемы на отбор не влияет» в докстринге
`_schemes_for` заменено с разбором, почему оно давало этот дефект.

Тест двусторонний: при прочих равных строка со схемой выше строки без
неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие
`row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8]
вместо [8, 9]), не по исключению.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:22:39 +05:00
f31cb56081 прод: лендинг ходил в чужой бэкенд — имя сервиса двоится между продуктами
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 17m35s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m11s
Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта», без подписи разброса. Страница про точность — без
единого доказательства. Отдавалось 106 КБ вместо 241 КБ.

Причина. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свой сервис
backend и оба подключены к общей сети gendesign_shared. Изнутри фронта:

    backend         → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
    tradein-backend → 172.18.0.6

BACKEND_URL=http://backend:8000 уводил серверный рендер в бэкенд ПТИЦЫ, тот
отвечал 401 no authenticated user, и страница рендерилась пустой.

Отказ тихий вдвойне. fetch не бросает — приходит валидный HTTP-ответ, просто
чужой. И имя двоится, поэтому часть перегенераций попадала в правильный адрес:
утром страница была с данными, к обеду без них, и это выглядело случайной
поломкой, а не ошибкой конфигурации.

DATABASE_URL болен тем же: @postgres:5432 мог уйти в базу ПТИЦЫ. Там спасало
лишь несовпадение кредов — отказ вместо тихого чтения не тех данных. Полагаться
на это нельзя: защита держится на том, что у чужой базы нет пользователя с
нашим паролем. Переведён на однозначное имя во всех трёх сервисах.

Гейт check-compose-ambiguous-hosts.py: пересечение имён сервисов обоих compose
и запрет ссылаться на них как на хост. Селфтест по конвенции соседних гейтов —
он провалился дважды на моих же фикстурах (в них не было общего имени, то есть
ловить было нечего), и это ровно то, ради чего селфтест и нужен.

Фальсификация на настоящем файле: возврат backend:8000 даёт точную строку 479,
возврат @postgres — все четыре ссылки.
2026-08-30 16:07:41 +05:00
24 changed files with 1097 additions and 66 deletions

View file

@ -82,6 +82,22 @@ jobs:
python3 scripts/check-caddy-snippet-mounts.py --selftest python3 scripts/check-caddy-snippet-mounts.py --selftest
python3 scripts/check-caddy-snippet-mounts.py python3 scripts/check-caddy-snippet-mounts.py
- name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя"
# ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы
# `backend`/`postgres`/`frontend` и оба сидят в общей сети
# gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент
# берёт любой.
#
# 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд
# ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ
# ленты сделок, без строк сверки и без подписи разброса — то есть без
# единого доказательства. Отказ тихий: fetch не бросает, приходит
# валидный чужой ответ; а из-за двоения часть перегенераций попадала
# в правильный адрес, и поломка выглядела случайной.
run: |
python3 scripts/check-compose-ambiguous-hosts.py --selftest
python3 scripts/check-compose-ambiguous-hosts.py
- name: "Guard: Caddyfile синтаксически валиден" - name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge. # на КАЖДОМ PR, стоит секунды, падение блокирует merge.

View file

@ -347,7 +347,7 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется. # «/», и запрос закольцуется.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
handle @meraPages { handle @meraPages {
rewrite * /trade-in/mera-public{path} rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 { reverse_proxy tradein-frontend:3000 {
@ -361,7 +361,7 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это # голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются. # со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs)/$ @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
handle @meraShortSlash { handle @meraShortSlash {
redir * /{re.shortslash.1} permanent redir * /{re.shortslash.1} permanent
} }
@ -446,7 +446,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir` # порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде). # (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
handle @meraLongPages { handle @meraLongPages {
route { route {
uri strip_prefix /trade-in/mera-public uri strip_prefix /trade-in/mera-public

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282).
ПОЧЕМУ. ПТИЦА и МЕРА разные compose-проекты, но оба назвали свои сервисы
`backend`, `postgres`, `frontend`, и оба подключены к общей сети
`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой:
изнутри tradein-frontend:
backend 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
tradein-backend 172.18.0.6
30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000`
уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated
user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта» и без подписи разброса. Страница про точность без
единого доказательства.
Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP-
ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций
попадала в правильный адрес утром страница отдавалась с данными, к обеду без
них, и выглядело это как случайная поломка, а не как ошибка конфигурации.
У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу
ПТИЦЫ. Там спасало лишь несовпадение кредов то есть отказ вместо тихого
чтения не тех данных. На такую защиту полагаться нельзя: она держится на том,
что у чужой базы нет пользователя с нашим паролем.
ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть
двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост
ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно:
подставить `container_name` (`tradein-backend`, `tradein-postgres`), он
однозначен по построению.
ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в
контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба
compose-файла.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml"
PTICA_COMPOSE = REPO / "docker-compose.prod.yml"
# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие.
_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M)
# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN).
_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+")
def service_names(text: str) -> set[str]:
"""Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они
объявляются в своих секциях с тем же отступом, поэтому список заведомо шире
нужного и это безопасная сторона, лишнее имя даст лишнюю проверку, а не
пропуск."""
return set(_SERVICE_RE.findall(text))
def violations(mera: str, ptica: str) -> list[str]:
ambiguous = service_names(mera) & service_names(ptica)
if not ambiguous:
return []
found: list[str] = []
for num, line in enumerate(mera.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
for host in _HOST_RE.findall(line):
if host in ambiguous:
found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}")
return found
def selftest() -> int:
"""Гейт обязан уметь краснеть — иначе зелёный ничего не значит."""
ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n"
# У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение
# пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить».
# На этом селфтест и провалился в первой редакции.
bad = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n URL: \"http://backend:8000\"\n"
)
if not violations(bad, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr)
return 1
bad_dsn = (
"services:\n postgres:\n image: y\n"
" backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n"
)
if not violations(bad_dsn, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr)
return 1
ok = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n"
)
if violations(ok, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr)
return 1
# Закомментированная строка — не конфигурация.
commented = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n # URL: \"http://backend:8000\"\n"
)
if violations(commented, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr)
return 1
print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть")
args = ap.parse_args()
if args.selftest:
return selftest()
for path in (MERA_COMPOSE, PTICA_COMPOSE):
if not path.exists():
print(f"НЕ НАЙДЕН: {path}", file=sys.stderr)
return 1
mera = MERA_COMPOSE.read_text(encoding="utf-8")
ptica = PTICA_COMPOSE.read_text(encoding="utf-8")
shared = service_names(mera) & service_names(ptica)
if not shared:
print("пересекающихся имён сервисов нет — проверять нечего")
return 0
found = violations(mera, ptica)
if not found:
print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет")
return 0
print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr)
for line in found:
print(f" {line}", file=sys.stderr)
print(
"\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n"
"в два адреса и клиент берёт любой. Подставьте container_name\n"
"(tradein-backend, tradein-postgres) — он однозначен.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -90,6 +90,11 @@ check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404. # лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200 check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# 1d2. Страница «МЕРА для бизнеса» — короткий адрес, на который ведут пункт
# шапки и подвала. До 31.08.2026 они вели в закрытый контур и приводили
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один # 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать. # канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер # ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер

View file

@ -30,8 +30,8 @@
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является. квартала, нет площади) «число некрасивое» причиной не является.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность), Полнота сколько из полей, которые видит посетитель (район, этаж, этажность,
у строки заполнено. Свежесть порядок квартала сделки. схема улицы), у строки заполнено. Свежесть порядок квартала сделки.
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт) ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
---------------------------------------------------------- ----------------------------------------------------------
@ -69,7 +69,10 @@
нет ни координат окна, ни констант проекции: точку дома по ней нельзя нет ни координат окна, ни констант проекции: точку дома по ней нельзя
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт 92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`). показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`):
строка, которой нечем нарисовать карту, полнее строки с картой быть не
может. Это признак «поле заполнено», как район и этаж, а не величина
ошибки, правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
@ -97,7 +100,7 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.street_scheme import build_street_scheme, load_street_index from app.services.street_scheme import StreetIndex, build_street_scheme, load_street_index
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -158,15 +161,26 @@ class ShowcaseRow:
# None штатно: у части сделок координаты нет, подставлять туда нечего. # None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None lat: float | None = None
lon: float | None = None lon: float | None = None
# Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM).
# Саму схему строим только для показанных строк — см. `_schemes_for`.
has_street: bool = False
def completeness(row: ShowcaseRow) -> int: def completeness(row: ShowcaseRow) -> int:
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..3). """Сколько ВИДИМЫХ посетителю полей заполнено (0..4).
Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по Считаем район/этаж/этажность и схему улицы: комнаты и площадь есть у всех
построению выборки, поэтому в оценке полноты они бесполезны. кандидатов по построению выборки, поэтому в оценке полноты они бесполезны.
СХЕМА УЛИЦЫ ТАКОЕ ЖЕ ВИДИМОЕ ПОЛЕ, как район. Строка без улицы рисует на
фронте полигон РАЙОНА вместо схемы улиц, то есть этого поля у неё просто
нет, и полнее строки с картой она быть не может. Это признак наличия
данных, а не величина ошибки: строка с большим отклонением, но со схемой,
показывается как есть.
""" """
return sum(x is not None for x in (row.district, row.floor, row.total_floors)) return int(row.has_street) + sum(
x is not None for x in (row.district, row.floor, row.total_floors)
)
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]: def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
@ -197,6 +211,7 @@ def build_row(
n_analogs: int, n_analogs: int,
lat: float | None = None, lat: float | None = None,
lon: float | None = None, lon: float | None = None,
has_street: bool = False,
) -> ShowcaseRow | None: ) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего. """Кандидат → строка витрины, либо None если считать не из чего.
@ -251,6 +266,7 @@ def build_row(
n_analogs=n_analogs, n_analogs=n_analogs,
lat=lat, lat=lat,
lon=lon, lon=lon,
has_street=has_street,
) )
@ -320,19 +336,27 @@ _INSERT_SQL = text(
) )
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict: def _schemes_for(
db: Session,
index: StreetIndex,
chosen: list[ShowcaseRow],
addresses: dict[int, str | None],
) -> dict:
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема. """Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
Считаем после отбора, а не до: схема это два пространственных запроса на Считаем после отбора, а не до: схема это два пространственных запроса на
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
лишних запросов в чужую базу. лишних запросов в чужую базу.
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ и это то же правило, что запрещает отбор НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина входит наравне с районом и этажом. До 2026-08-31 здесь было записано
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на обратное «схема на отбор не влияет», и первой строкой витрины и первым
OSM. Схема украшение строки, а не причина её показать. раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо
схемы улиц рисовался полигон района. Отсутствие видимого поля не может
делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не
затронут: строка с большим отклонением, но со схемой, стоит в витрине как
есть.
""" """
index = load_street_index(db)
out = {} out = {}
for row in chosen: for row in chosen:
scheme = build_street_scheme(db, index, addresses.get(row.deal_id)) scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
@ -377,6 +401,10 @@ def refresh_landing_showcase_deals(
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since) logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
districts = _fetch_districts(db, [d.id for d in deals]) districts = _fetch_districts(db, [d.id for d in deals])
# Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак
# полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на
# прогон, дорогие пространственные запросы остались в `_schemes_for`.
street_index = load_street_index(db)
candidates: list[ShowcaseRow] = [] candidates: list[ShowcaseRow] = []
n_priced = 0 n_priced = 0
@ -408,6 +436,7 @@ def refresh_landing_showcase_deals(
# тип этого не поймает: обе величины float. # тип этого не поймает: обе величины float.
lat=deal.lat, lat=deal.lat,
lon=deal.lon, lon=deal.lon,
has_street=street_index.lookup(deal.address) is not None,
) )
if row is None: if row is None:
n_incomplete += 1 n_incomplete += 1
@ -415,7 +444,7 @@ def refresh_landing_showcase_deals(
candidates.append(row) candidates.append(row)
chosen = select_rows(candidates, limit) chosen = select_rows(candidates, limit)
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals}) schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
db.execute(_DELETE_SQL) db.execute(_DELETE_SQL)
db.execute(_DELETE_RUNS_SQL) db.execute(_DELETE_RUNS_SQL)

View file

@ -1129,6 +1129,88 @@ _SAMPLE_SQL = text(
""" """
) )
# ── ВЫБОРКА ВРАЗБРОС (--spread scattered) ────────────────────────────────────
#
# ЗАЧЕМ. `ORDER BY id DESC` выше — это последние ВСТАВЛЕННЫЕ строки, а Росреестр
# грузится пачками по домам: соседние id это один дом и одна улица. Замер на
# проде 31.08.2026 по Екатеринбургу, выборка 200:
#
# последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
# случайные 200 : 129 разных улиц, максимум 6 с одной
#
# То есть заголовочная точность и вся витрина лэндинга считались по выборке
# втрое беднее случайной. Ошибка при этом не «шумит», а СМЕЩЕНА: пара новостроек
# ведёт себя иначе, чем город.
#
# ПОЧЕМУ НЕ СТАЛО УМОЛЧАНИЕМ. Замороженный регресс-гейт сравнивает прогоны между
# собой и требует от дефолтного пути побайтово того же SQL (см. докстринг
# `_sample_sql`). Менять умолчание значило бы обнулить сравнимость всей истории
# замеров. Режим выбирается явно.
#
# ПОЧЕМУ md5, А НЕ random(). `random()` даёт разную выборку на каждом прогоне —
# два замера подряд отличались бы и из-за правки, и из-за выборки, и разделить
# эти вклады было бы нечем. md5(id || seed) — псевдослучайный, но
# ВОСПРОИЗВОДИМЫЙ порядок: тот же seed → та же выборка.
_SAMPLE_SQL_SCATTERED = text(
"""
SELECT
id,
ST_X(geom::geometry) AS lon,
ST_Y(geom::geometry) AS lat,
rooms,
price_per_m2 AS sold_ppm2,
price_rub,
deal_date,
area_m2,
address,
floor,
total_floors,
year_built,
house_type
FROM deals
WHERE source = 'rosreestr'
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND area_m2 > 0
AND deal_date >= CAST(:since AS date)
ORDER BY md5(CAST(id AS text) || CAST(:seed AS text))
LIMIT CAST(:sample AS integer)
"""
)
_SAMPLE_SQL_SCATTERED_CITY = text(
"""
SELECT
id,
ST_X(geom::geometry) AS lon,
ST_Y(geom::geometry) AS lat,
rooms,
price_per_m2 AS sold_ppm2,
price_rub,
deal_date,
area_m2,
address,
floor,
total_floors,
year_built,
house_type
FROM deals
WHERE source = 'rosreestr'
AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL
AND area_m2 IS NOT NULL
AND area_m2 > 0
AND deal_date >= CAST(:since AS date)
AND city = CAST(:city AS text)
ORDER BY md5(CAST(id AS text) || CAST(:seed AS text))
LIMIT CAST(:sample AS integer)
"""
)
# Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi # Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers # (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
# is applied in Python for byte-for-byte fidelity with the estimator. # is applied in Python for byte-for-byte fidelity with the estimator.
@ -1163,7 +1245,7 @@ _CITY_PPM2_BAND_SQL = text(
) )
def _sample_sql(city: str | None) -> Any: def _sample_sql(city: str | None, scattered: bool = False) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D). """ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used ``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
@ -1177,6 +1259,8 @@ def _sample_sql(city: str | None) -> Any:
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
slug see the ``--city`` CLI help for the naming decision. slug see the ``--city`` CLI help for the naming decision.
""" """
if scattered:
return _SAMPLE_SQL_SCATTERED_CITY if city is not None else _SAMPLE_SQL_SCATTERED
if city is None: if city is None:
return _SAMPLE_SQL return _SAMPLE_SQL
return text( return text(
@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float
def _load_sample( def _load_sample(
db: Session, *, sample: int, since: str, city: str | None = None db: Session,
*,
sample: int,
since: str,
city: str | None = None,
scattered: bool = False,
seed: str = "mera",
) -> list[DealSample]: ) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]. """Run the held-out ДКП deal sampling SELECT → list[DealSample].
@ -1259,7 +1349,9 @@ def _load_sample(
} }
if city is not None: if city is not None:
params["city"] = city params["city"] = city
rows = db.execute(_sample_sql(city), params).mappings().all() if scattered:
params["seed"] = seed
rows = db.execute(_sample_sql(city, scattered), params).mappings().all()
out: list[DealSample] = [] out: list[DealSample] = []
for r in rows: for r in rows:
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None: if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
@ -2025,6 +2117,8 @@ def run_backtest(
rooms_tolerance: int, rooms_tolerance: int,
holdout_split: bool = False, holdout_split: bool = False,
city: str | None = None, city: str | None = None,
scattered: bool = False,
seed: str = "mera",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict. """Drive the full read-only backtest and return a metrics dict.
@ -2044,7 +2138,7 @@ def run_backtest(
``deals.city`` value see ``_load_sample``. Default None is unscoped ``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour). (byte-identical to the pre-oblast-D behaviour).
""" """
deals = _load_sample(db, sample=sample, since=since, city=city) deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city) logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
matched_rows: list[tuple[float, float, int]] = [] matched_rows: list[tuple[float, float, int]] = []
@ -2118,6 +2212,8 @@ def run_backtest_full(
dump_fixture: str | None = None, dump_fixture: str | None = None,
resolve_house_id: bool = False, resolve_house_id: bool = False,
city: str | None = None, city: str | None = None,
scattered: bool = False,
seed: str = "mera",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966). """Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -2150,7 +2246,7 @@ def run_backtest_full(
``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring. ``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring.
""" """
est = _import_estimator_full() est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since, city=city) deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
logger.info( logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city "loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
) )
@ -2318,6 +2414,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default="2025-06-01", default="2025-06-01",
help="Only deals with deal_date >= this ISO date (default 2025-06-01).", help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
) )
p.add_argument(
"--spread",
choices=("recent", "scattered"),
default="recent",
help="Как берётся выборка сделок. 'recent' (умолчание) — ORDER BY id DESC, "
"т.е. последние ВСТАВЛЕННЫЕ строки: Росреестр грузится пачками по домам, "
"поэтому такая выборка садится на несколько улиц (замер 31.08.2026 по ЕКБ: "
"38 улиц, до 22 сделок с одной). 'scattered' — воспроизводимый "
"псевдослучайный порядок по md5(id||seed): 129 улиц, максимум 6 с одной. "
"Умолчание оставлено 'recent' ради сравнимости с историей замеров — "
"замороженный регресс-гейт требует от дефолтного пути того же SQL.",
)
p.add_argument(
"--seed",
default="mera",
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
"прогона подряд отличаются только правкой, а не составом выборки.",
)
p.add_argument( p.add_argument(
"--city", "--city",
default=None, default=None,
@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int:
dump_fixture=args.dump_fixture, dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id, resolve_house_id=args.resolve_house_id,
city=args.city, city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
) )
else: else:
metrics = run_backtest( metrics = run_backtest(
@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int:
rooms_tolerance=args.rooms_tolerance, rooms_tolerance=args.rooms_tolerance,
holdout_split=args.holdout_split, holdout_split=args.holdout_split,
city=args.city, city=args.city,
scattered=(args.spread == "scattered"),
seed=args.seed,
) )
finally: finally:
db.close() db.close()

View file

@ -0,0 +1,88 @@
"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания.
ЗАЧЕМ. `ORDER BY id DESC` берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр
грузится пачками по домам: соседние id это один дом и одна улица. Замер на
проде 31.08.2026 по Екатеринбургу, выборка 200:
последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
случайные 200 : 129 разных улиц, максимум 6 с одной
Выборка втрое беднее случайной а на ней стояла витрина лэндинга (20 строк с
ЧЕТЫРЁХ улиц, 16 из них с двух).
ЧТО ЭТОТ ТЕСТ ДЕРЖИТ. Две вещи, и вторая не менее важна первой:
1. Режим «вразброс» действительно меняет порядок и воспроизводим по seed.
2. УМОЛЧАНИЕ НЕ ТРОНУТО дефолтный путь обязан отдавать ТОТ ЖЕ объект SQL.
Замороженный регресс-гейт сравнивает прогоны между собой; сменить умолчание
значило бы молча обнулить сравнимость всей истории замеров.
"""
from __future__ import annotations
import re
from scripts.backtest_estimator import (
_SAMPLE_SQL,
_SAMPLE_SQL_SCATTERED,
_SAMPLE_SQL_SCATTERED_CITY,
_sample_sql,
)
def _sql(obj) -> str:
return str(obj.text if hasattr(obj, "text") else obj)
class TestУмолчаниеНеТронуто:
def test_дефолтный_путь_отдаёт_тот_же_объект(self):
# Именно identity, а не равенство текста: докстринг `_sample_sql`
# обещает побайтовую неизменность, и «текст совпал» это обещание не
# проверяет — объект мог быть пересобран с другими bind-параметрами.
assert _sample_sql(None) is _SAMPLE_SQL
assert _sample_sql(None, scattered=False) is _SAMPLE_SQL
def test_умолчание_сортирует_по_id_и_не_знает_про_seed(self):
sql = _sql(_SAMPLE_SQL)
assert "ORDER BY id DESC" in sql
assert ":seed" not in sql, "в умолчание протёк параметр режима вразброс"
class TestРежимВразброс:
def test_выбирается_явно_и_меняет_порядок(self):
assert _sample_sql(None, scattered=True) is _SAMPLE_SQL_SCATTERED
assert _sample_sql("Екатеринбург", scattered=True) is _SAMPLE_SQL_SCATTERED_CITY
def test_порядок_воспроизводим_ае_случаен(self):
# random() дал бы разную выборку на каждом прогоне, и два замера
# подряд отличались бы и из-за правки, и из-за состава выборки —
# разделить вклады было бы нечем.
for obj in (_SAMPLE_SQL_SCATTERED, _SAMPLE_SQL_SCATTERED_CITY):
sql = _sql(obj)
assert "md5(" in sql, "порядок обязан считаться от id и seed"
assert ":seed" in sql
assert re.search(r"\brandom\s*\(", sql) is None, (
"random() делает выборку неповторимой между прогонами"
)
assert "ORDER BY id DESC" not in sql
def test_городской_вариант_фильтрует_город_а_общий_нет(self):
assert "city = CAST(:city AS text)" in _sql(_SAMPLE_SQL_SCATTERED_CITY)
assert "city = CAST(:city AS text)" not in _sql(_SAMPLE_SQL_SCATTERED)
def test_оба_режима_отбирают_по_одним_и_тем_же_условиям(self):
# Иначе разница между режимами объяснялась бы не порядком, а другим
# набором сделок — и сравнение двух прогонов ничего бы не значило.
общие = [
"source = 'rosreestr'",
"geom IS NOT NULL",
"price_per_m2 BETWEEN",
"rooms IS NOT NULL",
"area_m2 > 0",
"deal_date >= CAST(:since AS date)",
]
база = _sql(_SAMPLE_SQL)
вразброс = _sql(_SAMPLE_SQL_SCATTERED)
for условие in общие:
assert условие in база, условие
assert условие in вразброс, условие

View file

@ -31,6 +31,7 @@ def _row(
total_floors: int | None = 9, total_floors: int | None = 9,
deal_date: date = date(2026, 1, 1), deal_date: date = date(2026, 1, 1),
err_pct: float = 10.0, err_pct: float = 10.0,
has_street: bool = True,
) -> ShowcaseRow: ) -> ShowcaseRow:
return ShowcaseRow( return ShowcaseRow(
deal_id=deal_id, deal_id=deal_id,
@ -45,6 +46,7 @@ def _row(
fact_rub=5_500_000, fact_rub=5_500_000,
err_pct=err_pct, err_pct=err_pct,
n_analogs=40, n_analogs=40,
has_street=has_street,
) )
@ -72,6 +74,31 @@ def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2] assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
def test_selection_prefers_row_with_street_scheme() -> None:
"""Строка без улицы не может быть самой полной: ей нечем нарисовать карту.
Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры
«Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо
схемы улиц у неё рисовался полигон РАЙОНА.
Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы
выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из
`completeness` тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
Вторая сторона проверки строка без улицы ОСТАЁТСЯ в витрине: она не
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
"""
no_street = _row(9, has_street=False, err_pct=75.7)
with_street = _row(8, err_pct=3.0)
chosen = select_rows([no_street, with_street], limit=2)
assert [r.deal_id for r in chosen] == [8, 9], (
"строка, которой нечем нарисовать карту, оказалась полнее строки со "
"схемой улиц — витрина открывается полигоном района"
)
def test_selection_is_deterministic_on_full_ties() -> None: def test_selection_is_deterministic_on_full_ties() -> None:
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает».""" """Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
rows = [_row(7), _row(9), _row(8)] rows = [_row(7), _row(9), _row(8)]

View file

@ -294,7 +294,7 @@ services:
# Деградация источника в None — существующее состояние ответа (так же # Деградация источника в None — существующее состояние ответа (так же
# ведёт себя таймаут), контракт API не меняется. # ведёт себя таймаут), контракт API не меняется.
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true" ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
PUBLIC_URL: "https://gendsgn.ru/trade-in" PUBLIC_URL: "https://gendsgn.ru/trade-in"
CORS_ORIGINS: '["https://gendsgn.ru"]' CORS_ORIGINS: '["https://gendsgn.ru"]'
ENVIRONMENT: "production" ENVIRONMENT: "production"
@ -373,7 +373,7 @@ services:
- path: ./backend/.env.runtime - path: ./backend/.env.runtime
required: false required: false
environment: environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
ENVIRONMENT: "production" ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}" GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
@ -432,7 +432,7 @@ services:
- path: ./backend/.env.runtime - path: ./backend/.env.runtime
required: false required: false
environment: environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
ENVIRONMENT: "production" ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
# Этот процесс — не scheduler_main; false на всякий случай, если общий # Этот процесс — не scheduler_main; false на всякий случай, если общий
@ -468,7 +468,15 @@ services:
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000. # только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
# Принудительно 0.0.0.0 чтобы Caddy достучался. # Принудительно 0.0.0.0 чтобы Caddy достучался.
HOSTNAME: "0.0.0.0" HOSTNAME: "0.0.0.0"
BACKEND_URL: "http://backend:8000" # internal SSR # ИМЯ ОБЯЗАНО БЫТЬ ОДНОЗНАЧНЫМ. Оба продукта назвали свой сервис
# `backend`, и оба подключены к общей сети gendesign_shared —
# изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6
# и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает
# 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и
# подписи разброса, то есть страница про точность без доказательств.
# Отказ тихий: fetch не бросает, приходит валидный 401.
# `tradein-backend` — container_name, он однозначен.
BACKEND_URL: "http://tradein-backend:8000" # internal SSR
depends_on: [backend] depends_on: [backend]
restart: unless-stopped restart: unless-stopped
networks: networks:

View file

@ -19,6 +19,9 @@
* квартал), значит чаще чем раз в квартал замеру обновляться не от чего. * квартал), значит чаще чем раз в квартал замеру обновляться не от чего.
* Срок = квартал плюс запас на загрузку следующей пачки. * Срок = квартал плюс запас на загрузку следующей пачки.
*/ */
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
@ -78,3 +81,55 @@ describe("свежесть ручного замера бэктеста", () =>
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4)); expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
}); });
}); });
/**
* Разброс пересборок и происхождение величин.
*
* ЗАЧЕМ. 31.08.2026 три представительные пересборки одной и той же сверки дали
* попадание 82,8-86,0 % и расхождение 14,7-15,7 %. Опубликованные до этого
* 88 % лежали ВЫШЕ всего диапазона значение получалось только на
* кластеризованной выборке (`ORDER BY id DESC`, 20 строк на четыре улицы).
* Точечное число без диапазона обещает точность, которой у замера нет, поэтому
* оговорка про разброс не украшение, а часть величины: снимут её красное.
*
* ВТОРАЯ ПОЛОВИНА откуда число берёт компонент. Прежние 88 % пережили бы
* правку `landing-facts.ts`, будь они вписаны в разметку; здесь проверяется,
* что в витринных компонентах нет самих значений, а есть ссылки на факты.
*/
const V3 = join(__dirname, "..", "_components", "v3");
const SPREAD_RANGES = ["14,7-15,7", "82,8-86,0"];
function caveatsText(): string {
return [...BACKTEST.priceError.caveats, ...BACKTEST.coverage.caveats].join(" ");
}
describe("разброс пересборок назван, а величины не вписаны в компонент", () => {
it.each(SPREAD_RANGES)("оговорка называет диапазон пересборок %s", (range) => {
expect(
caveatsText(),
[
`в оговорках priceError/coverage нет диапазона ${range}.`,
"Точечное значение разового замера гуляет при пересборке выборки —",
"без диапазона витрина обещает точность, которой у замера нет.",
].join(" "),
).toContain(range);
});
it("проверка диапазона краснеет на тексте без него — иначе она зелёная по построению", () => {
const withoutRange = "Попадание достигнуто шириной коридора, а не точностью точки.";
for (const range of SPREAD_RANGES) {
expect(withoutRange).not.toContain(range);
}
});
it.each(["AccuracyV3.tsx", "HeroV3.tsx"])("%s берёт величины из landing-facts, а не из своей разметки", (file) => {
const src = readFileSync(join(V3, file), "utf8");
for (const value of [BACKTEST.priceError.text, BACKTEST.coverage.text]) {
expect(
src,
`${file}: величина ${value} вписана в компонент — правка landing-facts её не догонит`,
).not.toContain(value);
}
expect(src, `${file}: величина берётся не из BACKTEST`).toContain("BACKTEST.");
});
});

View file

@ -0,0 +1,68 @@
/**
* Подписи бэктеста обязаны описывать ТО окно, по которому числа посчитаны.
*
* 31.08.2026 числа перевели на представительную выборку (медиана трёх
* пересборок), но подписи остались от прежнего прогона: «сделки II квартала
* 2026 года» и знаменатель 5 954 (только этот квартал). Замер показал, что
* пересборка покрывает ЧЕТЫРЕ квартала и II квартал 2026 лишь 24 % её
* сделок. Доля выборки при старом знаменателе выходила 5,5 % вместо 1,3 %,
* то есть вчетверо льстила.
*
* Отдельно: плитка «уверенность низкая» показывалась БЕЗ своей оговорки
* `AccuracyV3` собирал оговорки только двух величин из трёх.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import {
BACKTEST,
BACKTEST_PERIOD_LABEL,
BACKTEST_POPULATION,
BACKTEST_SHARE_LABEL,
} from "../landing-facts";
describe("подписи окна бэктеста", () => {
it("окно названо целиком, а не одним кварталом из четырёх", () => {
// Прежняя подпись называла II квартал 2026 — в представительной выборке
// это 24 % сделок, а не все.
expect(BACKTEST_PERIOD_LABEL).not.toMatch(/^сделки II квартала/u);
expect(BACKTEST_PERIOD_LABEL).toMatch(/четыре квартала/u);
});
it("знаменатель — того же окна, что и выборка", () => {
// 5 954 — только II квартал 2026; окно целиком — 24 333 (замер 31.08.2026).
expect(BACKTEST_POPULATION).toBe(24_333);
});
it("доля выборки выводится и получается около 1,3 %, а не 5,5 %", () => {
const share = (BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100;
expect(share).toBeGreaterThan(1.0);
expect(share).toBeLessThan(2.0);
expect(BACKTEST_SHARE_LABEL).toBe("1,3 %");
});
it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
// «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
// приписывало старому числу новую дату.
expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
});
});
describe("оговорки в интерфейсе", () => {
it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => {
render(<AccuracyV3 stats={{}} showcase={null} />);
for (const набор of [
BACKTEST.priceError.caveats,
BACKTEST.coverage.caveats,
BACKTEST.confidenceLow.caveats,
]) {
for (const line of набор) {
expect(
screen.queryByText(line),
`оговорка не выводится: ${line.slice(0, 48)}`,
).not.toBeNull();
}
}
});
});

View file

@ -79,8 +79,57 @@ const V3_DIR = "_components/v3";
const GENERATED = ["ekb-districts.ts"]; const GENERATED = ["ekb-districts.ts"];
const GENERATED_MARK = "СГЕНЕРИРОВАН"; const GENERATED_MARK = "СГЕНЕРИРОВАН";
/**
* ПОДСТРАНИЦЫ ПЕРИМЕТРА тоже под гейтом, а не только корень и v3-секции.
*
* 31.08.2026 завели `/business`, и ревьюер заметил, что новая ПУБЛИЧНАЯ
* страница чисел ничем не сторожится: сканер знал ровно два места корневой
* `page.tsx` и каталог `_components/v3`. Чисел там тогда не было, но правило
* держалось на том, что автор о нём помнил.
*
* Каталоги перечисляются ОБХОДОМ, а не списком: список снимок, и следующую
* страницу в него снова забудут вписать. Обход находит её сам.
*/
/**
* ЮРИДИЧЕСКИЕ СТРАНИЦЫ ПОД ЭТОТ ГЕЙТ НЕ ПОПАДАЮТ и это не послабление.
*
* Гейт ищет ФОРМУ величины: «14,5 %», «42 700», «150 ». Тексты оферты,
* политики и возврата состоят из ссылок на законы, и по форме они те же самые:
*
* Закон РФ от 07.02.1992 2300-1 «О защите прав потребителей»
* Федеральный закон от 27.07.2006 152-ФЗ
* ст. 18.1 152-ФЗ
*
* Отличить номер закона от замера рынка регулярным выражением нельзя, а
* подгонять под них исключения по одному значило бы дырявить сам сканер.
* Правило, которое гейт защищает, к этим страницам и не относится: там числа
* ЦИТИРУЮТСЯ из нормативных актов, а не утверждаются о рынке.
*
* Список ЯВНЫЙ и короткий: молчаливое расширение (например «все страницы с
* длинным текстом») однажды укроет и маркетинговую.
*/
const LEGAL_PAGES = new Set(["oferta", "privacy", "refund", "docs"]);
const PAGE_DIRS = readdirSync(ROOT, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() &&
e.name !== "__tests__" &&
e.name !== "_components" &&
!LEGAL_PAGES.has(e.name),
)
.map((e) => e.name)
.sort();
const SUBPAGES = PAGE_DIRS.flatMap((dir) =>
readdirSync(join(ROOT, dir))
.filter((name) => /^page\.tsx?$/u.test(name))
.map((name) => `${dir}/${name}`),
);
const V3_SOURCES = [ const V3_SOURCES = [
LANDING_ROOT, LANDING_ROOT,
...SUBPAGES,
...readdirSync(join(ROOT, V3_DIR)) ...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name)) .filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
.sort() .sort()

View file

@ -13,6 +13,7 @@ import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3"; import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3"; import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3"; import { HeroV3 } from "../_components/v3/HeroV3";
import { BACKTEST } from "../landing-facts";
import type { LandingStat, ShowcaseResponse } from "../public-api"; import type { LandingStat, ShowcaseResponse } from "../public-api";
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({ const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
@ -77,9 +78,9 @@ const SHOWCASE: ShowcaseResponse = {
describe("витрина лэндинга v3 без данных", () => { describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => { it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />); render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.getByText("14,5 %")).toBeTruthy(); expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy(); expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
expect(screen.getByText("325 из 327")).toBeTruthy(); expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy(); expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно. // Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy(); expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
@ -92,7 +93,7 @@ describe("витрина лэндинга v3 без данных", () => {
expect(container.querySelector('[role="table"]')).toBeNull(); expect(container.querySelector('[role="table"]')).toBeNull();
expect(container.textContent).not.toMatch(/[—-]\s*дн\./); expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками. // Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
expect(screen.getByText("14,5 %")).toBeTruthy(); expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
}); });
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => { it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {

View file

@ -0,0 +1,92 @@
/**
* Липкая панель не должна лежать поверх первого экрана.
*
* Владелец увидел на проде, что полоса накрывает абзац «Мы называем реальную
* цену вашей квартиры». `.barSpacer` это не лечит и не может: он добавляет
* высоту в КОНЕЦ документа, а `.barRoot` `fixed`, то есть накрывает нижние
* ~67px вьюпорта при любой прокрутке, включая самый верх страницы.
*
* Проверяется ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается
* пересечение и проверяется, что панель убралась, затем обратное.
*/
import { render, screen } from "@testing-library/react";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
let срабатывание: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
beforeEach(() => {
срабатывание = null;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
срабатывание = cb;
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* Корень панели ищем ПО КОНТЕЙНЕРУ, а не через getByRole: когда панель убрана,
* на ней стоит aria-hidden, и роль «ссылка» из дерева доступности пропадает
* это и есть желаемое поведение, но локатор по роли на нём слепнет.
*/
function корень(c: HTMLElement): HTMLElement {
const link = c.querySelector('a[href*="estimate"]');
if (!link?.parentElement) throw new Error("панель не отрендерилась");
return link.parentElement;
}
const скрыта = (c: HTMLElement) => /barHidden/u.test(корень(c).className);
describe("липкая панель против первого экрана", () => {
it("без секции героя панель остаётся видимой, а не пропадает", () => {
// Наблюдать нечего — поведение обязано остаться сегодняшним.
const { container } = render(<StickyCtaV3 />);
expect(скрыта(container)).toBe(false);
});
it("герой в кадре — панель убрана и недоступна с клавиатуры", () => {
const hero = document.createElement("section");
hero.id = "check";
document.body.appendChild(hero);
const { container } = render(<StickyCtaV3 />);
expect(скрыта(container), "до срабатывания панель видима").toBe(false);
act(() => срабатывание!([{ isIntersecting: true }]));
expect(скрыта(container)).toBe(true);
expect(корень(container).getAttribute("aria-hidden")).toBe("true");
// Убранная панель обязана уйти и из дерева доступности — иначе Tab
// уводит в кнопку, которой на экране нет.
expect(screen.queryByRole("link", { name: /Проверить квартиру/u })).toBeNull();
hero.remove();
});
it("герой ушёл — панель возвращается", () => {
const hero = document.createElement("section");
hero.id = "check";
document.body.appendChild(hero);
const { container } = render(<StickyCtaV3 />);
act(() => срабатывание!([{ isIntersecting: true }]));
expect(скрыта(container)).toBe(true);
act(() => срабатывание!([{ isIntersecting: false }]));
expect(скрыта(container), "панель не вернулась — призыв потерян навсегда").toBe(false);
expect(screen.getByRole("link", { name: /Проверить квартиру/u })).toBeTruthy();
hero.remove();
});
});

View file

@ -1,4 +1,3 @@
import { B2B_URL } from "../content";
import styles from "../landing-v3.module.css"; import styles from "../landing-v3.module.css";
import { PublicLink } from "./PublicLink"; import { PublicLink } from "./PublicLink";
@ -57,9 +56,9 @@ export function InnerHeader() {
</PublicLink> </PublicLink>
<div className={styles.hdrRight}> <div className={styles.hdrRight}>
<a href={B2B_URL} className={styles.hdrBizLink}> <PublicLink to="business" className={styles.hdrBizLink}>
Для бизнеса Для бизнеса
</a> </PublicLink>
</div> </div>
</div> </div>
</header> </header>

View file

@ -114,9 +114,14 @@ export function AccuracyV3({
}); });
} }
// Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается
// отдельной плиткой, но её оговорка сюда не попадала — то есть на экране
// стояло «400 из 400 — уверенность низкая» без единого слова о том, почему.
// Плитка без своей оговорки — ровно то, что этот блок и не должен делать.
const caveats = [ const caveats = [
...BACKTEST.priceError.caveats, ...BACKTEST.priceError.caveats,
...BACKTEST.coverage.caveats, ...BACKTEST.coverage.caveats,
...BACKTEST.confidenceLow.caveats,
]; ];
const deals = showcase?.deals ?? []; const deals = showcase?.deals ?? [];

View file

@ -21,16 +21,20 @@
* а добавляет одну точку входа с тарифами и порядком возврата. * а добавляет одну точку входа с тарифами и порядком возврата.
* *
* ССЫЛКИ КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` * ССЫЛКИ КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate`
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса» * отдельный экран проверки, а не на якорь формы в герое. Все переходы
* стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг, * обычным `<a>` через `PublicLink`, потому что `next/link` подставляет
* то есть пункт не работал. Все переходы обычным `<a>` через `PublicLink`, * basePath (см. его шапку).
* потому что `next/link` подставляет basePath (см. его шапку). *
* «МЕРА ДЛЯ БИЗНЕСА» ВЕДЁТ НА `/business`, А НЕ НА `B2B_URL` (31.08.2026).
* Абсолютный адрес B2B-контура заканчивался формой входа `/trade-in/login`
* посетителю публичного сайта предлагали залогиниться в аккаунт, которого у
* него нет. Теперь пункт ведёт на публичную страницу связи, а ссылка на вход
* живёт внутри неё, второстепенной.
*/ */
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
import { import {
B2B_URL,
LEGAL_ENTITY, LEGAL_ENTITY,
REGION_NAME, REGION_NAME,
SUPPORT_EMAIL, SUPPORT_EMAIL,
@ -70,9 +74,9 @@ export function FooterV3() {
<PublicLink to="articles" className={styles.ftrLink}> <PublicLink to="articles" className={styles.ftrLink}>
Статьи Статьи
</PublicLink> </PublicLink>
<a href={B2B_URL} className={styles.ftrLink}> <PublicLink to="business" className={styles.ftrLink}>
МЕРА для бизнеса МЕРА для бизнеса
</a> </PublicLink>
</div> </div>
</div> </div>

View file

@ -49,7 +49,6 @@
* был `CityPicker`). * был `CityPicker`).
*/ */
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink"; import { PublicLink } from "../PublicLink";
@ -87,9 +86,9 @@ export function HeaderV3() {
</nav> </nav>
<div className={styles.hdrRight}> <div className={styles.hdrRight}>
<a href={B2B_URL} className={styles.hdrBizLink}> <PublicLink to="business" className={styles.hdrBizLink}>
Для бизнеса Для бизнеса
</a> </PublicLink>
<a href="#check" className={styles.hdrCta}> <a href="#check" className={styles.hdrCta}>
Проверить квартиру Проверить квартиру
</a> </a>

View file

@ -1,3 +1,5 @@
"use client";
/** /**
* StickyCtaV3 липкая нижняя CTA-панель (макет ~ строки 518-521). * StickyCtaV3 липкая нижняя CTA-панель (макет ~ строки 518-521).
* *
@ -32,17 +34,64 @@
* чаще всего. В тексте панели по той же причине нет и «срока продажи»: * чаще всего. В тексте панели по той же причине нет и «срока продажи»:
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз * платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
* срока (`_estimate_days_on_market`, см. HeroV3). * срока (`_estimate_days_on_market`, см. HeroV3).
*
* ПАНЕЛЬ ПРЯЧЕТСЯ, ПОКА ГЕРОЙ В КАДРЕ (31.08.2026). Владелец увидел на первом
* экране, что полоса лежит поверх абзаца «Мы называем реальную цену вашей
* квартиры». `.barSpacer` это НЕ лечит и не может: он добавляет высоту в
* КОНЕЦ документа, а `.barRoot` `fixed`, то есть накрывает нижние ~67px
* вьюпорта при ЛЮБОЙ прокрутке, включая `scrollTop = 0`.
*
* Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки,
* и панель дублирует призыв, закрывая текст. Уходит герой панель появляется.
*
* ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА, и это важно. Скрытие включает только клиентский
* наблюдатель; если скрипт не выполнился (ошибка, старый браузер, отключённый
* JS), поведение остаётся ровно сегодняшним полоса на месте. Обратный
* порядок (по умолчанию скрыта, показывает скрипт) в тех же условиях убрал бы
* призыв со страницы совсем, то есть чинил бы вид ценой работы.
*
* Наблюдатель смотрит на `#check` секцию героя (`HeroV3`). Если её на
* странице нет (компонент вставили в другую композицию), наблюдать нечего и
* панель просто остаётся видимой: снова сегодняшнее поведение, а не пустой
* экран.
*/ */
import { useEffect, useState } from "react";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink"; import { PublicLink } from "../PublicLink";
/** Секция героя: пока она в кадре, панель молчит. */
const HERO_ID = "check";
export function StickyCtaV3() { export function StickyCtaV3() {
const [heroVisible, setHeroVisible] = useState(false);
useEffect(() => {
const hero = document.getElementById(HERO_ID);
if (!hero || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setHeroVisible(entry.isIntersecting),
// Панель высотой ~67px снизу: считаем героя «в кадре», пока он заходит
// хотя бы на эту полосу — иначе она успевала бы мигнуть на его хвосте.
{ rootMargin: "0px 0px -68px 0px" },
);
io.observe(hero);
return () => io.disconnect();
}, []);
return ( return (
<> <>
<div className={styles.barSpacer} aria-hidden="true" /> <div className={styles.barSpacer} aria-hidden="true" />
<div className={styles.barRoot}> <div
className={`${styles.barRoot} ${heroVisible ? styles.barHidden : ""}`}
// Пока панель убрана, её не должно быть и для скринридера с
// клавиатуры: иначе Tab уходит в кнопку, которой на экране нет.
aria-hidden={heroVisible || undefined}
inert={heroVisible || undefined}
>
<p className={styles.barText}> <p className={styles.barText}>
Узнайте цену своей квартиры и что происходит с похожими рядом Узнайте цену своей квартиры и что происходит с похожими рядом
</p> </p>

View file

@ -0,0 +1,155 @@
import type { Metadata } from "next";
import { safeUrl } from "@/lib/safeUrl";
import { InnerHeader } from "../_components/InnerHeader";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import {
B2B_URL,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing-v3.module.css";
/**
* «МЕРА для бизнеса» страница связи, а не вход.
*
* ЗАЧЕМ ЗАВЕДЕНА. Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале
* вели на `B2B_URL`. Реальная цепочка на проде: 308 `/trade-in` 307
* `/trade-in/v2` клиентский редирект `/trade-in/login`. Посетитель
* публичного сайта упирался в форму «Логин / Пароль» от аккаунта, которого у
* него нет и завести который на этом экране нельзя. Владелец просил починить
* это дважды.
*
* ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Ни цен, ни сроков ответа, ни объёмов, ни числа
* клиентов: таких величин никто не мерил, а на странице они читались бы как
* обязательство (то же решение, что в `docs/page.tsx` про часы поддержки).
* Формы заявки тоже нет принимать её в продукте нечем: анонимной ручки для
* B2B-лида не существует, и форма, которая никуда не пишет, хуже честной
* ссылки на живой канал. Каналы взяты из `content.ts` те же, что в подвале и
* в документах, а не заведены для этой страницы отдельно.
*
* ССЫЛКА НА ВХОД ОСТАВЛЕНА, но второстепенной: у кого доступ уже есть, тот
* пришёл именно за ней, и убрать её значило бы чинить один дефект вторым.
*/
export const metadata: Metadata = {
title: "МЕРА для бизнеса — связаться",
// Тот же noindex, что у остального публичного дерева (см. layout.tsx и
// docs/page.tsx): страница открыта по адресу, но в выдачу не идёт.
robots: { index: false, follow: false },
};
export default function BusinessPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<>
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<span aria-current="page">Для бизнеса</span>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>ДЛЯ БИЗНЕСА</div>
<h1 className={styles.artPageTitle}>МЕРА для бизнеса</h1>
<p className={styles.artPageLead}>
Оценка квартир по живым объявлениям под своей учётной записью
для агентства, застройщика или компании, которая принимает квартиры
в зачёт.
</p>
</header>
<section className={styles.docsSection} aria-labelledby="biz-who">
<h2 id="biz-who" className={styles.docsH2}>
Кому это нужно
</h2>
<p className={styles.artP}>
Тем, кто считает не одну квартиру, а много и регулярно: агентствам
недвижимости, отделам продаж застройщиков, компаниям с программами
трейд-ин. Расчёт идёт по тем же источникам, что и бесплатная
проверка на сайте, но под учётными записями вашей команды, с
сохранением истории и месячным лимитом на аккаунт.
</p>
<p className={styles.artP}>
Если задача разовая квартира одна, и нужно понять цену, идти
сюда незачем: бесплатная проверка на сайте отвечает на этот вопрос
без договорённостей и без регистрации.
</p>
</section>
<section className={styles.docsSection} aria-labelledby="biz-diff">
<h2 id="biz-diff" className={styles.docsH2}>
Чем отличается от бесплатной проверки
</h2>
<div className={styles.docsLinks}>
<PublicLink to="estimate" className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Бесплатная проверка</span>
<span className={styles.docsLinkNote}>
Одна квартира, здесь и сейчас, без регистрации. Картина рынка
вокруг дома: сколько похожих квартир продаётся рядом и как давно
висят объявления
</span>
</PublicLink>
<span className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Бизнес-контур</span>
<span className={styles.docsLinkNote}>
Закрытый контур с доступом по учётной записи: история своих
расчётов, учётные записи команды, месячный лимит на аккаунт.
Доступ выдаётся вручную, после разговора, самостоятельной
регистрации в нём нет
</span>
</span>
</div>
<p className={styles.artP}>
Что именно войдёт в работу обсуждается: задача разная у
агентства, у застройщика и у трейд-ин программы. Поэтому на этой
странице нет ни тарифа, ни срока: назвать их до разговора значило бы
их придумать.
</p>
</section>
<section className={styles.docsSection} aria-labelledby="biz-contact">
<h2 id="biz-contact" className={styles.docsH2}>
Как связаться
</h2>
<p className={styles.artP}>
Напишите в любой из каналов коротко, что за компания и какая
задача: сколько адресов, как часто, что должно быть на выходе.
Этого хватает, чтобы ответить по существу с первого письма.
</p>
<div className={styles.artKv}>
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>ПОЧТА</span>
<span className={styles.artKvVal}>
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
</span>
</div>
{telegramHref ? (
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>TELEGRAM</span>
<span className={styles.artKvVal}>
<a href={telegramHref} target="_blank" rel="noreferrer">
{SUPPORT_TELEGRAM_LABEL}
</a>
</span>
</div>
) : null}
</div>
<p className={styles.artFootnote}>
Уже работаете с нами? <a href={B2B_URL}>Вход в бизнес-контур</a>
по логину и паролю, которые вам выдали.
</p>
</section>
</main>
<FooterV3 />
</>
);
}

View file

@ -195,6 +195,10 @@ export const PUBLIC_ROUTES = {
articles: "/articles", articles: "/articles",
articleHowTo: "/articles/kak-ocenit-kvartiru", articleHowTo: "/articles/kak-ocenit-kvartiru",
docs: "/docs", docs: "/docs",
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
// и заканчивалась формой входа для аккаунта, которого у посетителя нет.
business: "/business",
} as const; } as const;
/** Канонический публичный origin сервиса — база для canonical-ссылок. */ /** Канонический публичный origin сервиса — база для canonical-ссылок. */
@ -209,6 +213,12 @@ export const PUBLIC_ORIGIN = "https://meraocenka.ru";
* осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её * осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её
* из текущего хоста было бы неверно. * из текущего хоста было бы неверно.
*/ */
/**
* ЭТО ВХОД В ЗАКРЫТЫЙ КОНТУР, А НЕ ВИТРИНА. Цепочка редиректов приводит на
* `/trade-in/login` форму «Логин / Пароль». Человеку с улицы показывать её
* нельзя, поэтому шапка и подвал ведут на `PUBLIC_ROUTES.business`, а этот
* адрес остался ссылкой «у меня уже есть доступ» внутри той страницы.
*/
export const B2B_URL = "https://gendsgn.ru/trade-in/"; export const B2B_URL = "https://gendsgn.ru/trade-in/";
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */ /** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */

View file

@ -42,8 +42,29 @@ export interface MeasuredValue {
readonly caveats: readonly string[]; readonly caveats: readonly string[];
} }
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */ /**
* Источник двух головных величин ПРЕДСТАВИТЕЛЬНЫЙ прогон 31.08.2026.
*
* ЧТО ПОМЕНЯЛОСЬ ПРОТИВ 29.08. Прежний прогон брал строки `ORDER BY id DESC`,
* то есть последние загруженные: 20 строк выборки приходились на ЧЕТЫРЕ улицы.
* Это не выборка города, а её кластер, и покрытие на нём выходило 88 %
* ВЫШЕ всего диапазона представительных пересборок (82,8-86,0 %). Значение
* было недостижимым, а не «чуть оптимистичным»; расхождение 14,5 % лежало
* чуть ниже диапазона 14,66-15,66 % тот же благоприятный край, но на
* порядок слабее. Здесь стоит МЕДИАНА трёх пересборок с разными солями.
*/
const BACKTEST_SOURCE = const BACKTEST_SOURCE =
"Бэктест на боевой базе (31.08.2026, engine=full): прогноз МЕРЫ против цены " +
"ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
"адресам, три пересборки с разными солями), медиана трёх прогонов";
/**
* Источник `confidenceLow` ПРЕЖНИЙ прогон, он не пересчитывался 31.08.
* Держится отдельной строкой, чтобы дата и выборка не выглядели общими с
* головными числами: там 325 сделок представительной выборки, здесь 327
* кластеризованных.
*/
const BACKTEST_CONFIDENCE_SOURCE =
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " + "Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки II квартала 2026 года"; "Росреестра по Екатеринбургу, сделки II квартала 2026 года";
@ -54,18 +75,21 @@ const BACKTEST_SOURCE =
*/ */
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = { export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
priceError: { priceError: {
text: "14,5 %", text: "15,3 %",
sampleN: 327, sampleN: 325,
source: BACKTEST_SOURCE, source: BACKTEST_SOURCE,
caveats: [ caveats: [
"Величина разового замера гуляет при пересборке выборки: на трёх пересборках " +
"расхождение вышло 14,7-15,7 %, попадание в коридор — 82,8-86,0 %. " +
"Точечное значение без этого диапазона обещает точность, которой у замера нет.",
"Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " + "Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
"это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.", "это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
"Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.", "Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
], ],
}, },
coverage: { coverage: {
text: "88 %", text: "84,3 %",
sampleN: 327, sampleN: 325,
source: BACKTEST_SOURCE, source: BACKTEST_SOURCE,
caveats: [ caveats: [
"Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " + "Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
@ -73,10 +97,21 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
], ],
}, },
confidenceLow: { confidenceLow: {
text: "325 из 327", // Тот же прогон, что и остальные числа блока: пересборка «mera» от
sampleN: 327, // 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
source: BACKTEST_SOURCE, // общей подписью «замер 31.08» это приписывало старому числу новую дату.
caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."], // Знаменатель 400 — это ЗАПРОШЕННЫЕ сделки, а не 325 сматчившихся:
// уверенность считается на каждой оценке, включая те, что не дошли до
// сверки с ценой ДКП.
text: "400 из 400",
sampleN: 400,
source: BACKTEST_CONFIDENCE_SOURCE,
caveats: [
"«Уверенность высокая» за три пересборки по 400 оценок встретилась один " +
"раз, «средняя» — 0, 6 и 9 раз. Это не скромность: порог требует " +
"разброса цен среди сопоставимых объявлений не выше 24 %, а на замере " +
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
],
}, },
}; };
@ -102,20 +137,47 @@ export const BACKTEST_CORRIDOR_LABEL = "±37 %";
* 01.04.2026). Поэтому «квартал» предельная точность, которую данные вообще * 01.04.2026). Поэтому «квартал» предельная точность, которую данные вообще
* позволяют назвать; «сделки за апрель» было бы вторым враньём. * позволяют назвать; «сделки за апрель» было бы вторым враньём.
*/ */
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года"; export const BACKTEST_PERIOD_LABEL =
"сделки за четыре квартала — с III квартала 2025 по II квартал 2026";
/** /**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 327 сделок» не * КВАРТАЛЬНЫЙ СОСТАВ ПРЕДСТАВИТЕЛЬНОЙ ВЫБОРКИ ЗАМЕРЕН 31.08.2026.
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
* же фильтры выборки (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms,
* area) сделок Екатеринбурга с `deal_date = 2026-04-01` 5 954.
* *
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, что и выборка. Соблазн подставить сюда * Здесь стояла оговорка «никто не смотрел» с критерием снятия: посчитать
* 24 333 (все сделки ЕКБ с июня 2025) даёт долю красивее 1,3 % вместо * `deal_date` по сделкам выборки. Посчитано, и ответ поменял подпись.
* 5,5 %, но это ровно та же ошибка, что чинится выше: доля от окна, которое *
* замер не покрывал. * Прогон 29.08 (`ORDER BY id DESC`) брал последние ЗАГРУЖЕННЫЕ строки, и у
* всех 327 сделок стояло `deal_date = 2026-04-01` один квартал. Пересборка
* вразброс покрывает ВСЁ окно `--since`, и её 400 сделок легли так:
*
* III кв 2025 106 (26,5 %) I кв 2026 88 (22,0 %)
* IV кв 2025 110 (27,5 %) II кв 2026 96 (24,0 %)
*
* То есть II квартал 2026 лишь четверть выборки. Прежняя подпись «сделки
* II квартала 2026 года» для этих чисел была бы прямым враньём, а знаменатель
* 5 954 (только II квартал) завышал бы долю вчетверо.
*
* Поэтому подпись называет окно целиком, а знаменатель взят того же окна.
*/ */
export const BACKTEST_POPULATION = 5_954;
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 325 сделок» не
* читалось как «столько их и было».
*
* Замер 31.08.2026 на проде, ровно те же фильтры, что у выборки бэктеста
* (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms, area > 0,
* `deal_date >= 2025-06-01`, город Екатеринбург): **24 333** сделки.
*
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, ЧТО И ВЫБОРКА правило не изменилось,
* изменилось окно. Раньше здесь стояло 5 954 (только II квартал 2026), и это
* было верно для прогона 29.08, который в один квартал и укладывался. Для
* пересборок вразброс, покрывающих четыре квартала, тот же знаменатель дал бы
* долю вчетверо больше реальной 5,5 % вместо 1,3 %.
*
* Обе величины проверены одним запросом: в окне 24 333, из них с
* `deal_date = 2026-04-01` 5 954.
*/
export const BACKTEST_POPULATION = 24_333;
/** /**
* Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может. * Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.
@ -134,7 +196,7 @@ export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей * BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
* задачей. * задачей.
*/ */
export const BACKTEST_MEASURED_ON = "2026-08-29"; export const BACKTEST_MEASURED_ON = "2026-08-31";
/** Через сколько дней замер считается протухшим (см. тест свежести). */ /** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100; export const BACKTEST_MAX_AGE_DAYS = 100;

View file

@ -365,6 +365,24 @@
страницы при докрутке до конца (реквизиты в подвале, последний вопрос страницы при докрутке до конца (реквизиты в подвале, последний вопрос
FAQ). Высота одна на все ширины: панель везде в одну строку на узком FAQ). Высота одна на все ширины: панель везде в одну строку на узком
экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */ экране за счёт того, что текст скрыт (см. `.barText`), а не переносится. */
/* Панель убрана, пока герой в кадре (см. докстринг StickyCtaV3).
Убирается ТРАНСФОРМОМ, а не display:none: transform идёт на композиторе и
не вызывает перекладку. Страница и так дорога в отрисовке на мобильном
(1824 мс в Style & Layout), и лечение перекрытия не должно стоить ещё
одного пересчёта макета при каждом пересечении границы героя. */
.barHidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform 180ms ease, opacity 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
.barHidden {
transition: none;
}
}
.barSpacer { .barSpacer {
height: 68px; height: 68px;
} }
@ -1015,9 +1033,14 @@
text-wrap: pretty; text-wrap: pretty;
} }
/* Плиток бывает четыре или пять (пятая тёмная, приходит из /stats и может
не прийти). Грид с фиксированной minmax-колонкой ронял пятую на вторую
строку одну и узкую читалось как обрыв. Flex-wrap с общим базисом делает
строку цельной при любом числе плиток: что помещается стоит в ряд,
остаток растягивается на всю ширину, а не висит слева огрызком. */
.accKpiGrid { .accKpiGrid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); flex-wrap: wrap;
gap: 20px; gap: 20px;
margin: 0 0 32px; margin: 0 0 32px;
padding: 0; padding: 0;
@ -1025,6 +1048,8 @@
} }
.accKpiTile { .accKpiTile {
flex: 1 1 200px;
min-width: 0;
background: var(--b2c-page-bg); background: var(--b2c-page-bg);
border: 1px solid var(--b2c-line); border: 1px solid var(--b2c-line);
border-radius: 10px; border-radius: 10px;
@ -1132,10 +1157,14 @@
color: var(--b2c-ink); color: var(--b2c-ink);
} }
/* Правый край общий с заголовком столбца (.accTableHeadCellRight);
иначе значения стоят левее шапки и столбец читается пустым.
На мобильном брейке ниже text-align сбрасывается в left. */
.accCellErr { .accCellErr {
font-family: var(--b2c-font-mono); font-family: var(--b2c-font-mono);
font-size: 12px; font-size: 12px;
color: var(--b2c-muted); color: var(--b2c-muted);
text-align: right;
} }
.accCellGain { .accCellGain {

View file

@ -103,6 +103,7 @@ const PUBLIC_SHORT_PATHS = [
"/articles", "/articles",
"/articles/kak-ocenit-kvartiru", "/articles/kak-ocenit-kvartiru",
"/docs", "/docs",
"/business",
]; ];
export function isPublicPath(rawPath: string): boolean { export function isPublicPath(rawPath: string): boolean {