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..673a2f88 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -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 ], 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..23630632 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -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() 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/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index b0033ac7..3ea6d9aa 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -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( 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']}")