diff --git a/data/sql/195_tradein_osm_roads_view.sql b/data/sql/195_tradein_osm_roads_view.sql
new file mode 100644
index 00000000..c7ba85d3
--- /dev/null
+++ b/data/sql/195_tradein_osm_roads_view.sql
@@ -0,0 +1,57 @@
+-- 195_tradein_osm_roads_view.sql
+-- Улицы ЕКБ на витрину сделок МЕРЫ: сторона gendesign (PR 1 из 2).
+--
+-- Карточка игры на лэндинге показывала ПОЛИГОН РАЙОНА — единственную геометрию,
+-- которая у tradein была. Улицы живут здесь, в `osm_noise_sources_ekb`, а у
+-- tradein нет ни foreign table, ни гранта. Эта миграция открывает вьюху ровно
+-- по образцу соседей (`v_tradein_cad_buildings`, `v_tradein_osm_poi_ekb`,
+-- 185_tradein_osm_poi_view.sql): плоский срез нужных колонок + GRANT SELECT
+-- уже существующей роли `tradein_fdw_reader` (100_tradein_fdw_role.sql).
+-- Новых ролей и user mapping здесь нет.
+--
+-- ГРАНТ СТОИТ РЯДОМ С CREATE, В ЭТОМ ЖЕ ФАЙЛЕ — И ЭТО НЕ СТИЛЬ. 2026-08-29
+-- (#3227) объект пересоздали, а GRANT жил отдельной миграцией и потерялся:
+-- пересоздание снимает права молча, и чтение с той стороны падает на
+-- `permission denied`. То же было с `ekb_districts_geom` (194) и
+-- `mv_quarter_price_index` (188). Кто будет менять эту вьюху — меняйте файл
+-- целиком, вместе с грантом.
+--
+-- ЧТО В СРЕЗЕ И ЧЕГО В НЁМ НЕТ (замер на проде 2026-08-29):
+-- * 10 025 линий, 1377 различных названий улиц. Выгрузка собиралась как
+-- «источники шума», поэтому именованные улицы покрыты хорошо, а дворовые
+-- проезды и служебные ОТСУТСТВУЮТ. Потребитель обязан это знать: схема из
+-- этих данных — улицы района, а не полная уличная сеть.
+-- * `source_type IN ('highway','water')` — только то, что рисуется на схеме.
+-- Железные дороги, ЛЭП, трубопроводы, подстанции и промзона (ещё ~4 400
+-- строк) сюда НЕ едут: на карточке 320 px они дают шум, а не смысл.
+-- * ЗДАНИЙ ЗДЕСЬ НЕТ И НЕ БУДЕТ. `cad_buildings` — 18 307 контуров на весь
+-- город; в плотном центре это 51 здание на радиус 450 м, где их в разы
+-- больше. Нарисованная застройка заявляла бы полноту, которой в данных
+-- нет, и показывала бы пустые кварталы там, где стоят дома.
+--
+-- Идемпотентна: CREATE OR REPLACE VIEW + повторяемый GRANT.
+
+BEGIN;
+-- Конвенция проекта (#2752). CREATE OR REPLACE VIEW и GRANT берут лок на объект;
+-- ждать его выдачи за чужой сессией — значит собрать очередь перед приложением.
+SET LOCAL lock_timeout = '5s';
+
+CREATE OR REPLACE VIEW v_tradein_osm_roads_ekb AS
+SELECT
+ name,
+ road_class,
+ geom
+FROM osm_noise_sources_ekb
+WHERE source_type IN ('highway', 'water');
+
+GRANT SELECT ON v_tradein_osm_roads_ekb TO tradein_fdw_reader;
+
+COMMENT ON VIEW v_tradein_osm_roads_ekb IS
+ 'FDW-источник для tradein-mvp: улицы и вода ЕКБ для схемы улицы на витрине '
+ 'сделок лэндинга МЕРЫ. Срез osm_noise_sources_ekb по source_type '
+ 'IN (highway, water) — 10 025 линий, 1377 названий. Выгрузка фильтрованная '
+ '(«источники шума»): дворовых и служебных проездов в ней нет. Зданий нет '
+ 'намеренно, см. шапку 195_tradein_osm_roads_view.sql. GRANT живёт в том же '
+ 'файле — пересоздание вьюхи снимает права молча (#3227).';
+
+COMMIT;
diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py
index 4b8288c0..7b8c4d85 100644
--- a/tradein-mvp/backend/app/api/public/mera.py
+++ b/tradein-mvp/backend/app/api/public/mera.py
@@ -393,8 +393,36 @@ class ShowcaseDeal(BaseModel):
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
+ правдоподобную подстановку.
+
+ `street_name` / `street_scheme` — УЛИЦА, А НЕ ДОМ. Номер дома есть у 2.7%
+ сделок (разбор в миграции 276), поэтому дома в витрине нет и не будет.
+ Оба поля НУЛЛАБЕЛЬНЫ, и null — штатный случай: название сматчилось с OSM у
+ 550 названий из 654 (92.3% сделок, замер 2026-08-29), остальным схемы нет и
+ фронт показывает район.
+
+ `street_scheme` — уже спроецированные SVG-пути окна 840×840 м вокруг центра
+ улицы::
+
+ {"street": "улица Краснолесья", "w": 1000, "h": 1000,
+ "target": ["M…L…"], # подсвеченная улица
+ "roads": [{"c": "primary", "d": "M…L…"}], # фон, c = класс дороги
+ "water": ["M…L…"],
+ "labels": [{"t": "Чкалова", "x": 431.2, "y": 88.0}]}
+
+ Координат окна и констант проекции в схеме НЕТ намеренно: по ней нельзя
+ положить (lon, lat) в её систему координат, то есть нарисовать точку дома
+ невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON
+ того же окна — 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29).
правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома
есть у 2.7% сделок (разбор в миграции 276).
+
+ `lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
+ сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов
+ (замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
+ и НЕ честна на масштабе дома — то же самое написано в `note` строки,
+ которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
+ такая строка остаётся на витрине без точки, а не выбрасывается.
"""
district: str | None
@@ -408,6 +436,10 @@ class ShowcaseDeal(BaseModel):
err_pct: float
n_analogs: int
note: str
+ street_name: str | None
+ street_scheme: dict | None
+ lat: float | None = None
+ lon: float | None = None
class ShowcaseStats(BaseModel):
@@ -458,7 +490,8 @@ _SHOWCASE_RUN_SQL = text(
_SHOWCASE_SQL = text(
"""
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
- predicted_rub, fact_rub, err_pct, n_analogs, note
+ predicted_rub, fact_rub, err_pct, n_analogs, note,
+ lat, lon, street_name, street_scheme
FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id
@@ -515,6 +548,13 @@ def public_showcase(
err_pct=float(r["err_pct"]),
n_analogs=int(r["n_analogs"]),
note=r["note"],
+ street_name=r["street_name"],
+ street_scheme=r["street_scheme"],
+ # Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота
+ # (~60.6). Перепутанные местами координаты остаются валидными
+ # float и уедут на карту точкой в другой стране.
+ lat=(float(r["lat"]) if r["lat"] is not None else None),
+ lon=(float(r["lon"]) if r["lon"] is not None else None),
)
for r in rows
],
diff --git a/tradein-mvp/backend/app/services/street_scheme.py b/tradein-mvp/backend/app/services/street_scheme.py
new file mode 100644
index 00000000..a493e46c
--- /dev/null
+++ b/tradein-mvp/backend/app/services/street_scheme.py
@@ -0,0 +1,328 @@
+"""Схема улицы сделки: готовые SVG-пути для карточки лэндинга.
+
+ЗАЧЕМ ГОТОВАЯ СХЕМА, А НЕ ГЕОМЕТРИЯ. Отдать фронту GeoJSON окна 840×840 м —
+это десятки килобайт на КАЖДУЮ строку витрины плюс проекция в рантайме. Здесь
+всё считается один раз при пересчёте витрины и хранится строкой SVG-путей;
+порядок величины на прототипе — 4 КБ на сделку.
+
+ЧТО ЭТА СХЕМА ЗАЯВЛЯЕТ, А ЧТО НЕТ
+---------------------------------
+ * ДОМА В НЕЙ НЕТ И БЫТЬ НЕ МОЖЕТ. `deals.address` — уровня улицы
+ («Екатеринбург, Краснолесья»), номер дома известен у 2.7% сделок. Точка
+ дома была бы выдумана, поэтому её не рисует даже фронт: в схеме нет ни
+ координат окна, ни констант проекции — по ней физически нельзя положить
+ (lon, lat) в её систему координат. Это не забывчивость, а замок.
+ * ЗДАНИЙ В НЕЙ НЕТ. Замер 2026-08-29: `cad_buildings` содержит 18 307
+ контуров на весь ЕКБ, в плотном центре это 51 здание на радиус 450 м, где
+ их в разы больше. Нарисованная застройка заявляла бы полноту, которой в
+ данных нет, и показывала бы пустые кварталы там, где стоят дома.
+ * УЛИЦЫ — ФИЛЬТРОВАННАЯ ВЫГРУЗКА. `osm_noise_sources_ekb` собиралась как
+ «источники шума»: 10 025 линий, 1377 различных названий. Именованные улицы
+ покрыты хорошо, дворовые проезды и служебные отсутствуют. Схема — это
+ именно улицы района, а не полная уличная сеть.
+ * УЛИЦА СМАТЧИЛАСЬ НЕ ВСЕГДА. Нормализация даёт совпадение у 550 названий из
+ 654 — это 31 410 сделок из 34 021 (92.3%, замер на проде 2026-08-29 по той
+ же выборке, из которой набирается витрина). Оставшиеся 8% — штатный
+ случай: схемы нет, фронт показывает район.
+
+ПРОЕКЦИЯ — та же равнопромежуточная с поправкой cos(широты), что в
+`scripts/export_ekb_districts_svg.py`. Второй проекции в проекте нет намеренно.
+Окно квадратное в метрах, поэтому viewBox тоже квадратный: 1000×1000.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import math
+import re
+from dataclasses import dataclass
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+# Полуразмер окна в метрах. 420 м на 1000 единиц viewBox — это ~1.2 м на
+# единицу: улица читается как улица, а соседние кварталы попадают в кадр.
+RADIUS_M = 420.0
+VIEWBOX = 1000.0
+# Метров в градусе широты (сфера). Долгота — то же, делённое на cos(широты).
+M_PER_DEG_LAT = 111_320.0
+# Допуск упрощения в градусах ≈ 3 м: меньше трёх единиц viewBox, то есть тоньше
+# линии. Смена значения обязана сопровождаться замером размера схемы.
+SIMPLIFY_DEG = 0.00003
+# Сколько подписей соседних улиц кладём в схему. Больше — каша на карточке
+# 320 px и лишние байты; отбираем самые длинные в кадре.
+MAX_LABELS = 7
+
+# Родовые слова, которые в `deals.address` и в OSM пишутся по-разному
+# («Краснолесья» ↔ «улица Краснолесья»). Снимаем с обеих сторон.
+_STREET_TYPES = frozenset(
+ {
+ "улица",
+ "ул",
+ "проспект",
+ "пр",
+ "пр-кт",
+ "пркт",
+ "проспкт",
+ "переулок",
+ "пер",
+ "бульвар",
+ "бул",
+ "б-р",
+ "шоссе",
+ "ш",
+ "проезд",
+ "набережная",
+ "наб",
+ "тракт",
+ "аллея",
+ "площадь",
+ "пл",
+ "тупик",
+ }
+)
+
+_KEEP = re.compile(r"[^0-9a-zа-я\s-]")
+
+
+def normalize_street(address: str | None) -> str | None:
+ """`'Екатеринбург, ул. Краснолесья'` → ``'краснолесья'``. Нет улицы — None.
+
+ Что делает и почему именно так:
+ * берёт часть ПОСЛЕ последней запятой — в `deals.address` город стоит
+ первым («Екатеринбург, 8 Марта»);
+ * ПЕРЕКРЁСТОК берёт первой улицей. «Екатеринбург, Челюскинцев/Шейнкмана»
+ → «челюскинцев». Таких адресов в выборке ТРИ на 34 021 сделку (по
+ одной сделке на каждый, замер 2026-08-29), и обе улицы одинаково
+ верны: адрес и так уровня улицы, а не дома. Рисовать перекрёсток
+ точкой значило бы заявить точность, которой в остальных 34 018 строках
+ нет, ради трёх;
+ * складывает ё→е: «Королёва» в адресе и «Королева» в OSM — одна улица;
+ * снимает родовое слово (улица/проспект/…), потому что в адресе его
+ обычно нет, а в OSM обычно есть;
+ * цифры оставляет: «8 Марта» и «22 Партсъезда» — названия, а не номера.
+ """
+ if not address:
+ return None
+ tail = address.rsplit(",", 1)[-1]
+ tail = tail.split("/")[0]
+ tail = tail.replace("ё", "е").replace("Ё", "Е").lower()
+ tail = _KEEP.sub(" ", tail)
+ words = [w for w in tail.split() if w.strip("-") not in _STREET_TYPES]
+ return " ".join(words) or None
+
+
+# ── Запросы ──────────────────────────────────────────────────────────────────
+#
+# Обе идут в foreign table `gendesign_osm_roads_ekb` (миграция 281) над вьюхой
+# `v_tradein_osm_roads_ekb` чужой базы. Сервер `gendesign_remote` объявлен с
+# OPTIONS (extensions 'postgis'), поэтому PostGIS-операторы shippable: `&&` с
+# готовым конвертом уходит на ту сторону и попадает в GiST-индекс, а обратно
+# едет уже обрезанный GeoJSON, а не десять тысяч линий.
+
+_NAMES_SQL = text("SELECT DISTINCT name FROM gendesign_osm_roads_ekb WHERE name IS NOT NULL")
+
+_CENTER_SQL = text(
+ """
+ SELECT ST_X(p) AS lon, ST_Y(p) AS lat
+ FROM (
+ SELECT ST_ClosestPoint(g, ST_Centroid(g)) AS p
+ FROM (
+ SELECT ST_Collect(geom) AS g
+ FROM gendesign_osm_roads_ekb
+ WHERE name = ANY(CAST(:names AS text[]))
+ ) t
+ WHERE g IS NOT NULL
+ ) q
+ """
+)
+
+_WINDOW_SQL = text(
+ """
+ SELECT name, road_class,
+ ST_AsGeoJSON(
+ ST_SimplifyPreserveTopology(
+ ST_Intersection(
+ geom,
+ ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326)
+ ),
+ CAST(:tol AS double precision)
+ ), 6
+ ) AS gj
+ FROM gendesign_osm_roads_ekb
+ WHERE geom && ST_MakeEnvelope(:min_lon, :min_lat, :max_lon, :max_lat, 4326)
+ """
+)
+
+# Классы из `osm_noise_sources_ekb`, которые рисуются водой. Остальные (trunk /
+# primary / secondary / tertiary / residential) — дороги. Разделение
+# перечислением, а не «всё, что не дорога»: во вьюху могут доехать новые классы,
+# и тогда лучше не нарисовать, чем нарисовать реку поверх улицы.
+_WATER_CLASSES = frozenset({"river", "canal", "stream", "lake_or_pond"})
+
+
+@dataclass(frozen=True)
+class StreetIndex:
+ """Нормализованное название → как эта улица называется в OSM.
+
+ Строится ОДИН раз на прогон: 1377 названий на всю базу, тянуть их на каждую
+ из двадцати сделок незачем.
+ """
+
+ by_norm: dict[str, list[str]]
+
+ def lookup(self, address: str | None) -> tuple[str, list[str]] | None:
+ key = normalize_street(address)
+ if key is None:
+ return None
+ raw = self.by_norm.get(key)
+ return (key, raw) if raw else None
+
+
+def load_street_index(db: Session) -> StreetIndex:
+ """Список названий улиц из FDW-вьюхи. Недоступна — пустой индекс.
+
+ SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит
+ транзакцию в aborted, и следующий запрос упал бы уже не по своей вине.
+ Гранты на той стороне уже терялись при пересоздании объекта (#3227).
+ """
+ try:
+ with db.begin_nested():
+ rows = db.execute(_NAMES_SQL).all()
+ except Exception as exc:
+ logger.warning("улицы не резолвятся (витрина будет без схем): %s", exc)
+ return StreetIndex(by_norm={})
+ by_norm: dict[str, list[str]] = {}
+ for (name,) in rows:
+ key = normalize_street(name)
+ if key:
+ by_norm.setdefault(key, []).append(name)
+ return StreetIndex(by_norm=by_norm)
+
+
+def _parts(gj: dict[str, Any]) -> list[list[list[float]]]:
+ """GeoJSON любой из встречающихся форм → список цепочек точек."""
+ t = gj.get("type")
+ c = gj.get("coordinates") or []
+ if t == "LineString":
+ return [c]
+ if t == "MultiLineString":
+ return list(c)
+ if t == "Polygon":
+ return list(c)
+ if t == "MultiPolygon":
+ return [ring for poly in c for ring in poly]
+ if t == "GeometryCollection":
+ return [p for g in gj.get("geometries", []) for p in _parts(g)]
+ return []
+
+
+def _length(chain: list[list[float]]) -> float:
+ return sum(math.dist(chain[i], chain[i + 1]) for i in range(len(chain) - 1))
+
+
+def build_street_scheme(db: Session, index: StreetIndex, address: str | None) -> dict | None:
+ """Схема улицы для одной сделки, либо None — и None это ШТАТНО.
+
+ None означает ровно одно: «улицы сделки в геометрии нет» (нет адреса, не
+ сматчилось название, вьюха недоступна, у улицы нет геометрии). Витрина в
+ этом случае отдаёт строку без схемы, а фронт показывает район — запасной
+ вариант, который для этого и оставлен.
+ """
+ found = index.lookup(address)
+ if found is None:
+ return None
+ key, raw_names = found
+ try:
+ with db.begin_nested():
+ center = db.execute(_CENTER_SQL, {"names": raw_names}).mappings().first()
+ if center is None:
+ return None
+ lat, lon = float(center["lat"]), float(center["lon"])
+ dlat = RADIUS_M / M_PER_DEG_LAT
+ k = math.cos(math.radians(lat))
+ dlon = dlat / k
+ bounds = {
+ "min_lon": lon - dlon,
+ "max_lon": lon + dlon,
+ "min_lat": lat - dlat,
+ "max_lat": lat + dlat,
+ "tol": SIMPLIFY_DEG,
+ }
+ rows = db.execute(_WINDOW_SQL, bounds).mappings().all()
+ except Exception as exc:
+ logger.warning("схема улицы %r не собралась: %s", key, exc)
+ return None
+
+ min_lon, max_lat = bounds["min_lon"], bounds["max_lat"]
+ # Окно квадратное в метрах, значит после поправки cos(широты) его ширина и
+ # высота в единицах viewBox равны: масштаб один на обе оси.
+ scale = VIEWBOX / (2 * dlat)
+
+ def path(chain: list[list[float]]) -> str:
+ out = []
+ for i, (plon, plat) in enumerate(chain):
+ x = round((plon - min_lon) * k * scale, 1)
+ y = round((max_lat - plat) * scale, 1)
+ out.append(f"{'M' if i == 0 else 'L'}{x} {y}")
+ return "".join(out)
+
+ target: list[str] = []
+ roads: list[dict[str, str]] = []
+ water: list[str] = []
+ label_pool: list[tuple[float, str, list[float]]] = []
+ targets = set(raw_names)
+
+ for row in rows:
+ if not row["gj"]:
+ continue
+ chains = [c for c in _parts(json.loads(row["gj"])) if len(c) > 1]
+ if not chains:
+ continue
+ cls = row["road_class"]
+ is_target = row["name"] in targets
+ for chain in chains:
+ d = path(chain)
+ if is_target:
+ target.append(d)
+ elif cls in _WATER_CLASSES:
+ water.append(d)
+ else:
+ roads.append({"c": cls or "residential", "d": d})
+ if row["name"] and not is_target and cls not in _WATER_CLASSES:
+ longest = max(chains, key=_length)
+ label_pool.append((_length(longest), row["name"], longest[len(longest) // 2]))
+
+ if not target:
+ return None
+
+ # Подписи: самые длинные в кадре, по одной на название.
+ labels: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for _, name, point in sorted(label_pool, key=lambda t: -t[0]):
+ if name in seen:
+ continue
+ seen.add(name)
+ labels.append(
+ {
+ "t": name,
+ "x": round((point[0] - min_lon) * k * scale, 1),
+ "y": round((max_lat - point[1]) * scale, 1),
+ }
+ )
+ if len(labels) >= MAX_LABELS:
+ break
+
+ return {
+ "street": raw_names[0],
+ "w": VIEWBOX,
+ "h": VIEWBOX,
+ "target": target,
+ "roads": roads,
+ "water": water,
+ "labels": labels,
+ }
diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
index 5f8a064a..40aaed0d 100644
--- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
+++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
@@ -37,6 +37,13 @@
----------------------------------------------------------
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район +
2-к, 54 м², 5 эт.», и никогда не улица с домом.
+ * ТОЧКА НА КАРТЕ — ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
+ с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
+ 34 017 сделок с координатой — ≈34 сделки в одной точке, при 2.7% известных
+ номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
+ масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
+ (миграция 280), потому что докстринг на фронт не едет, а зумить карту
+ будет тот, кто его не читал.
* ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений
на всю таблицу), поэтому в витрине только «II квартал 2026».
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
@@ -56,6 +63,13 @@
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
Неработающая проверка читается как работающая, поэтому её нет.
+ * КАРТА ПОКАЗЫВАЕТ УЛИЦУ, А НЕ ДОМ. `deals.address` — уровня улицы
+ («Екатеринбург, Краснолесья»), поэтому в строку кладётся схема окна вокруг
+ ЦЕНТРА улицы (`app/services/street_scheme.py`), и в этой схеме намеренно
+ нет ни координат окна, ни констант проекции: точку дома по ней нельзя
+ поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
+ 92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
+ показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
@@ -74,6 +88,7 @@
from __future__ import annotations
import argparse
+import json
import logging
from dataclasses import dataclass
from datetime import date
@@ -82,6 +97,8 @@ 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
+
logger = logging.getLogger(__name__)
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
@@ -102,7 +119,12 @@ NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
- "строка выглядит как промах оценщика, хотя врёт документ."
+ "строка выглядит как промах оценщика, хотя врёт документ. "
+ "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
+ "есть у 2.7% строк, поэтому дом не показан и показан быть не может."
+ "Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
+ "координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
+ "у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
)
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@@ -131,6 +153,10 @@ class ShowcaseRow:
fact_rub: int
err_pct: float
n_analogs: int
+ # Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
+ # None штатно: у части сделок координаты нет, подставлять туда нечего.
+ lat: float | None = None
+ lon: float | None = None
def completeness(row: ShowcaseRow) -> int:
@@ -168,6 +194,8 @@ def build_row(
predicted_rub: float | None,
fact_ppm2: float,
n_analogs: int,
+ lat: float | None = None,
+ lon: float | None = None,
) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего.
@@ -175,6 +203,11 @@ def build_row(
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
(делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
+
+ ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
+ витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
+ сделки из-за отсутствия координаты — отбор по признаку, не связанному с
+ качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
"""
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
return None
@@ -200,6 +233,8 @@ def build_row(
fact_rub=round(fact_rub),
err_pct=round(err_pct, 2),
n_analogs=n_analogs,
+ lat=lat,
+ lon=lon,
)
@@ -255,17 +290,41 @@ _INSERT_SQL = text(
"""
INSERT INTO landing_showcase_deals
(district, rooms, area_m2, floor, total_floors, deal_quarter,
- predicted_rub, fact_rub, err_pct, n_analogs, note)
+ predicted_rub, fact_rub, err_pct, n_analogs, note,
+ lat, lon, street_name, street_scheme)
VALUES
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
CAST(:floor AS integer), CAST(:total_floors AS integer),
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
- CAST(:n_analogs AS integer), CAST(:note AS text))
+ CAST(:n_analogs AS integer), CAST(:note AS text),
+ CAST(:lat AS double precision), CAST(:lon AS double precision),
+ CAST(:street_name AS text), CAST(:street_scheme AS jsonb))
"""
)
+def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict:
+ """Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
+
+ Считаем после отбора, а не до: схема — это два пространственных запроса на
+ сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
+ лишних запросов в чужую базу.
+
+ НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор
+ по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
+ показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
+ OSM. Схема — украшение строки, а не причина её показать.
+ """
+ index = load_street_index(db)
+ out = {}
+ for row in chosen:
+ scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
+ if scheme is not None:
+ out[row.deal_id] = scheme
+ return out
+
+
def refresh_landing_showcase_deals(
db: Session,
*,
@@ -328,6 +387,11 @@ def refresh_landing_showcase_deals(
predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
+ # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
+ # (~60.6). Перепутать местами — это точка в другой стране, и никакой
+ # тип этого не поймает: обе величины float.
+ lat=deal.lat,
+ lon=deal.lon,
)
if row is None:
n_incomplete += 1
@@ -335,13 +399,17 @@ 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})
db.execute(_DELETE_SQL)
db.execute(_DELETE_RUNS_SQL)
for row in chosen:
+ scheme = schemes.get(row.deal_id)
db.execute(
_INSERT_SQL,
{
+ "street_name": scheme["street"] if scheme else None,
+ "street_scheme": json.dumps(scheme, ensure_ascii=False) if scheme else None,
"district": row.district,
"rooms": row.rooms,
"area_m2": row.area_m2,
@@ -353,6 +421,8 @@ def refresh_landing_showcase_deals(
"err_pct": row.err_pct,
"n_analogs": row.n_analogs,
"note": NOTE,
+ "lat": row.lat,
+ "lon": row.lon,
},
)
counters = {
@@ -364,6 +434,10 @@ def refresh_landing_showcase_deals(
"written": len(chosen),
"with_district": sum(1 for r in chosen if r.district is not None),
}
+ # Схем — не счётчик в `landing_showcase_runs` намеренно: у КАЖДОЙ строки
+ # витрины street_scheme либо есть, либо null, и это едет на фронт вместе со
+ # строкой. Отдельное число повторяло бы то, что посетитель и так видит.
+ logger.info("схем улиц собрано: %d из %d показанных", len(schemes), len(chosen))
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
db.commit()
diff --git a/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql b/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql
new file mode 100644
index 00000000..c78803fd
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/280_landing_showcase_deals_coords.sql
@@ -0,0 +1,48 @@
+-- 280: координата сделки в витрине лэндинга — чтобы карта могла показать точку.
+--
+-- ЧТО ЭТО ЗА КООРДИНАТА. Не координата ДОМА. Замер по той самой выборке,
+-- из которой набирается витрина (deals, city='Екатеринбург', deal_date >=
+-- '2025-01-01'; прод, 2026-08-29):
+--
+-- 34 021 сделка, из них 34 017 с координатой (без неё — 4),
+-- но РАЗЛИЧНЫХ точек (lat, lon) всего 991 → ≈34 сделки на одну точку,
+-- номер дома в адресе известен у 2.7% сделок.
+--
+-- Тридцать четыре сделки в одной точке при 2.7% известных домов — это не дома,
+-- это центроиды улиц (тот же разбор, из-за которого в миграции 276 нет колонки
+-- адреса). Отсюда граница честности: точка верна на масштабе района и улицы и
+-- НЕВЕРНА на масштабе дома. Карта, зумящаяся до дома, покажет 34 разные сделки
+-- в одном подъезде — это будет враньё интерфейса, а не данных.
+--
+-- Поэтому: то же самое написано в `note` каждой строки витрины (note едет на
+-- фронт вместе с числами, а комментарий к колонке — нет), и это единственное
+-- место, которое увидит следующий, кто возьмётся зумить карту.
+--
+-- NULLABLE — потому что у части сделок координаты нет (4 из 34 021 в замере
+-- выше). Подставлять туда центр города или район значило бы поставить точку
+-- там, где её в данных нет. Строка без координаты остаётся на витрине:
+-- выбрасывать сделку из-за отсутствия точки — это отбор по признаку, не
+-- связанному с качеством оценки, ровно то, что в этой витрине уже чинили.
+
+BEGIN;
+-- Конвенция проекта (#2752): ADD COLUMN на существующей таблице берёт
+-- ACCESS EXCLUSIVE и без lock_timeout встанет в очередь за чужой сессией,
+-- утащив за собой запросы приложения.
+SET LOCAL lock_timeout = '5s';
+
+ALTER TABLE landing_showcase_deals
+ ADD COLUMN IF NOT EXISTS lat double precision,
+ ADD COLUMN IF NOT EXISTS lon double precision;
+
+COMMENT ON COLUMN landing_showcase_deals.lat IS
+ 'Широта сделки — ЦЕНТРОИД УЛИЦЫ, не дом. Замер 2026-08-29 по выборке '
+ 'витрины (ЕКБ, с 2025-01-01): 991 различная точка на 34 017 сделок с '
+ 'координатой (≈34 сделки на точку), номер дома известен у 2.7%. Честно '
+ 'на масштабе района/улицы, НЕ честно на масштабе дома. NULL = координаты '
+ 'нет (4 сделки из 34 021); строка при этом остаётся на витрине.';
+
+COMMENT ON COLUMN landing_showcase_deals.lon IS
+ 'Долгота сделки — ЦЕНТРОИД УЛИЦЫ, не дом; см. COMMENT на lat: 991 точка '
+ 'на 34 017 сделок, дом известен у 2.7%. NULL = координаты нет.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/281_street_scheme.sql b/tradein-mvp/backend/data/sql/281_street_scheme.sql
new file mode 100644
index 00000000..1ff97dff
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/281_street_scheme.sql
@@ -0,0 +1,86 @@
+-- 281_street_scheme.sql
+-- Улицы ЕКБ на витрине сделок: сторона tradein (PR 2 из 2).
+--
+-- ДВА ДЕЙСТВИЯ В ОДНОМ ФАЙЛЕ, И ЭТО ОСОЗНАННО:
+-- 1) foreign table над `v_tradein_osm_roads_ekb` (миграция gendesign 195);
+-- 2) колонки `street_name` / `street_scheme` в `landing_showcase_deals`.
+-- Порознь у них разные зависимости, но смысл один: без геометрии колонки некому
+-- заполнить, без колонок геометрию некуда положить. Один файл = один
+-- транзакционный исход: если 195 на той стороне ещё не задеплоена, падает
+-- ВСЁ и не остаётся полусостояния «колонка есть, данных не будет никогда».
+-- Файл идемпотентен, поэтому повтор после деплоя 195 доводит дело до конца.
+--
+-- ПОЧЕМУ КОЛОНКА В `landing_showcase_deals`, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Схема
+-- живёт ровно столько же, сколько строка витрины: пересчёт делает
+-- DELETE+INSERT всей витрины, и схема обязана умереть вместе со своей строкой.
+-- Отдельная таблица дала бы собственный жизненный цикл (то есть возможность
+-- пережить свой батч и уехать на фронт рядом с ЧУЖИМИ числами), join в
+-- публичной ручке и вторую точку отказа — в обмен ни на что: связь строго 1:1.
+--
+-- ЧТО ЛЕЖИТ В `street_scheme` — ГОТОВЫЕ SVG-ПУТИ, А НЕ ГЕОМЕТРИЯ. Окно
+-- 840×840 м в GeoJSON — это 7-8 КБ на сделку плюс проекция в рантайме у
+-- посетителя. Уже спроецированная схема того же окна — 2.8-2.9 КБ (замер
+-- 2026-08-29 на «улица Краснолесья» и «улица 8 Марта»).
+--
+-- ГРАНИЦА ЧЕСТНОСТИ, ЗАПИСАННАЯ ЗДЕСЬ, А НЕ ТОЛЬКО В ГОЛОВЕ АВТОРА:
+-- * ЭТО УЛИЦА, А НЕ ДОМ. `deals.address` — уровня улицы («Екатеринбург,
+-- Краснолесья»), номер дома известен у 2.7% сделок. В схеме намеренно НЕТ
+-- ни координат окна, ни констант проекции: по ней физически нельзя
+-- положить (lon, lat) в её систему координат, то есть нарисовать точку
+-- дома нельзя даже случайно.
+-- * `street_name` NULLABLE, и NULL — ШТАТНЫЙ СЛУЧАЙ. Нормализация названия
+-- даёт совпадение с OSM у 550 названий из 654, это 31 410 сделок из
+-- 34 021 — 92.3% (замер 2026-08-29 по той же выборке, из которой
+-- набирается витрина). Оставшиеся 8% схемы не получают, и фронт
+-- показывает район — запасной вариант, который для этого и оставлен.
+--
+-- Зависимости:
+-- - 060_postgres_fdw_extension.sql (SERVER gendesign_remote + USER MAPPING,
+-- пароль подставляется на старте бэкенда). Сервер объявлен с
+-- OPTIONS (extensions 'postgis') — именно поэтому `&&` с конвертом уходит
+-- на ту сторону в GiST-индекс, а не тянет сюда десять тысяч линий.
+-- - gendesign: вьюха public.v_tradein_osm_roads_ekb + GRANT SELECT роли
+-- tradein_fdw_reader (миграция 195, деплоится ПЕРВОЙ).
+-- - 276_landing_showcase_deals.sql (сама витрина).
+--
+-- Идемпотентна: DROP FOREIGN TABLE IF EXISTS + ADD COLUMN IF NOT EXISTS.
+
+BEGIN;
+-- Конвенция проекта (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE и, ожидая его
+-- за чужой сессией, встаёт в очередь ПЕРЕД запросами приложения.
+SET LOCAL lock_timeout = '5s';
+
+ALTER TABLE landing_showcase_deals
+ ADD COLUMN IF NOT EXISTS street_name text,
+ ADD COLUMN IF NOT EXISTS street_scheme jsonb;
+
+COMMENT ON COLUMN landing_showcase_deals.street_name IS
+ 'Улица сделки как она называется в OSM. NULL — штатно: адрес не сматчился '
+ '(8% сделок), схемы нет, фронт показывает район. Это УЛИЦА, а не дом: '
+ 'номер дома известен у 2.7% сделок.';
+
+COMMENT ON COLUMN landing_showcase_deals.street_scheme IS
+ 'Готовые SVG-пути окна 840x840 м вокруг центра улицы: {street, w, h, '
+ 'target[], roads[{c,d}], water[], labels[{t,x,y}]}. Координат окна и '
+ 'констант проекции в ней НЕТ намеренно — по схеме нельзя положить точку '
+ 'дома, которого в данных нет. Зданий нет тоже (cad_buildings покрывает '
+ 'город на 18 307 контуров и в центре показывает пустые кварталы).';
+
+DROP FOREIGN TABLE IF EXISTS gendesign_osm_roads_ekb;
+
+CREATE FOREIGN TABLE gendesign_osm_roads_ekb (
+ name text,
+ road_class text,
+ geom geometry(Geometry, 4326)
+)
+SERVER gendesign_remote
+OPTIONS (schema_name 'public', table_name 'v_tradein_osm_roads_ekb');
+
+COMMENT ON FOREIGN TABLE gendesign_osm_roads_ekb IS
+ 'Живой срез gendesign.osm_noise_sources_ekb (улицы и вода ЕКБ). Выгрузка '
+ 'фильтрованная («источники шума»): именованные улицы покрыты хорошо, '
+ 'дворовые и служебные проезды отсутствуют. Читает '
+ 'app/services/street_scheme.py при пересчёте витрины лэндинга; в публичной '
+ 'ручке НЕ используется — там читаются готовые схемы из landing_showcase_deals.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/scripts/export_ekb_districts_svg.py b/tradein-mvp/backend/scripts/export_ekb_districts_svg.py
new file mode 100644
index 00000000..ae9d2697
--- /dev/null
+++ b/tradein-mvp/backend/scripts/export_ekb_districts_svg.py
@@ -0,0 +1,108 @@
+"""Экспорт границ районов Екатеринбурга в SVG-пути для лэндинга МЕРЫ.
+
+ЗАЧЕМ ГЕНЕРАТОР, А НЕ РУЧНОЙ БЛОБ. Файл
+`frontend/src/app/mera-public/_components/v3/ekb-districts.ts` — данные, а у
+данных обязано быть проверяемое происхождение. Этот скрипт единственный
+источник того файла: запусти его и получишь тот же результат.
+
+ПОЧЕМУ ГЕОМЕТРИЯ ЕДЕТ СТАТИКОЙ, А НЕ ТАЙЛАМИ.
+1. Публичное дерево `app/mera-public/**` держится без единого внешнего запроса
+ (см. шапку layout.tsx: шрифты self-hosted, Leaflet с unpkg сюда намеренно не
+ тянули). Тайловый провайдер — это внешний CDN на каждой загрузке.
+2. Тайлы утекают провайдеру IP посетителя и то, ЧТО он смотрит, — на странице,
+ которая обещает «без звонков и регистрации». Своя геометрия такого не делает.
+3. Границы районов не меняются годами: раздавать их запросом в рантайме незачем.
+
+ТОЧНОСТЬ. Упрощение 0.0012° (~70-130 м на широте Екатеринбурга) выбрано под
+масштаб карточки: она показывает район целиком, где такая ошибка меньше
+толщины линии. Для более крупного масштаба этот файл не годится — перегенерируй
+с меньшим допуском и проверь размер.
+
+ПРОЕКЦИЯ. Равнопромежуточная с поправкой cos(широты): на масштабе одного города
+искажение незаметно, а взамен фронту не нужна ни библиотека, ни рантайм-математика
+сложнее двух умножений. Константы проекции едут вместе с путями, чтобы точку по
+(lon, lat) можно было положить в ту же систему координат.
+
+Запуск (только чтение):
+ docker exec tradein-backend python scripts/export_ekb_districts_svg.py
+"""
+
+from __future__ import annotations
+
+import json
+import math
+import os
+from typing import Any
+
+import psycopg
+
+# Допуск упрощения в градусах. Смена значения обязана сопровождаться замером
+# длины путей: файл едет в бандл каждому посетителю.
+SIMPLIFY_DEG = 0.0012
+# Ширина системы координат. Высота считается из формы города.
+VIEWBOX_WIDTH = 1000.0
+
+SQL = """
+ SELECT district_name,
+ ST_AsGeoJSON(ST_SimplifyPreserveTopology(geom, %(tol)s)) AS gj
+ FROM gendesign_ekb_districts_geom
+ ORDER BY district_name
+"""
+
+
+def _rings(geojson: dict[str, Any]) -> list[list[list[float]]]:
+ """Полигон и мультиполигон — к одному виду: список колец."""
+ if geojson["type"] == "Polygon":
+ return geojson["coordinates"]
+ return [ring for poly in geojson["coordinates"] for ring in poly]
+
+
+def main() -> int:
+ dsn = os.environ["DATABASE_URL"].replace("postgresql+psycopg://", "postgresql://")
+ with psycopg.connect(dsn) as conn, conn.cursor() as cur:
+ cur.execute(SQL, {"tol": SIMPLIFY_DEG})
+ rows = [(name, _rings(json.loads(gj))) for name, gj in cur.fetchall()]
+
+ points = [p for _, rings in rows for ring in rings for p in ring]
+ min_lon = min(p[0] for p in points)
+ max_lat = max(p[1] for p in points)
+ lat_mid = (min(p[1] for p in points) + max_lat) / 2
+ k = math.cos(math.radians(lat_mid))
+
+ width_deg = max((p[0] - min_lon) * k for p in points)
+ scale = VIEWBOX_WIDTH / width_deg
+ height = round(max((max_lat - p[1]) for p in points) * scale, 1)
+
+ def path(rings: list[list[list[float]]]) -> str:
+ out: list[str] = []
+ for ring in rings:
+ for i, (lon, lat) in enumerate(ring):
+ x = round((lon - min_lon) * k * scale, 1)
+ y = round((max_lat - lat) * scale, 1)
+ out.append(f"{'M' if i == 0 else 'L'}{x} {y}")
+ out.append("Z")
+ return "".join(out)
+
+ districts = [{"name": name, "d": path(rings)} for name, rings in rows]
+ print(
+ json.dumps(
+ {
+ "meta": {
+ "minLon": min_lon,
+ "maxLat": max_lat,
+ "k": round(k, 10),
+ "scale": round(scale, 6),
+ "width": VIEWBOX_WIDTH,
+ "height": height,
+ "simplifyDeg": SIMPLIFY_DEG,
+ },
+ "districts": districts,
+ },
+ ensure_ascii=False,
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py
index 7b5a51ba..42bde9bd 100644
--- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py
+++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py
@@ -93,6 +93,8 @@ def _build(**over: object) -> ShowcaseRow | None:
"predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
"n_analogs": 30,
+ "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
+ "lon": 60.6055, # поэтому перестановка ловится по значению.
}
kwargs.update(over)
return build_row(**kwargs) # type: ignore[arg-type]
@@ -169,3 +171,34 @@ def test_unknown_quarter_is_not_invented() -> None:
assert _build(deal_date=None) is None
assert quarter_label(None) is None
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"
+
+
+# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ──
+
+
+def test_coords_are_carried_through_and_not_swapped() -> None:
+ """Широта остаётся широтой. Обе величины float, тип перестановку не поймает.
+
+ Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест
+ покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме.
+ """
+ row = _build()
+ assert row is not None
+ assert row.lat == 56.8386, "широта уехала не в ту колонку"
+ assert row.lon == 60.6055, "долгота уехала не в ту колонку"
+
+
+def test_row_without_coords_stays_on_showcase() -> None:
+ """Нет точки — строка всё равно на витрине, с lat=lon=None.
+
+ Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
+ связанному с качеством оценки: та же порча витрины, что и отбор по
+ величине ошибки, просто по другому полю. Карта переживёт строку без точки.
+
+ Ломать так: добавить в `build_row` `if lat is None or lon is None: return
+ None` — тест покраснеет на None вместо строки.
+ """
+ row = _build(lat=None, lon=None)
+ assert row is not None, "сделка выброшена из витрины за отсутствие координаты"
+ assert row.lat is None and row.lon is None, "координата придумана там, где её нет"
+ assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали"
diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py
index b0033ac7..33a8ead9 100644
--- a/tradein-mvp/backend/tests/test_public_mera_api.py
+++ b/tradein-mvp/backend/tests/test_public_mera_api.py
@@ -179,6 +179,20 @@ _SHOWCASE_ROW = {
"err_pct": 3.39,
"n_analogs": 41,
"note": "не point-in-time",
+ "street_name": "улица Краснолесья",
+ "street_scheme": {
+ "street": "улица Краснолесья",
+ "w": 1000,
+ "h": 1000,
+ "target": ["M10 20L30 40"],
+ "roads": [{"c": "primary", "d": "M0 0L100 100"}],
+ "water": [],
+ "labels": [{"t": "улица Чкалова", "x": 431.2, "y": 88.0}],
+ },
+ # Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 —
+ # величины намеренно непохожие, чтобы перестановка ловилась по значению.
+ "lat": 56.8386,
+ "lon": 60.6055,
}
_SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
@@ -217,6 +231,62 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
assert body["deals"][0]["fact_rub"] == 5_900_000
# Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк.
assert "address" not in body["deals"][0]
+ # Схема улицы едет готовыми SVG-путями, а не геометрией.
+ assert body["deals"][0]["street_name"] == "улица Краснолесья"
+ assert body["deals"][0]["street_scheme"]["target"] == ["M10 20L30 40"]
+
+
+def test_showcase_row_without_street_stays_null(client: TestClient) -> None:
+ """Несматчившаяся улица — 8% сделок — проходит насквозь как null.
+
+ Ни подстановки соседней улицы, ни падения: фронт по этому null показывает
+ район. Ломать так: сделать street_name/street_scheme обязательными в
+ `ShowcaseDeal` — тест покраснеет на 500.
+ """
+ row = {**_SHOWCASE_ROW, "street_name": None, "street_scheme": None}
+ client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
+
+ body = client.get(f"{PREFIX}/showcase").json()
+ assert body["deals"][0]["street_name"] is None
+ assert body["deals"][0]["street_scheme"] is None
+
+
+def test_showcase_carries_coords_without_swapping_them(client: TestClient) -> None:
+ """Координата доезжает до JSON и не переставлена местами.
+
+ Обе величины float, поэтому перестановку не поймает ни схема, ни тип —
+ только значение: 56.8 (широта ЕКБ) против 60.6 (долгота). Ломать так:
+ поменять местами `lat=` и `lon=` в сборке `ShowcaseDeal` — покраснеет здесь.
+
+ Точка при этом — ЦЕНТРОИД УЛИЦЫ, а не дом (991 различная координата на
+ 34 017 сделок выборки); ровно это написано в `note`, который едет рядом.
+ """
+ client.app.dependency_overrides[get_db] = lambda: _showcase_db()
+
+ deal = client.get(f"{PREFIX}/showcase").json()["deals"][0]
+ assert deal["lat"] == 56.8386, "широта приехала не в то поле"
+ assert deal["lon"] == 60.6055, "долгота приехала не в то поле"
+
+
+def test_showcase_row_without_coords_reaches_the_client_as_null(
+ client: TestClient,
+) -> None:
+ """Сделка без координаты доезжает до ответа целиком, с lat=lon=null.
+
+ Карта переживёт строку без точки; выбрасывание такой сделки было бы
+ отбором по признаку, не связанному с качеством оценки. Подстановка центра
+ города вместо отсутствующей точки — тем более враньё.
+
+ Ломать так: заменить null на любую подстановку или отфильтровать строку в
+ ручке — покраснеет по значению, а не по форме.
+ """
+ row = {**_SHOWCASE_ROW, "lat": None, "lon": None}
+ client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
+
+ deals = client.get(f"{PREFIX}/showcase").json()["deals"]
+ assert len(deals) == 1, "строка без координаты выброшена из витрины"
+ assert deals[0]["lat"] is None and deals[0]["lon"] is None
+ assert deals[0]["fact_rub"] == 5_900_000, "остальные величины строки не пострадали"
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
diff --git a/tradein-mvp/backend/tests/test_street_scheme.py b/tradein-mvp/backend/tests/test_street_scheme.py
new file mode 100644
index 00000000..2576d5f2
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_street_scheme.py
@@ -0,0 +1,214 @@
+"""Схема улицы для витрины лэндинга: нормализация, отказ и размер.
+
+Каждый тест здесь сломан вручную перед коммитом и покраснел — иначе он не
+проверка, а декорация. Что именно ловится:
+ * нормализация названий на РЕАЛЬНЫХ строках `deals.address` и OSM;
+ * несматчившаяся улица не роняет пересчёт (штатные 8%);
+ * схема не раздувается: без округления координат она вырастает в разы.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import re
+
+import pytest
+
+from app.services import street_scheme as ss
+
+
+# ── Нормализация ────────────────────────────────────────────────────────────
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ # Реальные значения deals.address (прод, ЕКБ).
+ ("Екатеринбург, Краснолесья", "краснолесья"),
+ ("Екатеринбург, 8 Марта", "8 марта"),
+ ("Екатеринбург, 22 Партсъезда", "22 партсъезда"),
+ ("Екатеринбург, Академика Сахарова", "академика сахарова"),
+ # Перекрёсток: берём ПЕРВУЮ улицу (обоснование — в докстринге функции).
+ ("Екатеринбург, Челюскинцев/Шейнкмана", "челюскинцев"),
+ ("Екатеринбург, Шаумяна/Чкалова", "шаумяна"),
+ ("Екатеринбург, пр-кт Космонавтов/ Ильича", "космонавтов"),
+ # Реальные значения name в osm_noise_sources_ekb.
+ ("улица Красных Командиров", "красных командиров"),
+ ("Амбулаторная улица", "амбулаторная"),
+ ("проспект Космонавтов", "космонавтов"),
+ ("Сибирский тракт", "сибирский"),
+ # ё → е: адрес и OSM пишут по-разному, улица одна.
+ ("Екатеринбург, Королёва", "королева"),
+ ("улица Королева", "королева"),
+ # Пусто — значит пусто, а не выдуманная улица.
+ (None, None),
+ ("", None),
+ ("Екатеринбург, улица", None),
+ ],
+)
+def test_normalize_street(raw: str | None, expected: str | None) -> None:
+ assert ss.normalize_street(raw) == expected
+
+
+def test_normalize_matches_address_to_osm_name() -> None:
+ """Смысл нормализации: адрес сделки и название OSM сходятся в один ключ."""
+ assert ss.normalize_street("Екатеринбург, Краснолесья") == ss.normalize_street(
+ "улица Краснолесья"
+ )
+ assert ss.normalize_street("Екатеринбург, Королёва") == ss.normalize_street("улица Королева")
+
+
+# ── Тестовый двойник сессии ─────────────────────────────────────────────────
+class _Result:
+ def __init__(self, rows: list) -> None:
+ self._rows = rows
+
+ def all(self) -> list:
+ return self._rows
+
+ def mappings(self) -> _Result:
+ return self
+
+ def first(self):
+ return self._rows[0] if self._rows else None
+
+
+class _FakeDB:
+ """Отвечает на три запроса модуля. `fail` — имитация недоступной вьюхи."""
+
+ def __init__(self, *, names: list[str], center=None, window=None, fail: bool = False) -> None:
+ self.names = names
+ self.center = center
+ self.window = window or []
+ self.fail = fail
+ self.calls = 0
+
+ @contextlib.contextmanager
+ def begin_nested(self):
+ yield
+
+ def execute(self, stmt, params=None):
+ self.calls += 1
+ if self.fail:
+ raise RuntimeError("permission denied for view v_tradein_osm_roads_ekb")
+ sql = str(stmt)
+ if "DISTINCT name" in sql:
+ return _Result([(n,) for n in self.names])
+ if "ST_ClosestPoint" in sql:
+ return _Result([self.center] if self.center else [])
+ return _Result(self.window)
+
+
+def _line(n: int, lon0: float = 60.53, lat0: float = 56.78) -> str:
+ pts = [[round(lon0 + i * 0.0002, 6), round(lat0 + i * 0.0001, 6)] for i in range(n)]
+ return json.dumps({"type": "LineString", "coordinates": pts})
+
+
+# ── Несматчившаяся улица — штатный случай, а не авария ──────────────────────
+def test_unmatched_street_returns_none_without_touching_db() -> None:
+ db = _FakeDB(names=["улица Краснолесья"])
+ index = ss.load_street_index(db)
+ before = db.calls
+ assert ss.build_street_scheme(db, index, "Екатеринбург, Такой Улицы Нет") is None
+ # Ни одного лишнего запроса: не сматчилось — не ходим в чужую базу.
+ assert db.calls == before
+
+
+def test_missing_address_returns_none() -> None:
+ db = _FakeDB(names=["улица Краснолесья"])
+ index = ss.load_street_index(db)
+ assert ss.build_street_scheme(db, index, None) is None
+
+
+def test_unavailable_view_gives_empty_index_not_exception() -> None:
+ """Грант на той стороне терялся уже трижды. Пересчёт обязан это пережить."""
+ db = _FakeDB(names=[], fail=True)
+ index = ss.load_street_index(db)
+ assert index.by_norm == {}
+ assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
+
+
+def test_query_failure_gives_none_not_exception() -> None:
+ class _FailOnSecond(_FakeDB):
+ def execute(self, stmt, params=None):
+ if "DISTINCT name" not in str(stmt):
+ raise RuntimeError("FDW connection refused")
+ return super().execute(stmt, params)
+
+ db = _FailOnSecond(names=["улица Краснолесья"])
+ index = ss.load_street_index(db)
+ assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
+
+
+# ── Форма схемы и её размер ─────────────────────────────────────────────────
+def _scheme_with(rows: list[dict]) -> dict:
+ db = _FakeDB(
+ names=["улица Краснолесья"],
+ center={"lon": 60.537283, "lat": 56.784694},
+ window=rows,
+ )
+ index = ss.load_street_index(db)
+ scheme = ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья")
+ assert scheme is not None
+ return scheme
+
+
+def test_scheme_shape() -> None:
+ scheme = _scheme_with(
+ [
+ {"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(5)},
+ {"name": "улица Чкалова", "road_class": "primary", "gj": _line(4, 60.534)},
+ {"name": "Патрушиха", "road_class": "river", "gj": _line(3, 60.535)},
+ ]
+ )
+ assert scheme["street"] == "улица Краснолесья"
+ assert scheme["w"] == scheme["h"] == ss.VIEWBOX
+ assert len(scheme["target"]) == 1 and scheme["target"][0].startswith("M")
+ assert [r["c"] for r in scheme["roads"]] == ["primary"]
+ assert len(scheme["water"]) == 1
+ assert [label["t"] for label in scheme["labels"]] == ["улица Чкалова"]
+ # Ни координат окна, ни констант проекции: по схеме нельзя поставить дом.
+ assert not {"minLon", "maxLat", "k", "scale", "lat", "lon"} & set(scheme)
+
+
+def _dense_window() -> list[dict]:
+ """Плотность как в реальном окне (замер 2026-08-29 по «улица 8 Марта» и
+ «улица Краснолесья»): 56-58 отрезков в кадре, 114-118 точек после упрощения
+ на 3 м, то есть 2-2.7 точки на отрезок. Берём 3 — чуть плотнее замера."""
+ rows = [
+ {"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(3, 60.537 + i * 0.0002)}
+ for i in range(16)
+ ]
+ rows += [
+ {
+ "name": f"улица №{i}",
+ "road_class": "residential",
+ "gj": _line(3, 60.5340 + i * 0.0004, 56.7820 + i * 0.0002),
+ }
+ for i in range(40)
+ ]
+ return rows
+
+
+def test_scheme_stays_small() -> None:
+ """Порядок величины — 4 КБ. Замер на проде 2026-08-29: 2.8-2.9 КБ.
+
+ Сломать: убрать `round(..., 1)` в `path()` — координаты станут длинными
+ float'ами, и схема на том же окне перевалит за потолок.
+ """
+ scheme = _scheme_with(_dense_window())
+ size = len(json.dumps(scheme, ensure_ascii=False, separators=(",", ":")).encode())
+ assert size < 4400, f"схема раздулась до {size} байт"
+ assert len(scheme["labels"]) == ss.MAX_LABELS
+
+
+def test_coordinates_carry_one_decimal() -> None:
+ """Прямая проверка того, чем держится размер: одна цифра после точки.
+
+ Потолок в байтах ловит раздутие только на той плотности, что в тесте; этот
+ ассерт ловит саму потерю округления на любой.
+ """
+ scheme = _scheme_with(_dense_window())
+ blob = " ".join(scheme["target"] + [r["d"] for r in scheme["roads"]])
+ assert not re.search(r"\.\d\d", blob), "координаты перестали округляться до 0.1"
+ for label in scheme["labels"]:
+ assert not re.search(r"\.\d\d", f"{label['x']} {label['y']}")
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx
new file mode 100644
index 00000000..987bb353
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx
@@ -0,0 +1,228 @@
+/**
+ * Карта сделки: что она обязана показать и о чём обязана промолчать.
+ *
+ * Проверяется не «рисуется ли svg», а три решения, каждое из которых при поломке
+ * даёт правдоподобную картинку с неверным смыслом:
+ * · кадр берётся по району сделки, а не по городу (иначе район — пятно);
+ * · точка ставится ТОЛЬКО когда координата попала в кадр (иначе далёкая
+ * координата прижмётся к краю и будет прочитана как место сделки);
+ * · подпись говорит, что точка — середина улицы, а не дом, и не называет её
+ * адресом объекта.
+ */
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { DealMapV3 } from "../_components/v3/DealMapV3";
+import { GuessGameV3 } from "../_components/v3/GuessGameV3";
+import { StreetMapV3 } from "../_components/v3/StreetMapV3";
+import { EKB_MAP } from "../_components/v3/ekb-districts";
+import { dealTitle, toGameRound } from "../_components/v3/deal-view";
+import type { ShowcaseDeal, StreetScheme } from "../public-api";
+
+/** Ширина кадра из атрибута viewBox отрисованной карты. */
+function frameWidth(container: HTMLElement): number {
+ const box = container.querySelector("svg")!.getAttribute("viewBox")!;
+ return Number(box.split(" ")[2]);
+}
+
+const dots = (container: HTMLElement) => container.querySelectorAll("circle").length;
+
+describe("карта сделки", () => {
+ it("кадрируется по району сделки, а не по городу", () => {
+ const home = render(
+ ,
+ );
+ const nowhere = render(
+ ,
+ );
+ expect(frameWidth(home.container)).toBeLessThan(frameWidth(nowhere.container));
+ // Без района показывается город целиком — это и есть «кадра нет».
+ expect(frameWidth(nowhere.container)).toBeGreaterThanOrEqual(EKB_MAP.width);
+ });
+
+ it("кадр разный у разных районов — иначе карта не про эту сделку", () => {
+ const frames = ["Кировский", "Чкаловский", "Академический"].map((name) => {
+ const { container } = render(
+ ,
+ );
+ return container.querySelector("svg")!.getAttribute("viewBox")!;
+ });
+ expect(new Set(frames).size).toBe(frames.length);
+ });
+
+ it("точка внутри кадра рисуется, а подпись говорит, что это улица, а не дом", () => {
+ // Точка внутри полигона Чкаловского — проверена тем же `project()`, что
+ // рисует карту.
+ const { container, getByText } = render(
+ ,
+ );
+ expect(dots(container)).toBeGreaterThan(0);
+ expect(getByText(/СЕРЕДИНА УЛИЦЫ, НЕ ДОМ · ЧКАЛОВСКИЙ/u)).toBeTruthy();
+ });
+
+ it("координата вне кадра не рисуется и не выдаётся за место сделки", () => {
+ // Такие строки в данных есть: город записан «Екатеринбург», координата — за
+ // сотни километров. Прижать её к краю кадра значило бы показать место,
+ // которого в данных нет.
+ const { container, getByText } = render(
+ ,
+ );
+ expect(dots(container)).toBe(0);
+ expect(getByText(/КООРДИНАТЫ НЕТ · ЧКАЛОВСКИЙ/u)).toBeTruthy();
+ });
+
+ it("без координаты карта остаётся, район подсвечен, прочерка нет", () => {
+ const { container, getByText } = render(
+ ,
+ );
+ expect(dots(container)).toBe(0);
+ expect(container.querySelectorAll("path").length).toBeGreaterThan(0);
+ expect(getByText(/КООРДИНАТЫ НЕТ · ЛЕНИНСКИЙ/u)).toBeTruthy();
+ expect(container.textContent).not.toMatch(/—\s*$/u);
+ });
+
+ it("карта не называет себя адресом объекта", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
+ });
+});
+
+// ── Студия — это тип квартиры, а не ноль комнат ─────────────────────────────
+//
+// В витрине на проде (29.08.2026) 3 строки из 20 имеют rooms = 0. Пока
+// dealTitle клеил «${rooms}-к», карточка писала «0-к, 25,9 м²» — это читается
+// как ошибка выгрузки, а не как студия. Тест держит обе ветки: сломай любую —
+// покраснеет по значению.
+describe("подпись сделки различает студию и комнатные", () => {
+ const deal = (rooms: number, area: number): ShowcaseDeal => ({
+ district: "Кировский",
+ rooms,
+ area_m2: area,
+ floor: 9,
+ total_floors: null,
+ deal_quarter: "II квартал 2026",
+ predicted_rub: 5_000_000,
+ fact_rub: 4_800_000,
+ err_pct: 4.2,
+ n_analogs: 24,
+ note: "",
+ lat: null,
+ lon: null,
+ street_name: null,
+ street_scheme: null,
+ });
+
+ it("rooms = 0 подписывается студией, а не «0-к»", () => {
+ const title = dealTitle(deal(0, 25.9));
+ expect(title).toContain("Студия");
+ expect(title).not.toContain("0-к");
+ });
+
+ it("комнатные подписываются числом комнат", () => {
+ expect(dealTitle(deal(2, 52))).toContain("2-к");
+ expect(dealTitle(deal(2, 52))).not.toContain("Студия");
+ });
+});
+
+// ── Схема улиц: что она обязана сказать и чего не должна заявлять ───────────
+//
+// Схема приходит готовыми путями из ручки — рисовать тут нечего, и «рисуется
+// ли svg» проверять бессмысленно. Ломаются другие вещи, и каждая ломается
+// тихо: пропадает иерархия дорог (схема превращается в решётку), теряется
+// подсветка целевой улицы (карта перестаёт быть про эту сделку), подпись
+// начинает выдавать улицу за адрес объекта, а выбор между двумя картами
+// съезжает — и 8% строк без схемы получают пустоту вместо района.
+
+const SCHEME: StreetScheme = {
+ street: "улица Краснолесья",
+ w: 1000,
+ h: 1000,
+ target: ["M431.2 88L500.1 210.4", "M500.1 210.4L512 640"],
+ roads: [
+ { c: "trunk", d: "M0 0L100 100" },
+ { c: "residential", d: "M0 900L100 950" },
+ { c: "unknown_class", d: "M10 10L20 20" },
+ ],
+ water: ["M0 500L1000 520"],
+ labels: [{ t: "улица Чкалова", x: 431.2, y: 88 }],
+};
+
+const widths = (container: HTMLElement, cls: string): number[] =>
+ [...container.querySelectorAll(`path[class*="${cls}"]`)].map((n) =>
+ Number(n.getAttribute("stroke-width")),
+ );
+
+describe("схема улиц в карточке сделки", () => {
+ it("кадр берётся из данных, а не из константы фронта", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 800 800");
+ // slice, а не meet: окно квадратное, карточка — нет. Целевая улица лежит в
+ // центре окна по построению, поэтому обрезка краёв её не трогает.
+ expect(container.querySelector("svg")!.getAttribute("preserveAspectRatio")).toContain("slice");
+ });
+
+ it("целевая улица подсвечена и несёт ореол — по одному на каждый путь", () => {
+ const { container } = render();
+ expect(widths(container, "streetTarget")).toHaveLength(SCHEME.target.length);
+ const halo = widths(container, "streetHalo");
+ expect(halo).toHaveLength(SCHEME.target.length);
+ // Ореол обязан быть ШИРЕ линии — иначе он не ореол, а вторая линия.
+ expect(Math.min(...halo)).toBeGreaterThan(Math.max(...widths(container, "streetTarget")));
+ });
+
+ it("иерархия дорог сохраняется, а незнакомый класс не даёт нулевой толщины", () => {
+ const { container } = render();
+ const [trunk, residential, unknown] = widths(container, "streetRoad");
+ expect(trunk).toBeGreaterThan(residential);
+ expect(unknown).toBeGreaterThan(0);
+ });
+
+ it("вода нарисована и отделена от дорог", () => {
+ const { container } = render();
+ expect(widths(container, "streetWater")).toHaveLength(SCHEME.water.length);
+ });
+
+ it("подпись называет улицу и сразу говорит, что дом не известен", () => {
+ const { container, getByText } = render();
+ expect(getByText(/УЛИЦА КРАСНОЛЕСЬЯ · ДОМ НЕ ИЗВЕСТЕН/u)).toBeTruthy();
+ expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
+ });
+
+ it("схема ничего не сообщает скринридеру — всё, что она знает, стоит рядом текстом", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true");
+ });
+});
+
+describe("карточка игры выбирает карту по наличию схемы", () => {
+ const deal = (scheme: StreetScheme | null): ShowcaseDeal => ({
+ district: "Ленинский",
+ rooms: 2,
+ area_m2: 52,
+ floor: 3,
+ total_floors: 9,
+ deal_quarter: "II кв. 2026",
+ predicted_rub: 5_210_000,
+ fact_rub: 5_180_000,
+ err_pct: 0.58,
+ n_analogs: 12,
+ note: "",
+ lat: null,
+ lon: null,
+ street_name: scheme ? scheme.street : null,
+ street_scheme: scheme,
+ });
+
+ it("схема есть — показана улица", () => {
+ const { container } = render();
+ expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН");
+ });
+
+ it("схемы нет — остаётся район, а не пустое место", () => {
+ const { container } = render();
+ expect(container.textContent).toContain("ЛЕНИНСКИЙ");
+ expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН");
+ });
+});
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 6e34f1ab..369ba1a0 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
@@ -64,10 +64,25 @@ const V3_DIR = "_components/v3";
* Компонент, положенный ВНЕ каталога, каталогом не ловится — за это отвечает
* отдельная проверка «всё, что импортирует корень, попало в список».
*/
+/**
+ * Единственное исключение — таблица координат, СГЕНЕРИРОВАННАЯ из боевой
+ * геометрии (`backend/scripts/export_ekb_districts_svg.py`). Её числа — не
+ * величины о рынке, а границы районов: «56.8» в пути полигона ничего не
+ * утверждает и источник у неё проверяемый, записанный в шапке файла. Правило
+ * (1) на ней даёт шум, за которым правило перестают читать.
+ *
+ * Исключение НЕ бланковое: ниже отдельная проверка требует, чтобы каждый
+ * исключённый файл нёс маркер генератора. Иначе строчка в этом списке стала бы
+ * способом занести в компонент любое число, сославшись на генератор, которого
+ * нет.
+ */
+const GENERATED = ["ekb-districts.ts"];
+const GENERATED_MARK = "СГЕНЕРИРОВАН";
+
const V3_SOURCES = [
LANDING_ROOT,
...readdirSync(join(ROOT, V3_DIR))
- .filter((name) => /\.tsx?$/u.test(name))
+ .filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
.sort()
.map((name) => `${V3_DIR}/${name}`),
];
@@ -140,6 +155,10 @@ describe("гейт витринных чисел (#2904)", () => {
}
});
+ it.each(GENERATED)("исключённый из скана %s действительно сгенерирован, а не написан руками", (name) => {
+ expect(read(`${V3_DIR}/${name}`)).toContain(GENERATED_MARK);
+ });
+
it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
// Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
// (величины из прежнего marketing-v3.ts), справа — код, который в
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index ed59741d..2fdbbac7 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -54,6 +54,11 @@ const SHOWCASE: ShowcaseResponse = {
err_pct: 0.58,
n_analogs: 12,
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
+ lat: null,
+ lon: null,
+ // Схемы у этой строки нет — рендер идёт запасным путём, картой района.
+ street_name: null,
+ street_scheme: null,
},
],
stats: {
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx
deleted file mode 100644
index 7ef9ee8d..00000000
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-"use client";
-
-/**
- * CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
- *
- * Презентационный клиентский остров: сегодня публичный расчёт выключен
- * (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
- * город никуда не отправляется — состояние живёт только в этом компоненте и
- * показывает посетителю, что сервис знает больше одного города области.
- * Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
- * наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/
- * sticky-баром, самой формы здесь нет.
- *
- * Список городов и деление на «полное покрытие» / «данных меньше» берутся из
- * `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
- * объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
- * по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет,
- * а гадать нельзя (правило честности в content.ts).
- */
-
-import { useEffect, useId, useRef, useState } from "react";
-
-import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
-
-import { PRIMARY_CITY, REGION_NAME } from "../../content";
-import styles from "../../landing-v3.module.css";
-
-export function CityPicker() {
- const [open, setOpen] = useState(false);
- const [query, setQuery] = useState("");
- const [city, setCity] = useState(DEFAULT_CITY.label);
- const wrapRef = useRef(null);
- const buttonRef = useRef(null);
- const panelId = useId();
-
- // Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
- useEffect(() => {
- if (!open) return;
- function handleClick(e: MouseEvent) {
- if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
- setOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [open]);
-
- // Escape — закрыть и вернуть фокус на кнопку.
- useEffect(() => {
- if (!open) return;
- function handleKey(e: KeyboardEvent) {
- if (e.key === "Escape") {
- setOpen(false);
- buttonRef.current?.focus();
- }
- }
- document.addEventListener("keydown", handleKey);
- return () => document.removeEventListener("keydown", handleKey);
- }, [open]);
-
- const normalizedQuery = query.trim().toLowerCase();
- const filtered = normalizedQuery
- ? OBLAST_CITIES.filter((c) =>
- c.label.toLowerCase().includes(normalizedQuery),
- )
- : OBLAST_CITIES;
-
- return (
-
- Города нет в списке. Мы работаем там, где хватает данных по
- сделкам — напишите нам, и добавим.
-
- )}
-
-
- ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
- {REGION_NAME.toUpperCase()}
-
-
- ) : null}
-
- );
-}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx
new file mode 100644
index 00000000..d56f964c
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealMapV3.tsx
@@ -0,0 +1,159 @@
+/**
+ * DealMapV3 — карта сделки в карточке игры «Сыграйте против МЕРЫ».
+ *
+ * Заменяет декорацию, которая стояла тут раньше: CSS-сетку и две «дороги»,
+ * нарисованные прямоугольниками. Географии в них не было вовсе, а подпись
+ * «ОБЪЕКТ · РАЙОН» заявляла местоположение объекта — то, чего в данных нет.
+ *
+ * БЕЗ ЕДИНОГО ВНЕШНЕГО ЗАПРОСА. Геометрия районов приезжает статикой из
+ * `ekb-districts.ts` (сгенерирована из боевой OSM-таблицы), рисуется инлайновым
+ *