feat(sf): ПАГЕ-парсер изъятия/резервирования → land_reservation (#1091, #1062)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Successful in 5m56s
CI / backend-tests (pull_request) Successful in 5m57s

This commit is contained in:
bot-backend 2026-06-07 12:58:57 +03:00
parent 3423a1db74
commit b58f399d2d
9 changed files with 926 additions and 0 deletions

View file

@ -0,0 +1,252 @@
"""Парсер постановлений об изъятии/резервировании ЗУ из PDF/текста (#1091, #1062, #1067).
Источник PDF-постановления Правительства Свердловской области (pravo.gov66.ru / ПАГЕ).
Геометрии зон изъятия/резервирования нет ни в одном spatial-источнике; перечень ЗУ существует
ТОЛЬКО текстом. Этот модуль чистая функция от bytes/text: извлекает кад-номера, тип
(изъятие/резервирование), реквизиты акта, цель. Без сети; тестируемо без PDF-файлов.
Seed-документ: 509-ПП (Правительство СО, изъятие под реконструкцию ул. Татищева, ЕКБ).
Зависимость pdfplumber: добавлена в backend/pyproject.toml. Если недоступна модуль падает
с ImportError на уровне extract_from_pdf (parse-функции работают без неё).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
# ── Константы / регулярки ──────────────────────────────────────────────────────
# Полный кадастровый номер ЗУ: 66:41:1234567:12 (6 или 7 цифр в квартале, любой номер ЗУ).
_RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
_RE_ACT_NUMBER = re.compile(
r"\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
re.UNICODE,
)
# Дата акта: «от 12.03.2024» (приоритетно) или «12 марта 2024 г
_RE_DATE_DMY = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
_RE_DATE_WORD = re.compile(
r"(\d{1,2})\s+"
r"(январ[яь]|феврал[яь]|март[аа]?|апрел[яь]|ма[йя]|июн[яь]|"
r"июл[яь]|август[аа]?|сентябр[яь]|октябр[яь]|ноябр[яь]|декабр[яь])"
r"\s+(\d{4})",
re.UNICODE | re.IGNORECASE,
)
_MONTH_MAP: dict[str, int] = {
"январ": 1,
"феврал": 2,
"март": 3,
"апрел": 4,
"ма": 5,
"июн": 6,
"июл": 7,
"август": 8,
"сентябр": 9,
"октябр": 10,
"ноябр": 11,
"декабр": 12,
}
# Слова-маркеры типа документа.
_RE_REZERV = re.compile(r"резервир", re.IGNORECASE | re.UNICODE)
_RE_IZYAT = re.compile(r"изъят", re.IGNORECASE | re.UNICODE)
# Маркеры цели: «для ...», «в целях ...», «под ...» — best-effort.
_RE_PURPOSE = re.compile(
r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})",
re.IGNORECASE | re.UNICODE,
)
_EXCERPT_RADIUS = 120 # символов в каждую сторону от первого кад-номера
# ── Dataclass результата ───────────────────────────────────────────────────────
@dataclass(frozen=True)
class ReservationRecord:
"""Одна запись кад-номера ЗУ, извлечённая из постановления."""
cad_num: str
reservation_kind: str # 'изъятие' | 'резервирование'
act_number: str | None
act_date: date | None
purpose: str | None
raw_excerpt: str | None
# ── Вспомогательные функции ────────────────────────────────────────────────────
def _parse_act_number(text: str) -> str | None:
"""Извлекает номер постановления из текста (первое вхождение)."""
m = _RE_ACT_NUMBER.search(text)
if not m:
return None
# Нормализуем пробелы внутри номера.
return re.sub(r"\s+", "", m.group(1))
def _parse_act_date(text: str) -> date | None:
"""Извлекает дату акта: сначала «от DD.MM.YYYY», потом «DD <месяц-словом> YYYY»."""
m = _RE_DATE_DMY.search(text)
if m:
try:
return date(int(m.group(3)), int(m.group(2)), int(m.group(1)))
except ValueError:
logger.debug("_parse_act_date: некорректная числовая дата %s", m.group(0))
m2 = _RE_DATE_WORD.search(text)
if m2:
day_s, month_s, year_s = m2.group(1), m2.group(2).lower(), m2.group(3)
month_num = next(
(v for k, v in _MONTH_MAP.items() if month_s.startswith(k)),
None,
)
if month_num is not None:
try:
return date(int(year_s), month_num, int(day_s))
except ValueError:
logger.debug("_parse_act_date: некорректная словесная дата %s", m2.group(0))
return None
def _detect_kind(text: str, default_kind: str) -> str:
"""Определяет тип операции: 'резервирование' | 'изъятие' | default_kind."""
if _RE_REZERV.search(text):
return "резервирование"
if _RE_IZYAT.search(text):
return "изъятие"
return default_kind
def _extract_purpose(text: str) -> str | None:
"""Извлекает цель изъятия/резервирования (best-effort)."""
m = _RE_PURPOSE.search(text)
if m:
# Обрезаем хвост и нормализуем пробелы.
return re.sub(r"\s+", " ", m.group(1)).strip().rstrip(".,;")
return None
def _build_excerpt(text: str, pos: int) -> str:
"""±120 символов вокруг позиции первого кад-номера."""
start = max(0, pos - _EXCERPT_RADIUS)
end = min(len(text), pos + _EXCERPT_RADIUS)
return text[start:end]
# ── Основные публичные функции ─────────────────────────────────────────────────
def extract_reservations(
text: str,
*,
default_kind: str = "изъятие",
) -> list[ReservationRecord]:
"""Извлекает записи ЗУ из текста постановления об изъятии/резервировании.
Args:
text: полный текст постановления (конкатенация всех страниц PDF или plain-text).
default_kind: тип по умолчанию, если ни «изъят», ни «резервир» не встретились.
Returns:
Список ReservationRecord, дедуплицированный по cad_num внутри документа.
Пустой список если кад-номеров нет.
"""
if not text or not text.strip():
return []
kind = _detect_kind(text, default_kind)
act_number = _parse_act_number(text)
act_date = _parse_act_date(text)
purpose = _extract_purpose(text)
seen: set[str] = set()
records: list[ReservationRecord] = []
first_pos: int | None = None
for m in _RE_CAD_NUM.finditer(text):
cad = m.group(1)
if cad in seen:
continue
seen.add(cad)
if first_pos is None:
first_pos = m.start()
records.append(
ReservationRecord(
cad_num=cad,
reservation_kind=kind,
act_number=act_number,
act_date=act_date,
purpose=purpose,
raw_excerpt=_build_excerpt(text, m.start()) if first_pos == m.start() else None,
)
)
# Ставим raw_excerpt только первой записи (выше он уже записан через first_pos == m.start()).
# Для остальных строк оставляем None (достаточно для верификации).
logger.debug(
"extract_reservations: kind=%s act=%s date=%s cad_count=%d",
kind,
act_number,
act_date,
len(records),
)
return records
def extract_from_pdf(pdf_bytes: bytes) -> list[ReservationRecord]:
"""Извлекает записи ЗУ из байт PDF-постановления.
Args:
pdf_bytes: сырые байты PDF-файла.
Returns:
Список ReservationRecord. Пустой список при пустом/нечитаемом PDF.
Raises:
ImportError: если pdfplumber не установлен (добавить в backend/pyproject.toml).
"""
try:
import pdfplumber # type: ignore[import-untyped]
except ImportError as exc:
raise ImportError(
"pdfplumber не установлен. Добавь 'pdfplumber>=0.10.0' в backend/pyproject.toml "
"и выполни 'uv lock'."
) from exc
import io
text_parts: list[str] = []
try:
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
for page in pdf.pages:
page_text = page.extract_text() or ""
text_parts.append(page_text)
except Exception as exc:
logger.warning("extract_from_pdf: не удалось прочитать PDF: %s", exc)
return []
full_text = "\n".join(text_parts)
return extract_reservations(full_text)
__all__ = ["ReservationRecord", "extract_from_pdf", "extract_reservations"]

View file

@ -0,0 +1,67 @@
"""Lookup ЗУ под изъятие/резервирование из ``land_reservation`` (#1091, #1062, #1067).
Читает land_reservation (м.136, наполняется reservation_ingest task) и возвращает
актуальные постановления по кад-номеру. Геометрия не хранится join к cad_parcels.geom
делается на уровне analyze (follow-up после merge #1115).
Graceful: нет cad_num / таблица ещё не задеплоена (OperationalError/ProgrammingError) [].
Зеркалит стиль ird_overlay_lookup.py.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_LOOKUP_SQL = text(
"""
SELECT reservation_kind, basis_act, act_number, act_date, purpose, doc_url
FROM land_reservation
WHERE cad_num = CAST(:cad_num AS text)
AND is_active
ORDER BY act_date DESC NULLS LAST, id DESC
"""
)
def parcel_reservations(db: Session, cad_num: str | None) -> list[dict[str, object]]:
"""Возвращает актуальные постановления об изъятии/резервировании по кад-номеру.
Args:
db: сессия SQLAlchemy.
cad_num: кадастровый номер ЗУ (например, «66:41:0101001:123»).
None пустой список.
Returns:
Список словарей: [{reservation_kind, basis_act, act_number, act_date,
purpose, doc_url}]. Пустой список если cad_num=None / нет записей /
таблица не задеплоена.
"""
if not cad_num:
return []
try:
rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_reservations: land_reservation недоступна, skip: %s", exc)
return []
return [
{
"reservation_kind": r["reservation_kind"],
"basis_act": r["basis_act"],
"act_number": r["act_number"],
"act_date": r["act_date"],
"purpose": r["purpose"],
"doc_url": r["doc_url"],
}
for r in rows
]
__all__ = ["parcel_reservations"]

View file

@ -67,6 +67,7 @@ celery_app = Celery(
"app.workers.tasks.opportunity_harvest",
"app.workers.tasks.planning_harvest",
"app.workers.tasks.zone_regulation_refresh",
"app.workers.tasks.reservation_ingest",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,188 @@
"""Celery task: ingest постановлений об изъятии/резервировании ЗУ → ``land_reservation`` (#1091).
#1062 (ИРД-5), эпик #1067 (GG-форсайт). Загружает PDF-постановления (pravo.gov66.ru / ПАГЕ),
извлекает кад-номера через page_reservation_parser, UPSERT-ит в land_reservation (м.136).
Геометрии зон здесь НЕТ join к cad_parcels.geom на read-side (reservation_lookup.py).
Beat НЕ настроен: постановления выходят редко, запуск ручной/по требованию.
celery call tasks.reservation_ingest.ingest_reservations
Conventions (зеркало ird_harvest.py / backend.md):
SessionLocal() + try/finally close.
SAVEPOINT per-row (``with db.begin_nested():``) битая строка не валит батч.
CAST(:x AS text) НИКОГДА :x::text (psycopg v3).
ON CONFLICT (cad_num, act_number) обновляет метаданные + fetched_at.
httpx.Client (не requests). Сбой одного дока не валит прогон.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.scrapers.page_reservation_parser import extract_from_pdf
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── Seed-документы ─────────────────────────────────────────────────────────────
# Единственный известный seed (2026-06): Постановление Правительства Свердловской обл.
# № 509-ПП об изъятии ЗУ под реконструкцию ул. Татищева, ЕКБ.
# VERIFY URL: точный PDF-URL нужно уточнить на pravo.gov66.ru — документ может лежать под
# разными путями (поиск по «509-ПП» или номеру в реестре). Placeholder установлен намеренно.
_SEED_DOCS: list[dict[str, Any]] = [
{
"url": "https://pravo.gov66.ru/documents/view/509-pp", # VERIFY URL — placeholder
"kind": "изъятие",
"act_number": "509-ПП",
"basis_act": "Постановление Правительства Свердловской области № 509-ПП",
"purpose": "реконструкция улицы Татищева, г. Екатеринбург",
},
]
# UPSERT: ON CONFLICT (cad_num, act_number) → обновляем все поля кроме id.
# CAST(:x AS type) — psycopg v3. DO NOTHING fallback если act_number IS NULL (NULL != NULL → нет
# конфликта, вставляется дубль — acceptable для NULL-act_number edge-case).
_UPSERT_SQL = text(
"""
INSERT INTO land_reservation (
cad_num, reservation_kind, basis_act, act_number, act_date,
purpose, doc_url, source, is_active, raw_excerpt, fetched_at
) VALUES (
CAST(:cad_num AS text),
CAST(:reservation_kind AS text),
CAST(:basis_act AS text),
CAST(:act_number AS text),
CAST(:act_date AS date),
CAST(:purpose AS text),
CAST(:doc_url AS text),
CAST(:source AS text),
true,
CAST(:raw_excerpt AS text),
now()
)
ON CONFLICT (cad_num, act_number) DO UPDATE SET
reservation_kind = EXCLUDED.reservation_kind,
basis_act = EXCLUDED.basis_act,
act_date = EXCLUDED.act_date,
purpose = EXCLUDED.purpose,
doc_url = EXCLUDED.doc_url,
source = EXCLUDED.source,
raw_excerpt = EXCLUDED.raw_excerpt,
fetched_at = now()
"""
)
def _fetch_pdf(url: str) -> bytes | None:
"""Загружает PDF по URL. Возвращает None при ошибке (сбой одного дока не валит прогон).
Примечание по TLS: pravo.gov66.ru использует российский УЦ, verify=False. В prod-окружении
следует настроить доверенный CA-bundle (аналогично ekb_geoportal_client.py).
"""
try:
with httpx.Client(timeout=60.0, verify=False, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
except Exception as exc:
logger.error("_fetch_pdf: не удалось загрузить %s: %s", url, exc)
return None
def _upsert_records(db: Session, records_meta: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число обработанных строк."""
count = 0
for row in records_meta:
try:
with db.begin_nested(): # SAVEPOINT per-row
db.execute(_UPSERT_SQL, row)
count += 1
except Exception as exc:
logger.warning(
"_upsert_records: пропуск cad_num=%s: %s",
row.get("cad_num"),
exc,
)
return count
@celery_app.task(name="tasks.reservation_ingest.ingest_reservations")
def ingest_reservations(docs: list[dict[str, Any]] | None = None) -> dict[str, int]:
"""Загружает и ингестирует постановления об изъятии/резервировании ЗУ.
Args:
docs: список документов для обработки. Каждый словарь должен содержать поля:
url (str), kind (str), act_number (str), basis_act (str), purpose (str | None).
Если None используется _SEED_DOCS.
Returns:
{'docs': N, 'parcels': M} количество обработанных документов и вставленных/
обновлённых записей ЗУ.
"""
target_docs = docs if docs is not None else _SEED_DOCS
total_parcels = 0
db: Session = SessionLocal()
try:
for doc in target_docs:
url = doc.get("url", "")
act_number = doc.get("act_number")
basis_act = doc.get("basis_act", "")
doc_purpose = doc.get("purpose")
doc_kind = doc.get("kind", "изъятие")
logger.info("ingest_reservations: загрузка документа url=%s act=%s", url, act_number)
pdf_bytes = _fetch_pdf(url)
if pdf_bytes is None:
logger.warning("ingest_reservations: пропуск документа url=%s (не загружен)", url)
continue
records = extract_from_pdf(pdf_bytes)
logger.info(
"ingest_reservations: url=%s → извлечено %d КН",
url,
len(records),
)
if not records:
continue
rows: list[dict[str, Any]] = []
for rec in records:
rows.append(
{
"cad_num": rec.cad_num,
"reservation_kind": rec.reservation_kind or doc_kind,
"basis_act": basis_act,
"act_number": rec.act_number or act_number,
"act_date": rec.act_date,
"purpose": rec.purpose or doc_purpose,
"doc_url": url,
"source": "page_pdf",
"raw_excerpt": rec.raw_excerpt,
}
)
count = _upsert_records(db, rows)
db.commit()
total_parcels += count
logger.info(
"ingest_reservations: готово docs=%d parcels=%d",
len(target_docs),
total_parcels,
)
finally:
db.close()
return {"docs": len(target_docs), "parcels": total_parcels}
__all__ = ["ingest_reservations"]

View file

@ -34,6 +34,7 @@ dependencies = [
"rosreestr2coord>=5.0.0",
"ijson>=3.2.0",
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
"pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091)
]
[dependency-groups]

View file

@ -0,0 +1,160 @@
"""Тесты page_reservation_parser (#1091, #1062) — парсер постановлений об изъятии/резервировании."""
from __future__ import annotations
from datetime import date
import pytest
from app.services.scrapers.page_reservation_parser import (
ReservationRecord,
extract_reservations,
)
# ── Фикстура-текст с типичным содержимым постановления ────────────────────────
_FULL_TEXT = """
ПОСТАНОВЛЕНИЕ
Правительства Свердловской области
от 12.03.2024 509-ПП
Об изъятии земельных участков для государственных нужд в целях реконструкции улицы Татищева
в городе Екатеринбурге.
В соответствии со статьёй 49 Земельного кодекса Российской Федерации Правительство
Свердловской области ПОСТАНОВЛЯЕТ:
1. Изъять для государственных нужд следующие земельные участки:
66:41:0101001:123 площадь 450 кв.м;
66:41:0101001:456 площадь 300 кв.м;
66:41:0101002:789 площадь 680 кв.м.
2. Министерству по управлению государственным имуществом Свердловской области обеспечить
государственную регистрацию права собственности.
Губернатор Свердловской области
"""
_REZERV_TEXT = """
ПОСТАНОВЛЕНИЕ
Правительства Свердловской области
от 05 июня 2024 г.
210-ПП
О резервировании земельных участков для строительства дороги.
Зарезервировать земельные участки:
66:41:0203010:11
66:41:0203010:22
в целях строительства объекта дорожной инфраструктуры.
"""
_GARBAGE_TEXT = "Это текст без кад-номеров и без постановлений."
_BAD_DATE_TEXT = """
99-ПП от 99.99.9999
Изъять земельный участок 66:41:0101001:500.
"""
# ── Тесты extract_reservations ────────────────────────────────────────────────
def test_extracts_cad_nums() -> None:
"""Все три КН извлекаются из постановления об изъятии."""
records = extract_reservations(_FULL_TEXT)
cad_nums = {r.cad_num for r in records}
assert cad_nums == {"66:41:0101001:123", "66:41:0101001:456", "66:41:0101002:789"}
def test_deduplicates_cad_nums() -> None:
"""Дублирующиеся КН в документе схлопываются в одну запись."""
text_with_dup = _FULL_TEXT + "\n66:41:0101001:123 повторно упомянут."
records = extract_reservations(text_with_dup)
cad_nums = [r.cad_num for r in records]
assert cad_nums.count("66:41:0101001:123") == 1
def test_kind_izyatie() -> None:
"""Определяет 'изъятие' из текста с «Изъять»."""
records = extract_reservations(_FULL_TEXT)
assert all(r.reservation_kind == "изъятие" for r in records)
def test_kind_rezervirovanie() -> None:
"""Определяет 'резервирование' из текста с «резервирование»."""
records = extract_reservations(_REZERV_TEXT)
assert all(r.reservation_kind == "резервирование" for r in records)
def test_act_number_parsed() -> None:
"""Номер постановления 509-ПП корректно извлекается."""
records = extract_reservations(_FULL_TEXT)
assert records[0].act_number == "509-ПП"
def test_act_date_numeric() -> None:
"""Дата «от 12.03.2024» парсится как date(2024, 3, 12)."""
records = extract_reservations(_FULL_TEXT)
assert records[0].act_date == date(2024, 3, 12)
def test_act_date_word_month() -> None:
"""Дата «05 июня 2024 г.» парсится как date(2024, 6, 5)."""
records = extract_reservations(_REZERV_TEXT)
assert records[0].act_date == date(2024, 6, 5)
def test_purpose_extracted() -> None:
"""Поле purpose содержит осмысленный текст (best-effort)."""
records = extract_reservations(_FULL_TEXT)
# Хотя бы у одной записи purpose не None.
assert any(r.purpose is not None for r in records)
def test_raw_excerpt_first_record() -> None:
"""raw_excerpt есть у первой записи (±120 символов)."""
records = extract_reservations(_FULL_TEXT)
first = records[0]
assert first.raw_excerpt is not None
assert "66:41:0101001:123" in (first.raw_excerpt or "")
def test_garbage_text_returns_empty() -> None:
"""Мусорный текст без КН → пустой список."""
records = extract_reservations(_GARBAGE_TEXT)
assert records == []
def test_empty_string_returns_empty() -> None:
"""Пустая строка → пустой список."""
assert extract_reservations("") == []
def test_bad_date_does_not_raise() -> None:
"""Нечитаемая дата (99.99.9999) → act_date=None, не падает."""
records = extract_reservations(_BAD_DATE_TEXT)
assert len(records) == 1
assert records[0].act_date is None
assert records[0].cad_num == "66:41:0101001:500"
def test_default_kind_applied() -> None:
"""Если нет маркеров изъятия/резервирования — берётся default_kind."""
text = "Земельный участок 66:41:0101001:777 подлежит ограничению."
records = extract_reservations(text, default_kind="резервирование")
assert records[0].reservation_kind == "резервирование"
def test_record_is_frozen() -> None:
"""ReservationRecord — frozen dataclass."""
r = ReservationRecord(
cad_num="66:41:0101001:1",
reservation_kind="изъятие",
act_number="509-ПП",
act_date=None,
purpose=None,
raw_excerpt=None,
)
with pytest.raises((AttributeError, TypeError)):
r.cad_num = "other" # type: ignore[misc]

View file

@ -0,0 +1,120 @@
"""Тесты reservation_lookup (#1091, #1062) — read-side lookup из land_reservation."""
from __future__ import annotations
from datetime import date
from typing import Any
from sqlalchemy.exc import OperationalError, ProgrammingError
from app.services.site_finder.reservation_lookup import parcel_reservations
# ── Моки БД (аналог test_ird_overlay_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)
# ── Фикстура строк ────────────────────────────────────────────────────────────
_ROWS = [
{
"reservation_kind": "изъятие",
"basis_act": "Постановление Правительства СО № 509-ПП",
"act_number": "509-ПП",
"act_date": date(2024, 3, 12),
"purpose": "реконструкция улицы Татищева",
"doc_url": "https://pravo.gov66.ru/documents/view/509-pp",
},
{
"reservation_kind": "резервирование",
"basis_act": "Постановление Правительства СО № 210-ПП",
"act_number": "210-ПП",
"act_date": date(2024, 6, 5),
"purpose": "строительство дороги",
"doc_url": "https://pravo.gov66.ru/documents/view/210-pp",
},
]
# ── Тесты ────────────────────────────────────────────────────────────────────
def test_maps_rows_correctly() -> None:
"""Строки из БД корректно маппируются в список словарей."""
result = parcel_reservations(_FakeDB(_ROWS), "66:41:0101001:123")
assert len(result) == 2
assert result[0]["reservation_kind"] == "изъятие"
assert result[0]["act_number"] == "509-ПП"
assert result[0]["act_date"] == date(2024, 3, 12)
assert result[1]["reservation_kind"] == "резервирование"
def test_expected_keys_present() -> None:
"""Каждый элемент содержит все ожидаемые ключи."""
result = parcel_reservations(_FakeDB(_ROWS), "66:41:0101001:123")
expected_keys = {
"reservation_kind",
"basis_act",
"act_number",
"act_date",
"purpose",
"doc_url",
}
for item in result:
assert set(item.keys()) == expected_keys
def test_empty_when_no_rows() -> None:
"""Нет записей в БД → пустой список."""
result = parcel_reservations(_FakeDB([]), "66:41:0101001:999")
assert result == []
def test_empty_when_cad_num_none() -> None:
"""cad_num=None → пустой список, БД не вызывается."""
result = parcel_reservations(_FakeDB(_ROWS), None)
assert result == []
def test_empty_when_cad_num_empty_string() -> None:
"""cad_num='' → пустой список (falsy guard)."""
result = parcel_reservations(_FakeDB(_ROWS), "")
assert result == []
def test_graceful_on_operational_error() -> None:
"""OperationalError (таблица не задеплоена) → [] без падения."""
db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("no such table")))
result = parcel_reservations(db, "66:41:0101001:123")
assert result == []
def test_graceful_on_programming_error() -> None:
"""ProgrammingError → [] без падения."""
db = _FakeDB(raise_exc=ProgrammingError("stmt", {}, Exception("column not found")))
result = parcel_reservations(db, "66:41:0101001:123")
assert result == []

58
backend/uv.lock generated
View file

@ -572,6 +572,7 @@ dependencies = [
{ name = "numpy" },
{ name = "openpyxl" },
{ name = "pandas" },
{ name = "pdfplumber" },
{ name = "pillow" },
{ name = "playwright" },
{ name = "psycopg", extra = ["binary"] },
@ -616,6 +617,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.0.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pandas", specifier = ">=2.2.0" },
{ name = "pdfplumber", specifier = ">=0.10.0" },
{ name = "pillow", specifier = ">=10.4.0" },
{ name = "playwright", specifier = ">=1.45.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
@ -1334,6 +1336,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" },
]
[[package]]
name = "pdfminer-six"
version = "20251230"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
]
[[package]]
name = "pdfplumber"
version = "0.11.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pdfminer-six" },
{ name = "pillow" },
{ name = "pypdfium2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
@ -1721,6 +1750,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pypdfium2"
version = "5.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/98/6b44bf82ddb3c7a3e0249203772aad8981b4491d6227f182685f310faeff/pypdfium2-5.9.0.tar.gz", hash = "sha256:db1274bd27844db6fda17ef1dbcd0026c47d357437058d838e98060c0da9e92e", size = 272455, upload-time = "2026-06-01T15:43:38.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/d9/59630cb40e5f37e7712e6ea65e9cac633f4195e8b737bb3a46054aa63340/pypdfium2-5.9.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:91914837c4a4285b3e0724a84eca8079363db7475acbcab405933d1807785664", size = 3407817, upload-time = "2026-06-01T15:42:58.426Z" },
{ url = "https://files.pythonhosted.org/packages/0f/3d/e205708835a3730d5242652b6577ac06ad4721e6fcef77cc7c9d3541c686/pypdfium2-5.9.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:90610d352f050b065b703f3a46602a852fce7dd8787300c8c7a472485b644d8f", size = 2862706, upload-time = "2026-06-01T15:43:00.581Z" },
{ url = "https://files.pythonhosted.org/packages/01/47/e843fb895a891438b3f8c6d834fdc9c19183cd60980fc9325429d5c01505/pypdfium2-5.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6c4fbe3a7190b329c526358fb2855d797f7b74b5ecfc61d19657ef20bcebc108", size = 3489945, upload-time = "2026-06-01T15:43:02.542Z" },
{ url = "https://files.pythonhosted.org/packages/35/bd/f5e6afd556f97fcaa2bec4cb04669664c166028fc2a059bd65447c852b43/pypdfium2-5.9.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:e93f0cf440169a3e445e6fbd06c803877e7418f3e13254287875cb67f208bb5a", size = 3674186, upload-time = "2026-06-01T15:43:04.496Z" },
{ url = "https://files.pythonhosted.org/packages/6d/4d/5286812216a292d51dfba8e7bff276da198f126508f8c2afa3630bf701dc/pypdfium2-5.9.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d902e03dff5efd51d93cd23d3e55bde53802fa6207bcd0e455239518859a069", size = 3669571, upload-time = "2026-06-01T15:43:06.571Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c8/822db2c89baa13e6cee321d587fcd42df463a1fc2f7520b3f6814768bc71/pypdfium2-5.9.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cf38d7ad3575947b82384869f2ab69ba345eb21d83118d25db3e83f967b0421", size = 3400412, upload-time = "2026-06-01T15:43:08.35Z" },
{ url = "https://files.pythonhosted.org/packages/1a/dd/7d09d8cdc28383df13f739a97ac4f1215a704a97a29506dee2bf89d8a350/pypdfium2-5.9.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77f7479a28b43aa658735e3ce79cfd1fccd5d42db035c21bb4c26e8bd7e280e5", size = 3803326, upload-time = "2026-06-01T15:43:10.054Z" },
{ url = "https://files.pythonhosted.org/packages/99/58/3f4e04ffe1ae62b437de07a96da672091cef62b619d0dc78207c1af442e6/pypdfium2-5.9.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07e6ba170d577eabf60dbba701d051c64318dd029d38ca5907d83ae1a66fe779", size = 4216890, upload-time = "2026-06-01T15:43:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f6/2dde4656750c4a6da99e1f070ca09d2b5a9d68186b42e711a1a3e5b1cb32/pypdfium2-5.9.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce3a3dd23ec0adaa079d8be54565ba2aa2f6060e76a4989cd42dabc163d74ee", size = 3728830, upload-time = "2026-06-01T15:43:13.329Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ca/f2ff8b9200c7dfc5aee85126edc856eb93c7056085da2454a75ef1e4dbc4/pypdfium2-5.9.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae177938f5cf95a275db25a4f8553e2ebd954ecda2f9bc84848ba4b027ce438f", size = 4063322, upload-time = "2026-06-01T15:43:15.158Z" },
{ url = "https://files.pythonhosted.org/packages/64/88/0b587de03c873c28adc59f6ac959de4032d3f3bc946094523b14a192d9c3/pypdfium2-5.9.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffe49edde2ac86f28ca7e58f565255a442f38a7508fff31b79a55f508f25a31e", size = 4039738, upload-time = "2026-06-01T15:43:16.975Z" },
{ url = "https://files.pythonhosted.org/packages/83/4c/fa627f00a954e66465e929077cf43bd012595091fff82758d989486e7bdc/pypdfium2-5.9.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b7b760bc2957ecf73c274af6ed8b168a2dcb328ac0a0f7ed6123cd92f6e7c9c9", size = 4997259, upload-time = "2026-06-01T15:43:18.915Z" },
{ url = "https://files.pythonhosted.org/packages/32/f0/1736d80c5d12d931f74ca6b4213b006ee016ec33c6325fad870234cc240c/pypdfium2-5.9.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7cdc8e5d2f8d82add1e4f70a4fbe5f3b33c17f301ebde38c669fd7f78a7d032c", size = 4537061, upload-time = "2026-06-01T15:43:20.879Z" },
{ url = "https://files.pythonhosted.org/packages/01/00/aa8890dfd385b2e7365034231987029cff15cc7eb4f06e8380da5608738a/pypdfium2-5.9.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:38a058dbd4929acaf0ab9171179eb86c24d8c6655a6836006796105a9f200890", size = 5232786, upload-time = "2026-06-01T15:43:23.73Z" },
{ url = "https://files.pythonhosted.org/packages/65/12/8f45ea698781a0bed96ac4fbde440060790863273943461f0f160a993d52/pypdfium2-5.9.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:1894511a0e862e7ec5679f3a6dc43ac72c4ef92c7ca438357203913e8634a643", size = 5170121, upload-time = "2026-06-01T15:43:25.858Z" },
{ url = "https://files.pythonhosted.org/packages/25/bd/9bb6ba375796e1de1d6c1af8d8303dd1781190346871c81a94d4e09eddfd/pypdfium2-5.9.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:040f5513b808db705d4878f57e2bf0b9dc6e6a0ad8d765c36cf62febf3933b28", size = 4663540, upload-time = "2026-06-01T15:43:27.677Z" },
{ url = "https://files.pythonhosted.org/packages/d2/4a/fd103bac197f22038bf70be1f7507ced7519f1214ea0dae137f37803ab8a/pypdfium2-5.9.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:f4991ae39bcea757552579bba4aebfaedb71c96dd35c2292f957b8ac9132f1ff", size = 5090619, upload-time = "2026-06-01T15:43:29.522Z" },
{ url = "https://files.pythonhosted.org/packages/22/89/9531fa1e6e004fe522cdca0cd945cd6a9d7338e7125e6b0734d632d31fa6/pypdfium2-5.9.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:25ff1a5abd08ff9e87f62e5dac114ea95647c257fbbdbe029be8db71a6d7650b", size = 5050806, upload-time = "2026-06-01T15:43:31.322Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d0/e53c68555ff128b2470e4a468762b320d9c6ae2c914decea3487d923982f/pypdfium2-5.9.0-py3-none-win32.whl", hash = "sha256:b0057dc8c2033584dc3e61afb5f23a135dab52b081695b435e27f9b7b074c605", size = 3670966, upload-time = "2026-06-01T15:43:32.991Z" },
{ url = "https://files.pythonhosted.org/packages/da/0c/22e5fc035ad1594b44f265bc0a59ae34d377bc2ea74a92793e7a674bf96d/pypdfium2-5.9.0-py3-none-win_amd64.whl", hash = "sha256:06508c33b9772cf3878e48364c6e14c70cefc18a3abd6983ac9f338da9305275", size = 3800959, upload-time = "2026-06-01T15:43:34.536Z" },
{ url = "https://files.pythonhosted.org/packages/11/e3/cf1711add7add22a17f7c7633cd795edc92f17ab7bdf1930493ae0f56680/pypdfium2-5.9.0-py3-none-win_arm64.whl", hash = "sha256:565ddfc98795fd2f6054b544ee9791d7b9032f9cf77a57891b6e501fafd0ef3f", size = 3585718, upload-time = "2026-06-01T15:43:36.521Z" },
]
[[package]]
name = "pyphen"
version = "0.17.2"

View file

@ -0,0 +1,79 @@
-- 136_land_reservation.sql
-- #1091 / #1062 (ИРД-5, «GG-форсайт» #1067) — хранилище ЗУ под изъятие/резервирование.
--
-- Геометрии зон изъятия/резервирования НЕТ ни в одном spatial-источнике (НСПД/геопортал/ГИСОГД
-- проверены пусто, vault research 2026-06). Перечень ЗУ существует ТОЛЬКО текстом в постановлениях
-- (PDF, pravo.gov66.ru / ПАГЕ). Геометрию получаем join'ом кад-номера к cad_parcels.geom — это
-- покрывает read-side analyse без хранения геометрии здесь.
--
-- Что/зачем: per-parcel флаг «в действующем постановлении №X об изъятии/резервировании» + ссылка
-- на документ. Парсер текста постановлений (page_reservation_parser.py) извлекает кад-номера и
-- метаданные акта; ingest-task пишет сюда; reservation_lookup.py читает для analyze.
--
-- Grain: одна строка = один кад-номер в одном акте (cad_num, act_number — дедуп-ключ).
-- Источник: pravo.gov66.ru PDF-постановления (seed: 509-ПП Правительства СО, ул. Татищева ЕКБ).
--
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN). Idempotent
-- (ре-apply дважды чисто): CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для UNIQUE
-- (constraint нельзя добавить дважды → guard по pg_constraint, прецедент м.132).
-- НИКАКИХ строк здесь не вставляется (данные приходят из reservation_ingest task).
BEGIN;
-- ── 1. Таблица land_reservation ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS land_reservation (
id BIGSERIAL PRIMARY KEY,
cad_num TEXT NOT NULL, -- кадастровый номер ЗУ (66:41:XXXXXXX:NN)
reservation_kind TEXT NOT NULL, -- 'изъятие' | 'резервирование'
basis_act TEXT NOT NULL, -- «Постановление Правительства СО № 509-ПП»
act_number TEXT, -- «509-ПП» (None если не распарсилось)
act_date DATE, -- дата акта (NULL если не распарсилось)
purpose TEXT, -- цель (реконструкция ул. Татищева, ...)
doc_url TEXT, -- ссылка на PDF/страницу
source TEXT NOT NULL DEFAULT 'page_pdf', -- источник: page_pdf | ...
is_active BOOLEAN NOT NULL DEFAULT true, -- актуальное постановление
raw_excerpt TEXT, -- ±120 символов вокруг первого кад-номера
fetched_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE land_reservation IS
'ЗУ под изъятие/резервирование (#1091/#1062, GG-форсайт #1067). 1 строка = 1 кад-номер '
'в 1 постановлении. Геометрия не хранится — join к cad_parcels.geom на read-side. '
'Парсер page_reservation_parser.py извлекает кад-номера из PDF-постановлений; '
'reservation_lookup.py читает для analyze.';
COMMENT ON COLUMN land_reservation.cad_num IS 'Кадастровый номер ЗУ вида 66:41:XXXXXXX:NN';
COMMENT ON COLUMN land_reservation.reservation_kind IS 'изъятие | резервирование';
COMMENT ON COLUMN land_reservation.basis_act IS 'Читаемое описание акта-основания';
COMMENT ON COLUMN land_reservation.act_number IS 'Номер постановления (509-ПП, NNN-ПА и т.п.)';
COMMENT ON COLUMN land_reservation.raw_excerpt IS '±120 симв. вокруг первого кад-номера; debug/верификация';
-- ── 2. Дедуп/UPSERT-ключ (cad_num, act_number) ───────────────────────────────
-- Guard по pg_constraint → idempotent re-apply (constraint нельзя добавить дважды).
-- Прецедент: м.132 (uq_ird_overlays_feature).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_land_reservation_cad_act'
) THEN
ALTER TABLE land_reservation
ADD CONSTRAINT uq_land_reservation_cad_act
UNIQUE (cad_num, act_number);
END IF;
END
$$;
-- ── 3. Индексы ────────────────────────────────────────────────────────────────
-- Основной read-path: lookup по кад-номеру (reservation_lookup.py → analyze).
CREATE INDEX IF NOT EXISTS idx_land_reservation_cad_num
ON land_reservation (cad_num);
-- Фильтр активных постановлений (is_active=true — рабочий подмножество).
CREATE INDEX IF NOT EXISTS idx_land_reservation_active
ON land_reservation (cad_num) WHERE is_active;
-- Lookup по номеру акта (cross-ref / ручные проверки).
CREATE INDEX IF NOT EXISTS idx_land_reservation_act_number
ON land_reservation (act_number) WHERE act_number IS NOT NULL;
COMMIT;