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.zone_regulation_refresh",
|
||||
"app.workers.tasks.reservation_ingest",
|
||||
"app.workers.tasks.ekb_ppt_tep_sync",
|
||||
],
|
||||
)
|
||||
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