From 1e5341627764dce4eb194d254cd65b105cdd4e6e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 7 Jun 2026 11:54:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(sf):=20=D0=9F=D0=9F=D0=A2/=D0=9F=D0=9C?= =?UTF-8?q?=D0=A2=20=D0=A2=D0=AD=D0=9F-=D0=BF=D0=B0=D1=80=D1=81=D0=B5?= =?UTF-8?q?=D1=80=20(=D0=A2=D0=B0=D0=B1=D0=BB.11/12/13)=20=E2=86=92=20ekb?= =?UTF-8?q?=5Fppt=5Ftep=20MVP=20(#1133)=20Co-authored-by:=20lekss361=20=20Co-committed-by:=20lekss361=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/scrapers/ekb_ppt_tep_parser.py | 416 +++++++++++++++++ backend/app/workers/celery_app.py | 1 + backend/app/workers/tasks/ekb_ppt_tep_sync.py | 145 ++++++ .../scrapers/test_ekb_ppt_tep_parser.py | 418 ++++++++++++++++++ data/sql/138_ekb_ppt_tep.sql | 86 ++++ 5 files changed, 1066 insertions(+) create mode 100644 backend/app/services/scrapers/ekb_ppt_tep_parser.py create mode 100644 backend/app/workers/tasks/ekb_ppt_tep_sync.py create mode 100644 backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py create mode 100644 data/sql/138_ekb_ppt_tep.sql 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/workers/celery_app.py b/backend/app/workers/celery_app.py index 44f49fe3..b40ec8b6 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -70,6 +70,7 @@ celery_app = Celery( "app.workers.tasks.planning_harvest", "app.workers.tasks.zone_regulation_refresh", "app.workers.tasks.reservation_ingest", + "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/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;