feat(sf): граддокументация (статус+ПАГЕ-реквизиты) из planning_projects в analyze (#1061) #1154

Merged
bot-reviewer merged 1 commit from feat/granddoc-status-lookup into main 2026-06-07 15:15:04 +00:00
4 changed files with 369 additions and 0 deletions

View file

@ -0,0 +1,129 @@
"""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)
is_active = r["doc_status_name"] == "Действующий"
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"]

View file

@ -32,6 +32,7 @@ from sqlalchemy.orm import Session
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
from app.services.site_finder.functional_zone_lookup import parcel_functional_zones
from app.services.site_finder.granddoc_lookup import parcel_granddoc
from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps
from app.services.site_finder.krt_lookup import parcel_krt_requisites
from app.services.site_finder.okn_lookup import parcel_okn_objects
@ -133,6 +134,10 @@ def build_ird_analyze_block(
``zone_regulation`` (dict|None),
``okn_objects`` (list, ОКН в радиусе 100 м от участка из okn_objects, #1066;
[{source_id, category, distance_m}], пусто при отсутствии объектов/таблицы).
``granddoc`` разработанная граддокументация (ППТ/ПМТ) на участок: статус + реквизиты
ПАГЕ (#1061); [{project_type, doc_status_name, is_active, paga_number, paga_date,
subject, doc_full_name}]. Правообладатель/срок по КРТ-территориям отдаётся отдельно
в ``krt_requisites`` (#1131).
Любой источник деградирует независимо блок всегда well-formed.
"""
overlay = parcel_ird_overlaps(db, geom_wkt)
@ -144,6 +149,10 @@ def build_ird_analyze_block(
"opportunity_overlaps": opportunity,
"planning_projects": parcel_planning_overlaps(db, geom_wkt),
"reservations": parcel_reservations(db, cad_num),
# Граддокументация (#1061): status «разработана» + реквизиты ПАГЕ из planning_projects.
# DB, дёшево (ST_Intersects GIST), graceful. Вместе с planning_projects — один источник.
# Правообладатель/срок КРТ-территорий → krt_requisites (#1131), не сюда.
"granddoc": parcel_granddoc(db, geom_wkt),
# ОКН-объекты (#1066): ST_DWithin к okn_objects (428 строк, GIST), дёшево, graceful.
# Идёт рядом с reservations — DB-источники основного потока, без сетевых зависимостей.
"okn_objects": parcel_okn_objects(db, geom_wkt),

View file

@ -0,0 +1,181 @@
"""Тесты granddoc_lookup (#1061) — read-side lookup граддокументации из planning_projects."""
from __future__ import annotations
from datetime import date
from typing import Any
from sqlalchemy.exc import OperationalError, ProgrammingError
from app.services.site_finder.granddoc_lookup import parcel_granddoc
# ── Моки БД (аналог test_reservation_lookup.py) ────────────────────────────
class _Result:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _Result:
return self
def all(self) -> list[dict[str, Any]]:
return self._rows
class _FakeDB:
def __init__(
self,
rows: list[dict[str, Any]] | None = None,
raise_exc: Exception | None = None,
) -> None:
self._rows = rows or []
self._raise = raise_exc
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
if self._raise is not None:
raise self._raise
return _Result(self._rows)
# ── Фикстура строк ────────────────────────────────────────────────────────────
_ROW_PAGA = {
"project_type": "ppt",
"doc_status_name": "Действующий",
"full_name": "ПАГЕ № 2792 от 16.12.2021\nРеконструкция ул. Декабристов",
"doc_full_name": "Проект планировки территории ул. Декабристов",
}
_ROW_NO_PAGA = {
"project_type": "pmt",
"doc_status_name": "Недействующий",
"full_name": "Проект межевания без реквизитов ПАГЕ",
"doc_full_name": "ПМТ квартал 24",
}
_ROW_INACTIVE = {
"project_type": "ppt",
"doc_status_name": "Недействующий",
"full_name": "ПАГЕ № 111 от 01.03.2015\nПрежний проект",
"doc_full_name": "Старый ППТ",
}
_ROW_ACTIVE_NEWER = {
"project_type": "ppt",
"doc_status_name": "Действующий",
"full_name": "ПАГЕ № 3500 от 20.06.2023\nНовый квартал",
"doc_full_name": "ППТ 2023",
}
# ── Тесты: парсинг ПАГЕ ───────────────────────────────────────────────────────
def test_paga_number_and_date_parsed() -> None:
"""full_name с «ПАГЕ № 2792 от 16.12.2021» → paga_number='2792', paga_date=date(2021,12,16)."""
result = parcel_granddoc(_FakeDB([_ROW_PAGA]), "POINT(60 56)")
assert len(result) == 1
row = result[0]
assert row["paga_number"] == "2792"
assert row["paga_date"] == date(2021, 12, 16)
def test_paga_subject_extracted() -> None:
"""Предмет (текст после реквизитов ПАГЕ) извлекается как subject."""
result = parcel_granddoc(_FakeDB([_ROW_PAGA]), "POINT(60 56)")
assert result[0]["subject"] == "Реконструкция ул. Декабристов"
def test_is_active_true_for_dejstvuyushiy() -> None:
"""doc_status_name='Действующий' → is_active=True."""
result = parcel_granddoc(_FakeDB([_ROW_PAGA]), "POINT(60 56)")
assert result[0]["is_active"] is True
def test_is_active_false_for_nedejstvuyushiy() -> None:
"""doc_status_name='Недействующий' → is_active=False."""
result = parcel_granddoc(_FakeDB([_ROW_INACTIVE]), "POINT(60 56)")
assert result[0]["is_active"] is False
def test_no_paga_in_full_name_returns_none_graceful() -> None:
"""full_name без «ПАГЕ № …» → paga_number=None, paga_date=None, subject=None (graceful)."""
result = parcel_granddoc(_FakeDB([_ROW_NO_PAGA]), "POINT(60 56)")
assert len(result) == 1
row = result[0]
assert row["paga_number"] is None
assert row["paga_date"] is None
assert row["subject"] is None
def test_all_expected_keys_present() -> None:
"""Каждый элемент содержит все ожидаемые ключи."""
result = parcel_granddoc(_FakeDB([_ROW_PAGA, _ROW_NO_PAGA]), "POINT(60 56)")
expected = {
"project_type",
"doc_status_name",
"is_active",
"paga_number",
"paga_date",
"subject",
"doc_full_name",
}
for item in result:
assert set(item.keys()) == expected
# ── Тесты: сортировка ────────────────────────────────────────────────────────
def test_active_first_sorting() -> None:
"""Действующие документы идут первыми независимо от порядка в БД."""
rows = [_ROW_INACTIVE, _ROW_PAGA] # inactive первый в БД
result = parcel_granddoc(_FakeDB(rows), "POINT(60 56)")
assert result[0]["is_active"] is True
assert result[1]["is_active"] is False
def test_active_sorted_by_date_desc() -> None:
"""Среди действующих: более новая дата идёт раньше (desc)."""
rows = [_ROW_PAGA, _ROW_ACTIVE_NEWER] # 2021 первый в БД, 2023 второй
result = parcel_granddoc(_FakeDB(rows), "POINT(60 56)")
# Оба active; 2023 > 2021 → 2023 должен быть первым
active_results = [r for r in result if r["is_active"]]
assert len(active_results) == 2
assert active_results[0]["paga_date"] == date(2023, 6, 20)
assert active_results[1]["paga_date"] == date(2021, 12, 16)
# ── Тесты: graceful ──────────────────────────────────────────────────────────
def test_empty_when_parcel_wkt_none() -> None:
"""parcel_wkt=None → пустой список, БД не вызывается."""
result = parcel_granddoc(_FakeDB([_ROW_PAGA]), None)
assert result == []
def test_empty_when_parcel_wkt_empty_string() -> None:
"""parcel_wkt='' → пустой список (falsy guard)."""
result = parcel_granddoc(_FakeDB([_ROW_PAGA]), "")
assert result == []
def test_empty_when_no_rows() -> None:
"""Нет пересечений в БД → пустой список."""
result = parcel_granddoc(_FakeDB([]), "POINT(60 56)")
assert result == []
def test_graceful_on_operational_error() -> None:
"""OperationalError (таблица не задеплоена) → [] без падения."""
db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("no such table")))
result = parcel_granddoc(db, "POINT(60 56)")
assert result == []
def test_graceful_on_programming_error() -> None:
"""ProgrammingError → [] без падения."""
db = _FakeDB(raise_exc=ProgrammingError("stmt", {}, Exception("column not found")))
result = parcel_granddoc(db, "POINT(60 56)")
assert result == []

View file

@ -71,6 +71,8 @@ def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
monkeypatch.setattr(ird_analyze, "parcel_functional_zones", lambda db, wkt: [])
# По умолчанию: пустой список — okn_objects=[] (graceful, таблица может не быть задеплоена).
monkeypatch.setattr(ird_analyze, "parcel_okn_objects", lambda db, wkt: [])
# По умолчанию: пустой список — granddoc=[] (graceful, см. #1061).
monkeypatch.setattr(ird_analyze, "parcel_granddoc", lambda db, wkt: [])
def test_full_block_assembly(monkeypatch: Any) -> None:
@ -409,3 +411,51 @@ def test_okn_objects_empty_when_none(monkeypatch: Any) -> None:
assert "okn_objects" in block
assert block["okn_objects"] == []
# ---------------------------------------------------------------------------
# Граддокументация (#1061)
# ---------------------------------------------------------------------------
_GRANDDOC_ROWS: list[dict[str, Any]] = [
{
"project_type": "ppt",
"doc_status_name": "Действующий",
"is_active": True,
"paga_number": "2792",
"paga_date": "2021-12-16",
"subject": "Реконструкция ул. Декабристов",
"doc_full_name": "ППТ ул. Декабристов",
}
]
def test_granddoc_wired_to_block(monkeypatch: Any) -> None:
"""parcel_granddoc возвращает запись → ключ granddoc присутствует в block."""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(ird_analyze, "parcel_granddoc", lambda db, wkt: list(_GRANDDOC_ROWS))
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
assert "granddoc" in block
assert len(block["granddoc"]) == 1
row = block["granddoc"][0]
assert row["paga_number"] == "2792"
assert row["is_active"] is True
def test_granddoc_empty_when_no_overlap(monkeypatch: Any) -> None:
"""parcel_granddoc пуст → granddoc == [] (ключ присутствует)."""
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
monkeypatch.setattr(ird_analyze, "parcel_granddoc", lambda db, wkt: [])
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
client = _FakeClient(fz=[])
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)
assert "granddoc" in block
assert block["granddoc"] == []