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
94 lines
4.4 KiB
Python
94 lines
4.4 KiB
Python
"""ТЭП ППТ/ПМТ-lookup для analyze_parcel (#1136 follow-up #1133, GG-форсайт #1067).
|
||
|
||
Читает ``ekb_ppt_tep`` (м.138, наполняется ``ekb_ppt_tep_sync`` Celery-воркером #1085) и
|
||
отдаёт ТЭП-контент пояснительных записок (zone_balance / tep / phasing) для проектов
|
||
планировки/межевания, пересекающих участок.
|
||
|
||
Связка ТЭП ↔ геометрия (best-effort, как описано в м.138):
|
||
``ekb_ppt_tep.doc_ref`` ↔ ``planning_projects.source_key`` (текстовое равенство)
|
||
ИЛИ ``planning_projects.doc_full_name`` (LIKE).
|
||
|
||
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||
1) ``doc_ref = source_key::text`` (прямое равенство);
|
||
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
|
||
|
||
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ТЭП-контент для проектов планировки, пересекающих участок.
|
||
# Join: planning_projects (геометрия) ⋈ ekb_ppt_tep (контент) по doc_ref↔source_key/doc_full_name.
|
||
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
||
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
||
_PPT_TEP_OVERLAP_SQL = text(
|
||
"""
|
||
SELECT
|
||
pp.project_type,
|
||
pp.doc_status_name,
|
||
pp.full_name,
|
||
pp.doc_full_name,
|
||
pp.source_key,
|
||
t.doc_ref,
|
||
t.zone_balance,
|
||
t.tep,
|
||
t.phasing,
|
||
t.source_pdf,
|
||
t.fetched_at
|
||
FROM planning_projects pp
|
||
JOIN ekb_ppt_tep t ON (
|
||
t.doc_ref = CAST(pp.source_key AS text)
|
||
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
|
||
)
|
||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
||
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
|
||
"""
|
||
)
|
||
|
||
|
||
def parcel_ppt_tep(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
|
||
"""ТЭП-контент ППТ/ПМТ для участка (Табл.11/12/13 пояснительных записок).
|
||
|
||
Args:
|
||
db: SQLAlchemy-сессия.
|
||
parcel_wkt: геометрия участка в WKT (EPSG:4326). None / пусто → [].
|
||
|
||
Returns:
|
||
list[{project_type, doc_status_name, full_name, doc_full_name, source_key,
|
||
doc_ref, zone_balance, tep, phasing, source_pdf, fetched_at}].
|
||
Каждая запись — один ППТ/ПМТ-документ, чья геометрия пересекает участок И
|
||
для которого есть ТЭП-контент в ``ekb_ppt_tep``.
|
||
|
||
Пустой список при:
|
||
- parcel_wkt пуст / None;
|
||
- таблица ``ekb_ppt_tep`` или ``planning_projects`` ещё не задеплоена;
|
||
- нет пересечений по геометрии;
|
||
- нет матча doc_ref ↔ source_key/doc_full_name (best-effort, graceful);
|
||
- БД-ошибка (analyze не падает).
|
||
"""
|
||
if not parcel_wkt:
|
||
return []
|
||
try:
|
||
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||
except (OperationalError, ProgrammingError) as exc:
|
||
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
||
logger.warning("parcel_ppt_tep: ekb_ppt_tep / planning_projects недоступна, skip: %s", exc)
|
||
return []
|
||
except Exception as exc:
|
||
logger.warning("parcel_ppt_tep: неожиданная ошибка, skip: %s", exc)
|
||
return []
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
__all__ = ["parcel_ppt_tep"]
|