diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 172729e4..02574451 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: runs-on: ubuntu-latest outputs: backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -53,6 +54,9 @@ jobs: - 'backend/**' - 'data/sql/**' - '.forgejo/workflows/ci.yml' + frontend: + - 'frontend/**' + - '.forgejo/workflows/ci.yml' backend-tests: runs-on: ubuntu-latest @@ -116,3 +120,35 @@ jobs: # (5432 недоступен в этом mock-lane) → SKIP. Это intended. # PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK. run: uv run pytest -q --ignore=tests/smoke + + frontend-tests: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.frontend == 'true' + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + # Node 20 — совпадает с major из frontend/Dockerfile (node:20-alpine). + # cache=npm + cache-dependency-path на lockfile → переиспользуем ~/.npm + # между прогонами (mirror Dockerfile's `--mount=type=cache,target=/root/.npm`). + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install deps (npm ci, frozen lockfile) + # ТОЧНЫЕ флаги из frontend/Dockerfile (deps stage): + # --legacy-peer-deps — Tailwind 4 alpha + React 19 peer-dep mismatches; + # --no-audit --no-fund — тише и быстрее в CI. `ci` (не `install`) = + # детерминированно из package-lock.json, fail при дрейфе lock vs package.json. + run: npm ci --legacy-peer-deps --no-audit --no-fund + + - name: Test (vitest) + # `npm run test` = `vitest run` (single-shot, не watch). Только тесты — + # `next build` НАМЕРЕННО не здесь (тяжёлый, verified в deploy.yml build). + run: npm run test diff --git a/backend/app/services/scrapers/ekb_ppt_tep_parser.py b/backend/app/services/scrapers/ekb_ppt_tep_parser.py new file mode 100644 index 00000000..0dc7c40f --- /dev/null +++ b/backend/app/services/scrapers/ekb_ppt_tep_parser.py @@ -0,0 +1,416 @@ +"""Парсер ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067). + +Источник: PDF-документы планировочных проектов (ППТ/ПМТ) Екатеринбурга. +Геометрия ППТ/ПМТ уже в БД (planning_projects, 1170 объектов, WFS). + +Три целевые таблицы: + Табл.11 — Баланс территории по зонам (га, %); + Табл.12 — Основные технико-экономические показатели; + Табл.13 — Очередность строительства (зона / состав / финансирование). + +Особенности: + - Таблицы обнаруживаются по заголовку-caption через extract_text() на каждой странице. + - Каждая таблица может занимать несколько страниц (особенно Табл.12 и 13). + - Структура ППТ разнится — парсер работает best-effort: + не найдена таблица → соответствующее поле = [] (не исключение). + - Кириллица: используем ТОЛЬКО pdfplumber (pdftotext/poppler даёт mojibake). + - Нормализация числовых значений: «15,41 га», «(42,61 %)» → float. +""" + +from __future__ import annotations + +import io +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +import pdfplumber + +logger = logging.getLogger(__name__) + +# ── Нормализация значений ──────────────────────────────────────────────────── + +_RE_NUM = re.compile(r"[\d\s]+(?:[.,]\d+)?") + + +def _norm_value(raw: str | None) -> str: + """Очищаем ячейку: убираем лишние пробелы, переносы, управляющие символы.""" + if raw is None: + return "" + # Убираем управляющие глифы (например  — «») + text = re.sub(r"[-]", "", raw) + # Нормализуем переносы строк внутри ячейки → пробел + text = re.sub(r"\s*\n\s*", " ", text) + return text.strip() + + +def _try_float(value: str) -> float | None: + """Пытаемся превратить строку вида '15,41 га (42,61 %)' в float первого числа.""" + m = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)", value) + if not m: + return None + num_str = m.group(1).replace(" ", "").replace(",", ".") + try: + return float(num_str) + except ValueError: + return None + + +# ── Обнаружение таблиц ─────────────────────────────────────────────────────── + +# Паттерны для определения номера таблицы в тексте страницы. +# Ищем «Таблица 11», «Таблица 12», «Таблица 13» (латинская «а» вместо кириллической +# тоже встречается в некоторых PDF — поэтому без локали). +_TABLE_CAPTION_RE = { + 11: re.compile(r"Таблица\s+11\b", re.IGNORECASE), + 12: re.compile(r"Таблица\s+12\b", re.IGNORECASE), + 13: re.compile(r"Таблица\s+13\b", re.IGNORECASE), +} + + +def _page_contains_table(text: str, table_no: int) -> bool: + """Возвращает True если текст страницы содержит заголовок таблицы N. + + Требуем явный caption «Таблица N» — hint-только режим (оглавление, перекрёстные ссылки) + даёт false positive и подавляется. Если caption присутствует — достаточно. + """ + cap = _TABLE_CAPTION_RE[table_no] + return bool(cap.search(text)) + + +# ── Парсинг конкретных таблиц ──────────────────────────────────────────────── + + +def _parse_table11(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: + """Баланс территории по зонам (Табл.11). + + Структура: № | Наименование зоны | Сущ., га (%) | Проект, га (%) + Заголовок (первые 2 строки) пропускается по признаку: №/None в col[0]. + """ + result: list[dict[str, Any]] = [] + header_skipped = False + + for row in raw_rows: + if not row: + continue + no = _norm_value(row[0] if len(row) > 0 else None) + zone = _norm_value(row[1] if len(row) > 1 else None) + existing = _norm_value(row[2] if len(row) > 2 else None) + project = _norm_value(row[3] if len(row) > 3 else None) + + # Строка-заголовок: col[0] в {№, None, ''} и col[1] содержит + # «Наименование» или «Площадь» — пропускаем. + if not header_skipped: + zone_lo = zone.lower() + if no in ("", "№", None) and ("наименование" in zone_lo or "площадь" in zone_lo): + header_skipped = True + continue + if no in ("", "№", None) and not zone: + continue + + # Пропускаем строки без номера — вложенные подзаголовки. + # Итоговую строку включаем (распознаём по «Итого»). + is_total = "итого" in (no + zone).lower() + + if not no and not is_total: + continue + + record: dict[str, Any] = { + "no": no, + "zone_name": zone, + "existing_ha": _norm_value(existing), + "existing_ha_num": _try_float(existing), + "project_ha": _norm_value(project), + "project_ha_num": _try_float(project), + } + if is_total: + record["is_total"] = True + result.append(record) + + return result + + +def _parse_table12(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: + """Основные ТЭП (Табл.12). + + Структура: № | Наименование показателей | Единица измерения | + Современное состояние | Расчётный срок. + Таблица может содержать дополнительный столбец «I этап» + (на страницах 112-113 этапы разнесены). + """ + result: list[dict[str, Any]] = [] + header_skipped = False + + for row in raw_rows: + if not row: + continue + # Чистим все ячейки + cells = [_norm_value(c) for c in row] + if not any(cells): + continue + + no = cells[0] if cells else "" + name = cells[1] if len(cells) > 1 else "" + unit = cells[2] if len(cells) > 2 else "" + current = cells[3] if len(cells) > 3 else "" + target = cells[4] if len(cells) > 4 else "" + + # Пропуск строки-заголовка таблицы + if not header_skipped: + if "наименование" in name.lower() or "показател" in name.lower(): + header_skipped = True + continue + if no in ("", "№") and ("единица" in unit.lower() or "измерени" in unit.lower()): + header_skipped = True + continue + + # Пропускаем полностью пустые строки + if not (no or name or current or target): + continue + + record: dict[str, Any] = { + "no": no, + "indicator": name, + "unit": unit, + "current": current, + "target": target, + } + # Если 6 колонок — есть этапная разбивка (I этап / II этап) + if len(cells) > 5: + record["stage1"] = cells[4] + record["target"] = cells[5] + + result.append(record) + + return result + + +def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: + """Очередность строительства (Табл.13). + + Структура сложная (20 колонок из-за merged cells): + Нас интересуют: фаза (col[0]), состав объекта (col[1..2]), зона (col[13]), + площадь (col[15]), финансирование (col[17]), программа (col[19]). + Извлекаем только строки с непустым полем «зона» или «состав объекта». + """ + result: list[dict[str, Any]] = [] + current_phase: str = "" + header_skipped = False + + for row in raw_rows: + if not row: + continue + cells = [_norm_value(c) for c in row] + + # Пропуск заголовка (первые 2 строки) + if not header_skipped: + first_meaningful = next((c for c in cells if c), "") + if "номер" in first_meaningful.lower() or "состав" in first_meaningful.lower(): + header_skipped = True + continue + # Строка с «1 2 4 5 6 7» (номера колонок) + if first_meaningful in ("1", ""): + continue + + # Определяем фазу/этап строительства (ячейка 0, начинается с «I», «II», «III»). + col0 = cells[0] if cells else "" + if re.match(r"^(I{1,3}V?|IV|V)\s*(этап|очередь)?", col0, re.IGNORECASE) and col0: + current_phase = col0 + + # Состав объекта — из колонок 1 или 3 (после слияния). + # При 20-колоночной таблице ищем непустой текст в диапазоне 1..5. + composition = "" + for ci in range(1, min(6, len(cells))): + if cells[ci]: + composition = cells[ci] + break + + # Зона планируемого размещения. + zone = "" + area_ha_str = "" + funding = "" + program = "" + + if len(cells) >= 14: + zone = cells[13] + if len(cells) >= 16: + area_ha_str = cells[15] + if len(cells) >= 18: + funding = cells[17] + if len(cells) >= 20: + program = cells[19] + + # Пропускаем строки без осмысленного содержания + if not (composition or zone): + continue + # Пропускаем строки-секционные заголовки (нет зоны, состав = подзаголовок раздела) + if not zone and "объект" in composition.lower() and len(composition) < 50: + continue + + result.append( + { + "phase": current_phase, + "composition": composition, + "zone": zone, + "area_ha": area_ha_str, + "area_ha_num": _try_float(area_ha_str), + "funding": funding, + "program": program, + } + ) + + return result + + +# ── Сборка сырых строк со всех страниц блока таблицы ──────────────────────── + + +def _collect_table_rows( + pages: list[pdfplumber.page.Page], + start_page_idx: int, + table_no: int, + next_table_no: int | None, +) -> tuple[list[list[str | None]], list[dict[str, Any]]]: + """Собирает строки таблицы N начиная со страницы start_page_idx. + + Останавливается когда встречает заголовок следующей таблицы (next_table_no) + или «Рис.» / другой раздел. + + Returns: + (all_rows, raw_table_records) — строки для парсинга и сырые данные для + поля raw_tables. + """ + all_rows: list[list[str | None]] = [] + raw_records: list[dict[str, Any]] = [] + found_start = False + + for pi in range(start_page_idx, len(pages)): + page = pages[pi] + page_text = page.extract_text() or "" + + # Обнаружение начала нашей таблицы + if not found_start: + if _page_contains_table(page_text, table_no): + found_start = True + else: + continue + + # Стоп-условие: начался следующий раздел/таблица + if found_start and pi > start_page_idx: + if next_table_no and _page_contains_table(page_text, next_table_no): + break + + # Извлекаем таблицы со страницы + page_tables = page.extract_tables() + for ti, tbl in enumerate(page_tables): + if not tbl: + continue + # Берём только таблицы с ≥2 строками и ≥2 колонками + if len(tbl) < 2 or (tbl[0] and len(tbl[0]) < 2): + continue + + # На первой странице таблицы пропускаем маленькие таблицы (заголовки/рамки) + # — они идут ДО основной таблицы данных. + if pi == start_page_idx and len(tbl) < 4: + continue + + all_rows.extend(tbl) + raw_records.append( + { + "page": pi + 1, # 1-based для отладки + "table_index": ti, + "rows": [[_norm_value(c) for c in row] for row in tbl[:10]], # cap 10 rows + } + ) + + return all_rows, raw_records + + +# ── Публичный API ──────────────────────────────────────────────────────────── + + +@dataclass +class PptTep: + """Результат парсинга пояснительной записки ППТ/ПМТ. + + Каждое поле — list[dict] с нормализованными строками таблицы. + Пустой список означает «таблица не найдена» (best-effort, не ошибка). + """ + + zone_balance: list[dict[str, Any]] = field(default_factory=list) + tep: list[dict[str, Any]] = field(default_factory=list) + phasing: list[dict[str, Any]] = field(default_factory=list) + raw_tables: list[dict[str, Any]] = field(default_factory=list) + + +def parse_ppt_tep(pdf_bytes: bytes) -> PptTep: + """Парсит PDF пояснительной записки ППТ/ПМТ → ТЭП (Табл.11/12/13). + + Чистая функция от bytes; сеть/fetch — вне этой функции. + Graceful при отсутствии таблиц: поле = [] вместо исключения. + + Args: + pdf_bytes: сырые байты PDF-документа. + + Returns: + PptTep с заполненными полями zone_balance / tep / phasing / raw_tables. + """ + result = PptTep() + + try: + with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: + pages = pdf.pages + n_pages = len(pages) + logger.info("parse_ppt_tep: opened PDF, %d pages", n_pages) + + # Находим страницы-начала Табл.11/12/13 + page_11 = page_12 = page_13 = None + for pi, page in enumerate(pages): + text = page.extract_text() or "" + if page_11 is None and _page_contains_table(text, 11): + page_11 = pi + if page_12 is None and _page_contains_table(text, 12): + page_12 = pi + if page_13 is None and _page_contains_table(text, 13): + page_13 = pi + + logger.info( + "parse_ppt_tep: Табл.11 на стр.%s, Табл.12 на стр.%s, Табл.13 на стр.%s", + page_11 and page_11 + 1, + page_12 and page_12 + 1, + page_13 and page_13 + 1, + ) + + # Табл.11 — Баланс территории + if page_11 is not None: + rows_11, raw_11 = _collect_table_rows(pages, page_11, 11, 12) + result.zone_balance = _parse_table11(rows_11) + result.raw_tables.extend(raw_11) + logger.info("parse_ppt_tep: Табл.11 → %d строк", len(result.zone_balance)) + else: + logger.warning("parse_ppt_tep: Таблица 11 не найдена в PDF") + + # Табл.12 — ТЭП + if page_12 is not None: + rows_12, raw_12 = _collect_table_rows(pages, page_12, 12, 13) + result.tep = _parse_table12(rows_12) + result.raw_tables.extend(raw_12) + logger.info("parse_ppt_tep: Табл.12 → %d строк", len(result.tep)) + else: + logger.warning("parse_ppt_tep: Таблица 12 не найдена в PDF") + + # Табл.13 — Очередность строительства + if page_13 is not None: + rows_13, raw_13 = _collect_table_rows(pages, page_13, 13, None) + result.phasing = _parse_table13(rows_13) + result.raw_tables.extend(raw_13) + logger.info("parse_ppt_tep: Табл.13 → %d строк", len(result.phasing)) + else: + logger.warning("parse_ppt_tep: Таблица 13 не найдена в PDF") + + except Exception as exc: + logger.error("parse_ppt_tep: ошибка парсинга PDF: %s", exc) + raise + + return result diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py index 2a705ab4..fd7dc104 100644 --- a/backend/app/services/site_finder/ird_analyze.py +++ b/backend/app/services/site_finder/ird_analyze.py @@ -28,6 +28,7 @@ from sqlalchemy.orm import Session from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient 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.planning_lookup import parcel_planning_overlaps from app.services.site_finder.reservation_lookup import parcel_reservations from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation @@ -96,6 +97,7 @@ def build_ird_analyze_block( ``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'), ``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085), ``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062), + ``krt_requisites`` (list, КРТ-реквизиты из ekb_krt_sites + planning_projects, #1060), ``functional_zone`` (генплан, dict|None), ``krt`` (list), ``zone_regulation`` (dict|None). Любой источник деградирует независимо — блок всегда well-formed. """ @@ -108,6 +110,9 @@ def build_ird_analyze_block( "opportunity_overlaps": opportunity, "planning_projects": parcel_planning_overlaps(db, geom_wkt), "reservations": parcel_reservations(db, cad_num), + # КРТ-реквизиты (DB, дёшево, graceful): planning_projects ⋈ ekb_krt_sites (#1060). + # Идёт рядом с planning_projects — оба DB-источника, не в geoportal-конкурентной части. + "krt_requisites": parcel_krt_requisites(db, geom_wkt), "functional_zone": None, "krt": [], "zone_regulation": None, diff --git a/backend/app/services/site_finder/krt_lookup.py b/backend/app/services/site_finder/krt_lookup.py new file mode 100644 index 00000000..5d37c9b4 --- /dev/null +++ b/backend/app/services/site_finder/krt_lookup.py @@ -0,0 +1,189 @@ +"""КРТ-реквизиты для analyze_parcel (#1060, эпик #1067). + +Связывает геометрию КРТ-площадок из ``planning_projects`` (ППТ/ПМТ, м.134) с реестром +реквизитов ``ekb_krt_sites`` (застройщик / аукционная цена / градпотенциал). + +Ключ связки: номер постановления ПАГЕ из ``ekb_krt_sites.paga_decisions`` + год совпадает с +заголовком ``planning_projects.full_name``. Даёт ``confidence='exact'`` для 3 из 15 площадок +(по состоянию на 2026-06). Для остальных КРТ-геом-пересечений (opportunity_krt в ird_overlays — +это ККР, не КРТ-площадки застройки) надёжного ключа нет → ``matched=False, confidence='none'``. + +Graceful: нет таблицы / пустой WKT / исключение БД → []. +Зеркалит стиль ``ird_overlay_lookup.py`` + ``planning_lookup.py``. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError, ProgrammingError +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Участок → planning_projects, которые пересекают его И имеют в full_name «ПАГЕ № N от» +# (первичный документ, не «Внесение изменений»). Geom — 4326. CAST psycopg v3. +_PLANNING_KRT_SQL = text( + """ + SELECT + pp.id AS pp_id, + pp.full_name AS pp_full_name, + pp.dmd_actual_year, + pp.doc_status_name, + k.id AS krt_id, + k.site_name, + k.developer_name, + k.contract_number, + k.auction_final_price, + k.gradpotential_sqm, + k.area_ha, + k.krt_kind + FROM planning_projects pp + -- только первичные документы (не «Внесение изменений») + CROSS JOIN ekb_krt_sites k + -- пересечение участка с PP-геометрией + WHERE ST_Intersects( + pp.geom, + ST_GeomFromText(CAST(:parcel_wkt AS text), 4326) + ) + -- матч по ПАГЕ-номеру: в full_name есть «ПАГЕ № {paga_num} от», год совпадает + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(k.paga_decisions) d + WHERE pp.full_name ~ ('^[[:space:]]*ПАГЕ № ' || (d->>'number') || ' от') + AND pp.dmd_actual_year::text = split_part(d->>'date', '.', 3) + AND (d->>'title' ILIKE '%планировки%' OR d->>'title' ILIKE '%межевания%') + ) + ORDER BY pp.dmd_actual_year DESC, k.id + """ +) + +# Вторичный запрос: planning_projects без матча с ekb_krt_sites — geom-only без реквизитов. +# Запускается только если первый не дал результатов, чтобы не путать пользователя +# двумя overlapping-источниками. +_PLANNING_NO_MATCH_SQL = text( + """ + SELECT + pp.id AS pp_id, + pp.full_name AS pp_full_name, + pp.dmd_actual_year, + pp.doc_status_name + FROM planning_projects pp + WHERE ST_Intersects( + pp.geom, + ST_GeomFromText(CAST(:parcel_wkt AS text), 4326) + ) + AND pp.full_name ~ '[кК][рР][тТ]|комплексн.*развити|Краснофлотцев|Донбасской + |Шефской|Азина|Черкасской|Кварцевой|Летчиков|Вилонова|Лумумбы + |Студенческой|Васильева|Гурзуфск|Бакинских|Шадринский|Бабушкина' + ORDER BY pp.dmd_actual_year DESC + """ +) + + +def _extract_paga_num(full_name: str) -> str | None: + """Извлечь номер ПАГЕ из заголовка planning_project.full_name.""" + m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name) + return m.group(1) if m else None + + +def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]: + """КРТ-реквизиты для участка: застройщик / цена / градпотенциал. + + Алгоритм: + 1. ST_Intersects участка с ``planning_projects`` + матч ПАГЕ-номер+год с + ``ekb_krt_sites.paga_decisions`` → ``confidence='exact'``. + 2. Если матчей нет, ищем ППТ/ПМТ в ``planning_projects`` с КРТ-топонимами + → ``matched=False, confidence='none'`` (геом-факт без реквизитов). + 3. Никакой ложной атрибуции: при отсутствии exact-матча — NOT передаём + developer_name/auction_final_price (остаются None). + + Args: + db: сессия. + parcel_wkt: геометрия участка в WKT (EPSG:4326). None/пусто → []. + + Returns: + list[dict]: каждый элемент содержит поля ниже; список пуст при graceful-деградации. + Поля: ``matched`` (bool), ``confidence`` ('exact'|'none'), + ``krt_geom_ref`` (pp_id или None), ``pp_full_name`` (str), + ``site_name``, ``developer_name``, ``auction_final_price``, + ``gradpotential_sqm``, ``area_ha``, ``contract_number`` — None при matched=False. + """ + if not parcel_wkt: + return [] + + try: + rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + except (OperationalError, ProgrammingError) as exc: + logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc) + return [] + except Exception as exc: + logger.warning("parcel_krt_requisites: неожиданная ошибка, skip: %s", exc) + return [] + + if rows: + # Есть exact-матч — возвращаем с реквизитами + seen: set[tuple[int, int]] = set() + result: list[dict[str, Any]] = [] + for r in rows: + key = (r["pp_id"], r["krt_id"]) + if key in seen: + continue + seen.add(key) + result.append( + { + "matched": True, + "confidence": "exact", + "krt_geom_ref": r["pp_id"], + "pp_full_name": r["pp_full_name"], + "site_name": r["site_name"], + "developer_name": r["developer_name"], + "contract_number": r["contract_number"], + "auction_final_price": ( + float(r["auction_final_price"]) + if r["auction_final_price"] is not None + else None + ), + "gradpotential_sqm": ( + float(r["gradpotential_sqm"]) + if r["gradpotential_sqm"] is not None + else None + ), + "area_ha": float(r["area_ha"]) if r["area_ha"] is not None else None, + } + ) + return result + + # Нет exact-матча — geom-only поиск по КРТ-топонимам + try: + no_match_rows = ( + db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + ) + except (OperationalError, ProgrammingError) as exc: + logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc) + return [] + except Exception as exc: + logger.warning("parcel_krt_requisites: no-match fallback ошибка: %s", exc) + return [] + + return [ + { + "matched": False, + "confidence": "none", + "krt_geom_ref": r["pp_id"], + "pp_full_name": r["pp_full_name"], + "site_name": None, + "developer_name": None, + "contract_number": None, + "auction_final_price": None, + "gradpotential_sqm": None, + "area_ha": None, + } + for r in no_match_rows + ] + + +__all__ = ["parcel_krt_requisites"] diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 8ca50b91..ed9fcc87 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -71,6 +71,7 @@ celery_app = Celery( "app.workers.tasks.zone_regulation_refresh", "app.workers.tasks.reservation_ingest", "app.workers.tasks.genplan_zones_sync", + "app.workers.tasks.ekb_ppt_tep_sync", ], ) celery_app.conf.timezone = "Europe/Moscow" diff --git a/backend/app/workers/tasks/ekb_ppt_tep_sync.py b/backend/app/workers/tasks/ekb_ppt_tep_sync.py new file mode 100644 index 00000000..4ee36d09 --- /dev/null +++ b/backend/app/workers/tasks/ekb_ppt_tep_sync.py @@ -0,0 +1,145 @@ +"""Celery task: ingest ТЭП из PDF пояснительных записок ППТ/ПМТ (#1085). + +Fetch PDF → parse_ppt_tep → UPSERT в ekb_ppt_tep (ON CONFLICT по doc_ref). +SAVEPOINT per-doc: ошибка одного документа не откатывает всю транзакцию. +Beat НЕ регистрируется — обновление редкое, запуск ручной/через admin. +""" + +from __future__ import annotations + +import json +import logging + +import httpx +from sqlalchemy import text + +from app.core.db import SessionLocal +from app.services.scrapers.ekb_ppt_tep_parser import PptTep, parse_ppt_tep +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + +# ── Seed-документы ───────────────────────────────────────────────────────────── +# Один образец: ППТ 22823 (2018), пояснительная записка. +# URL — placeholder: VERIFY на екатеринбург.рф/документация/ппт/... +# Реальный URL уточнить у пользователя перед прод-прогоном. +_SEED_DOCS: list[dict] = [ + { + "doc_ref": "ppt2018_22823", + "url": "# VERIFY URL: https://xn--80acgfbsl1azdqr.xn--p1ai/документация/ппт/22823", + "project_type": "ppt", + }, +] + +_UPSERT_SQL = text(""" + INSERT INTO ekb_ppt_tep ( + doc_ref, project_type, + zone_balance, tep, phasing, raw_tables, + source_pdf, fetched_at + ) + VALUES ( + :doc_ref, :project_type, + CAST(:zone_balance AS jsonb), + CAST(:tep AS jsonb), + CAST(:phasing AS jsonb), + CAST(:raw_tables AS jsonb), + :source_pdf, now() + ) + ON CONFLICT (doc_ref) DO UPDATE SET + project_type = EXCLUDED.project_type, + zone_balance = EXCLUDED.zone_balance, + tep = EXCLUDED.tep, + phasing = EXCLUDED.phasing, + raw_tables = EXCLUDED.raw_tables, + source_pdf = EXCLUDED.source_pdf, + fetched_at = now() +""") + + +def _fetch_pdf(url: str) -> bytes: + """Скачивает PDF по URL; verify=False из-за CA Минцифры (как ekburg_permits, #242).""" + with httpx.Client(verify=False, timeout=60.0, follow_redirects=True) as client: + resp = client.get(url, headers={"User-Agent": "GenDesign-SF/1.0 (+https://gendsgn.ru/)"}) + resp.raise_for_status() + return resp.content + + +def _tep_to_params(doc_ref: str, project_type: str, source_pdf: str, tep: PptTep) -> dict: + return { + "doc_ref": doc_ref, + "project_type": project_type, + "zone_balance": json.dumps(tep.zone_balance, ensure_ascii=False), + "tep": json.dumps(tep.tep, ensure_ascii=False), + "phasing": json.dumps(tep.phasing, ensure_ascii=False), + "raw_tables": json.dumps(tep.raw_tables, ensure_ascii=False), + "source_pdf": source_pdf, + } + + +@celery_app.task(name="tasks.ekb_ppt_tep_sync.ingest_ppt_tep") +def ingest_ppt_tep(docs: list[dict] | None = None) -> dict[str, int]: + """Fetch PDF + parse ТЭП + UPSERT в ekb_ppt_tep. + + Args: + docs: список [{doc_ref, url, project_type}] или None → используется + _SEED_DOCS (1 seed-документ образца ppt2018_22823). + + Returns: + {"docs": N} — число успешно обработанных документов. + """ + target_docs: list[dict] = docs if docs is not None else _SEED_DOCS + logger.info("ekb_ppt_tep_sync: start, docs=%d", len(target_docs)) + + processed = 0 + failed = 0 + + db = SessionLocal() + try: + for doc in target_docs: + doc_ref = doc["doc_ref"] + url = doc["url"] + project_type = doc.get("project_type", "ppt") + + # Пропускаем placeholder-URL (seed без реального источника) + if url.startswith("# VERIFY"): + logger.warning( + "ekb_ppt_tep_sync: doc_ref=%s — URL не задан (placeholder), пропускаем", + doc_ref, + ) + continue + + try: + logger.info("ekb_ppt_tep_sync: fetch %s (%s)", doc_ref, url) + pdf_bytes = _fetch_pdf(url) + tep = parse_ppt_tep(pdf_bytes) + + params = _tep_to_params(doc_ref, project_type, url, tep) + + with db.begin_nested(): + db.execute(_UPSERT_SQL, params) + + processed += 1 + logger.info( + "ekb_ppt_tep_sync: upserted %s — zones=%d tep=%d phasing=%d", + doc_ref, + len(tep.zone_balance), + len(tep.tep), + len(tep.phasing), + ) + except Exception as exc: + failed += 1 + logger.warning( + "ekb_ppt_tep_sync: failed for doc_ref=%s: %s", + doc_ref, + exc, + ) + + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + logger.info("ekb_ppt_tep_sync: done — processed=%d failed=%d", processed, failed) + return {"docs": processed} diff --git a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py new file mode 100644 index 00000000..9647ed61 --- /dev/null +++ b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py @@ -0,0 +1,418 @@ +"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#1085). + +Синтетические тесты: pdfplumber мокируется; реальный PDF-тест — отдельный +класс с @pytest.mark.skipif (skip в CI, запуск вручную). +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from app.services.scrapers.ekb_ppt_tep_parser import ( + PptTep, + _parse_table11, + _parse_table12, + _parse_table13, + parse_ppt_tep, +) + +# ── Вспомогательные фикстуры ───────────────────────────────────────────────── + +# Минимальные данные Табл.11 (баланс территории) +_TABLE11_ROWS: list[list[str | None]] = [ + ["№", "Наименование зоны", "Площадь", None], + [None, None, "Сущ., га (%)", "Проект, га\n(%)"], + ["1", "Многофункциональная зона", "15,41 га\n(42,61 %)", "11,34 га\n(31,36 %)"], + ["2", "Зона объектов здравоохранения", "0,24 га\n(0,66 %)", "0,25 га\n(0,69 %)"], + ["Итого:", None, "36,16 га\n(100 %)", "36,16 га\n(100 %)"], +] + +# Минимальные данные Табл.12 (ТЭП) +_TABLE12_ROWS: list[list[str | None]] = [ + [ + "№", + "Наименование показателей", + "Единица\nизмерения", + "Современное\nсостояние", + "Расчётный\nсрок", + ], + ["1", "Территория", None, None, None], + ["", "Площадь проектируемой территории", "га", "36,1", "36,16"], + ["2", "Жилищный фонд", None, None, None], + ["2.1", "Общая площадь жилых помещений", "тыс. кв. м", "", "120,5"], +] + +# Минимальные данные Табл.13 (очередность) +_TABLE13_ROWS: list[list[str | None]] = [ + # Заголовок + [ + "Номер\nочереди\nстроит-ва", + "Состав этапа строительства", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "Зона планируемого\nразмещения", + None, + "Площадь зон, га", + None, + "Источник\nфинансирования", + None, + "Программа", + ], + # Строка-нумерация «1 2 4 5 6 7» + [ + "1", + "2", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "4", + None, + "5", + None, + "6", + None, + "7", + ], + # Реальные строки + [ + "I этап\n2018-2021", + "", + None, + None, + None, + None, + None, + None, + None, + None, + None, + "Объекты капитального строительства", + None, + None, + None, + None, + "", + None, + None, + None, + ], + [ + None, + "", + "", + "Многофункциональная ледовая арена", + None, + None, + None, + None, + None, + None, + None, + None, + "", + "Зона объектов спорта", + None, + "4,97", + None, + "Внебюджетные", + None, + "", + ], +] + + +# ── Тесты _parse_table11 ────────────────────────────────────────────────────── + + +class TestParseTable11: + def test_returns_list(self) -> None: + result = _parse_table11(_TABLE11_ROWS) + assert isinstance(result, list) + + def test_correct_row_count(self) -> None: + """Должны получить 2 зоны + 1 Итого-строку.""" + result = _parse_table11(_TABLE11_ROWS) + assert len(result) == 3 + + def test_zone_name_present(self) -> None: + result = _parse_table11(_TABLE11_ROWS) + zone_names = [r["zone_name"] for r in result] + assert any("Многофункциональная" in n for n in zone_names) + + def test_numeric_parsed(self) -> None: + result = _parse_table11(_TABLE11_ROWS) + row1 = next(r for r in result if r["no"] == "1") + assert row1["existing_ha_num"] == pytest.approx(15.41, abs=0.01) + assert row1["project_ha_num"] == pytest.approx(11.34, abs=0.01) + + def test_itogo_row_flagged(self) -> None: + result = _parse_table11(_TABLE11_ROWS) + totals = [r for r in result if r.get("is_total")] + assert len(totals) == 1 + + def test_empty_input_returns_empty(self) -> None: + assert _parse_table11([]) == [] + + def test_missing_table_returns_empty(self) -> None: + """Строки без полезного содержимого → пустой результат.""" + garbage = [["", None, None, None], [None, None, None, None]] + result = _parse_table11(garbage) + assert result == [] + + +# ── Тесты _parse_table12 ────────────────────────────────────────────────────── + + +class TestParseTable12: + def test_returns_list(self) -> None: + result = _parse_table12(_TABLE12_ROWS) + assert isinstance(result, list) + + def test_header_skipped(self) -> None: + result = _parse_table12(_TABLE12_ROWS) + # Первая строка с «№»/«Наименование показателей» должна быть пропущена + no_values = [r["no"] for r in result] + assert "№" not in no_values + + def test_indicator_present(self) -> None: + result = _parse_table12(_TABLE12_ROWS) + indicators = [r["indicator"] for r in result] + assert any("Территория" in i for i in indicators) + + def test_numeric_values(self) -> None: + result = _parse_table12(_TABLE12_ROWS) + area_row = next((r for r in result if "Площадь" in r["indicator"]), None) + assert area_row is not None + assert area_row["target"] == "36,16" + + def test_empty_input_returns_empty(self) -> None: + assert _parse_table12([]) == [] + + +# ── Тесты _parse_table13 ────────────────────────────────────────────────────── + + +class TestParseTable13: + def test_returns_list(self) -> None: + result = _parse_table13(_TABLE13_ROWS) + assert isinstance(result, list) + + def test_phase_extracted(self) -> None: + result = _parse_table13(_TABLE13_ROWS) + phases = [r["phase"] for r in result if r["phase"]] + assert any("этап" in p.lower() or "2018" in p for p in phases) + + def test_zone_extracted(self) -> None: + result = _parse_table13(_TABLE13_ROWS) + zones = [r["zone"] for r in result if r["zone"]] + assert any("спорт" in z.lower() or "Зона" in z for z in zones) + + def test_area_ha_num_parsed(self) -> None: + result = _parse_table13(_TABLE13_ROWS) + rows_with_area = [r for r in result if r.get("area_ha_num") is not None] + assert len(rows_with_area) >= 1 + areas = [r["area_ha_num"] for r in rows_with_area] + assert any(abs(a - 4.97) < 0.01 for a in areas) + + def test_empty_input_returns_empty(self) -> None: + assert _parse_table13([]) == [] + + +# ── Тест parse_ppt_tep с mock pdfplumber ───────────────────────────────────── + + +def _make_mock_page(text: str, tables: list[list[list[str | None]]]) -> MagicMock: + """Создаёт mock pdfplumber Page с заданным текстом и таблицами.""" + page = MagicMock() + page.extract_text.return_value = text + page.extract_tables.return_value = tables + return page + + +class TestParsePptTepMocked: + """Тесты parse_ppt_tep с мокированным pdfplumber.open.""" + + def _build_mock_pdf(self) -> list[MagicMock]: + """Собирает 3-страничный mock-PDF с Табл.11/12/13.""" + # Страница 1: Таблица 11 (баланс) + page11 = _make_mock_page( + "Таблица 11\nБаланс территории", + [_TABLE11_ROWS], + ) + # Страница 2: Таблица 12 (ТЭП) + page12 = _make_mock_page( + "Таблица 12\nОсновные технико-экономические показатели", + [_TABLE12_ROWS], + ) + # Страница 3: Таблица 13 (очередность) + page13 = _make_mock_page( + "Таблица 13\nОчередность строительства", + [_TABLE13_ROWS], + ) + return [page11, page12, page13] + + def test_returns_ppt_tep_instance(self) -> None: + pages = self._build_mock_pdf() + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = pages + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert isinstance(result, PptTep) + + def test_zone_balance_not_empty(self) -> None: + pages = self._build_mock_pdf() + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = pages + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert len(result.zone_balance) > 0, "zone_balance не должен быть пустым" + + def test_tep_not_empty(self) -> None: + pages = self._build_mock_pdf() + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = pages + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert len(result.tep) > 0, "tep не должен быть пустым" + + def test_phasing_not_empty(self) -> None: + pages = self._build_mock_pdf() + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = pages + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert len(result.phasing) > 0, "phasing не должен быть пустым" + + def test_missing_table13_returns_empty_phasing(self) -> None: + """Если Табл.13 нет в PDF — phasing = [].""" + pages_no13 = self._build_mock_pdf()[:2] # только стр. 1-2 + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = pages_no13 + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert result.phasing == [], "phasing должен быть [] если Табл.13 не найдена" + + def test_missing_all_tables_returns_empty_result(self) -> None: + """PDF без таблиц → все поля [].""" + page_empty = _make_mock_page("Пустая страница без таблиц", []) + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = [page_empty] + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert result.zone_balance == [] + assert result.tep == [] + assert result.phasing == [] + + def test_raw_tables_populated(self) -> None: + """raw_tables заполняется данными о найденных таблицах.""" + pages = self._build_mock_pdf() + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + ctx.pages = pages + + with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): + result = parse_ppt_tep(b"fake-pdf-bytes") + + assert len(result.raw_tables) > 0 + # Проверяем что raw_tables содержит page, table_index, rows + first = result.raw_tables[0] + assert "page" in first + assert "table_index" in first + assert "rows" in first + + +# ── Тест на реальном PDF-образце (skip в CI) ───────────────────────────────── + +_REAL_PDF_PATH = Path( + "C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf" +) + + +@pytest.mark.skipif( + not _REAL_PDF_PATH.exists(), + reason="Реальный PDF-образец отсутствует (только для локального запуска)", +) +class TestParsePptTepRealPdf: + """Интеграционные тесты на реальном образце ppt2018_22823.""" + + @pytest.fixture(scope="class") + def result(self) -> PptTep: + pdf_bytes = _REAL_PDF_PATH.read_bytes() + return parse_ppt_tep(pdf_bytes) + + def test_zone_balance_has_rows(self, result: PptTep) -> None: + """Табл.11 должна иметь ≥5 зон.""" + n = len(result.zone_balance) + assert n >= 5, f"Ожидалось ≥5 зон, получено {n}" + + def test_zone_balance_has_itogo(self, result: PptTep) -> None: + """Итоговая строка присутствует в Табл.11.""" + has_total = any( + "итого" in r.get("zone_name", "").lower() or r.get("is_total") + for r in result.zone_balance + ) + assert has_total, "Итого-строка не найдена в zone_balance" + + def test_tep_has_rows(self, result: PptTep) -> None: + """Табл.12 должна иметь ≥10 показателей.""" + assert len(result.tep) >= 10, f"Ожидалось ≥10 показателей, получено {len(result.tep)}" + + def test_phasing_has_rows(self, result: PptTep) -> None: + """Табл.13 должна иметь ≥3 строки.""" + n = len(result.phasing) + assert n >= 3, f"Ожидалось ≥3 строки очередности, получено {n}" + + def test_phasing_has_area(self, result: PptTep) -> None: + """Хотя бы одна строка очередности содержит площадь > 0.""" + areas = [r["area_ha_num"] for r in result.phasing if r.get("area_ha_num")] + assert areas, "Площади зон в Табл.13 не распарсились" + assert max(areas) > 0 + + def test_raw_tables_not_empty(self, result: PptTep) -> None: + """raw_tables содержит хотя бы 3 записи (по одной на таблицу).""" + assert len(result.raw_tables) >= 3 diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py index fc55b9d3..f9839da9 100644 --- a/backend/tests/services/test_ird_analyze.py +++ b/backend/tests/services/test_ird_analyze.py @@ -45,10 +45,27 @@ _OVERLAY = { _PLANNING = [{"project_type": "ppt", "doc_status_name": "действующий", "full_name": "ПАГЕ №1"}] +_KRT_REQUISITES: list[dict[str, Any]] = [ + { + "matched": True, + "confidence": "exact", + "krt_geom_ref": 1166, + "pp_full_name": "ПАГЕ № 1970", + "site_name": "КРТ Летчиков", + "developer_name": "ООО СЗ Летчиков", + "contract_number": "2-7/21.7-14", + "auction_final_price": 5_000_000.0, + "gradpotential_sqm": 25_000.0, + "area_ha": 1.5, + } +] + + def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None: monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay) monkeypatch.setattr(ird_analyze, "parcel_planning_overlaps", lambda db, wkt: list(_PLANNING)) monkeypatch.setattr(ird_analyze, "parcel_reservations", lambda db, cad_num: []) + monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: list(_KRT_REQUISITES)) def test_full_block_assembly(monkeypatch: Any) -> None: @@ -239,3 +256,37 @@ def test_reservations_none_cad_num_returns_empty(monkeypatch: Any) -> None: assert "reservations" in block assert block["reservations"] == [] + + +# --------------------------------------------------------------------------- +# KRT-реквизиты (#1060) +# --------------------------------------------------------------------------- + + +def test_krt_requisites_wired_to_block(monkeypatch: Any) -> None: + """parcel_krt_requisites возвращает реквизит → ключ krt_requisites присутствует в блоке.""" + _patch_overlay(monkeypatch, _OVERLAY) + monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) + client = _FakeClient(fz=[], krt=[]) + + block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client) + + assert "krt_requisites" in block + assert len(block["krt_requisites"]) == 1 + r = block["krt_requisites"][0] + assert r["matched"] is True + assert r["confidence"] == "exact" + assert r["developer_name"] == "ООО СЗ Летчиков" + + +def test_krt_requisites_empty_when_no_overlap(monkeypatch: Any) -> None: + """parcel_krt_requisites пуст → krt_requisites == [] в блоке (ключ присутствует).""" + _patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}}) + monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: []) + monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None) + client = _FakeClient(fz=[], krt=[]) + + block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client) + + assert "krt_requisites" in block + assert block["krt_requisites"] == [] diff --git a/backend/tests/services/test_krt_lookup.py b/backend/tests/services/test_krt_lookup.py new file mode 100644 index 00000000..fedd03e6 --- /dev/null +++ b/backend/tests/services/test_krt_lookup.py @@ -0,0 +1,176 @@ +"""Тесты parcel_krt_requisites (#1060) — КРТ-реквизиты из ekb_krt_sites + planning_projects. + +DB/сеть не дёргаются: мокаем db.execute через FakeDB. +Проверяем: + - exact-матч → matched=True, confidence='exact', реквизиты заполнены; + - planning overlap без матча → matched=False, confidence='none', без застройщика; + - пустой WKT → []; + - OperationalError (таблица не задеплоена) → graceful []; + - дубликаты строк (один pp_id × krt_id) → дедублируются. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from sqlalchemy.exc import OperationalError + +from app.services.site_finder.krt_lookup import parcel_krt_requisites + + +class _MappingRow(dict): # type: ignore[type-arg] + """Имитирует sqlalchemy MappingResult row (dict-like).""" + + +class _Result: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = [_MappingRow(r) for r in rows] + + def mappings(self) -> _Result: + return self + + def all(self) -> list[_MappingRow]: + return self._rows + + +class _FakeDB: + """Мок SQLAlchemy Session: первый вызов execute → first_rows; второй → second_rows.""" + + def __init__( + self, + first_rows: list[dict[str, Any]] | None = None, + second_rows: list[dict[str, Any]] | None = None, + raise_first: Exception | None = None, + raise_second: Exception | None = None, + ) -> None: + self._calls = 0 + self._first = first_rows or [] + self._second = second_rows or [] + self._raise_first = raise_first + self._raise_second = raise_second + + def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result: + self._calls += 1 + if self._calls == 1: + if self._raise_first: + raise self._raise_first + return _Result(self._first) + if self._raise_second: + raise self._raise_second + return _Result(self._second) + + +_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))" + +# Типичная строка exact-матча (planning_projects ⋈ ekb_krt_sites) +_EXACT_ROW = { + "pp_id": 1166, + "pp_full_name": ( + " ПАГЕ № 1970 от 16.08.2024\nПроект межевания территории в районе улицы Летчиков" + ), + "dmd_actual_year": 2024, + "doc_status_name": "Действующий", + "krt_id": 7, + "site_name": "2. КРТ жилой застройки в районе ул. Летчиков", + "developer_name": "ООО СЗ Летчиков", + "contract_number": "2-7/21.7-14", + "auction_final_price": Decimal("5000000"), + "gradpotential_sqm": Decimal("25000"), + "area_ha": Decimal("1.50"), + "krt_kind": "жилой застройки", +} + + +def test_exact_match_returns_requisites() -> None: + """Exact-матч по ПАГЕ-номер+год → matched=True, реквизиты застройщика заполнены.""" + db = _FakeDB(first_rows=[_EXACT_ROW]) + result = parcel_krt_requisites(db, _WKT) + + assert len(result) == 1 + r = result[0] + assert r["matched"] is True + assert r["confidence"] == "exact" + assert r["krt_geom_ref"] == 1166 + assert r["site_name"] == "2. КРТ жилой застройки в районе ул. Летчиков" + assert r["developer_name"] == "ООО СЗ Летчиков" + assert r["contract_number"] == "2-7/21.7-14" + assert r["auction_final_price"] == 5_000_000.0 + assert r["gradpotential_sqm"] == 25_000.0 + assert r["area_ha"] == 1.50 + + +def test_exact_match_deduplicates_pp_krt_pair() -> None: + """Дублирующиеся строки (pp_id × krt_id) дедублируются → один результат.""" + db = _FakeDB(first_rows=[_EXACT_ROW, _EXACT_ROW]) + result = parcel_krt_requisites(db, _WKT) + assert len(result) == 1 + + +def test_no_match_returns_geom_only() -> None: + """Первый запрос пуст (нет exact-матча) → второй запрос → matched=False без реквизитов.""" + no_match_row = { + "pp_id": 999, + "pp_full_name": " ПАГЕ № 4033 от 27.12.2022\n" + "Проект планировки территории в районе улицы Краснофлотцев", + "dmd_actual_year": 2022, + "doc_status_name": "Действующий", + } + db = _FakeDB(first_rows=[], second_rows=[no_match_row]) + result = parcel_krt_requisites(db, _WKT) + + assert len(result) == 1 + r = result[0] + assert r["matched"] is False + assert r["confidence"] == "none" + assert r["krt_geom_ref"] == 999 + assert r["developer_name"] is None + assert r["auction_final_price"] is None + assert r["gradpotential_sqm"] is None + assert r["site_name"] is None + + +def test_empty_wkt_returns_empty_list() -> None: + """parcel_wkt=None → пустой список без вызова DB.""" + db = _FakeDB(first_rows=[_EXACT_ROW]) + assert parcel_krt_requisites(db, None) == [] + assert db._calls == 0 # DB не вызывалась + + +def test_empty_string_wkt_returns_empty_list() -> None: + """parcel_wkt='' → пустой список.""" + assert parcel_krt_requisites(_FakeDB(), "") == [] + + +def test_operational_error_first_query_returns_empty() -> None: + """OperationalError на первом запросе (таблица не задеплоена) → [].""" + db = _FakeDB(raise_first=OperationalError("stmt", {}, Exception("relation does not exist"))) + result = parcel_krt_requisites(db, _WKT) + assert result == [] + + +def test_operational_error_second_query_returns_empty() -> None: + """OperationalError на втором запросе (no-match fallback) → [].""" + db = _FakeDB( + first_rows=[], + raise_second=OperationalError("stmt", {}, Exception("relation does not exist")), + ) + result = parcel_krt_requisites(db, _WKT) + assert result == [] + + +def test_none_decimal_fields_become_none() -> None: + """NULL Decimal-поля → None в результате, не падают при float().""" + row = {**_EXACT_ROW, "auction_final_price": None, "gradpotential_sqm": None, "area_ha": None} + db = _FakeDB(first_rows=[row]) + result = parcel_krt_requisites(db, _WKT) + assert result[0]["auction_final_price"] is None + assert result[0]["gradpotential_sqm"] is None + assert result[0]["area_ha"] is None + + +def test_no_overlap_at_all_returns_empty() -> None: + """Оба запроса возвращают пустые списки → [].""" + db = _FakeDB(first_rows=[], second_rows=[]) + result = parcel_krt_requisites(db, _WKT) + assert result == [] diff --git a/data/sql/138_ekb_ppt_tep.sql b/data/sql/138_ekb_ppt_tep.sql new file mode 100644 index 00000000..f00acc26 --- /dev/null +++ b/data/sql/138_ekb_ppt_tep.sql @@ -0,0 +1,86 @@ +-- 138_ekb_ppt_tep.sql +-- #1085 (Site Finder v2 / GG-форсайт #1067) — ТЭП из ППТ/ПМТ-пояснительных записок. +-- +-- Что/зачем: структурированный контент пояснительных записок ППТ/ПМТ (PDF). +-- Три логических таблицы документа: +-- Табл.11 — баланс территории по зонам (га + %); +-- Табл.12 — основные ТЭП (площади, этажность, плотность, инфраструктура); +-- Табл.13 — очередность строительства (объект/зона/срок/финансирование). +-- +-- Линковка к геометрии: doc_ref сопоставляется с planning_projects.source_key / +-- doc_full_name (данные ППТ/ПМТ уже в БД через WFS, #1085). Прямой FK не ставим +-- из-за best-effort nature ссылок (одна пояснительная → несколько границ и наоборот). +-- +-- Grain: одна строка = один PDF-документ пояснительной записки. +-- UNIQUE(doc_ref) — не допускает дублей при повторном ingest. +-- +-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN). +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для UNIQUE. +-- Без INSERT (данные приходят из ekb_ppt_tep_sync Celery-воркера). + +BEGIN; + +-- ── 1. Таблица ekb_ppt_tep ──────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ekb_ppt_tep ( + id BIGSERIAL PRIMARY KEY, + doc_ref TEXT NOT NULL, -- ид/ссылка документа ППТ (для join к planning_projects) + project_type TEXT NOT NULL, -- 'ppt' | 'pmt' + zone_balance JSONB, -- Табл.11: баланс территории по зонам + tep JSONB, -- Табл.12: основные ТЭП + phasing JSONB, -- Табл.13: очередность строительства + raw_tables JSONB, -- сырые таблицы для отладки/re-parse + source_pdf TEXT, -- URL или путь к исходному PDF + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE ekb_ppt_tep IS + 'ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067). ' + '1 строка = 1 PDF-документ. ' + 'Три JSONB-поля: zone_balance (Табл.11), tep (Табл.12), phasing (Табл.13). ' + 'Линковка к геометрии: doc_ref → planning_projects.source_key.'; + +COMMENT ON COLUMN ekb_ppt_tep.doc_ref IS + 'Идентификатор/ссылка документа ППТ. Для сопоставления с ' + 'planning_projects.source_key или doc_full_name.'; +COMMENT ON COLUMN ekb_ppt_tep.project_type IS + '''ppt'' — проект планировки территории; ''pmt'' — проект межевания территории.'; +COMMENT ON COLUMN ekb_ppt_tep.zone_balance IS + 'Баланс территории по зонам (Табл.11): [{no, zone_name, existing_ha, project_ha, ...}]. ' + 'Пустой массив [] если таблица не найдена в PDF.'; +COMMENT ON COLUMN ekb_ppt_tep.tep IS + 'Основные ТЭП (Табл.12): [{no, indicator, unit, current, target}]. ' + 'Пустой массив [] если таблица не найдена.'; +COMMENT ON COLUMN ekb_ppt_tep.phasing IS + 'Очередность строительства (Табл.13): [{phase, composition, zone, area_ha, ' + 'funding, program}]. Пустой массив [] если таблица не найдена.'; +COMMENT ON COLUMN ekb_ppt_tep.raw_tables IS + 'Сырые таблицы из pdfplumber для отладки: [{page, table_index, rows: [[...]]}]. '; + +-- ── 2. UNIQUE(doc_ref) — дедуп-ключ для UPSERT воркера ────────────────────── +-- Guard по pg_constraint → idempotent re-apply (прецедент: м.132, 136, 137). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'uq_ekb_ppt_tep_doc_ref' + ) THEN + ALTER TABLE ekb_ppt_tep + ADD CONSTRAINT uq_ekb_ppt_tep_doc_ref UNIQUE (doc_ref); + END IF; +END +$$; + +-- ── 3. Индексы ──────────────────────────────────────────────────────────────── +-- Фильтр по типу проекта (ppt/pmt). +CREATE INDEX IF NOT EXISTS idx_ekb_ppt_tep_project_type + ON ekb_ppt_tep (project_type); + +-- Свежесть / ORDER BY fetched_at для мониторинга. +CREATE INDEX IF NOT EXISTS idx_ekb_ppt_tep_fetched_at + ON ekb_ppt_tep (fetched_at DESC); + +-- GIN-индекс на zone_balance для поиска по зонам (follow-up: @> queries). +CREATE INDEX IF NOT EXISTS idx_ekb_ppt_tep_zone_balance_gin + ON ekb_ppt_tep USING GIN (zone_balance) + WHERE zone_balance IS NOT NULL; + +COMMIT;