fix(ptica): фильтр класса в velocity ссылался на алиас, которого нет в CTE (#2464-G) (#2865)
This commit is contained in:
parent
7de5aa0551
commit
c40261cb16
4 changed files with 97 additions and 45 deletions
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
.coverage
|
||||
# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит.
|
||||
coverage.xml
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "комфорт"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue