feat(sf): граддокументация (статус+ПАГЕ-реквизиты) из planning_projects в analyze (#1061)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m20s
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m20s
This commit is contained in:
parent
7af87c338c
commit
015f63583a
4 changed files with 369 additions and 0 deletions
129
backend/app/services/site_finder/granddoc_lookup.py
Normal file
129
backend/app/services/site_finder/granddoc_lookup.py
Normal 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"]
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
181
backend/tests/services/test_granddoc_lookup.py
Normal file
181
backend/tests/services/test_granddoc_lookup.py
Normal 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 == []
|
||||
|
|
@ -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"] == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue