Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
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"]
|