diff --git a/backend/app/services/site_finder/granddoc_lookup.py b/backend/app/services/site_finder/granddoc_lookup.py new file mode 100644 index 00000000..e91c3f5d --- /dev/null +++ b/backend/app/services/site_finder/granddoc_lookup.py @@ -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"] diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py index a0d5c2ce..ada78730 100644 --- a/backend/app/services/site_finder/ird_analyze.py +++ b/backend/app/services/site_finder/ird_analyze.py @@ -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), diff --git a/backend/tests/services/test_granddoc_lookup.py b/backend/tests/services/test_granddoc_lookup.py new file mode 100644 index 00000000..9eda6beb --- /dev/null +++ b/backend/tests/services/test_granddoc_lookup.py @@ -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 == [] diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py index fd065b40..54ed2180 100644 --- a/backend/tests/services/test_ird_analyze.py +++ b/backend/tests/services/test_ird_analyze.py @@ -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"] == []