gendesign/backend/app/schemas/parcel.py
Light1YT 7e2065a74f
Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Failing after 2m5s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m35s
CI / backend-tests (push) Successful in 9m0s
CI / backend-tests (pull_request) Successful in 8m43s
feat(site-finder): wire domrf→cad_buildings geom-match + parking_ratio конкурентов (#96)
domrf-конкуренты живут в lat/lon без cad_num — PostGIS-мост resolve_cad_for_domrf
(GIST KNN nearest ≤50м + ST_Contains footprint) → on-demand НСПД premises → parking_ratio.
Prod: 47% domrf-объектов (1543) матчатся ≤50м, медиана 56м. Доставка через ленивый
POST /{cad}/competitors-parking (top-3), НЕ inline в analyze/forecast (N×НСПД убил бы p95).
cad_buildings.objdoc_id short-circuit (1 НСПД-запрос вместо 2). Graceful → None. 16 тестов.
Завершает #1326 MVP (premises_lookup) — parking_ratio теперь доходит до конкурентов.

Closes #96
2026-06-14 19:17:30 +05:00

681 lines
28 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.

import datetime as dt
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
class RecentPermit(BaseModel):
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
permit_type: str
permit_number: str
issue_date: str | None
developer_name: str | None
developer_inn: str | None
object_name: str | None
object_type: str | None
construction_address: str | None
total_area_sqm: float | None
class PermitsSummary(BaseModel):
"""Агрегированная сводка по разрешениям в квартале."""
rns_count: int
rve_count: int
rns_total_area_sqm: float
by_developer: list[dict[str, Any]]
# ── Connection points schemas (issue #115) ────────────────────────────────────
class EngineeringStructure(BaseModel):
name: str | None
type: str | None
cad_num: str | None
distance_to_boundary_m: float
geometry_geojson: dict[str, Any]
readable_address: str | None
raw_props: dict[str, Any]
source: str
class ZouitOverlap(BaseModel):
reg_numb_border: str | None
type_zone: str | None
subcategory: int | None
intersects_parcel: bool
geometry_geojson: dict[str, Any]
raw_props: dict[str, Any]
source: str
class ConnectionPointsSummary(BaseModel):
nearest_structure_distance_m: float | None
in_protection_zone: bool
protection_zones_intersecting: int
total_structures_in_radius: int
class ConnectionPointsResponse(BaseModel):
engineering_structures: list[EngineeringStructure]
zouit_engineering_overlaps: list[ZouitOverlap]
summary: ConnectionPointsSummary
dump_available: bool
dump_fetched_at: str | None
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
class RiskZone(BaseModel):
"""Одна риск-зона НСПД (TIER 3), пересекающая участок."""
layer: str # e.g. "risk_flooding", "risk_landslide"
subtype: str | None # человекочитаемое название из NSPD properties
geom_wkt: str | None # WKT геометрии risk-зоны в EPSG:4326
intersection_area_sqm: float | None # площадь пересечения с участком, м²
# ── NSPD Opportunity + Red Lines schemas (issue #94 TIER 4) ──────────────────
class OpportunityParcel(BaseModel):
"""TIER 4: opportunity ЗУ вблизи участка (auction/scheme/free/future/oopt).
Поля:
layer: тип opportunity — "auction_parcels" | "scheme_parcels" |
"free_parcels" | "future_parcels" | "oopt"
cad_num: кадастровый номер ЗУ (если доступен в NSPD properties)
distance_m: расстояние от centroid анализируемого участка до ЗУ, м
geom_wkt: WKT геометрии ЗУ в EPSG:4326
"""
layer: str
cad_num: str | None
distance_m: float | None
geom_wkt: str | None
class RedLine(BaseModel):
"""TIER 4: красная линия застройки (NSPD layer 879243).
Поля:
geom_wkt: WKT геометрии линии в EPSG:4326
intersection_length_m: длина пересечения линии с участком, м
(null если только nearby, не intersect)
distance_m: расстояние от ближайшей точки линии до участка, м
(null если линия пересекает участок)
UI-семантика:
intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT)
distance_m != null → красная линия рядом (warning)
"""
geom_wkt: str | None
intersection_length_m: float | None # null если только nearby, не intersect
distance_m: float | None # null если intersect
# ── Parcel search/detail schemas ──────────────────────────────────────────────
class ParcelFilter(BaseModel):
vri: list[str] | None = None
area_min_sqm: float | None = None
area_max_sqm: float | None = None
region: str | None = None
polygon_geojson: dict[str, Any] | None = None
text_search: str | None = None
class ScoringWeights(BaseModel):
distance_to_center: float = 0.4
poi_density: float = 0.3
neighborhood_price: float = 0.3
class ParcelSearchRequest(BaseModel):
filters: ParcelFilter = Field(default_factory=ParcelFilter)
weights: ScoringWeights = Field(default_factory=ScoringWeights)
limit: int = 50
class ScoreBreakdown(BaseModel):
total: float
distance_to_center: float
poi_density: float
neighborhood_price: float
class ParcelSummary(BaseModel):
id: str
cadastral_number: str
area_sqm: float
vri: str
address: str | None
score: ScoreBreakdown
class ParcelSearchResponse(BaseModel):
items: list[ParcelSummary]
total: int
# ── Competitors endpoint schemas ──────────────────────────────────────────────
TimeWindow = Literal["last_month", "last_quarter", "last_year"]
ObjClassFilter = Literal["economy", "comfort", "business"]
class CompetitorsRequest(BaseModel):
radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
time_window: TimeWindow = "last_quarter"
obj_class_filter: ObjClassFilter | None = None
exclude_obj_ids: list[int] = Field(default_factory=list)
# #949 PR B (ТЗ §9.1): горизонт запуска (мес.) для horizon-aware
# relevance_weight. Конкурент, который к нашему запуску будет распродан,
# менее релевантен (см. stage_at_horizon в competitors.py).
horizon_months: int = Field(default=12, ge=1, le=60)
class Competitor(BaseModel):
obj_id: int
comm_name: str | None
dev_name: str | None
obj_class: str | None
distance_m: float
lat: float
lng: float
stage: str | None
site_status: str | None = None
ready_dt: dt.date | None = None
flats_total: int | None
flats_sold: int | None
sold_pct: float | None
velocity_per_month: float
avg_price_per_m2: float | None
is_active: bool
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
# stage_at_horizon. None только если расчёт недоступен (backward-safe).
# relevance_breakdown — explainability: 4 саб-скора (каждый 0..1).
relevance_weight: float | None = None
relevance_breakdown: dict[str, float] | None = None
class CompetitorsSummary(BaseModel):
total_competitors: int
active_count: int
weighted_avg_velocity: float
radius_km: float
time_window: str
class CompetitorsResponse(BaseModel):
competitors: list[Competitor]
summary: CompetitorsSummary
# ── #96 wiring: parking_ratio конкурентов (lazy-endpoint, НЕ inline в analyze) ──
# domrf-конкуренты живут в lat/lon без cad_num → geom-match на cad_buildings →
# on-demand НСПД premises-lookup. Дорого (N×НСПД ~0.6-2с) → отдельный ленивый
# эндпоинт /{cad}/competitors-parking, строгий top-N, фронт дёргает после
# отрисовки конкурентов. НЕ блокирует analyze/forecast p95.
class CompetitorParking(BaseModel):
"""parking_ratio одного конкурента (#96). obj_id связывает с Competitor.
matched_cad_num — здание cad_buildings, на которое сматчился domrf-центроид
(None если geom-match не нашёл здание в радиусе). parking_ratio /
parking_count / flats_count — из НСПД tab-group (None если здание не
сматчилось ИЛИ НСПД недоступен — graceful, НЕ нулевой паркинг).
match_distance_m — расстояние domrf-центроид → footprint (диагностика).
"""
obj_id: int
matched_cad_num: str | None = None
match_distance_m: float | None = None
parking_ratio: float | None = None
parking_count: int | None = None
flats_count: int | None = None
class CompetitorsParkingResponse(BaseModel):
"""Ответ ленивого /{cad}/competitors-parking (#96).
items — по одному CompetitorParking на top-N конкурентов (по relevance_weight
DESC из get_competitors). matched_count — сколько реально получили
parking_ratio (диагностика покрытия для фронта / мониторинга).
"""
items: list[CompetitorParking]
matched_count: int
# ── Developer attribution (#1088 «GG-форсайт») ─────────────────────────────────
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
# developer_registry (миграция 149). Источник истины формы — fn_developer_for_parcel
# SETOF + developer_registry track-record; схемы зеркалят его поля 1:1.
class DeveloperAttribution(BaseModel):
"""Топ-1 застройщик участка + его track-record (#1088).
developer_inn — нормализованный ИНН (10/12-знач), стабильный кросс-источниковый
ключ. match_method — лучшая сработавшая ступень резолвера
(exact_cadastral > spatial_150m > quarter). track-record-поля денормализованы
из developer_registry по ИНН (могут быть None для застройщика, известного только
одной стороне). project/status/obj_class — из конкретного матча (РНС или домрф-ЖК).
"""
developer_inn: str
developer_name: str | None
source: str | None # 'permits' | 'domrf'
match_method: str | None # 'exact_cadastral' | 'spatial_150m' | 'quarter'
distance_m: float | None
project: str | None
status: str | None
obj_class: str | None
# track-record (developer_registry, по норм-ИНН)
permits_total: int | None
domrf_objects_total: int | None
domrf_active_objects: int | None
in_both_sources: bool | None
class NearbyDeveloper(BaseModel):
"""Соседний застройщик участка (дедупнутый, match_method spatial_150m/quarter).
Контекст «кто ещё строит рядом / в квартале». Тот же набор полей, что primary,
но без exact-кадастра (это всегда вторичный матч). Дедупнут по developer_inn —
один застройщик в nearby представлен своим лучшим (первым по ORDER BY) матчем.
"""
developer_inn: str
developer_name: str | None
source: str | None
match_method: str | None
distance_m: float | None
project: str | None
status: str | None
obj_class: str | None
permits_total: int | None
domrf_objects_total: int | None
domrf_active_objects: int | None
in_both_sources: bool | None
class DeveloperAttributionResult(BaseModel):
"""Результат атрибуции застройщика участка (#1088).
primary — лучший матч (топ-1 по ORDER BY резолвера: exact dist=0 → spatial →
quarter). nearby_developers — остальные дедупнутые застройщики (spatial/quarter),
«кто ещё строит рядом». Сервис возвращает None при пустом результате (нет матча /
нет геометрии / миграция не применена) — analyze получает ключ только при наличии.
"""
primary: DeveloperAttribution
nearby_developers: list[NearbyDeveloper]
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
class LayoutSignature(BaseModel):
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
"""
room_bucket: Literal["studio", "1", "2", "3", "4+"]
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
class BestLayoutsRequest(BaseModel):
"""Параметры запроса top-планировок в радиусе вокруг участка."""
radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
time_window: Literal["last_month", "last_quarter", "last_year"] = "last_quarter"
filter_competitor_obj_ids: list[int] | None = None
exclude_competitor_obj_ids: list[int] = Field(default_factory=list)
min_velocity_per_month: float = Field(default=0.5, ge=0.0, le=100.0)
obj_class_filter: Literal["economy", "comfort", "business"] | None = None
target_total_flats: int | None = Field(default=None, ge=1, le=10000)
class TopLayoutRow(BaseModel):
"""Одна строка top-planirovok ranking'а."""
rank: int
room_bucket: str
area_bin: str
signature: str
competitor_obj_ids: list[int]
competitor_count: int
total_sold_in_window: int
velocity_per_month: float
avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj
avg_area_m2: float
supply_units_in_radius: int
sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
class LayoutTzMixRow(BaseModel):
"""Строка рекомендации unit-mix для ТЗ."""
room_bucket: str
pct: int # 0..100, sum total = 100
abs_units: int | None # NULL если target_total_flats не задан
avg_target_area_m2: float | None
class LayoutTzRecommendation(BaseModel):
"""Рекомендация для ТЗ на проектирование."""
rationale_text: str
mix: list[LayoutTzMixRow]
weighted_avg_price_per_m2_rub: float | None
based_on_obj_count: int
based_on_total_deals: int
data_window_start: dt.date
data_window_end: dt.date
# Fix SF-09 review: True если pathological case — все bucket'ы выше cap,
# redistribute невозможен. Frontend использует для отображения warning banner.
cap_skipped: bool = False
class LayoutDataQuality(BaseModel):
"""Метаданные качества данных (coverage)."""
objects_with_velocity_data: int
objects_total_in_radius: int
velocity_coverage_pct: float
confidence: Literal["high", "medium", "low"]
class BestLayoutsResponse(BaseModel):
"""Ответ /best-layouts endpoint'а."""
top_layouts: list[TopLayoutRow]
recommendation_for_tz: LayoutTzRecommendation
data_quality: LayoutDataQuality
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
class MarketPrice(BaseModel):
"""Ценовая статистика квартала из mv_quarter_price_per_m2 (Issue #33).
Источник: rosreestr_deals, фильтр realestate_type_code='002001003000' (ДДУ),
скользящее окно 24 мес., 30K800K руб/м², HAVING >= 3 сделок.
"""
p25: float | None = None
median: float | None = None
p75: float | None = None
mean: float | None = None
deals_count: int = 0
median_6m: float | None = None
median_12m: float | None = None
median_24m: float | None = None
last_deal_date: str | None = None # ISO date
source: Literal["quarter_mv", "no_data"] = "no_data"
# ── Analyze endpoint parcel meta (#29 G2) ────────────────────────────────────
class ParcelMeta(BaseModel):
"""Кадастровые метаданные участка из cad_parcels (#29 G2).
Поля берутся из NSPD bulk-ingest (issue #168). Строки сырые, без нормализации.
"""
permitted_use: str | None = None # permitted_use_established_by_document
land_category: str | None = None # land_record_category_type
land_subtype: str | None = None # land_record_subtype
cad_cost: float | None = None # cost_value (кадастровая стоимость, руб.)
source: str = "cad_parcels"
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
class AnalyzeRequest(BaseModel):
"""Опциональное тело запроса POST /analyze.
Позволяет передать inline POI-веса напрямую в запросе без сохранения
профиля. Если задан weights — применяется с наивысшим приоритетом
(выше profile_id и user default).
"""
weights: dict[str, float] | None = Field(
default=None,
description=(
"Inline POI weights override (категория → weight). "
"Если задан — применяется к scoring, без обязательного profile save. "
"Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
),
)
# ── SF-B1: by-bbox map entry schemas (#307) ──────────────────────────────────
class ParcelMapMarker(BaseModel):
"""Один маркер участка на карте — облегчённый ответ для bbox-запроса.
status берётся из parcel_user_status если передан user_id, иначе null.
last_analysis_date — placeholder до реализации B2 auth.
"""
cad_num: str
centroid_lat: float
centroid_lon: float
area_m2: float | None
land_category: str | None
status: str | None # 'in_work' | 'favorite' | 'dismissed' | null
last_analysis_date: str | None # placeholder, real after B2 auth
class ParcelBboxResponse(BaseModel):
"""Ответ GET /parcels/by-bbox."""
parcels: list[ParcelMapMarker]
count: int
limit: int
bbox_area_km2: float
# ── #994 (961-C3, EPIC 961): run-history read endpoints ───────────────────────
#
# Два typed-ответа поверх analysis_runs (миграция 127). Summary — LIGHT-список
# (без большого result-блоба) для GET /{cad_num}/runs; Detail — полная строка
# (с result) для GET /runs/{run_id}. Зеркалят колонки из repository.list_runs_for /
# get_run (см. analysis_runs/repository.py).
class AnalysisRunSummary(BaseModel):
"""Облегчённая карточка одного рана для списка истории по участку.
БЕЗ тяжёлого `result`-блоба (~90 ключей) — только метаданные для UI-списка
«история анализов». Источник колонок: repository.list_runs_for (LIGHT SELECT).
"""
id: int
cad_num: str
created_at: dt.datetime
status: str | None = None
schema_version: str | None = None
district: str | None = None
confidence: str | None = None
created_by: str | None = None
class AnalysisRunDetail(BaseModel):
"""Полная строка одного рана (включая `result`) для re-open / детального view.
`result` — JSONB-блоб analyze (schema_version "analyze-1.0", форма ParcelAnalysis)
ИЛИ §22 SiteFinderReport ("1.0") — отдаём как есть (dict), форму не навязываем
(модель истории, не контракт analyze). Источник: repository.get_run (full SELECT).
"""
id: int
cad_num: str
created_at: dt.datetime
status: str | None = None
schema_version: str | None = None
district: str | None = None
confidence: str | None = None
created_by: str | None = None
advisory: bool | None = None
params: dict[str, Any] | None = None
segment: dict[str, Any] | None = None
result: dict[str, Any] | None = None
class AnalysisRunListResponse(BaseModel):
"""Ответ GET /parcels/{cad_num}/runs — список (пустой, если ранов нет)."""
runs: list[AnalysisRunSummary]
# ── #992 (EPIC 961): typed AnalyzeResponse + response_model ────────────────────
class AnalyzeResponse(BaseModel):
"""Типизированный контракт ответа POST /parcels/{cad_num}/analyze (#992).
КРИТИЧЕСКИЕ инварианты (EPIC 961 — наивысший blast radius PR):
1) `extra="allow"` — ЛЮБОЙ ключ из `result_payload` (parcels.py), НЕ перечисленный
здесь явно, ПОПАДАЕТ в ответ без изменений (Pydantic v2: уходит в
__pydantic_extra__ → включается в model_dump → FastAPI отдаёт в body).
Это backstop против silent-drop: даже если новый ключ добавят в payload и
забудут сюда — frontend его получит. Без этого response_model молча выкинул
бы неперечисленные ключи и сломал Site Finder.
2) ВСЕ поля Optional с дефолтом — модель НИЧЕГО не требует. Эндпоинт имеет
graceful 202 fetch-stub ({status:"fetching", job_id, eta_seconds, message},
без score/competitors/...). Если бы тут были required-поля, FastAPI словил бы
ValidationError → 500 при сериализации 202-стаба через response_model. Все
поля nullable → 202-стаб проходит без ошибок (недостающие → null, additive,
frontend ветвится по HTTP-коду 202, не по телу — useSiteAnalysis.ts).
Перечень ниже документирует ~90 известных top-level ключей (зеркало
frontend ParcelAnalysis + result_payload) для OpenAPI / codegen — но НЕ
ограничивает ответ (extra="allow" + Optional). Источник истины формы — всё
ещё result_payload в parcels.py; модель его описывает, а не сужает.
"""
model_config = ConfigDict(extra="allow")
# — идентификация / геометрия —
cad_num: str | None = None
source: str | None = None
geom_geojson: dict[str, Any] | None = None
district: dict[str, Any] | None = None
# — score-блок —
score: float | None = None
score_without_center: float | None = None
score_label: str | None = None
score_max_reference: float | None = None
score_explanation: str | None = None
score_breakdown: dict[str, Any] | None = None
score_breakdown_detailed: list[dict[str, Any]] | None = None
score_top_3_positives: list[dict[str, Any]] | None = None
score_top_3_negatives: list[dict[str, Any]] | None = None
score_by_group: list[dict[str, Any]] | None = None
poi_count: int | None = None
location: dict[str, Any] | None = None
# — рынок / конкуренты / темп —
competitors: list[dict[str, Any]] | None = None
market_pulse: dict[str, Any] | None = None
market_avg_price_per_m2: float | None = None
market_data_coverage_pct: float | None = None
pipeline_24mo: dict[str, Any] | None = None
velocity: dict[str, Any] | None = None
market_trend: dict[str, Any] | None = None
market_price: dict[str, Any] | None = None
# — среда (шум / воздух / погода / геология) —
noise: dict[str, Any] | None = None
air_quality: dict[str, Any] | None = None
weather: dict[str, Any] | None = None
seasonal_weather: dict[str, Any] | None = None
wind: dict[str, Any] | None = None
geology: dict[str, Any] | None = None
hydrology: dict[str, Any] | None = None
utilities: dict[str, Any] | None = None
geotech_risk: dict[str, Any] | None = None
# — пригодность участка —
geometry_suitability: dict[str, Any] | None = None
neighbors_summary: dict[str, Any] | None = None
# — кадастр / разрешения / зонирование —
parcel_meta: dict[str, Any] | None = None
recent_permits_in_quarter: list[dict[str, Any]] | None = None
permits_summary: dict[str, Any] | None = None
zoning: dict[str, Any] | None = None
success_recommendation: dict[str, Any] | None = None
isochrones_available: bool | None = None
# — confidence-индикатор —
confidence: float | None = None
confidence_label: str | None = None
confidence_breakdown: dict[str, Any] | None = None
confidence_caveats: list[str] | None = None
# — NSPD-слои —
nspd_zoning: dict[str, Any] | None = None
nspd_zouit_overlaps: list[dict[str, Any]] | None = None
nspd_engineering_nearby: list[dict[str, Any]] | None = None
nspd_risk_zones: list[RiskZone] | None = None
nspd_opportunity_parcels: list[OpportunityParcel] | None = None
nspd_red_lines: list[RedLine] | None = None
nspd_dump: dict[str, Any] | None = None
gate_verdict: dict[str, Any] | None = None
# — веса / custom POI —
weights_profile: dict[str, Any] | None = None
custom_poi_score_items: list[dict[str, Any]] | None = None
# — SF-B5: ЕГРН / обременения / красные линии / метро / цены района / риски —
egrn: dict[str, Any] | None = None
encumbrance: dict[str, Any] | None = None
red_lines: dict[str, Any] | None = None
metro: dict[str, Any] | None = None
district_price_per_m2_min: float | None = None
district_price_per_m2_max: float | None = None
district_price_per_m2_median: float | None = None
district_price_sample_size: int | None = None
risks: dict[str, Any] | None = None
# — §22-форсайт stub (#995): pending|unavailable, добавляется после persist —
forecast: dict[str, Any] | None = None
# — ИРД-слой (#1067 D9b, flag enable_ird_analyze): ird_overlaps (м.132, incl opportunity) +
# functional_zone/krt (геопортал WFS) + zone_regulation (C8b). Отсутствует при флаге off —
ird: dict[str, Any] | None = None
# — Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). Additive,
# без флага (чистый DB-lookup, graceful). Отсутствует при пустом матче / pre-migration —
developer_attribution: dict[str, Any] | None = None
# — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют —
status: str | None = None
job_id: int | None = None
eta_seconds: int | None = None
message: str | None = None