feat(sf): ППТ/ПМТ ТЭП-парсер (Табл.11/12/13) → ekb_ppt_tep MVP (#1133)
Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
parent
795661557f
commit
1e53416277
5 changed files with 1066 additions and 0 deletions
416
backend/app/services/scrapers/ekb_ppt_tep_parser.py
Normal file
416
backend/app/services/scrapers/ekb_ppt_tep_parser.py
Normal 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
|
||||||
|
|
@ -70,6 +70,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.planning_harvest",
|
"app.workers.tasks.planning_harvest",
|
||||||
"app.workers.tasks.zone_regulation_refresh",
|
"app.workers.tasks.zone_regulation_refresh",
|
||||||
"app.workers.tasks.reservation_ingest",
|
"app.workers.tasks.reservation_ingest",
|
||||||
|
"app.workers.tasks.ekb_ppt_tep_sync",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
|
||||||
145
backend/app/workers/tasks/ekb_ppt_tep_sync.py
Normal file
145
backend/app/workers/tasks/ekb_ppt_tep_sync.py
Normal 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}
|
||||||
418
backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py
Normal file
418
backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py
Normal 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
|
||||||
86
data/sql/138_ekb_ppt_tep.sql
Normal file
86
data/sql/138_ekb_ppt_tep.sql
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue