gendesign/backend/app/services/site_finder/premises_lookup.py
bot-backend 14f3ef2019
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-17 17:13:38 +00:00

289 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Per-building помещения ЕГРН + машино-места + parking_ratio (#96, MVP on-demand).
Тонкая обёртка над уже-готовым foundation `NSPDBulkClient.list_objects_in_building`
(#168 Q3-deferred). Связывает кадастровый номер здания → objdoc_id → NSPD
tab-group `objectsList` → counts помещений/машино-мест → `parking_ratio`.
Зачем именно так (#96 scope decision):
- Полный план #96 (cad_premises/cad_parking таблицы, per-flat кадастровая
стоимость, bulk_parking_for_district, миграция) упирается в незакрытый
data-foundation gap: здания-конкуренты в analyze живут в координатах ДОМ.РФ
(obj_id + lat/lon), а НЕ имеют cad_num/objdoc_id НСПД. Массовый backfill
помещений по всем зданиям ЕКБ (38k × tab-group) — тяжёлый rate-limited
отдельный таск. Поэтому MVP: on-demand по конкретному cad_num здания, без
схемы и без bulk-сбора.
Поток (2 HTTP-запроса на здание):
1. NSPDClient.search_by_cad(building_cad) → options.objdoc_id (sync urllib)
2. NSPDBulkClient.list_objects_in_building(objdoc_id) → ObjectsListing (async)
objdoc_id из НСПД search приходит в camelCase `objdocId` — парсится через
NSPDOptions AliasChoices (см. schemas/nspd_bulk.py). Verified live на
66:41:0106036:183 (МКД, 232 помещения, 0 машино-мест).
Вызывается из синхронного кода (FastAPI threadpool handler / Celery task) —
async-часть изолирована в asyncio.run() внутри функции (паттерн poi_loader.py /
pzz_loader.py). НЕ блокирует основной event loop, т.к. analyze_parcel — `def`.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import ObjectsListing
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
from app.services.scrapers.nspd_client import NSPDClient, NspdLiteError
logger = logging.getLogger(__name__)
# thematic_id=5 — НСПД search-фильтр «Здания» (см. NSPDClient.search_by_cad
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
_THEMATIC_BUILDING = 5
# #96 wiring: дефолтный радиус geom-match domrf↔cad_buildings (метры). Подобран по
# прод-замеру (1543 domrf latest-snapshot vs 38016 cad_buildings): nearest-footprint
# медиана 56м, within-50м покрывает 47%, within-100м 65%. 50м — консервативный
# дефолт: domrf lat/lon = центроид ЖК-ПЛОЩАДКИ (несколько домов + дворы), поэтому
# смещён от ближайшего building-footprint; шире радиус ловит больше, но рискует
# приписать СОСЕДНЕЕ здание (precision важнее recall — parking_ratio чужого дома
# хуже отсутствия). ST_Contains (точка внутри footprint) всегда матчит независимо
# от радиуса. Caller может расширить через max_dist_m.
_DEFAULT_MATCH_RADIUS_M: float = 50.0
@dataclass(frozen=True, slots=True)
class BuildingMatch:
"""Результат geom-match domrf-объекта на здание ЕГРН (#96 wiring).
cad_num — кадастровый номер сматченного здания (ключ к premises-lookup).
objdoc_id — НСПД objdoc этого здания ИЗ cad_buildings (может быть None для
части строк). Если он есть — get_building_premises может ПРОПУСТИТЬ НСПД
search_by_cad и сразу дёрнуть tab-group (1 НСПД-запрос вместо 2).
distance_m — расстояние от domrf-центроида до footprint (0.0 = точка внутри
площадного footprint).
"""
cad_num: str
objdoc_id: int | None
distance_m: float
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
_RESOLVE_CAD_SQL = text("""
SELECT
b.cad_num,
b.objdoc_id,
ST_Distance(
b.geom::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:lon AS double precision),
CAST(:lat AS double precision)
),
4326
)::geography
) AS distance_m
FROM cad_buildings b
WHERE b.geom IS NOT NULL
ORDER BY b.geom::geography <-> ST_SetSRID(
ST_MakePoint(
CAST(:lon AS double precision),
CAST(:lat AS double precision)
),
4326
)::geography
LIMIT 1
""")
def resolve_cad_for_domrf(
db: Session,
*,
lat: float,
lon: float,
max_dist_m: float = _DEFAULT_MATCH_RADIUS_M,
) -> BuildingMatch | None:
"""Резолв domrf-объекта (lat/lon центроид) → здание cad_buildings (#96 wiring).
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
площадного footprint → distance 0.
Returns:
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
радиусе, иначе None. None трактуется как «здание не сопоставлено» —
caller продолжает без parking_ratio (graceful, не выдумываем).
Note:
objdoc_id из cad_buildings возвращается для short-circuit НСПД search в
get_building_premises_for_match — может быть None (тогда фоллбэк на
cad_num search).
"""
try:
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
except Exception:
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).
logger.exception("premises_lookup: resolve_cad_for_domrf failed lat=%s lon=%s", lat, lon)
return None
if row is None:
return None
distance_m = float(row["distance_m"])
if distance_m > max_dist_m:
logger.info(
"premises_lookup: nearest building %.1fm > %.1fm radius for lat=%s lon=%s — no match",
distance_m,
max_dist_m,
lat,
lon,
)
return None
raw_objdoc = row["objdoc_id"]
objdoc_id = int(raw_objdoc) if raw_objdoc is not None else None
return BuildingMatch(cad_num=row["cad_num"], objdoc_id=objdoc_id, distance_m=distance_m)
def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
"""objdoc_id здания по cad_num через NSPD geoportal search.
Возвращает None если здание не найдено или НСПД не отдал objdocId (часть
объектов его не имеет). Сетевые/WAF-ошибки логируются и дают None — caller
трактует как «нет данных», analyze не падает (graceful).
"""
try:
result = client.search_by_cad(cad_num, thematic_id=_THEMATIC_BUILDING)
except NspdLiteError as e:
# WAF/сеть/4xx — не наш баг, не валим analyze. Один building без
# parking_ratio лучше, чем 500 на весь отчёт.
logger.warning("premises_lookup: search_by_cad failed cad_num=%s: %s", cad_num, e)
return None
feature = result.first
if feature is None:
logger.info("premises_lookup: здание не найдено в НСПД cad_num=%s", cad_num)
return None
options = feature.properties.get("options") or {}
raw = options.get("objdocId") or options.get("objdoc_id")
if raw is None:
# Fallback: feature.id у зданий = objdoc (verified live на
# 66:41:0106036:183 — tab-group по feature.id вернул помещения).
raw = feature.feature_id
if raw is None:
logger.info("premises_lookup: нет objdoc_id для cad_num=%s", cad_num)
return None
try:
return int(raw)
except (TypeError, ValueError):
logger.warning("premises_lookup: невалидный objdoc_id %r для cad_num=%s", raw, cad_num)
return None
async def _fetch_listing(objdoc_id: int) -> ObjectsListing | None:
"""list_objects_in_building в одноразовом async-клиенте.
Изолированный async context (`async with`) — корректный lifecycle сокета +
semaphore под текущим event loop (см. NSPDBulkClient.__aenter__ / #260).
"""
try:
async with NSPDBulkClient() as client:
return await client.list_objects_in_building(objdoc_id)
except NspdBulkError as e:
logger.warning("premises_lookup: list_objects failed objdoc_id=%d: %s", objdoc_id, e)
return None
def get_building_premises(cad_num: str) -> ObjectsListing | None:
"""Помещения + машино-места + parking_ratio для здания по кад. номеру (#96).
On-demand, 2 НСПД-запроса. Возвращает None если здание не резолвится в
objdoc_id или НСПД недоступен (graceful — caller продолжает без сигнала).
`ObjectsListing.parking_ratio` = машино-места / помещения (None если
помещений 0).
Args:
cad_num: кадастровый номер ЗДАНИЯ (5-сегментный, напр.
`66:41:0106036:183`). Квартал/участок дадут пустой/нерелевантный
результат — caller обязан передавать именно здание.
Returns:
ObjectsListing | None. None трактуется как «нет данных», НЕ как нулевой
паркинг (не выдумываем дефицит из недоступности НСПД).
"""
sync_client = NSPDClient()
objdoc_id = _resolve_objdoc_id(cad_num, sync_client)
if objdoc_id is None:
return None
listing = asyncio.run(_fetch_listing(objdoc_id))
if listing is not None:
logger.info(
"premises_lookup: cad_num=%s objdoc_id=%d flats=%d parking=%d ratio=%s",
cad_num,
objdoc_id,
listing.flats_count,
listing.parking_count,
listing.parking_ratio,
)
return listing
def get_building_premises_for_match(match: BuildingMatch) -> ObjectsListing | None:
"""Помещения/parking_ratio для уже-сматченного здания (#96 wiring).
Тонкая обёртка над get_building_premises: если geom-match дал objdoc_id ИЗ
cad_buildings, ПРОПУСКАЕМ НСПД search_by_cad (1 НСПД-запрос вместо 2 — search
тратит rate-limit и иногда промахивается на не-канон cad_num). Иначе фоллбэк
на cad_num-резолв через get_building_premises.
Graceful: НСПД недоступен / objdoc не отдал помещения → None (как у
get_building_premises). Изолирует async в asyncio.run() — вызывается из
sync-контекста (FastAPI threadpool handler / Celery task).
"""
if match.objdoc_id is not None:
listing = asyncio.run(_fetch_listing(match.objdoc_id))
if listing is not None:
logger.info(
"premises_lookup: match cad_num=%s objdoc_id=%d (cad_buildings) "
"flats=%d parking=%d ratio=%s",
match.cad_num,
match.objdoc_id,
listing.flats_count,
listing.parking_count,
listing.parking_ratio,
)
return listing
# objdoc неизвестен в cad_buildings — полный путь по cad_num (2 НСПД-запроса).
return get_building_premises(match.cad_num)
__all__ = [
"BuildingMatch",
"get_building_premises",
"get_building_premises_for_match",
"resolve_cad_for_domrf",
]