Merge pull request 'ПТИЦА: гео-радиусная цена участка считается по лотам проектов ближних ЖК, а не по чужим лотам под устаревшим complex_id (#3583)' (#3591) from fix/3583-radius-price-complex into main
All checks were successful
Deploy / changes (push) Successful in 12s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m38s
Deploy / build-worker (push) Successful in 3m50s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m43s

This commit is contained in:
bot-backend 2026-09-17 13:32:55 +00:00
commit 98f587add6
3 changed files with 199 additions and 55 deletions

View file

@ -166,6 +166,74 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
_GEO_PRICE_MIN_LOTS: int = 10 _GEO_PRICE_MIN_LOTS: int = 10
_GEO_PRICE_MIN_COMPLEXES: int = 2 _GEO_PRICE_MIN_COMPLEXES: int = 2
_GEO_RADIUS_PRICE_SQL = text("""
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
-- (мульти lot_id на физлот) n/вес медианы/гейт n10
-- были по пере-листингам. Дедупим 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 # #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще # (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
# могла служить последним fallback'ом для карточки district.median_price_per_m2. # могла служить последним fallback'ом для карточки district.median_price_per_m2.
@ -3562,7 +3630,7 @@ def analyze_parcel(
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference: # радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё # только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
# проваливались в class_norm. У objective_lots нет geom — коорды берём через # проваливались в 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] geo_radius_price: dict[str, Any]
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная # Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную # медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
@ -3573,60 +3641,7 @@ def analyze_parcel(
with db.begin_nested(): with db.begin_nested():
grp_row = ( grp_row = (
db.execute( db.execute(
text(""" _GEO_RADIUS_PRICE_SQL,
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
-- (мульти lot_id на физлот) n/вес медианы/гейт n10
-- были по пере-листингам. Дедупим 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
"""),
{ {
"lon": centroid_lon, "lon": centroid_lon,
"lat": centroid_lat, "lat": centroid_lat,

View file

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

View file

@ -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_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 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) ────────────────────────────────── # ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме # Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции # (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции