fix(ptica): фильтр класса в velocity ссылался на алиас, которого нет в CTE (#2464-G) #2865

Merged
bot-backend merged 4 commits from fix/2464-velocity-class-filter into main 2026-08-13 17:46:29 +00:00
4 changed files with 97 additions and 45 deletions

2
backend/.gitignore vendored
View file

@ -1 +1,3 @@
.coverage
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
coverage.xml

View file

@ -36,6 +36,48 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы
# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`.
# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`,
# которого внутри CTE нет (`missing FROM-clause entry for table "o"`).
_COMPETITORS_SQL_TMPL = """
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median).
# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на
# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка:
@ -173,9 +215,17 @@ def compute_velocity(
# только если явно передан. #38: при NULL реального класса используем
# obj_class_fallback (yandex_match / price_inference) — реальный obj_class
# в приоритете (COALESCE), поведение для размеченных ЖК не меняется.
class_filter = (
"AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else ""
)
# Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM —
# голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT,
# и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"`
# (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity
# молча выпадал из отчёта. Не срабатывало только потому, что единственный
# вызывающий (parcels.py) obj_class не передаёт.
# NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а
# в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870,
# «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9,
# «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки.
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else ""
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
@ -183,45 +233,7 @@ def compute_velocity(
with db.begin_nested():
comp_rows = (
db.execute(
text(
f"""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
-- #38: эффективный класс — реальный, иначе fallback
COALESCE(obj_class, obj_class_fallback) AS obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
),
text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)),
{
"parcel_wkt": parcel_geom_wkt,
"radius_m": radius_km * 1000.0,

View file

@ -39,6 +39,7 @@ from sqlalchemy.orm import Session
from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL
from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL
from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL
from tests.integration.conftest import requires_test_db
# NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time
@ -103,9 +104,9 @@ class TestNeighborsSummarySql:
for kw in forbidden_aliases:
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
# формы CTE-биндинга.
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
)
assert (
f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql
), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern:
f"{name} содержит psycopg v3 antipattern: {matches}. "
f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md."
)
# ── velocity: конкуренты в радиусе (#2464-G) ─────────────────────────────────
class TestVelocityCompetitorsSql:
"""``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``.
Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G
не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который
существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE
``latest_obj`` (FROM domrf_kn_objects, без алиаса)
``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08).
Почему это не падало в проде: единственный вызывающий
(``analyze_parcel``) ``obj_class`` не передаёт ветка мёртвая.
Падало бы молча исключение глотает ``except`` в ``compute_velocity``,
и блок velocity просто исчезал бы из отчёта с одной строкой в логе.
Тест закрывает обе ветки, а не только ту, что сегодня исполняется.
"""
@requires_test_db
@pytest.mark.integration
@pytest.mark.parametrize(
"class_filter",
["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"],
ids=["no_class_filter", "with_class_filter"],
)
def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None:
"""Обе подстановки шаблона парсятся и планируются против реальной схемы."""
_explain_text(
phantom_check_session,
_COMPETITORS_SQL_TMPL.format(class_filter=class_filter),
{"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"},
)

View file

@ -34,6 +34,7 @@ tests/test_layout_tz_pdf.py
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query