Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
132 lines
6 KiB
Python
132 lines
6 KiB
Python
"""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"]
|