Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
123 lines
5.8 KiB
Python
123 lines
5.8 KiB
Python
"""ТЭП ППТ/ПМТ-lookup для analyze_parcel (#1136 follow-up #1133, GG-форсайт #1067).
|
||
|
||
Читает ``ekb_ppt_tep`` (м.138, наполняется ``ekb_ppt_tep_sync`` Celery-воркером #1085) и
|
||
отдаёт ТЭП-контент пояснительных записок (zone_balance / tep / phasing) для проектов
|
||
планировки/межевания, пересекающих участок.
|
||
|
||
Связка ТЭП ↔ геометрия (best-effort, как описано в м.138):
|
||
``ekb_ppt_tep.doc_ref`` ↔ ``planning_projects.source_key`` (текстовое равенство)
|
||
ИЛИ ``planning_projects.doc_full_name`` (LIKE).
|
||
|
||
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
|
||
2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
|
||
ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
|
||
в doc_ref экранируются ESCAPE '\').
|
||
|
||
Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
|
||
``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
|
||
держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
|
||
|
||
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ТЭП-контент для проектов планировки, пересекающих участок.
|
||
# Join: planning_projects (геометрия) ⋈ ekb_ppt_tep (контент) по doc_ref↔source_key/doc_full_name.
|
||
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
||
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
||
_PPT_TEP_OVERLAP_SQL = text(
|
||
r"""
|
||
SELECT
|
||
project_type,
|
||
doc_status_name,
|
||
full_name,
|
||
doc_full_name,
|
||
source_key,
|
||
doc_ref,
|
||
zone_balance,
|
||
tep,
|
||
phasing,
|
||
source_pdf,
|
||
fetched_at
|
||
FROM (
|
||
-- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
|
||
-- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
|
||
SELECT DISTINCT ON (pp.source_key)
|
||
pp.project_type,
|
||
pp.doc_status_name,
|
||
pp.full_name,
|
||
pp.doc_full_name,
|
||
pp.source_key,
|
||
pp.dmd_actual_year,
|
||
t.doc_ref,
|
||
t.zone_balance,
|
||
t.tep,
|
||
t.phasing,
|
||
t.source_pdf,
|
||
t.fetched_at
|
||
FROM planning_projects pp
|
||
JOIN ekb_ppt_tep t ON (
|
||
t.doc_ref = CAST(pp.source_key AS text)
|
||
OR (
|
||
pp.doc_full_name IS NOT NULL
|
||
AND pp.doc_full_name ILIKE
|
||
'%' || replace(replace(replace(t.doc_ref, '\', '\\'), '%', '\%'), '_', '\_')
|
||
|| '%' ESCAPE '\'
|
||
)
|
||
)
|
||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||
ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
|
||
) deduped
|
||
ORDER BY dmd_actual_year DESC NULLS LAST, project_type
|
||
"""
|
||
)
|
||
|
||
|
||
def parcel_ppt_tep(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
||
"""ТЭП-контент ППТ/ПМТ для участка (Табл.11/12/13 пояснительных записок).
|
||
|
||
Args:
|
||
db: SQLAlchemy-сессия.
|
||
parcel_wkt: геометрия участка в WKT (EPSG:4326). None / пусто → [].
|
||
|
||
Returns:
|
||
list[{project_type, doc_status_name, full_name, doc_full_name, source_key,
|
||
doc_ref, zone_balance, tep, phasing, source_pdf, fetched_at}].
|
||
Каждая запись — один ППТ/ПМТ-документ, чья геометрия пересекает участок И
|
||
для которого есть ТЭП-контент в ``ekb_ppt_tep``.
|
||
|
||
Пустой список при:
|
||
- parcel_wkt пуст / None;
|
||
- таблица ``ekb_ppt_tep`` или ``planning_projects`` ещё не задеплоена;
|
||
- нет пересечений по геометрии;
|
||
- нет матча doc_ref ↔ source_key/doc_full_name (best-effort, graceful);
|
||
- БД-ошибка (analyze не падает).
|
||
"""
|
||
if not parcel_wkt:
|
||
return []
|
||
try:
|
||
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
||
logger.warning("parcel_ppt_tep: ekb_ppt_tep / planning_projects недоступна, skip: %s", exc)
|
||
return []
|
||
except Exception as exc:
|
||
logger.warning("parcel_ppt_tep: неожиданная ошибка, skip: %s", exc)
|
||
return []
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
__all__ = ["parcel_ppt_tep"]
|