gendesign/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py
Light1YT 285e8f974a
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m12s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 4m34s
Deploy / deploy (push) Successful in 1m11s
fix(sf): ekb_ppt_tep post-фильтры + врезка в analyze (#1136)
Post-фильтры парсера (по образцу ppt2018_22823):
- Табл.11: отбрасываем строки без zone_name (преамбульные артефакты
  merged-cells давали 2 фантома при 12 реальных зонах).
- Табл.13: dedup по (phase, composition, zone, area_ha) + фильтры
  короткого-без-зоны и нумерационного шума (~364 строк → ~30-40 реальных).

Analyze-врезка: новый ppt_tep_lookup.parcel_ppt_tep — JOIN
planning_projects ⋈ ekb_ppt_tep по doc_ref↔source_key/doc_full_name
(best-effort, без FK). Wired в build_ird_analyze_block рядом с
planning_projects/krt_requisites — DB-источники, graceful.

Seed-URL остаётся placeholder с # VERIFY (ingest пропускает с WARNING).

Tests: 27 в parser (3 новых), 7 lookup, 22 wiring (2 новых).
All ruff/syntax green.

Closes #1136
2026-06-13 09:32:52 +00:00

534 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#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 == []
def test_preamble_artifact_filtered_out(self) -> None:
"""Post-фильтр #1136: строки с № но без zone_name отбрасываются.
Это «фантомные» строки преамбульного merged-cells шума (на ppt2018_22823
давали 2 артефакта при 12 реальных зонах).
"""
rows: list[list[str | None]] = [
["", "Наименование зоны", "Площадь", None],
[None, None, "Сущ., га (%)", "Проект, га\n(%)"],
# Артефакт: № есть, но zone_name пустой — должен отброситься.
["1", "", "15,41 га", "11,34 га"],
# Артефакт: № есть, zone_name пустой (None) — должен отброситься.
["2", None, "0,24 га", "0,25 га"],
# Реальная зона: остаётся.
["3", "Зона жилой застройки", "10,0 га", "12,0 га"],
# Итого: остаётся (zone_name пуст, но is_total).
["Итого:", None, "25,65 га", "23,59 га"],
]
result = _parse_table11(rows)
zone_names = [r["zone_name"] for r in result]
# Реальная зона должна быть в результате
assert "Зона жилой застройки" in zone_names
# Артефакты (zone_name пуст, не is_total) отброшены
non_total_no_zone = [
r for r in result if not r["zone_name"] and not r.get("is_total")
]
assert non_total_no_zone == []
# Итого-строка осталась
assert any(r.get("is_total") for r in 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([]) == []
def test_dedup_removes_continuation_noise(self) -> None:
"""Post-фильтр #1136: дубликаты merged-cells continuation схлопываются.
На ppt2018_22823 364 «строк» сворачивались в ~30-40 реальных.
Проверяем dedup по (phase, composition, zone, area_ha_norm).
"""
# Реальная строка и три её continuation-копии (merged-cells даёт повторы).
real_row: list[str | None] = [
"I этап\n2018-2021",
"",
"",
"Многофункциональная ледовая арена",
None,
None,
None,
None,
None,
None,
None,
None,
"",
"Зона объектов спорта",
None,
"4,97",
None,
"Внебюджетные",
None,
"",
]
rows: list[list[str | None]] = [
_TABLE13_ROWS[0],
_TABLE13_ROWS[1],
real_row,
real_row, # continuation-дубль 1
real_row, # continuation-дубль 2
real_row, # continuation-дубль 3
]
result = _parse_table13(rows)
# Все 4 копии должны схлопнуться в 1.
matching = [
r
for r in result
if r["zone"] == "Зона объектов спорта" and r.get("area_ha_num") == pytest.approx(4.97)
]
assert len(matching) == 1
def test_short_noise_rows_filtered(self) -> None:
"""Post-фильтр #1136: composition < 4 символов без zone — шум merged-cells."""
rows: list[list[str | None]] = [
_TABLE13_ROWS[0],
_TABLE13_ROWS[1],
# Шум: composition «-», без zone — должен отброситься
[None, "-"] + [None] * 18,
# Шум: composition «1», без zone — должен отброситься
[None, "1"] + [None] * 18,
# Реальная строка
[
"I этап",
"",
"Жилой комплекс",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Зона Ж-1",
None,
"5,0",
None,
"Бюджет",
None,
"",
],
]
result = _parse_table13(rows)
compositions = [r["composition"] for r in result]
assert "-" not in compositions
# Одиночная «1» должна отлететь как номер-осколок
# (real_row даёт composition='Жилой комплекс').
assert any("Жилой" in c for c in compositions)
# ── Тест 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