"""ТЭП ППТ/ПМТ-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"]