Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
CI / backend-tests (push) Failing after 8m36s
CI / backend-tests (pull_request) Failing after 8m36s
Имплементация фиксов 2-го аудита backend/app/** (после merge #1543). Воркер на файл, точечные правки. Верификация: py_compile 58/58 .py. Полностью исправлено (82). Оставлены открытыми (13): partial/needs-cross-file/needs-leha — #1569, #1590, #1593, #1606, #1609, #1617, #1633, #1635, #1637, #1638, #1640, #1642, #1650. Closes #1560 Closes #1561 Closes #1562 Closes #1563 Closes #1564 Closes #1565 Closes #1566 Closes #1567 Closes #1570 Closes #1571 Closes #1572 Closes #1573 Closes #1574 Closes #1576 Closes #1577 Closes #1578 Closes #1579 Closes #1580 Closes #1581 Closes #1582 Closes #1583 Closes #1584 Closes #1585 Closes #1586 Closes #1587 Closes #1588 Closes #1589 Closes #1591 Closes #1592 Closes #1594 Closes #1595 Closes #1596 Closes #1597 Closes #1598 Closes #1599 Closes #1600 Closes #1601 Closes #1602 Closes #1603 Closes #1604 Closes #1605 Closes #1607 Closes #1608 Closes #1610 Closes #1611 Closes #1612 Closes #1613 Closes #1614 Closes #1615 Closes #1616 Closes #1618 Closes #1619 Closes #1620 Closes #1621 Closes #1622 Closes #1623 Closes #1624 Closes #1625 Closes #1626 Closes #1627 Closes #1628 Closes #1629 Closes #1630 Closes #1631 Closes #1632 Closes #1634 Closes #1636 Closes #1639 Closes #1641 Closes #1643 Closes #1644 Closes #1645 Closes #1646 Closes #1647 Closes #1648 Closes #1649 Closes #1651 Closes #1652 Closes #1653 Closes #1654 Closes #1655 Closes #1656
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"]
|