fix(parcels): гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не чужие под устаревшим complex_id (#3583)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m48s
CI / backend-tests (pull_request) Successful in 7m42s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m48s
CI / backend-tests (pull_request) Successful in 7m42s
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 <noreply@anthropic.com>
This commit is contained in:
parent
a1c7f8ea92
commit
7c52622a76
3 changed files with 199 additions and 55 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue