gendesign/backend/app/services/site_finder/granddoc_lookup.py
bot-backend 7cde3b8eaa
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m31s
CI / backend-tests (pull_request) Successful in 17m27s
fix(ptica): шесть lookup'ов /analyze глотали ошибку БД без SAVEPOINT (#2464)
Шесть функций в site_finder работают на ОБЩЕЙ с analyze_parcel сессии и
глотают (OperationalError, ProgrammingError) вокруг db.execute. Без SAVEPOINT
проглоченная ошибка оставляет транзакцию в aborted-состоянии: сам lookup
возвращает пустой результат «штатно», а падает следующий за ним запрос —
в другом месте отчёта и с другой причиной в логе.

Соседний ppt_tep_lookup — та же семья, та же таблица planning_projects —
SAVEPOINT имел. Правка сводит шесть отставших к нему:

  planning_lookup, granddoc_lookup, ird_overlay_lookup,
  functional_zone_lookup, reservation_lookup  — по одному db.execute
  zone_regulation                             — upsert был защищён, SELECT нет

Тест параметризован по всем шести именам, плюс два контроля: «без SAVEPOINT
сессия травится» и «здоровый путь по-прежнему читает строки». На origin/main
шесть падают с AbortedTransactionError, оба контроля зелёные.

Стабы _FakeDB/_DB в пяти существующих тестах получили no-op begin_nested —
без него 28 тестов падали на AttributeError. В шапке каждого стаба указано,
где SAVEPOINT проверяется по-настоящему, чтобы no-op не читался как покрытие.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:18:59 +05:00

138 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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:
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
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"]