feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией

Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и
гранта на них не было.

Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb):
gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ
ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании);
tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name /
street_scheme в landing_showcase_deals.

Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м
вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в
export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON
того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ.

ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ
  · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок.
    В схеме намеренно НЕТ координат окна и констант проекции — точку дома по
    ней нельзя поставить даже случайно. Это замок, а не забывчивость.
  · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51
    здание на радиус 450 м, где их в разы больше. Нарисованная застройка
    заявляла бы полноту, которой в данных нет.
  · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты
    хорошо, дворовые и служебные проезды отсутствуют.
  · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%).
    Остальным street_scheme = NULL, и это штатно: фронт показывает район,
    который для этого и оставлен.
  · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов
    три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы.
  · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает
    отбор по величине ошибки: иначе витрина показывала бы не работу оценщика,
    а те 92% адресов, что удобно легли на OSM.

Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах
включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не
исключение; схема не раздувается — потолок в байтах на реальной плотности плюс
прямая проверка округления до 0.1.
This commit is contained in:
bot-backend 2026-08-29 22:16:05 +05:00
parent 555cce44ad
commit b2b702dca7
7 changed files with 786 additions and 6 deletions

View file

@ -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;

View file

@ -393,8 +393,27 @@ class ShowcaseDeal(BaseModel):
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
правдоподобную подстановку. Улицы и дома в модели нет вовсе номер дома
есть у 2.7% сделок (разбор в миграции 276).
правдоподобную подстановку.
`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).
"""
district: str | None
@ -408,6 +427,8 @@ class ShowcaseDeal(BaseModel):
err_pct: float
n_analogs: int
note: str
street_name: str | None
street_scheme: dict | None
class ShowcaseStats(BaseModel):
@ -458,7 +479,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,
street_name, street_scheme
FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id
@ -515,6 +537,8 @@ 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"],
)
for r in rows
],

View file

@ -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,
}

View file

@ -56,6 +56,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 +81,7 @@
from __future__ import annotations
import argparse
import json
import logging
from dataclasses import dataclass
from datetime import date
@ -82,6 +90,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 +112,9 @@ NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ."
"строка выглядит как промах оценщика, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
)
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@ -255,17 +267,40 @@ _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,
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(: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,
*,
@ -335,13 +370,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,
@ -364,6 +403,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()

View file

@ -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;

View file

@ -179,6 +179,16 @@ _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}],
},
}
_SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
@ -217,6 +227,24 @@ 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_counters_so_20_rows_cannot_read_as_all_there_was(

View file

@ -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']}")