All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-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 2m1s
CI / backend-tests (pull_request) Successful in 15m19s
krt_lookup (×2), okn_lookup, ppt_tep_lookup, riasurt_lookup, premises_lookup, ird_analyze._krt_at, and product_scoring._poi_weight_sum (both the centroid lookup and the compute_poi_weighted_top7 call) swallowed db.execute failures without a SAVEPOINT, leaving the shared analyze/report session aborted for later queries. Wrap each swallow site in db.begin_nested(). locations.py's pass-1 infra/POI block uses a plain db.rollback() instead — its session is owned by the weekly location_refresh Celery task (own SessionLocal, verified single caller-chain), not shared, and nothing is pending at that point. Refs #2464 (cluster A session-poisoning, Wave 2).
205 lines
9.2 KiB
Python
205 lines
9.2 KiB
Python
"""КРТ-реквизиты для analyze_parcel (#1060, эпик #1067).
|
||
|
||
Связывает геометрию КРТ-площадок из ``planning_projects`` (ППТ/ПМТ, м.134) с реестром
|
||
реквизитов ``ekb_krt_sites`` (застройщик / аукционная цена / градпотенциал).
|
||
|
||
Ключ связки: номер постановления ПАГЕ из ``ekb_krt_sites.paga_decisions`` + год совпадает с
|
||
заголовком ``planning_projects.full_name``. Даёт ``confidence='exact'`` для 3 из 15 площадок
|
||
(по состоянию на 2026-06). Для остальных КРТ-геом-пересечений (opportunity_krt в ird_overlays —
|
||
это ККР, не КРТ-площадки застройки) надёжного ключа нет → ``matched=False, confidence='none'``.
|
||
|
||
Graceful: нет таблицы / пустой WKT / исключение БД → [].
|
||
Зеркалит стиль ``ird_overlay_lookup.py`` + ``planning_lookup.py``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Участок → planning_projects, которые пересекают его И имеют в full_name «ПАГЕ № N от»
|
||
# (первичный документ, не «Внесение изменений»). Geom — 4326. CAST psycopg v3.
|
||
_PLANNING_KRT_SQL = text(
|
||
"""
|
||
SELECT
|
||
pp.id AS pp_id,
|
||
pp.full_name AS pp_full_name,
|
||
pp.dmd_actual_year,
|
||
pp.doc_status_name,
|
||
k.id AS krt_id,
|
||
k.site_name,
|
||
k.developer_name,
|
||
k.contract_number,
|
||
k.auction_final_price,
|
||
k.gradpotential_sqm,
|
||
k.area_ha,
|
||
k.krt_kind
|
||
FROM planning_projects pp
|
||
-- только первичные документы (не «Внесение изменений»)
|
||
CROSS JOIN ekb_krt_sites k
|
||
-- пересечение участка с PP-геометрией
|
||
WHERE ST_Intersects(
|
||
pp.geom,
|
||
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)
|
||
)
|
||
-- матч по ПАГЕ-номеру: в full_name есть «ПАГЕ № {paga_num} от», год совпадает
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM jsonb_array_elements(k.paga_decisions) d
|
||
WHERE pp.full_name ~ ('^[[:space:]]*ПАГЕ № ' || (d->>'number') || ' от')
|
||
AND pp.dmd_actual_year::text = split_part(d->>'date', '.', 3)
|
||
AND (d->>'title' ILIKE '%планировки%' OR d->>'title' ILIKE '%межевания%')
|
||
)
|
||
ORDER BY pp.dmd_actual_year DESC, k.id
|
||
"""
|
||
)
|
||
|
||
# Вторичный запрос: planning_projects без матча с ekb_krt_sites — geom-only без реквизитов.
|
||
# Запускается только если первый не дал результатов, чтобы не путать пользователя
|
||
# двумя overlapping-источниками.
|
||
#
|
||
# Regex-топонимы для geom-only КРТ-fallback — собран как ОДИН непрерывный паттерн.
|
||
# Раньше literal был разорван переносом строки внутри ОДНОЙ пары '...' внутри SQL,
|
||
# и ветки 'Донбасской\n ' / 'Лумумбы\n ' содержали NL+8 пробелов отступа,
|
||
# никогда не матчили однострочные full_name (PG `~` — POSIX ARE без (?x)) — #1230.
|
||
# Python склеивает соседние строковые литералы на этапе парсинга, поэтому regex попадает
|
||
# в SQL одной строкой. Не SQL-injection: статическая константа, без user input.
|
||
# При добавлении новых топонимов держим строки <100 chars и НЕ ломаем regex переносом.
|
||
_KRT_TOPONYM_REGEX = (
|
||
"[кК][рР][тТ]|комплексн.*развити|Краснофлотцев|Донбасской|Шефской|Азина"
|
||
"|Черкасской|Кварцевой|Летчиков|Вилонова|Лумумбы|Студенческой|Васильева"
|
||
"|Гурзуфск|Бакинских|Шадринский|Бабушкина"
|
||
)
|
||
|
||
_PLANNING_NO_MATCH_SQL = text(
|
||
"""
|
||
SELECT
|
||
pp.id AS pp_id,
|
||
pp.full_name AS pp_full_name,
|
||
pp.dmd_actual_year,
|
||
pp.doc_status_name
|
||
FROM planning_projects pp
|
||
WHERE ST_Intersects(
|
||
pp.geom,
|
||
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)
|
||
)
|
||
AND pp.full_name ~ '"""
|
||
+ _KRT_TOPONYM_REGEX
|
||
+ """'
|
||
ORDER BY pp.dmd_actual_year DESC
|
||
"""
|
||
)
|
||
|
||
|
||
def _extract_paga_num(full_name: str) -> str | None:
|
||
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
|
||
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
||
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.
|
||
|
||
Алгоритм:
|
||
1. ST_Intersects участка с ``planning_projects`` + матч ПАГЕ-номер+год с
|
||
``ekb_krt_sites.paga_decisions`` → ``confidence='exact'``.
|
||
2. Если матчей нет, ищем ППТ/ПМТ в ``planning_projects`` с КРТ-топонимами
|
||
→ ``matched=False, confidence='none'`` (геом-факт без реквизитов).
|
||
3. Никакой ложной атрибуции: при отсутствии exact-матча — NOT передаём
|
||
developer_name/auction_final_price (остаются None).
|
||
|
||
Args:
|
||
db: сессия.
|
||
parcel_wkt: геометрия участка в WKT (EPSG:4326). None/пусто → [].
|
||
|
||
Returns:
|
||
list[dict]: каждый элемент содержит поля ниже; список пуст при graceful-деградации.
|
||
Поля: ``matched`` (bool), ``confidence`` ('exact'|'none'),
|
||
``krt_geom_ref`` (pp_id или None), ``pp_full_name`` (str),
|
||
``site_name``, ``developer_name``, ``auction_final_price``,
|
||
``gradpotential_sqm``, ``area_ha``, ``contract_number`` — None при matched=False.
|
||
"""
|
||
if not parcel_wkt:
|
||
return []
|
||
|
||
try:
|
||
with db.begin_nested():
|
||
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
|
||
return []
|
||
except Exception as exc:
|
||
logger.warning("parcel_krt_requisites: неожиданная ошибка, skip: %s", exc)
|
||
return []
|
||
|
||
if rows:
|
||
# Есть exact-матч — возвращаем с реквизитами
|
||
seen: set[tuple[int, int]] = set()
|
||
result: list[dict[str, Any]] = []
|
||
for r in rows:
|
||
key = (r["pp_id"], r["krt_id"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
result.append(
|
||
{
|
||
"matched": True,
|
||
"confidence": "exact",
|
||
"krt_geom_ref": r["pp_id"],
|
||
"pp_full_name": r["pp_full_name"],
|
||
"site_name": r["site_name"],
|
||
"developer_name": r["developer_name"],
|
||
"contract_number": r["contract_number"],
|
||
"auction_final_price": (
|
||
float(r["auction_final_price"])
|
||
if r["auction_final_price"] is not None
|
||
else None
|
||
),
|
||
"gradpotential_sqm": (
|
||
float(r["gradpotential_sqm"])
|
||
if r["gradpotential_sqm"] is not None
|
||
else None
|
||
),
|
||
"area_ha": float(r["area_ha"]) if r["area_ha"] is not None else None,
|
||
}
|
||
)
|
||
return result
|
||
|
||
# Нет exact-матча — geom-only поиск по КРТ-топонимам
|
||
try:
|
||
with db.begin_nested():
|
||
no_match_rows = (
|
||
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||
)
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc)
|
||
return []
|
||
except Exception as exc:
|
||
logger.warning("parcel_krt_requisites: no-match fallback ошибка: %s", exc)
|
||
return []
|
||
|
||
return [
|
||
{
|
||
"matched": False,
|
||
"confidence": "none",
|
||
"krt_geom_ref": r["pp_id"],
|
||
"pp_full_name": r["pp_full_name"],
|
||
"site_name": None,
|
||
"developer_name": None,
|
||
"contract_number": None,
|
||
"auction_final_price": None,
|
||
"gradpotential_sqm": None,
|
||
"area_ha": None,
|
||
}
|
||
for r in no_match_rows
|
||
]
|
||
|
||
|
||
__all__ = ["parcel_krt_requisites"]
|