gendesign/backend/app/schemas/nspd_bulk.py
Light1YT 2c22c3f7ea
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (push) Successful in 8m48s
CI / backend-tests (pull_request) Successful in 8m48s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m58s
Deploy / build-worker (push) Successful in 5m46s
Deploy / deploy (push) Successful in 1m30s
feat(site-finder): per-building помещения/машино-места + parking_ratio (on-demand MVP, #96)
Оживляет мёртвый foundation list_objects_in_building (#168 Q3-deferred). Fix реального
блокера: NSPDOptions не парсил objdocId (camelCase из NSPD search) → objdoc_id всегда None,
метод нельзя было вызвать. AliasChoices(objdoc_id, objdocId) + registers.

premises_lookup.get_building_premises(cad_num) — резолв objdoc → list_objects_in_building,
graceful (WAF/сеть/not-found → None). parking_ratio = машино-места/помещения (None при 0
помещений, 0.0 при реальном отсутствии паркинга). Verified live 66:41:0106036:183.

MVP on-demand (без bulk/схемы/prod-записей). parking_ratio готов, но НЕ wired в analyze:
конкуренты приходят из ДОМ.РФ без cad_num → нужен domrf↔cad_buildings geom-match (отдельная
задача). Сервис примет cad_num как только matching появится. 11 тестов, ruff clean.

Refs #96
2026-06-14 18:48:16 +05:00

175 lines
7 KiB
Python
Raw Permalink 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.

"""Pydantic schemas для NSPD bulk cadastre ingest (PR 2/5, issue #168).
Отдельный модуль от legacy NSPDFeature dataclass (nspd_client.py) —
там frozen dataclass для on-demand flow, тут Pydantic для bulk pipeline
с extra='allow' чтобы не потерять неизвестные поля → raw_props JSONB.
"""
from __future__ import annotations
from typing import Any
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
class NSPDOptions(BaseModel):
"""Опции кадастрового объекта из NSPD.
Все поля Optional — разные categoryId возвращают разные subsets.
extra='allow' → любые незнакомые поля сохраняются (forward-compatible).
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
cad_num: str | None = None
cad_number: str | None = None # некоторые категории используют cad_number
quarter_cad_number: str | None = None
parent_cad_number: str | None = None
area: float | None = None
cost_value: float | None = None
cost_index: float | None = None
purpose: str | None = None
floors: str | int | None = None
year_built: str | int | None = None
readable_address: str | None = None
ownership_type: str | None = None
right_type: str | None = None
status: str | None = None
land_record_area: float | None = None
specified_area: float | None = None
declared_area: float | None = None
permitted_use_established_by_document: str | None = None
category_type: str | None = None
build_record_type: str | None = None
build_record_type_value: str | None = None
# NSPD geoportal search отдаёт objdoc/registers в camelCase (objdocId /
# registersId), а bulk grid-walk иногда в snake_case. AliasChoices ловит оба
# (verified live на 66:41:0106036:183 — search вернул objdocId=40995027,
# snake-only парсинг давал None и ломал list_objects_in_building). objdoc_id —
# ключ к tab-group objectsList (помещения/машино-места).
objdoc_id: int | None = Field(
default=None,
validation_alias=AliasChoices("objdoc_id", "objdocId"),
)
registers_id: int | None = Field(
default=None,
validation_alias=AliasChoices("registers_id", "registersId"),
)
okato: str | None = None
kladr: str | None = None
@property
def effective_cad_num(self) -> str | None:
"""Нормализованный кад. номер: cad_num или cad_number (разные категории)."""
return self.cad_num or self.cad_number
class NSPDBulkFeature(BaseModel):
"""GeoJSON Feature из NSPD bulk endpoints.
Pydantic-версия (не dataclass) — для удобной сериализации + extra='allow'.
geometry — GeoJSON-dict в EPSG:3857 (transform на стороне Celery-таска).
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
id: int | str | None = None
type: str = "Feature"
geometry: dict[str, Any] | None = None
properties: dict[str, Any] = {}
@property
def category_id(self) -> int | None:
"""category из properties (ключ для routing к target table).
NSPD response shape: `properties.category` (НЕ `categoryId`).
Field name был неверно interpretirovan в initial PR2 implementation
based on HAR audit; в #177 поправили оба пути.
Использует `is not None` (НЕ `or`) — иначе `category=0` truthy-trapped
бы skipped (хотя observed NSPD IDs все positive, paranoia не вредна).
int() wrapped в try/except — если NSPD когда-то вернёт string ID
("ЗУ"), мы не падаем посреди upsert_features loop.
"""
val = self.properties.get("category")
if val is None:
val = self.properties.get("categoryId")
if val is None:
return None
try:
return int(val)
except (TypeError, ValueError):
return None
@property
def options(self) -> NSPDOptions:
"""Распарсить options из properties.options если есть, иначе пустой."""
opts = self.properties.get("options") or {}
return NSPDOptions.model_validate(opts)
@property
def cad_num(self) -> str | None:
"""Shortcut: cad_num из options (часто нужен в caller)."""
return self.options.effective_cad_num
class QuarterSnapshot(BaseModel):
"""Результат search_by_quarter — snapshot данных для одного квартала.
Первые 20 объектов на категорию из NSPD search endpoint.
overflow_categories — категории требующие grid-walk (totalCount > 20).
"""
model_config = ConfigDict(extra="allow")
quarter: str
fetched_at: str # ISO datetime string
features: list[NSPDBulkFeature] = []
meta_counts: dict[int, int] = {} # {categoryId: totalCount}
@property
def overflow_categories(self) -> list[int]:
"""Категории где totalCount > 20 — нужен grid-walk для полноты."""
return [cat_id for cat_id, count in self.meta_counts.items() if count > 20]
@property
def total_meta_count(self) -> int:
"""Суммарное количество объектов по meta (до cap)."""
return sum(self.meta_counts.values())
class ObjectsListing(BaseModel):
"""Результат list_objects_in_building — список помещений и машино-мест здания.
Получается через /api/geoportal/v1/tab-group-data?tabClass=objectsList.
Q3 deferred — код готов, но в bulk_harvest_quarter MVP не используется.
"""
model_config = ConfigDict(extra="allow")
objdoc_id: int
flats_count: int = 0
parking_count: int = 0
flats_cad_nums: list[str] = []
parking_cad_nums: list[str] = []
@property
def parking_ratio(self) -> float | None:
"""Обеспеченность паркингом: машино-места / помещения (#96).
Сигнал класса/дефицита для recommend: бизнес обычно ≥1.0, эконом <0.5.
None если помещений 0 (нельзя делить) — caller трактует как «нет данных»,
НЕ как 0 (graceful, не выдумываем дефицит из пустоты). Округление до 0.01
— детерминированно, без накопления float-шума при агрегации по соседям.
"""
if self.flats_count <= 0:
return None
return round(self.parking_count / self.flats_count, 2)
__all__ = [
"NSPDBulkFeature",
"NSPDOptions",
"ObjectsListing",
"QuarterSnapshot",
]