From 7c52622a76857a30393ac0bfa1c025388a75e6e0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 18:21:15 +0500 Subject: [PATCH] =?UTF-8?q?fix(parcels):=20=D0=B3=D0=B5=D0=BE-=D1=80=D0=B0?= =?UTF-8?q?=D0=B4=D0=B8=D1=83=D1=81=D0=BD=D0=B0=D1=8F=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BA=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=B5=D1=80=D1=91=D1=82=20=D0=BB=D0=BE=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=B6=D0=BD=D0=B8=D1=85=20=D0=96=D0=9A,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=87=D1=83=D0=B6=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D0=BC=20?= =?UTF-8?q?complex=5Fid=20(#3583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit geo_radius_price отбирал лоты по objective_lots.complex_id. Эта колонка проставлена один раз миграцией 76, а еженедельный UPSERT переписывает project_name и не трогает complex_id, поэтому под id ближнего ЖК лежат лоты ЖК из других районов. На проде медиана любого участка сходилась к средней по городу: 125–138 тыс. ₽/м² при реальном разбросе 107–181 тыс. Как в #2962: complex → проект через complex_sources (source='objective'), лоты по project_name, имя проекта сверяется с именем ЖК. Сверка отсекает 4 неверные fuzzy-связи (VEER PARK/Clever Park, Клевер/Веер, Графит/Гранит, Традиции/Традиция) и 2 верные («Новая Ботаника-2», «Теплые кварталы (BAZA)»). Дедуп физлота перенесён в LATERAL по проекту с premise_kind='квартира', чтобы работал покрывающий индекс: иначе запрос шёл 690 мс вместо 62. Апартаменты сейчас есть только у проектов, чьи complexes без координат, так что на медиану фильтр не влияет (570 участков, 0 отличий). SQL вынесен в _GEO_RADIUS_PRICE_SQL, чтобы тест гонял его на временных таблицах. Co-Authored-By: Claude Opus 5 --- backend/app/api/v1/parcels.py | 125 ++++++++++-------- ...st_3583_geo_radius_price_project_bridge.py | 123 +++++++++++++++++ backend/tests/skip_allowlist.txt | 6 + 3 files changed, 199 insertions(+), 55 deletions(-) create mode 100644 backend/tests/api/v1/test_3583_geo_radius_price_project_bridge.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 28eea285..31c153a4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -166,6 +166,74 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу _GEO_PRICE_MIN_LOTS: int = 10 _GEO_PRICE_MIN_COMPLEXES: int = 2 +_GEO_RADIUS_PRICE_SQL = text(""" + -- #1964 physflat-дедуп: objective_lots раздут ~2.91× + -- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10 + -- были по пере-листингам. Дедупим INLINE через DISTINCT ON + -- (physflat-ключ, последний снапшот), scope протолкнут В + -- CTE через проекты ближних ЖК. НЕ через + -- v_objective_lots_latest: view материализует ВСЮ таблицу + -- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort + -- 1.76M (~6.4 s на request-path analyze_parcel). Дедуп до price-фильтра: + -- цена объективна по физлоту (последний снапшот). + -- #3583: дедуп по проекту в LATERAL + premise_kind='квартира' → Index Only Scan + -- objective_lots_physflat_covering_v2_idx без сортировки. Общий DISTINCT ON по + -- всем снапшотам проектов шёл external merge на диск: прод-EXPLAIN 17.09, 16 + -- проектов, 690 мс против 62 мс. Апартаменты на проде 17.09 есть у 6 проектов из + -- complex_sources, у всех complex без координат → в радиус не попадают, фильтр + -- медиану не меняет (замер на 570 участках: 0 отличий). + WITH nearby_cx AS ( + -- #3583: complex → проект Объектива из complex_sources (source='objective', + -- 1:1), а НЕ из objective_lots.complex_id. Тот проставлен один раз миграцией 76, + -- а еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id + -- переписывает project_name и не трогает complex_id → под id ближнего ЖК + -- лежат лоты чужих (прод 17.09: 236 354 из 303 677 строк). Тот же дефект, + -- что #2962 в competitors.py, и та же сверка имени: связь fuzzy, у «ЖК VEER + -- PARK» стоит 'Clever Park' в 11.7 км, у «ЖК Графит» — 'Гранит'. + SELECT c.id, cs.source_id AS project_name + FROM complexes c + JOIN complex_sources cs + ON cs.complex_id = c.id + AND cs.source = 'objective' + CROSS JOIN LATERAL ( + SELECT regexp_replace(lower(c.canonical_name), '[^0-9a-zа-яё]', '', 'g') AS cx_key, + regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + ) k + WHERE c.latitude IS NOT NULL + AND c.longitude IS NOT NULL + AND ST_DWithin( + ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, + ST_SetSRID( + ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)), 4326 + )::geography, + CAST(:radius_m AS float) + ) + AND ( k.cx_key LIKE '%' || k.project_key || '%' + OR k.project_key LIKE '%' || k.cx_key || '%') + ), + latest AS ( + SELECT l.price_per_m2_rub, nc.id AS complex_id + FROM nearby_cx nc + CROSS JOIN LATERAL ( + SELECT DISTINCT ON (ol.corpus_name, ol.section, ol.floor, ol.lot_number) + ol.price_per_m2_rub + FROM objective_lots ol + WHERE ol.project_name = nc.project_name + AND ol.premise_kind = 'квартира' + ORDER BY ol.corpus_name, ol.section, ol.floor, ol.lot_number, + ol.snapshot_date DESC, ol.id DESC + ) l + ) + SELECT + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY price_per_m2_rub + ) AS median, + count(*) AS n, + count(DISTINCT complex_id) AS n_complexes + FROM latest + WHERE price_per_m2_rub IS NOT NULL +""") + # #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV # (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще # могла служить последним fallback'ом для карточки district.median_price_per_m2. @@ -3562,7 +3630,7 @@ def analyze_parcel( # радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference: # только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё # проваливались в class_norm. У objective_lots нет geom — коорды берём через - # complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py). + # complexes (latitude/longitude), проект — через complex_sources (#3583, как #2962). geo_radius_price: dict[str, Any] # Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная # медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную @@ -3573,60 +3641,7 @@ def analyze_parcel( with db.begin_nested(): grp_row = ( db.execute( - text(""" - -- #1964 physflat-дедуп: objective_lots раздут ~2.91× - -- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10 - -- были по пере-листингам. Дедупим INLINE через DISTINCT ON - -- (physflat-ключ, последний снапшот), scope протолкнут В - -- CTE через complex_id ближних ЖК. НЕ через - -- v_objective_lots_latest: view материализует ВСЮ таблицу - -- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort - -- 1.76M (~6.4 s на request-path analyze_parcel). Inline: - -- geo-index по complexes → Nested Loop bitmap - -- objective_lots_complex_idx по ~186 ЖК → ~120 ms - -- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра: - -- цена объективна по физлоту (последний снапшот). - WITH nearby_cx AS ( - SELECT c.id - FROM complexes c - WHERE c.latitude IS NOT NULL - AND c.longitude IS NOT NULL - AND ST_DWithin( - ST_SetSRID( - ST_MakePoint(c.longitude, c.latitude), - 4326 - )::geography, - ST_SetSRID( - ST_MakePoint( - CAST(:lon AS float), - CAST(:lat AS float) - ), 4326 - )::geography, - CAST(:radius_m AS float) - ) - ), - latest AS ( - SELECT DISTINCT ON ( - ol.project_name, ol.corpus_name, ol.section, - ol.floor, ol.lot_number - ) - ol.price_per_m2_rub, - ol.complex_id - FROM objective_lots ol - WHERE ol.complex_id IN (SELECT id FROM nearby_cx) - ORDER BY ol.project_name, ol.corpus_name, ol.section, - ol.floor, ol.lot_number, - ol.snapshot_date DESC, ol.id DESC - ) - SELECT - percentile_cont(0.5) WITHIN GROUP ( - ORDER BY price_per_m2_rub - ) AS median, - count(*) AS n, - count(DISTINCT complex_id) AS n_complexes - FROM latest - WHERE price_per_m2_rub IS NOT NULL - """), + _GEO_RADIUS_PRICE_SQL, { "lon": centroid_lon, "lat": centroid_lat, diff --git a/backend/tests/api/v1/test_3583_geo_radius_price_project_bridge.py b/backend/tests/api/v1/test_3583_geo_radius_price_project_bridge.py new file mode 100644 index 00000000..0bd26249 --- /dev/null +++ b/backend/tests/api/v1/test_3583_geo_radius_price_project_bridge.py @@ -0,0 +1,123 @@ +"""Гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не всё под complex_id (#3583). + +`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный +`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и +не трогает complex_id: под id ближнего ЖК лежат лоты чужих. Тот же дефект, что #2962 +в competitors.py. + +Тест герметичный и прогоняет НАСТОЯЩИЙ `_GEO_RADIUS_PRICE_SQL`: временные таблицы +затеняют боевые в пределах сессии. Нужен Postgres с PostGIS (ST_DWithin по geography). +В CI он есть, и там тест не пропускается: без PostGIS падает с настоящей причиной. +Пропуск разрешён только вне CI и объявлен в skip_allowlist.txt. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"] + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _postgis_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first(): + return True, "" + return False, "нет расширения postgis" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _postgis_reachable() +_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI")) +pytestmark = pytest.mark.skipif( + not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}" +) + +_SCHEMA = [ + """CREATE TEMP TABLE complexes ( + id bigint, canonical_name text, latitude double precision, + longitude double precision) ON COMMIT DROP""", + """CREATE TEMP TABLE complex_sources ( + complex_id bigint, source text, source_id text) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_lots ( + id bigint, project_name text, corpus_name text, section text, floor int, + lot_number text, snapshot_date date, premise_kind text, complex_id bigint, + price_per_m2_rub numeric) ON COMMIT DROP""", +] + +# Участок в (56.840, 60.600), радиус 3 км. Малахит — в ~10 км, вне радиуса. +_DATA = [ + """INSERT INTO complexes VALUES + (10, 'ЖК Мичуринский', 56.841, 60.601), + (20, 'ЖК VEER PARK', 56.845, 60.605), + (30, 'СтудияПарк', 56.835, 60.595), + (40, 'ЖК Малахит', 56.930, 60.600)""", + # 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493). + """INSERT INTO complex_sources VALUES + (10, 'objective', 'Мичуринский'), + (20, 'objective', 'Clever Park'), + (30, 'objective', 'Студия Парк'), + (40, 'objective', 'Малахит')""", + # Под complex_id=10 лежат свой лот и три лота чужого «Малахита» с устаревшим + # complex_id; у новых лотов complex_id NULL. Лот «Мичуринский/1/1/3/3» в двух + # снапшотах: в медиану идёт последний (120 тыс.), а не старый (50 тыс.). + """INSERT INTO objective_lots VALUES + (1, 'Мичуринский', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 100000), + (2, 'Мичуринский', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 110000), + (3, 'Мичуринский', '1', '1', 3, '3', '2026-08-01', 'квартира', NULL, 50000), + (4, 'Мичуринский', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 120000), + (5, 'Малахит', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 300000), + (6, 'Малахит', '1', '1', 2, '2', '2026-05-10', 'квартира', 10, 300000), + (7, 'Малахит', '1', '1', 3, '3', '2026-05-10', 'квартира', 10, 300000), + (8, 'Clever Park', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 500000), + (9, 'Clever Park', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 500000), + (10, 'Clever Park', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 500000), + (11, 'Студия Парк', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 90000), + (12, 'Студия Парк', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 95000)""", +] + + +@pytest.fixture(scope="module") +def row() -> dict[str, float]: + from app.api.v1.parcels import _GEO_PRICE_RADIUS_M, _GEO_RADIUS_PRICE_SQL + + session = sessionmaker(bind=create_engine(_dsn()))() + try: + for stmt in _SCHEMA + _DATA: + session.execute(text(stmt)) + r = ( + session.execute( + _GEO_RADIUS_PRICE_SQL, + {"lon": 60.600, "lat": 56.840, "radius_m": _GEO_PRICE_RADIUS_M}, + ) + .mappings() + .one() + ) + return {k: float(v) for k, v in r.items()} + finally: + session.rollback() + session.close() + + +def test_median_counts_own_projects_of_nearby_complexes(row) -> None: + """Лоты 90/95 тыс. («Студия Парк») и 100/110/120 тыс. («Мичуринский») → медиана 100 тыс. + + По устаревшему complex_id было бы 100 + три «Малахита» по 300 тыс. → 300 тыс.; + без сверки имени добавились бы три лота «Clever Park» по 500 тыс. → 115 тыс. + """ + assert row == {"median": 100000.0, "n": 5.0, "n_complexes": 2.0} diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index fe4b428e..e6112a29 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -161,6 +161,12 @@ tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one +# ── #3583: гео-радиусная цена участка (complex_sources → project_name) ──────── +# Тот же случай, что #2962: гоняет НАСТОЯЩИЙ parcels._GEO_RADIUS_PRICE_SQL на +# временных таблицах, нужен PostGIS. В CI идёт и пропуститься не может (CI=true +# выключает skipif). Запись только для машины без базы. +tests/api/v1/test_3583_geo_radius_price_project_bridge.py::test_median_counts_own_projects_of_nearby_complexes + # ── #2464: backfill act_date (миграция 191) ────────────────────────────────── # Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме # (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции -- 2.45.3