From b58f399d2d3b04a4c171f19fdf76b614978dcd85 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 7 Jun 2026 12:58:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(sf):=20=D0=9F=D0=90=D0=93=D0=95-=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=B8=D0=B7=D1=8A=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=D1=8F/=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=E2=86=92=20land=5Freser?= =?UTF-8?q?vation=20(#1091,=20#1062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scrapers/page_reservation_parser.py | 252 ++++++++++++++++++ .../site_finder/reservation_lookup.py | 67 +++++ backend/app/workers/celery_app.py | 1 + .../app/workers/tasks/reservation_ingest.py | 188 +++++++++++++ backend/pyproject.toml | 1 + .../scrapers/test_page_reservation_parser.py | 160 +++++++++++ .../tests/services/test_reservation_lookup.py | 120 +++++++++ backend/uv.lock | 58 ++++ data/sql/136_land_reservation.sql | 79 ++++++ 9 files changed, 926 insertions(+) create mode 100644 backend/app/services/scrapers/page_reservation_parser.py create mode 100644 backend/app/services/site_finder/reservation_lookup.py create mode 100644 backend/app/workers/tasks/reservation_ingest.py create mode 100644 backend/tests/services/scrapers/test_page_reservation_parser.py create mode 100644 backend/tests/services/test_reservation_lookup.py create mode 100644 data/sql/136_land_reservation.sql diff --git a/backend/app/services/scrapers/page_reservation_parser.py b/backend/app/services/scrapers/page_reservation_parser.py new file mode 100644 index 00000000..9797acc5 --- /dev/null +++ b/backend/app/services/scrapers/page_reservation_parser.py @@ -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"] diff --git a/backend/app/services/site_finder/reservation_lookup.py b/backend/app/services/site_finder/reservation_lookup.py new file mode 100644 index 00000000..6771911b --- /dev/null +++ b/backend/app/services/site_finder/reservation_lookup.py @@ -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"] diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 3a8ecf8e..dc66bda1 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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" diff --git a/backend/app/workers/tasks/reservation_ingest.py b/backend/app/workers/tasks/reservation_ingest.py new file mode 100644 index 00000000..656308d3 --- /dev/null +++ b/backend/app/workers/tasks/reservation_ingest.py @@ -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"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 76ee41cd..c3ead1c9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/tests/services/scrapers/test_page_reservation_parser.py b/backend/tests/services/scrapers/test_page_reservation_parser.py new file mode 100644 index 00000000..68ae33e6 --- /dev/null +++ b/backend/tests/services/scrapers/test_page_reservation_parser.py @@ -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] diff --git a/backend/tests/services/test_reservation_lookup.py b/backend/tests/services/test_reservation_lookup.py new file mode 100644 index 00000000..644402de --- /dev/null +++ b/backend/tests/services/test_reservation_lookup.py @@ -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 == [] diff --git a/backend/uv.lock b/backend/uv.lock index 8b18a7f2..a30f6b6c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" diff --git a/data/sql/136_land_reservation.sql b/data/sql/136_land_reservation.sql new file mode 100644 index 00000000..298e062f --- /dev/null +++ b/data/sql/136_land_reservation.sql @@ -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; -- 2.45.3