feat(sf): ППТ/ПМТ ТЭП-парсер (Табл.11/12/13) → ekb_ppt_tep MVP (#1133)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m27s
Deploy / deploy (push) Successful in 1m21s

Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
lekss361 2026-06-07 11:54:40 +00:00 committed by bot-reviewer
parent 795661557f
commit 1e53416277
5 changed files with 1066 additions and 0 deletions

View file

@ -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

View file

@ -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"

View file

@ -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}

View file

@ -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

View file

@ -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;