Merge remote-tracking branch 'forgejo/main' into feat/ekb-genplan-functional-zones
All checks were successful
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m21s

# Conflicts:
#	backend/app/workers/celery_app.py
This commit is contained in:
bot-backend 2026-06-07 14:59:34 +03:00
commit 5514721a1c
10 changed files with 1523 additions and 0 deletions

View file

@ -43,6 +43,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -53,6 +54,9 @@ jobs:
- 'backend/**'
- 'data/sql/**'
- '.forgejo/workflows/ci.yml'
frontend:
- 'frontend/**'
- '.forgejo/workflows/ci.yml'
backend-tests:
runs-on: ubuntu-latest
@ -116,3 +120,35 @@ jobs:
# (5432 недоступен в этом mock-lane) → SKIP. Это intended.
# PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK.
run: uv run pytest -q --ignore=tests/smoke
frontend-tests:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend == 'true'
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node
# Node 20 — совпадает с major из frontend/Dockerfile (node:20-alpine).
# cache=npm + cache-dependency-path на lockfile → переиспользуем ~/.npm
# между прогонами (mirror Dockerfile's `--mount=type=cache,target=/root/.npm`).
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install deps (npm ci, frozen lockfile)
# ТОЧНЫЕ флаги из frontend/Dockerfile (deps stage):
# --legacy-peer-deps — Tailwind 4 alpha + React 19 peer-dep mismatches;
# --no-audit --no-fund — тише и быстрее в CI. `ci` (не `install`) =
# детерминированно из package-lock.json, fail при дрейфе lock vs package.json.
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Test (vitest)
# `npm run test` = `vitest run` (single-shot, не watch). Только тесты —
# `next build` НАМЕРЕННО не здесь (тяжёлый, verified в deploy.yml build).
run: npm run test

View file

@ -0,0 +1,416 @@
"""Парсер ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067).
Источник: PDF-документы планировочных проектов (ППТ/ПМТ) Екатеринбурга.
Геометрия ППТ/ПМТ уже в БД (planning_projects, 1170 объектов, WFS).
Три целевые таблицы:
Табл.11 Баланс территории по зонам (га, %);
Табл.12 Основные технико-экономические показатели;
Табл.13 Очередность строительства (зона / состав / финансирование).
Особенности:
- Таблицы обнаруживаются по заголовку-caption через extract_text() на каждой странице.
- Каждая таблица может занимать несколько страниц (особенно Табл.12 и 13).
- Структура ППТ разнится парсер работает best-effort:
не найдена таблица соответствующее поле = [] (не исключение).
- Кириллица: используем ТОЛЬКО pdfplumber (pdftotext/poppler даёт mojibake).
- Нормализация числовых значений: «15,41 га», «(42,61 %)» float.
"""
from __future__ import annotations
import io
import logging
import re
from dataclasses import dataclass, field
from typing import Any
import pdfplumber
logger = logging.getLogger(__name__)
# ── Нормализация значений ────────────────────────────────────────────────────
_RE_NUM = re.compile(r"[\d\s]+(?:[.,]\d+)?")
def _norm_value(raw: str | None) -> str:
"""Очищаем ячейку: убираем лишние пробелы, переносы, управляющие символы."""
if raw is None:
return ""
# Убираем управляющие глифы (например  — «»)
text = re.sub(r"[-]", "", raw)
# Нормализуем переносы строк внутри ячейки → пробел
text = re.sub(r"\s*\n\s*", " ", text)
return text.strip()
def _try_float(value: str) -> float | None:
"""Пытаемся превратить строку вида '15,41 га (42,61 %)' в float первого числа."""
m = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)", value)
if not m:
return None
num_str = m.group(1).replace(" ", "").replace(",", ".")
try:
return float(num_str)
except ValueError:
return None
# ── Обнаружение таблиц ───────────────────────────────────────────────────────
# Паттерны для определения номера таблицы в тексте страницы.
# Ищем «Таблица 11», «Таблица 12», «Таблица 13» (латинская «а» вместо кириллической
# тоже встречается в некоторых PDF — поэтому без локали).
_TABLE_CAPTION_RE = {
11: re.compile(r"Таблица\s+11\b", re.IGNORECASE),
12: re.compile(r"Таблица\s+12\b", re.IGNORECASE),
13: re.compile(r"Таблица\s+13\b", re.IGNORECASE),
}
def _page_contains_table(text: str, table_no: int) -> bool:
"""Возвращает True если текст страницы содержит заголовок таблицы N.
Требуем явный caption «Таблица N» hint-только режим (оглавление, перекрёстные ссылки)
даёт false positive и подавляется. Если caption присутствует достаточно.
"""
cap = _TABLE_CAPTION_RE[table_no]
return bool(cap.search(text))
# ── Парсинг конкретных таблиц ────────────────────────────────────────────────
def _parse_table11(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
"""Баланс территории по зонам (Табл.11).
Структура: | Наименование зоны | Сущ., га (%) | Проект, га (%)
Заголовок (первые 2 строки) пропускается по признаку: /None в col[0].
"""
result: list[dict[str, Any]] = []
header_skipped = False
for row in raw_rows:
if not row:
continue
no = _norm_value(row[0] if len(row) > 0 else None)
zone = _norm_value(row[1] if len(row) > 1 else None)
existing = _norm_value(row[2] if len(row) > 2 else None)
project = _norm_value(row[3] if len(row) > 3 else None)
# Строка-заголовок: col[0] в {№, None, ''} и col[1] содержит
# «Наименование» или «Площадь» — пропускаем.
if not header_skipped:
zone_lo = zone.lower()
if no in ("", "", None) and ("наименование" in zone_lo or "площадь" in zone_lo):
header_skipped = True
continue
if no in ("", "", None) and not zone:
continue
# Пропускаем строки без номера — вложенные подзаголовки.
# Итоговую строку включаем (распознаём по «Итого»).
is_total = "итого" in (no + zone).lower()
if not no and not is_total:
continue
record: dict[str, Any] = {
"no": no,
"zone_name": zone,
"existing_ha": _norm_value(existing),
"existing_ha_num": _try_float(existing),
"project_ha": _norm_value(project),
"project_ha_num": _try_float(project),
}
if is_total:
record["is_total"] = True
result.append(record)
return result
def _parse_table12(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
"""Основные ТЭП (Табл.12).
Структура: | Наименование показателей | Единица измерения |
Современное состояние | Расчётный срок.
Таблица может содержать дополнительный столбец «I этап»
(на страницах 112-113 этапы разнесены).
"""
result: list[dict[str, Any]] = []
header_skipped = False
for row in raw_rows:
if not row:
continue
# Чистим все ячейки
cells = [_norm_value(c) for c in row]
if not any(cells):
continue
no = cells[0] if cells else ""
name = cells[1] if len(cells) > 1 else ""
unit = cells[2] if len(cells) > 2 else ""
current = cells[3] if len(cells) > 3 else ""
target = cells[4] if len(cells) > 4 else ""
# Пропуск строки-заголовка таблицы
if not header_skipped:
if "наименование" in name.lower() or "показател" in name.lower():
header_skipped = True
continue
if no in ("", "") and ("единица" in unit.lower() or "измерени" in unit.lower()):
header_skipped = True
continue
# Пропускаем полностью пустые строки
if not (no or name or current or target):
continue
record: dict[str, Any] = {
"no": no,
"indicator": name,
"unit": unit,
"current": current,
"target": target,
}
# Если 6 колонок — есть этапная разбивка (I этап / II этап)
if len(cells) > 5:
record["stage1"] = cells[4]
record["target"] = cells[5]
result.append(record)
return result
def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
"""Очередность строительства (Табл.13).
Структура сложная (20 колонок из-за merged cells):
Нас интересуют: фаза (col[0]), состав объекта (col[1..2]), зона (col[13]),
площадь (col[15]), финансирование (col[17]), программа (col[19]).
Извлекаем только строки с непустым полем «зона» или «состав объекта».
"""
result: list[dict[str, Any]] = []
current_phase: str = ""
header_skipped = False
for row in raw_rows:
if not row:
continue
cells = [_norm_value(c) for c in row]
# Пропуск заголовка (первые 2 строки)
if not header_skipped:
first_meaningful = next((c for c in cells if c), "")
if "номер" in first_meaningful.lower() or "состав" in first_meaningful.lower():
header_skipped = True
continue
# Строка с «1 2 4 5 6 7» (номера колонок)
if first_meaningful in ("1", ""):
continue
# Определяем фазу/этап строительства (ячейка 0, начинается с «I», «II», «III»).
col0 = cells[0] if cells else ""
if re.match(r"^(I{1,3}V?|IV|V)\s*(этап|очередь)?", col0, re.IGNORECASE) and col0:
current_phase = col0
# Состав объекта — из колонок 1 или 3 (после слияния).
# При 20-колоночной таблице ищем непустой текст в диапазоне 1..5.
composition = ""
for ci in range(1, min(6, len(cells))):
if cells[ci]:
composition = cells[ci]
break
# Зона планируемого размещения.
zone = ""
area_ha_str = ""
funding = ""
program = ""
if len(cells) >= 14:
zone = cells[13]
if len(cells) >= 16:
area_ha_str = cells[15]
if len(cells) >= 18:
funding = cells[17]
if len(cells) >= 20:
program = cells[19]
# Пропускаем строки без осмысленного содержания
if not (composition or zone):
continue
# Пропускаем строки-секционные заголовки (нет зоны, состав = подзаголовок раздела)
if not zone and "объект" in composition.lower() and len(composition) < 50:
continue
result.append(
{
"phase": current_phase,
"composition": composition,
"zone": zone,
"area_ha": area_ha_str,
"area_ha_num": _try_float(area_ha_str),
"funding": funding,
"program": program,
}
)
return result
# ── Сборка сырых строк со всех страниц блока таблицы ────────────────────────
def _collect_table_rows(
pages: list[pdfplumber.page.Page],
start_page_idx: int,
table_no: int,
next_table_no: int | None,
) -> tuple[list[list[str | None]], list[dict[str, Any]]]:
"""Собирает строки таблицы N начиная со страницы start_page_idx.
Останавливается когда встречает заголовок следующей таблицы (next_table_no)
или «Рис.» / другой раздел.
Returns:
(all_rows, raw_table_records) строки для парсинга и сырые данные для
поля raw_tables.
"""
all_rows: list[list[str | None]] = []
raw_records: list[dict[str, Any]] = []
found_start = False
for pi in range(start_page_idx, len(pages)):
page = pages[pi]
page_text = page.extract_text() or ""
# Обнаружение начала нашей таблицы
if not found_start:
if _page_contains_table(page_text, table_no):
found_start = True
else:
continue
# Стоп-условие: начался следующий раздел/таблица
if found_start and pi > start_page_idx:
if next_table_no and _page_contains_table(page_text, next_table_no):
break
# Извлекаем таблицы со страницы
page_tables = page.extract_tables()
for ti, tbl in enumerate(page_tables):
if not tbl:
continue
# Берём только таблицы с ≥2 строками и ≥2 колонками
if len(tbl) < 2 or (tbl[0] and len(tbl[0]) < 2):
continue
# На первой странице таблицы пропускаем маленькие таблицы (заголовки/рамки)
# — они идут ДО основной таблицы данных.
if pi == start_page_idx and len(tbl) < 4:
continue
all_rows.extend(tbl)
raw_records.append(
{
"page": pi + 1, # 1-based для отладки
"table_index": ti,
"rows": [[_norm_value(c) for c in row] for row in tbl[:10]], # cap 10 rows
}
)
return all_rows, raw_records
# ── Публичный API ────────────────────────────────────────────────────────────
@dataclass
class PptTep:
"""Результат парсинга пояснительной записки ППТ/ПМТ.
Каждое поле list[dict] с нормализованными строками таблицы.
Пустой список означает «таблица не найдена» (best-effort, не ошибка).
"""
zone_balance: list[dict[str, Any]] = field(default_factory=list)
tep: list[dict[str, Any]] = field(default_factory=list)
phasing: list[dict[str, Any]] = field(default_factory=list)
raw_tables: list[dict[str, Any]] = field(default_factory=list)
def parse_ppt_tep(pdf_bytes: bytes) -> PptTep:
"""Парсит PDF пояснительной записки ППТ/ПМТ → ТЭП (Табл.11/12/13).
Чистая функция от bytes; сеть/fetch вне этой функции.
Graceful при отсутствии таблиц: поле = [] вместо исключения.
Args:
pdf_bytes: сырые байты PDF-документа.
Returns:
PptTep с заполненными полями zone_balance / tep / phasing / raw_tables.
"""
result = PptTep()
try:
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
pages = pdf.pages
n_pages = len(pages)
logger.info("parse_ppt_tep: opened PDF, %d pages", n_pages)
# Находим страницы-начала Табл.11/12/13
page_11 = page_12 = page_13 = None
for pi, page in enumerate(pages):
text = page.extract_text() or ""
if page_11 is None and _page_contains_table(text, 11):
page_11 = pi
if page_12 is None and _page_contains_table(text, 12):
page_12 = pi
if page_13 is None and _page_contains_table(text, 13):
page_13 = pi
logger.info(
"parse_ppt_tep: Табл.11 на стр.%s, Табл.12 на стр.%s, Табл.13 на стр.%s",
page_11 and page_11 + 1,
page_12 and page_12 + 1,
page_13 and page_13 + 1,
)
# Табл.11 — Баланс территории
if page_11 is not None:
rows_11, raw_11 = _collect_table_rows(pages, page_11, 11, 12)
result.zone_balance = _parse_table11(rows_11)
result.raw_tables.extend(raw_11)
logger.info("parse_ppt_tep: Табл.11 → %d строк", len(result.zone_balance))
else:
logger.warning("parse_ppt_tep: Таблица 11 не найдена в PDF")
# Табл.12 — ТЭП
if page_12 is not None:
rows_12, raw_12 = _collect_table_rows(pages, page_12, 12, 13)
result.tep = _parse_table12(rows_12)
result.raw_tables.extend(raw_12)
logger.info("parse_ppt_tep: Табл.12 → %d строк", len(result.tep))
else:
logger.warning("parse_ppt_tep: Таблица 12 не найдена в PDF")
# Табл.13 — Очередность строительства
if page_13 is not None:
rows_13, raw_13 = _collect_table_rows(pages, page_13, 13, None)
result.phasing = _parse_table13(rows_13)
result.raw_tables.extend(raw_13)
logger.info("parse_ppt_tep: Табл.13 → %d строк", len(result.phasing))
else:
logger.warning("parse_ppt_tep: Таблица 13 не найдена в PDF")
except Exception as exc:
logger.error("parse_ppt_tep: ошибка парсинга PDF: %s", exc)
raise
return result

View file

@ -28,6 +28,7 @@ from sqlalchemy.orm import Session
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps
from app.services.site_finder.krt_lookup import parcel_krt_requisites
from app.services.site_finder.planning_lookup import parcel_planning_overlaps
from app.services.site_finder.reservation_lookup import parcel_reservations
from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation
@ -96,6 +97,7 @@ def build_ird_analyze_block(
``opportunity_overlaps`` (подмножество layer_kind='opportunity_*'),
``planning_projects`` (ППТ/ПМТ overlap, DB, м.134 #1085),
``reservations`` (list, изъятие/резервирование из land_reservation по cad_num, #1091/#1062),
``krt_requisites`` (list, КРТ-реквизиты из ekb_krt_sites + planning_projects, #1060),
``functional_zone`` (генплан, dict|None), ``krt`` (list), ``zone_regulation`` (dict|None).
Любой источник деградирует независимо блок всегда well-formed.
"""
@ -108,6 +110,9 @@ def build_ird_analyze_block(
"opportunity_overlaps": opportunity,
"planning_projects": parcel_planning_overlaps(db, geom_wkt),
"reservations": parcel_reservations(db, cad_num),
# КРТ-реквизиты (DB, дёшево, graceful): planning_projects ⋈ ekb_krt_sites (#1060).
# Идёт рядом с planning_projects — оба DB-источника, не в geoportal-конкурентной части.
"krt_requisites": parcel_krt_requisites(db, geom_wkt),
"functional_zone": None,
"krt": [],
"zone_regulation": None,

View file

@ -0,0 +1,189 @@
"""КРТ-реквизиты для analyze_parcel (#1060, эпик #1067).
Связывает геометрию КРТ-площадок из ``planning_projects`` (ППТ/ПМТ, м.134) с реестром
реквизитов ``ekb_krt_sites`` (застройщик / аукционная цена / градпотенциал).
Ключ связки: номер постановления ПАГЕ из ``ekb_krt_sites.paga_decisions`` + год совпадает с
заголовком ``planning_projects.full_name``. Даёт ``confidence='exact'`` для 3 из 15 площадок
(по состоянию на 2026-06). Для остальных КРТ-геом-пересечений (opportunity_krt в ird_overlays
это ККР, не КРТ-площадки застройки) надёжного ключа нет ``matched=False, confidence='none'``.
Graceful: нет таблицы / пустой WKT / исключение БД [].
Зеркалит стиль ``ird_overlay_lookup.py`` + ``planning_lookup.py``.
"""
from __future__ import annotations
import logging
import re
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Участок → planning_projects, которые пересекают его И имеют в full_name «ПАГЕ № N от»
# (первичный документ, не «Внесение изменений»). Geom — 4326. CAST psycopg v3.
_PLANNING_KRT_SQL = text(
"""
SELECT
pp.id AS pp_id,
pp.full_name AS pp_full_name,
pp.dmd_actual_year,
pp.doc_status_name,
k.id AS krt_id,
k.site_name,
k.developer_name,
k.contract_number,
k.auction_final_price,
k.gradpotential_sqm,
k.area_ha,
k.krt_kind
FROM planning_projects pp
-- только первичные документы (не «Внесение изменений»)
CROSS JOIN ekb_krt_sites k
-- пересечение участка с PP-геометрией
WHERE ST_Intersects(
pp.geom,
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)
)
-- матч по ПАГЕ-номеру: в full_name есть «ПАГЕ {paga_num} от», год совпадает
AND EXISTS (
SELECT 1
FROM jsonb_array_elements(k.paga_decisions) d
WHERE pp.full_name ~ ('^[[:space:]]*ПАГЕ № ' || (d->>'number') || ' от')
AND pp.dmd_actual_year::text = split_part(d->>'date', '.', 3)
AND (d->>'title' ILIKE '%планировки%' OR d->>'title' ILIKE '%межевания%')
)
ORDER BY pp.dmd_actual_year DESC, k.id
"""
)
# Вторичный запрос: planning_projects без матча с ekb_krt_sites — geom-only без реквизитов.
# Запускается только если первый не дал результатов, чтобы не путать пользователя
# двумя overlapping-источниками.
_PLANNING_NO_MATCH_SQL = text(
"""
SELECT
pp.id AS pp_id,
pp.full_name AS pp_full_name,
pp.dmd_actual_year,
pp.doc_status_name
FROM planning_projects pp
WHERE ST_Intersects(
pp.geom,
ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)
)
AND pp.full_name ~ '[кК][рР][тТ]|комплексн.*развити|Краснофлотцев|Донбасской
|Шефской|Азина|Черкасской|Кварцевой|Летчиков|Вилонова|Лумумбы
|Студенческой|Васильева|Гурзуфск|Бакинских|Шадринский|Бабушкина'
ORDER BY pp.dmd_actual_year DESC
"""
)
def _extract_paga_num(full_name: str) -> str | None:
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
return m.group(1) if m else None
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.
Алгоритм:
1. ST_Intersects участка с ``planning_projects`` + матч ПАГЕ-номер+год с
``ekb_krt_sites.paga_decisions`` ``confidence='exact'``.
2. Если матчей нет, ищем ППТ/ПМТ в ``planning_projects`` с КРТ-топонимами
``matched=False, confidence='none'`` (геом-факт без реквизитов).
3. Никакой ложной атрибуции: при отсутствии exact-матча NOT передаём
developer_name/auction_final_price (остаются None).
Args:
db: сессия.
parcel_wkt: геометрия участка в WKT (EPSG:4326). None/пусто [].
Returns:
list[dict]: каждый элемент содержит поля ниже; список пуст при graceful-деградации.
Поля: ``matched`` (bool), ``confidence`` ('exact'|'none'),
``krt_geom_ref`` (pp_id или None), ``pp_full_name`` (str),
``site_name``, ``developer_name``, ``auction_final_price``,
``gradpotential_sqm``, ``area_ha``, ``contract_number`` None при matched=False.
"""
if not parcel_wkt:
return []
try:
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
return []
except Exception as exc:
logger.warning("parcel_krt_requisites: неожиданная ошибка, skip: %s", exc)
return []
if rows:
# Есть exact-матч — возвращаем с реквизитами
seen: set[tuple[int, int]] = set()
result: list[dict[str, Any]] = []
for r in rows:
key = (r["pp_id"], r["krt_id"])
if key in seen:
continue
seen.add(key)
result.append(
{
"matched": True,
"confidence": "exact",
"krt_geom_ref": r["pp_id"],
"pp_full_name": r["pp_full_name"],
"site_name": r["site_name"],
"developer_name": r["developer_name"],
"contract_number": r["contract_number"],
"auction_final_price": (
float(r["auction_final_price"])
if r["auction_final_price"] is not None
else None
),
"gradpotential_sqm": (
float(r["gradpotential_sqm"])
if r["gradpotential_sqm"] is not None
else None
),
"area_ha": float(r["area_ha"]) if r["area_ha"] is not None else None,
}
)
return result
# Нет exact-матча — geom-only поиск по КРТ-топонимам
try:
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc)
return []
except Exception as exc:
logger.warning("parcel_krt_requisites: no-match fallback ошибка: %s", exc)
return []
return [
{
"matched": False,
"confidence": "none",
"krt_geom_ref": r["pp_id"],
"pp_full_name": r["pp_full_name"],
"site_name": None,
"developer_name": None,
"contract_number": None,
"auction_final_price": None,
"gradpotential_sqm": None,
"area_ha": None,
}
for r in no_match_rows
]
__all__ = ["parcel_krt_requisites"]

View file

@ -71,6 +71,7 @@ celery_app = Celery(
"app.workers.tasks.zone_regulation_refresh",
"app.workers.tasks.reservation_ingest",
"app.workers.tasks.genplan_zones_sync",
"app.workers.tasks.ekb_ppt_tep_sync",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,145 @@
"""Celery task: ingest ТЭП из PDF пояснительных записок ППТ/ПМТ (#1085).
Fetch PDF parse_ppt_tep UPSERT в ekb_ppt_tep (ON CONFLICT по doc_ref).
SAVEPOINT per-doc: ошибка одного документа не откатывает всю транзакцию.
Beat НЕ регистрируется обновление редкое, запуск ручной/через admin.
"""
from __future__ import annotations
import json
import logging
import httpx
from sqlalchemy import text
from app.core.db import SessionLocal
from app.services.scrapers.ekb_ppt_tep_parser import PptTep, parse_ppt_tep
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── Seed-документы ─────────────────────────────────────────────────────────────
# Один образец: ППТ 22823 (2018), пояснительная записка.
# URL — placeholder: VERIFY на екатеринбург.рф/документация/ппт/...
# Реальный URL уточнить у пользователя перед прод-прогоном.
_SEED_DOCS: list[dict] = [
{
"doc_ref": "ppt2018_22823",
"url": "# VERIFY URL: https://xn--80acgfbsl1azdqr.xn--p1ai/документация/ппт/22823",
"project_type": "ppt",
},
]
_UPSERT_SQL = text("""
INSERT INTO ekb_ppt_tep (
doc_ref, project_type,
zone_balance, tep, phasing, raw_tables,
source_pdf, fetched_at
)
VALUES (
:doc_ref, :project_type,
CAST(:zone_balance AS jsonb),
CAST(:tep AS jsonb),
CAST(:phasing AS jsonb),
CAST(:raw_tables AS jsonb),
:source_pdf, now()
)
ON CONFLICT (doc_ref) DO UPDATE SET
project_type = EXCLUDED.project_type,
zone_balance = EXCLUDED.zone_balance,
tep = EXCLUDED.tep,
phasing = EXCLUDED.phasing,
raw_tables = EXCLUDED.raw_tables,
source_pdf = EXCLUDED.source_pdf,
fetched_at = now()
""")
def _fetch_pdf(url: str) -> bytes:
"""Скачивает PDF по URL; verify=False из-за CA Минцифры (как ekburg_permits, #242)."""
with httpx.Client(verify=False, timeout=60.0, follow_redirects=True) as client:
resp = client.get(url, headers={"User-Agent": "GenDesign-SF/1.0 (+https://gendsgn.ru/)"})
resp.raise_for_status()
return resp.content
def _tep_to_params(doc_ref: str, project_type: str, source_pdf: str, tep: PptTep) -> dict:
return {
"doc_ref": doc_ref,
"project_type": project_type,
"zone_balance": json.dumps(tep.zone_balance, ensure_ascii=False),
"tep": json.dumps(tep.tep, ensure_ascii=False),
"phasing": json.dumps(tep.phasing, ensure_ascii=False),
"raw_tables": json.dumps(tep.raw_tables, ensure_ascii=False),
"source_pdf": source_pdf,
}
@celery_app.task(name="tasks.ekb_ppt_tep_sync.ingest_ppt_tep")
def ingest_ppt_tep(docs: list[dict] | None = None) -> dict[str, int]:
"""Fetch PDF + parse ТЭП + UPSERT в ekb_ppt_tep.
Args:
docs: список [{doc_ref, url, project_type}] или None используется
_SEED_DOCS (1 seed-документ образца ppt2018_22823).
Returns:
{"docs": N} число успешно обработанных документов.
"""
target_docs: list[dict] = docs if docs is not None else _SEED_DOCS
logger.info("ekb_ppt_tep_sync: start, docs=%d", len(target_docs))
processed = 0
failed = 0
db = SessionLocal()
try:
for doc in target_docs:
doc_ref = doc["doc_ref"]
url = doc["url"]
project_type = doc.get("project_type", "ppt")
# Пропускаем placeholder-URL (seed без реального источника)
if url.startswith("# VERIFY"):
logger.warning(
"ekb_ppt_tep_sync: doc_ref=%s — URL не задан (placeholder), пропускаем",
doc_ref,
)
continue
try:
logger.info("ekb_ppt_tep_sync: fetch %s (%s)", doc_ref, url)
pdf_bytes = _fetch_pdf(url)
tep = parse_ppt_tep(pdf_bytes)
params = _tep_to_params(doc_ref, project_type, url, tep)
with db.begin_nested():
db.execute(_UPSERT_SQL, params)
processed += 1
logger.info(
"ekb_ppt_tep_sync: upserted %s — zones=%d tep=%d phasing=%d",
doc_ref,
len(tep.zone_balance),
len(tep.tep),
len(tep.phasing),
)
except Exception as exc:
failed += 1
logger.warning(
"ekb_ppt_tep_sync: failed for doc_ref=%s: %s",
doc_ref,
exc,
)
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
logger.info("ekb_ppt_tep_sync: done — processed=%d failed=%d", processed, failed)
return {"docs": processed}

View file

@ -0,0 +1,418 @@
"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#1085).
Синтетические тесты: pdfplumber мокируется; реальный PDF-тест отдельный
класс с @pytest.mark.skipif (skip в CI, запуск вручную).
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.services.scrapers.ekb_ppt_tep_parser import (
PptTep,
_parse_table11,
_parse_table12,
_parse_table13,
parse_ppt_tep,
)
# ── Вспомогательные фикстуры ─────────────────────────────────────────────────
# Минимальные данные Табл.11 (баланс территории)
_TABLE11_ROWS: list[list[str | None]] = [
["", "Наименование зоны", "Площадь", None],
[None, None, "Сущ., га (%)", "Проект, га\n(%)"],
["1", "Многофункциональная зона", "15,41 га\n(42,61 %)", "11,34 га\n(31,36 %)"],
["2", "Зона объектов здравоохранения", "0,24 га\n(0,66 %)", "0,25 га\n(0,69 %)"],
["Итого:", None, "36,16 га\n(100 %)", "36,16 га\n(100 %)"],
]
# Минимальные данные Табл.12 (ТЭП)
_TABLE12_ROWS: list[list[str | None]] = [
[
"",
"Наименование показателей",
"Единица\nизмерения",
"Современное\nсостояние",
"Расчётный\nсрок",
],
["1", "Территория", None, None, None],
["", "Площадь проектируемой территории", "га", "36,1", "36,16"],
["2", "Жилищный фонд", None, None, None],
["2.1", "Общая площадь жилых помещений", "тыс. кв. м", "", "120,5"],
]
# Минимальные данные Табл.13 (очередность)
_TABLE13_ROWS: list[list[str | None]] = [
# Заголовок
[
"Номер\nочереди\nстроит-ва",
"Состав этапа строительства",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Зона планируемого\nразмещения",
None,
"Площадь зон, га",
None,
"Источник\nфинансирования",
None,
"Программа",
],
# Строка-нумерация «1 2 4 5 6 7»
[
"1",
"2",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
"4",
None,
"5",
None,
"6",
None,
"7",
],
# Реальные строки
[
"I этап\n2018-2021",
"",
None,
None,
None,
None,
None,
None,
None,
None,
None,
"Объекты капитального строительства",
None,
None,
None,
None,
"",
None,
None,
None,
],
[
None,
"",
"",
"Многофункциональная ледовая арена",
None,
None,
None,
None,
None,
None,
None,
None,
"",
"Зона объектов спорта",
None,
"4,97",
None,
"Внебюджетные",
None,
"",
],
]
# ── Тесты _parse_table11 ──────────────────────────────────────────────────────
class TestParseTable11:
def test_returns_list(self) -> None:
result = _parse_table11(_TABLE11_ROWS)
assert isinstance(result, list)
def test_correct_row_count(self) -> None:
"""Должны получить 2 зоны + 1 Итого-строку."""
result = _parse_table11(_TABLE11_ROWS)
assert len(result) == 3
def test_zone_name_present(self) -> None:
result = _parse_table11(_TABLE11_ROWS)
zone_names = [r["zone_name"] for r in result]
assert any("Многофункциональная" in n for n in zone_names)
def test_numeric_parsed(self) -> None:
result = _parse_table11(_TABLE11_ROWS)
row1 = next(r for r in result if r["no"] == "1")
assert row1["existing_ha_num"] == pytest.approx(15.41, abs=0.01)
assert row1["project_ha_num"] == pytest.approx(11.34, abs=0.01)
def test_itogo_row_flagged(self) -> None:
result = _parse_table11(_TABLE11_ROWS)
totals = [r for r in result if r.get("is_total")]
assert len(totals) == 1
def test_empty_input_returns_empty(self) -> None:
assert _parse_table11([]) == []
def test_missing_table_returns_empty(self) -> None:
"""Строки без полезного содержимого → пустой результат."""
garbage = [["", None, None, None], [None, None, None, None]]
result = _parse_table11(garbage)
assert result == []
# ── Тесты _parse_table12 ──────────────────────────────────────────────────────
class TestParseTable12:
def test_returns_list(self) -> None:
result = _parse_table12(_TABLE12_ROWS)
assert isinstance(result, list)
def test_header_skipped(self) -> None:
result = _parse_table12(_TABLE12_ROWS)
# Первая строка с «№»/«Наименование показателей» должна быть пропущена
no_values = [r["no"] for r in result]
assert "" not in no_values
def test_indicator_present(self) -> None:
result = _parse_table12(_TABLE12_ROWS)
indicators = [r["indicator"] for r in result]
assert any("Территория" in i for i in indicators)
def test_numeric_values(self) -> None:
result = _parse_table12(_TABLE12_ROWS)
area_row = next((r for r in result if "Площадь" in r["indicator"]), None)
assert area_row is not None
assert area_row["target"] == "36,16"
def test_empty_input_returns_empty(self) -> None:
assert _parse_table12([]) == []
# ── Тесты _parse_table13 ──────────────────────────────────────────────────────
class TestParseTable13:
def test_returns_list(self) -> None:
result = _parse_table13(_TABLE13_ROWS)
assert isinstance(result, list)
def test_phase_extracted(self) -> None:
result = _parse_table13(_TABLE13_ROWS)
phases = [r["phase"] for r in result if r["phase"]]
assert any("этап" in p.lower() or "2018" in p for p in phases)
def test_zone_extracted(self) -> None:
result = _parse_table13(_TABLE13_ROWS)
zones = [r["zone"] for r in result if r["zone"]]
assert any("спорт" in z.lower() or "Зона" in z for z in zones)
def test_area_ha_num_parsed(self) -> None:
result = _parse_table13(_TABLE13_ROWS)
rows_with_area = [r for r in result if r.get("area_ha_num") is not None]
assert len(rows_with_area) >= 1
areas = [r["area_ha_num"] for r in rows_with_area]
assert any(abs(a - 4.97) < 0.01 for a in areas)
def test_empty_input_returns_empty(self) -> None:
assert _parse_table13([]) == []
# ── Тест parse_ppt_tep с mock pdfplumber ─────────────────────────────────────
def _make_mock_page(text: str, tables: list[list[list[str | None]]]) -> MagicMock:
"""Создаёт mock pdfplumber Page с заданным текстом и таблицами."""
page = MagicMock()
page.extract_text.return_value = text
page.extract_tables.return_value = tables
return page
class TestParsePptTepMocked:
"""Тесты parse_ppt_tep с мокированным pdfplumber.open."""
def _build_mock_pdf(self) -> list[MagicMock]:
"""Собирает 3-страничный mock-PDF с Табл.11/12/13."""
# Страница 1: Таблица 11 (баланс)
page11 = _make_mock_page(
"Таблица 11\nБаланс территории",
[_TABLE11_ROWS],
)
# Страница 2: Таблица 12 (ТЭП)
page12 = _make_mock_page(
"Таблица 12\nОсновные технико-экономические показатели",
[_TABLE12_ROWS],
)
# Страница 3: Таблица 13 (очередность)
page13 = _make_mock_page(
"Таблица 13\nОчередность строительства",
[_TABLE13_ROWS],
)
return [page11, page12, page13]
def test_returns_ppt_tep_instance(self) -> None:
pages = self._build_mock_pdf()
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = pages
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert isinstance(result, PptTep)
def test_zone_balance_not_empty(self) -> None:
pages = self._build_mock_pdf()
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = pages
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert len(result.zone_balance) > 0, "zone_balance не должен быть пустым"
def test_tep_not_empty(self) -> None:
pages = self._build_mock_pdf()
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = pages
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert len(result.tep) > 0, "tep не должен быть пустым"
def test_phasing_not_empty(self) -> None:
pages = self._build_mock_pdf()
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = pages
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert len(result.phasing) > 0, "phasing не должен быть пустым"
def test_missing_table13_returns_empty_phasing(self) -> None:
"""Если Табл.13 нет в PDF — phasing = []."""
pages_no13 = self._build_mock_pdf()[:2] # только стр. 1-2
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = pages_no13
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert result.phasing == [], "phasing должен быть [] если Табл.13 не найдена"
def test_missing_all_tables_returns_empty_result(self) -> None:
"""PDF без таблиц → все поля []."""
page_empty = _make_mock_page("Пустая страница без таблиц", [])
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = [page_empty]
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert result.zone_balance == []
assert result.tep == []
assert result.phasing == []
def test_raw_tables_populated(self) -> None:
"""raw_tables заполняется данными о найденных таблицах."""
pages = self._build_mock_pdf()
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
ctx.pages = pages
with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
result = parse_ppt_tep(b"fake-pdf-bytes")
assert len(result.raw_tables) > 0
# Проверяем что raw_tables содержит page, table_index, rows
first = result.raw_tables[0]
assert "page" in first
assert "table_index" in first
assert "rows" in first
# ── Тест на реальном PDF-образце (skip в CI) ─────────────────────────────────
_REAL_PDF_PATH = Path(
"C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf"
)
@pytest.mark.skipif(
not _REAL_PDF_PATH.exists(),
reason="Реальный PDF-образец отсутствует (только для локального запуска)",
)
class TestParsePptTepRealPdf:
"""Интеграционные тесты на реальном образце ppt2018_22823."""
@pytest.fixture(scope="class")
def result(self) -> PptTep:
pdf_bytes = _REAL_PDF_PATH.read_bytes()
return parse_ppt_tep(pdf_bytes)
def test_zone_balance_has_rows(self, result: PptTep) -> None:
"""Табл.11 должна иметь ≥5 зон."""
n = len(result.zone_balance)
assert n >= 5, f"Ожидалось ≥5 зон, получено {n}"
def test_zone_balance_has_itogo(self, result: PptTep) -> None:
"""Итоговая строка присутствует в Табл.11."""
has_total = any(
"итого" in r.get("zone_name", "").lower() or r.get("is_total")
for r in result.zone_balance
)
assert has_total, "Итого-строка не найдена в zone_balance"
def test_tep_has_rows(self, result: PptTep) -> None:
"""Табл.12 должна иметь ≥10 показателей."""
assert len(result.tep) >= 10, f"Ожидалось ≥10 показателей, получено {len(result.tep)}"
def test_phasing_has_rows(self, result: PptTep) -> None:
"""Табл.13 должна иметь ≥3 строки."""
n = len(result.phasing)
assert n >= 3, f"Ожидалось ≥3 строки очередности, получено {n}"
def test_phasing_has_area(self, result: PptTep) -> None:
"""Хотя бы одна строка очередности содержит площадь > 0."""
areas = [r["area_ha_num"] for r in result.phasing if r.get("area_ha_num")]
assert areas, "Площади зон в Табл.13 не распарсились"
assert max(areas) > 0
def test_raw_tables_not_empty(self, result: PptTep) -> None:
"""raw_tables содержит хотя бы 3 записи (по одной на таблицу)."""
assert len(result.raw_tables) >= 3

View file

@ -45,10 +45,27 @@ _OVERLAY = {
_PLANNING = [{"project_type": "ppt", "doc_status_name": "действующий", "full_name": "ПАГЕ №1"}]
_KRT_REQUISITES: list[dict[str, Any]] = [
{
"matched": True,
"confidence": "exact",
"krt_geom_ref": 1166,
"pp_full_name": "ПАГЕ № 1970",
"site_name": "КРТ Летчиков",
"developer_name": "ООО СЗ Летчиков",
"contract_number": "2-7/21.7-14",
"auction_final_price": 5_000_000.0,
"gradpotential_sqm": 25_000.0,
"area_ha": 1.5,
}
]
def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay)
monkeypatch.setattr(ird_analyze, "parcel_planning_overlaps", lambda db, wkt: list(_PLANNING))
monkeypatch.setattr(ird_analyze, "parcel_reservations", lambda db, cad_num: [])
monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: list(_KRT_REQUISITES))
def test_full_block_assembly(monkeypatch: Any) -> None:
@ -239,3 +256,37 @@ def test_reservations_none_cad_num_returns_empty(monkeypatch: Any) -> None:
assert "reservations" in block
assert block["reservations"] == []
# ---------------------------------------------------------------------------
# KRT-реквизиты (#1060)
# ---------------------------------------------------------------------------
def test_krt_requisites_wired_to_block(monkeypatch: Any) -> None:
"""parcel_krt_requisites возвращает реквизит → ключ krt_requisites присутствует в блоке."""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
client = _FakeClient(fz=[], krt=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
assert "krt_requisites" in block
assert len(block["krt_requisites"]) == 1
r = block["krt_requisites"][0]
assert r["matched"] is True
assert r["confidence"] == "exact"
assert r["developer_name"] == "ООО СЗ Летчиков"
def test_krt_requisites_empty_when_no_overlap(monkeypatch: Any) -> None:
"""parcel_krt_requisites пуст → krt_requisites == [] в блоке (ключ присутствует)."""
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
monkeypatch.setattr(ird_analyze, "parcel_krt_requisites", lambda db, wkt: [])
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
client = _FakeClient(fz=[], krt=[])
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)
assert "krt_requisites" in block
assert block["krt_requisites"] == []

View file

@ -0,0 +1,176 @@
"""Тесты parcel_krt_requisites (#1060) — КРТ-реквизиты из ekb_krt_sites + planning_projects.
DB/сеть не дёргаются: мокаем db.execute через FakeDB.
Проверяем:
- exact-матч matched=True, confidence='exact', реквизиты заполнены;
- planning overlap без матча matched=False, confidence='none', без застройщика;
- пустой WKT [];
- OperationalError (таблица не задеплоена) graceful [];
- дубликаты строк (один pp_id × krt_id) дедублируются.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from sqlalchemy.exc import OperationalError
from app.services.site_finder.krt_lookup import parcel_krt_requisites
class _MappingRow(dict): # type: ignore[type-arg]
"""Имитирует sqlalchemy MappingResult row (dict-like)."""
class _Result:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = [_MappingRow(r) for r in rows]
def mappings(self) -> _Result:
return self
def all(self) -> list[_MappingRow]:
return self._rows
class _FakeDB:
"""Мок SQLAlchemy Session: первый вызов execute → first_rows; второй → second_rows."""
def __init__(
self,
first_rows: list[dict[str, Any]] | None = None,
second_rows: list[dict[str, Any]] | None = None,
raise_first: Exception | None = None,
raise_second: Exception | None = None,
) -> None:
self._calls = 0
self._first = first_rows or []
self._second = second_rows or []
self._raise_first = raise_first
self._raise_second = raise_second
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
self._calls += 1
if self._calls == 1:
if self._raise_first:
raise self._raise_first
return _Result(self._first)
if self._raise_second:
raise self._raise_second
return _Result(self._second)
_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))"
# Типичная строка exact-матча (planning_projects ⋈ ekb_krt_sites)
_EXACT_ROW = {
"pp_id": 1166,
"pp_full_name": (
" ПАГЕ № 1970 от 16.08.2024\nПроект межевания территории в районе улицы Летчиков"
),
"dmd_actual_year": 2024,
"doc_status_name": "Действующий",
"krt_id": 7,
"site_name": "2. КРТ жилой застройки в районе ул. Летчиков",
"developer_name": "ООО СЗ Летчиков",
"contract_number": "2-7/21.7-14",
"auction_final_price": Decimal("5000000"),
"gradpotential_sqm": Decimal("25000"),
"area_ha": Decimal("1.50"),
"krt_kind": "жилой застройки",
}
def test_exact_match_returns_requisites() -> None:
"""Exact-матч по ПАГЕ-номер+год → matched=True, реквизиты застройщика заполнены."""
db = _FakeDB(first_rows=[_EXACT_ROW])
result = parcel_krt_requisites(db, _WKT)
assert len(result) == 1
r = result[0]
assert r["matched"] is True
assert r["confidence"] == "exact"
assert r["krt_geom_ref"] == 1166
assert r["site_name"] == "2. КРТ жилой застройки в районе ул. Летчиков"
assert r["developer_name"] == "ООО СЗ Летчиков"
assert r["contract_number"] == "2-7/21.7-14"
assert r["auction_final_price"] == 5_000_000.0
assert r["gradpotential_sqm"] == 25_000.0
assert r["area_ha"] == 1.50
def test_exact_match_deduplicates_pp_krt_pair() -> None:
"""Дублирующиеся строки (pp_id × krt_id) дедублируются → один результат."""
db = _FakeDB(first_rows=[_EXACT_ROW, _EXACT_ROW])
result = parcel_krt_requisites(db, _WKT)
assert len(result) == 1
def test_no_match_returns_geom_only() -> None:
"""Первый запрос пуст (нет exact-матча) → второй запрос → matched=False без реквизитов."""
no_match_row = {
"pp_id": 999,
"pp_full_name": " ПАГЕ № 4033 от 27.12.2022\n"
"Проект планировки территории в районе улицы Краснофлотцев",
"dmd_actual_year": 2022,
"doc_status_name": "Действующий",
}
db = _FakeDB(first_rows=[], second_rows=[no_match_row])
result = parcel_krt_requisites(db, _WKT)
assert len(result) == 1
r = result[0]
assert r["matched"] is False
assert r["confidence"] == "none"
assert r["krt_geom_ref"] == 999
assert r["developer_name"] is None
assert r["auction_final_price"] is None
assert r["gradpotential_sqm"] is None
assert r["site_name"] is None
def test_empty_wkt_returns_empty_list() -> None:
"""parcel_wkt=None → пустой список без вызова DB."""
db = _FakeDB(first_rows=[_EXACT_ROW])
assert parcel_krt_requisites(db, None) == []
assert db._calls == 0 # DB не вызывалась
def test_empty_string_wkt_returns_empty_list() -> None:
"""parcel_wkt='' → пустой список."""
assert parcel_krt_requisites(_FakeDB(), "") == []
def test_operational_error_first_query_returns_empty() -> None:
"""OperationalError на первом запросе (таблица не задеплоена) → []."""
db = _FakeDB(raise_first=OperationalError("stmt", {}, Exception("relation does not exist")))
result = parcel_krt_requisites(db, _WKT)
assert result == []
def test_operational_error_second_query_returns_empty() -> None:
"""OperationalError на втором запросе (no-match fallback) → []."""
db = _FakeDB(
first_rows=[],
raise_second=OperationalError("stmt", {}, Exception("relation does not exist")),
)
result = parcel_krt_requisites(db, _WKT)
assert result == []
def test_none_decimal_fields_become_none() -> None:
"""NULL Decimal-поля → None в результате, не падают при float()."""
row = {**_EXACT_ROW, "auction_final_price": None, "gradpotential_sqm": None, "area_ha": None}
db = _FakeDB(first_rows=[row])
result = parcel_krt_requisites(db, _WKT)
assert result[0]["auction_final_price"] is None
assert result[0]["gradpotential_sqm"] is None
assert result[0]["area_ha"] is None
def test_no_overlap_at_all_returns_empty() -> None:
"""Оба запроса возвращают пустые списки → []."""
db = _FakeDB(first_rows=[], second_rows=[])
result = parcel_krt_requisites(db, _WKT)
assert result == []

View file

@ -0,0 +1,86 @@
-- 138_ekb_ppt_tep.sql
-- #1085 (Site Finder v2 / GG-форсайт #1067) — ТЭП из ППТ/ПМТ-пояснительных записок.
--
-- Что/зачем: структурированный контент пояснительных записок ППТ/ПМТ (PDF).
-- Три логических таблицы документа:
-- Табл.11 — баланс территории по зонам (га + %);
-- Табл.12 — основные ТЭП (площади, этажность, плотность, инфраструктура);
-- Табл.13 — очередность строительства (объект/зона/срок/финансирование).
--
-- Линковка к геометрии: doc_ref сопоставляется с planning_projects.source_key /
-- doc_full_name (данные ППТ/ПМТ уже в БД через WFS, #1085). Прямой FK не ставим
-- из-за best-effort nature ссылок (одна пояснительная → несколько границ и наоборот).
--
-- Grain: одна строка = один PDF-документ пояснительной записки.
-- UNIQUE(doc_ref) — не допускает дублей при повторном ingest.
--
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN).
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для UNIQUE.
-- Без INSERT (данные приходят из ekb_ppt_tep_sync Celery-воркера).
BEGIN;
-- ── 1. Таблица ekb_ppt_tep ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ekb_ppt_tep (
id BIGSERIAL PRIMARY KEY,
doc_ref TEXT NOT NULL, -- ид/ссылка документа ППТ (для join к planning_projects)
project_type TEXT NOT NULL, -- 'ppt' | 'pmt'
zone_balance JSONB, -- Табл.11: баланс территории по зонам
tep JSONB, -- Табл.12: основные ТЭП
phasing JSONB, -- Табл.13: очередность строительства
raw_tables JSONB, -- сырые таблицы для отладки/re-parse
source_pdf TEXT, -- URL или путь к исходному PDF
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ekb_ppt_tep IS
'ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067). '
'1 строка = 1 PDF-документ. '
'Три JSONB-поля: zone_balance (Табл.11), tep (Табл.12), phasing (Табл.13). '
'Линковка к геометрии: doc_ref → planning_projects.source_key.';
COMMENT ON COLUMN ekb_ppt_tep.doc_ref IS
'Идентификатор/ссылка документа ППТ. Для сопоставления с '
'planning_projects.source_key или doc_full_name.';
COMMENT ON COLUMN ekb_ppt_tep.project_type IS
'''ppt'' — проект планировки территории; ''pmt'' — проект межевания территории.';
COMMENT ON COLUMN ekb_ppt_tep.zone_balance IS
'Баланс территории по зонам (Табл.11): [{no, zone_name, existing_ha, project_ha, ...}]. '
'Пустой массив [] если таблица не найдена в PDF.';
COMMENT ON COLUMN ekb_ppt_tep.tep IS
'Основные ТЭП (Табл.12): [{no, indicator, unit, current, target}]. '
'Пустой массив [] если таблица не найдена.';
COMMENT ON COLUMN ekb_ppt_tep.phasing IS
'Очередность строительства (Табл.13): [{phase, composition, zone, area_ha, '
'funding, program}]. Пустой массив [] если таблица не найдена.';
COMMENT ON COLUMN ekb_ppt_tep.raw_tables IS
'Сырые таблицы из pdfplumber для отладки: [{page, table_index, rows: [[...]]}]. ';
-- ── 2. UNIQUE(doc_ref) — дедуп-ключ для UPSERT воркера ──────────────────────
-- Guard по pg_constraint → idempotent re-apply (прецедент: м.132, 136, 137).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_ekb_ppt_tep_doc_ref'
) THEN
ALTER TABLE ekb_ppt_tep
ADD CONSTRAINT uq_ekb_ppt_tep_doc_ref UNIQUE (doc_ref);
END IF;
END
$$;
-- ── 3. Индексы ────────────────────────────────────────────────────────────────
-- Фильтр по типу проекта (ppt/pmt).
CREATE INDEX IF NOT EXISTS idx_ekb_ppt_tep_project_type
ON ekb_ppt_tep (project_type);
-- Свежесть / ORDER BY fetched_at для мониторинга.
CREATE INDEX IF NOT EXISTS idx_ekb_ppt_tep_fetched_at
ON ekb_ppt_tep (fetched_at DESC);
-- GIN-индекс на zone_balance для поиска по зонам (follow-up: @> queries).
CREATE INDEX IF NOT EXISTS idx_ekb_ppt_tep_zone_balance_gin
ON ekb_ppt_tep USING GIN (zone_balance)
WHERE zone_balance IS NOT NULL;
COMMIT;