diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy
index 8010c04b..085146a4 100644
--- a/caddy/sites/apps.caddy
+++ b/caddy/sites/apps.caddy
@@ -347,7 +347,7 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который 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 {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
@@ -361,7 +361,7 @@ meraocenka.ru {
# голый 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 {
redir * /{re.shortslash.1} permanent
}
@@ -446,7 +446,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `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 {
route {
uri strip_prefix /trade-in/mera-public
diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh
index 52357c7b..478adcda 100644
--- a/scripts/smoke-mera-perimeter.sh
+++ b/scripts/smoke-mera-perimeter.sh
@@ -90,6 +90,11 @@ check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
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 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
index ec69b54a..881f3728 100644
--- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
+++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
@@ -30,8 +30,8 @@
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) — «число некрасивое» причиной не является.
-Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность),
-у строки заполнено. Свежесть — порядок квартала сделки.
+Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
+схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
----------------------------------------------------------
@@ -69,7 +69,10 @@
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
- показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
+ показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`):
+ строка, которой нечем нарисовать карту, полнее строки с картой быть не
+ может. Это признак «поле заполнено», как район и этаж, а не величина
+ ошибки, — правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
@@ -97,7 +100,7 @@ from typing import Any
from sqlalchemy import text
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__)
@@ -158,15 +161,26 @@ class ShowcaseRow:
# None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None
lon: float | None = None
+ # Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM).
+ # Саму схему строим только для показанных строк — см. `_schemes_for`.
+ has_street: bool = False
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]:
@@ -197,6 +211,7 @@ def build_row(
n_analogs: int,
lat: float | None = None,
lon: float | None = None,
+ has_street: bool = False,
) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего.
@@ -251,6 +266,7 @@ def build_row(
n_analogs=n_analogs,
lat=lat,
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 сделки → схема.
Считаем после отбора, а не до: схема — это два пространственных запроса на
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
лишних запросов в чужую базу.
- НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор
- по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
- показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
- OSM. Схема — украшение строки, а не причина её показать.
+ НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы
+ входит наравне с районом и этажом. До 2026-08-31 здесь было записано
+ обратное — «схема на отбор не влияет», — и первой строкой витрины и первым
+ раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо
+ схемы улиц рисовался полигон района. Отсутствие видимого поля не может
+ делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не
+ затронут: строка с большим отклонением, но со схемой, стоит в витрине как
+ есть.
"""
- index = load_street_index(db)
out = {}
for row in chosen:
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)
districts = _fetch_districts(db, [d.id for d in deals])
+ # Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак
+ # полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на
+ # прогон, дорогие пространственные запросы остались в `_schemes_for`.
+ street_index = load_street_index(db)
candidates: list[ShowcaseRow] = []
n_priced = 0
@@ -408,6 +436,7 @@ def refresh_landing_showcase_deals(
# тип этого не поймает: обе величины float.
lat=deal.lat,
lon=deal.lon,
+ has_street=street_index.lookup(deal.address) is not None,
)
if row is None:
n_incomplete += 1
@@ -415,7 +444,7 @@ def refresh_landing_showcase_deals(
candidates.append(row)
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_RUNS_SQL)
diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py
index b2e39d47..41a5b555 100644
--- a/tradein-mvp/backend/scripts/backtest_estimator.py
+++ b/tradein-mvp/backend/scripts/backtest_estimator.py
@@ -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
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
# 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).
``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
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:
return _SAMPLE_SQL
return text(
@@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float
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]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
@@ -1259,7 +1349,9 @@ def _load_sample(
}
if city is not None:
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] = []
for r in rows:
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,
holdout_split: bool = False,
city: str | None = None,
+ scattered: bool = False,
+ seed: str = "mera",
) -> dict[str, Any]:
"""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
(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)
matched_rows: list[tuple[float, float, int]] = []
@@ -2118,6 +2212,8 @@ def run_backtest_full(
dump_fixture: str | None = None,
resolve_house_id: bool = False,
city: str | None = None,
+ scattered: bool = False,
+ seed: str = "mera",
) -> dict[str, Any]:
"""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.
"""
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(
"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",
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(
"--city",
default=None,
@@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int:
dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id,
city=args.city,
+ scattered=(args.spread == "scattered"),
+ seed=args.seed,
)
else:
metrics = run_backtest(
@@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int:
rooms_tolerance=args.rooms_tolerance,
holdout_split=args.holdout_split,
city=args.city,
+ scattered=(args.spread == "scattered"),
+ seed=args.seed,
)
finally:
db.close()
diff --git a/tradein-mvp/backend/tests/test_backtest_sampling_spread.py b/tradein-mvp/backend/tests/test_backtest_sampling_spread.py
new file mode 100644
index 00000000..b4cbeea0
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_backtest_sampling_spread.py
@@ -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 вразброс, условие
diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py
index 702e42f7..80bd3920 100644
--- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py
+++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py
@@ -31,6 +31,7 @@ def _row(
total_floors: int | None = 9,
deal_date: date = date(2026, 1, 1),
err_pct: float = 10.0,
+ has_street: bool = True,
) -> ShowcaseRow:
return ShowcaseRow(
deal_id=deal_id,
@@ -45,6 +46,7 @@ def _row(
fact_rub=5_500_000,
err_pct=err_pct,
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]
+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:
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
rows = [_row(7), _row(9), _row(8)]
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
index 369ba1a0..328fbf4d 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
@@ -79,8 +79,57 @@ const V3_DIR = "_components/v3";
const GENERATED = ["ekb-districts.ts"];
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 = [
LANDING_ROOT,
+ ...SUBPAGES,
...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
.sort()
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx
index be973f8f..89a5a042 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx
@@ -1,4 +1,3 @@
-import { B2B_URL } from "../content";
import styles from "../landing-v3.module.css";
import { PublicLink } from "./PublicLink";
@@ -57,9 +56,9 @@ export function InnerHeader() {
-
+
Для бизнеса
-
+
Проверить квартиру
diff --git a/tradein-mvp/frontend/src/app/mera-public/business/page.tsx b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx
new file mode 100644
index 00000000..a56fcfcf
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
ДЛЯ БИЗНЕСА
+
МЕРА для бизнеса
+
+ Оценка квартир по живым объявлениям под своей учётной записью —
+ для агентства, застройщика или компании, которая принимает квартиры
+ в зачёт.
+
+
+
+
+
+ Кому это нужно
+
+
+ Тем, кто считает не одну квартиру, а много и регулярно: агентствам
+ недвижимости, отделам продаж застройщиков, компаниям с программами
+ трейд-ин. Расчёт идёт по тем же источникам, что и бесплатная
+ проверка на сайте, — но под учётными записями вашей команды, с
+ сохранением истории и месячным лимитом на аккаунт.
+
+
+ Если задача разовая — квартира одна, и нужно понять цену, — идти
+ сюда незачем: бесплатная проверка на сайте отвечает на этот вопрос
+ без договорённостей и без регистрации.
+
+
+
+
+
+ Чем отличается от бесплатной проверки
+
+
+
+ Бесплатная проверка
+
+ Одна квартира, здесь и сейчас, без регистрации. Картина рынка
+ вокруг дома: сколько похожих квартир продаётся рядом и как давно
+ висят объявления
+
+
+
+ Бизнес-контур
+
+ Закрытый контур с доступом по учётной записи: история своих
+ расчётов, учётные записи команды, месячный лимит на аккаунт.
+ Доступ выдаётся вручную, после разговора, — самостоятельной
+ регистрации в нём нет
+
+
+
+
+ Что именно войдёт в работу — обсуждается: задача разная у
+ агентства, у застройщика и у трейд-ин программы. Поэтому на этой
+ странице нет ни тарифа, ни срока: назвать их до разговора значило бы
+ их придумать.
+
+
+
+
+
+ Как связаться
+
+
+ Напишите в любой из каналов — коротко, что за компания и какая
+ задача: сколько адресов, как часто, что должно быть на выходе.
+ Этого хватает, чтобы ответить по существу с первого письма.
+
+ Уже работаете с нами? Вход в бизнес-контур —
+ по логину и паролю, которые вам выдали.
+
+
+
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts
index 375dfc94..4164842e 100644
--- a/tradein-mvp/frontend/src/app/mera-public/content.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/content.ts
@@ -195,6 +195,10 @@ export const PUBLIC_ROUTES = {
articles: "/articles",
articleHowTo: "/articles/kak-ocenit-kvartiru",
docs: "/docs",
+ // Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
+ // взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
+ // и заканчивалась формой входа для аккаунта, которого у посетителя нет.
+ business: "/business",
} as const;
/** Канонический публичный 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/";
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */
diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
index 229e03ec..6299c473 100644
--- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
+++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
@@ -103,6 +103,7 @@ const PUBLIC_SHORT_PATHS = [
"/articles",
"/articles/kak-ocenit-kvartiru",
"/docs",
+ "/business",
];
export function isPublicPath(rawPath: string): boolean {