"""Lookup разработанной граддокументации (ППТ/ПМТ) из planning_projects (#1061). Читает ``planning_projects`` (м.134) и отдаёт для участка: тип документа, статус «разработан» и реквизиты ПАГЕ (номер + дата + предмет). Используется в ИРД-блоке analyze. Отличие от ``planning_lookup.parcel_planning_overlaps``: тот отдаёт сырые поля (future-supply обзор); этот — нормализованные поля с regex-парсингом ПАГЕ и is_active-флагом (для UI-отображения статуса разработки). Graceful: нет wkt / таблицы (OperationalError/ProgrammingError) → []. Зеркалит стиль ird_overlay_lookup.py. """ from __future__ import annotations import logging import re from datetime import date from typing import Any from sqlalchemy import text from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.orm import Session logger = logging.getLogger(__name__) # Реквизиты ПАГЕ из full_name: «ПАГЕ № 2792 от 16.12.2021\n<предмет>» _PAGA_RE = re.compile(r"ПАГЕ\s*№\s*(\d+)\s+от\s+(\d{2})\.(\d{2})\.(\d{4})", re.IGNORECASE) # ST_Intersects через GIST idx_planning_projects_geom; geom уже 4326 (WFS harvest). # Участок — WKT 4326. CAST psycopg v3 (никогда :param::type). _GRANDDOC_SQL = text( """ SELECT project_type, doc_status_name, full_name, doc_full_name FROM planning_projects WHERE ST_Intersects(geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)) ORDER BY project_type, doc_status_name """ ) def _parse_paga(full_name: str | None) -> tuple[str | None, date | None, str | None]: """Распарсить реквизиты ПАГЕ из full_name. Returns: (paga_number, paga_date, subject). Любое поле может быть None если не распарсилось. subject — текст после строки с реквизитами ПАГЕ (или None). """ if not full_name: return None, None, None m = _PAGA_RE.search(full_name) if m is None: return None, None, None paga_number = m.group(1) try: paga_date: date | None = date(int(m.group(4)), int(m.group(3)), int(m.group(2))) except ValueError: paga_date = None # Предмет — текст после первой строки с ПАГЕ-реквизитами; обрезаем незначащие символы. separators = "\n\r ,;— " after = full_name[m.end() :] for ch in separators: after = after.lstrip(ch) after = after.strip() subject: str | None = after if after else None return paga_number, paga_date, subject def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]: """Разработанная граддокументация (ППТ/ПМТ) на участок из planning_projects. Args: db: сессия SQLAlchemy. parcel_wkt: геометрия участка в WKT (EPSG:4326). None → пустой список. Returns: list[{project_type, doc_status_name, is_active, paga_number, paga_date, subject, doc_full_name}]. - ``is_active``: doc_status_name == 'действующий' (регистронезависимо). - ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось. - ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет. Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми). Пустой список при отсутствии участка / таблицы / пересечений (analyze не падает). """ if not parcel_wkt: return [] try: rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() except (OperationalError, ProgrammingError) as exc: logger.warning("parcel_granddoc: planning_projects недоступна, skip: %s", exc) return [] result: list[dict[str, Any]] = [] for r in rows: full_name: str | None = r["full_name"] paga_number, paga_date, subject = _parse_paga(full_name) status_name: str | None = r["doc_status_name"] # Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации, # DDL/COMMENT документируют хранимое значение как lowercase ('действующий'). is_active = (status_name or "").strip().lower() == "действующий" result.append( { "project_type": r["project_type"], "doc_status_name": r["doc_status_name"], "is_active": is_active, "paga_number": paga_number, "paga_date": paga_date, "subject": subject, "doc_full_name": r["doc_full_name"], } ) # Сначала действующие (is_active=True), внутри группы — по paga_date desc (свежие первыми). # None-дата уходит в конец (None < date → sentinel через _neg_date). result.sort( key=lambda d: (not d["is_active"], d["paga_date"] is None, _neg_date(d["paga_date"])) ) return result def _neg_date(d: date | None) -> tuple[int, int, int]: """Ключ для обратной сортировки по дате (None → максимум = уходит в хвост).""" if d is None: return (0, 0, 0) # Отрицание для desc: чем больше дата — тем меньше кортеж → выше в сортировке. return (-d.year, -d.month, -d.day) __all__ = ["parcel_granddoc"]