gendesign/backend/app/schemas/parcel.py
Light1YT e285cbe33a
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 14m28s
feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A)
Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность)
из верифицированных источников (research #2119). Все источники гео-блокируют
не-RU IP — лоадеры выполняются на проде (Celery weekly + ручной docker exec).

Backend:
- Миграция 180: power_supply_centers (ПС 35-220кВ, точки + резерв МВА),
  power_tp_rp_reserves (10.7k ТП/РП 6-10кВ, геокод — Фаза B),
  water_supply_reserves (ЦСВ/ЦСК Водоканала). UNIQUE NULLS NOT DISTINCT на
  nullable-ключах (грабли #140).
- rosseti_wfs_loader: открытый WFS портал-тп.рф (punycode) → 488 ПС Свердл обл,
  индекс загрузки 256184/6/8 → open/limited/closed, voltage из имени.
- rosseti_reserve_loader: раскрытие ПП№24 (xlsx) — ЦП 35-110кВ (строка 8+)
  матчится к WFS-точкам по нормализованному имени; ТП/РП <35кВ (строка 9+),
  кВА-санити (>2.5 МВА для ТП → ÷1000), «н/д»→NULL, SAVEPOINT per-row.
- vodokanal_reserve_loader: DOCX-раскрытие ПП№6 (stdlib parse, vMerge
  forward-fill), отрицательные резервы (город: дефицит) сохраняются со знаком.
- Endpoint GET /{cad}/connection-capacity: ПС в радиусе (ST_DWithin geography)
  + summary + вода за последний период per-system_kind. Celery: вт 04:00/04:30.

Frontend (§3):
- Карточка «Ресурсные резервы»: ближайший ЦП с резервом МВА + статус-бейдж;
  вода/канализация с красным «дефицит» при отрицательном резерве + примечание.
- Слой «Центры питания (резерв)» на карте: цвет по индексу загрузки
  (зелёный/янтарь/красный), попап с напряжением/мощностью/загрузкой/резервом,
  легенда под картой. Числа через toFiniteNumber (Decimal→str coercion).

api-types.ts перегенерён офлайн (+131/-0, аддитивно). 39 тестов зелёные.
Deep-review: ⚠️ MINOR → все 3 замечания исправлены (NULLS NOT DISTINCT,
per-kind MAX(period), settlement-комментарий).
2026-07-02 12:45:55 +05:00

777 lines
33 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
# Назначение сооружения из НСПД-дампа (params_purpose / object_type_value),
# напр. «1.1. Сооружения электроэнергетики». Заполняется ТОЛЬКО когда отличается
# от `type` (иначе дубль-шум). ADDITIVE (default None).
purpose: str | None = None
distance_to_boundary_m: float
geometry_geojson: dict[str, Any]
readable_address: str | None
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
characteristics: str | None = 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
# ── OSM engineering-networks schemas (#1746) ─────────────────────────────────
# No-B2B open-data слой инженерных сетей из OSM (Overpass): power/water/gas/heat/
# communication/sewage. Источник — osm_utility_infrastructure_ekb.
class UtilityInfrastructureFeature(BaseModel):
osm_id: int
osm_type: str
# 'power' | 'water' | 'gas' | 'heat' | 'communication' | 'sewage'
infrastructure_kind: str
name: str | None
source_tag: str | None
distance_m: float
geometry_geojson: dict[str, Any]
# Гуманизированные характеристики сети из OSM tags (напряжение / оператор /
# диаметр / материал), собранные в одну строку. None — данных нет.
# ADDITIVE (default None) → не ломает существующих потребителей.
characteristics: str | None = None
class UtilityInfrastructureSummary(BaseModel):
total_features: int
nearest_distance_m: float | None
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
nearest_by_kind: dict[str, float | None]
class UtilityInfrastructureResponse(BaseModel):
features: list[UtilityInfrastructureFeature]
summary: UtilityInfrastructureSummary
# ── Connection-capacity schemas (Forgejo #2119 Phase A) ──────────────────────
# «Точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3: центры питания
# (power_supply_centers) с резервом + city-level резервы воды (water_supply_reserves).
class PowerConnectionPoint(BaseModel):
"""Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью."""
name: str
dzo_name: str | None
voltage_class: str | None # напр. '110/35/10'
load_index: str | None # 'open'|'limited'|'closed'|None
installed_capacity_mva: float | None
current_load_mva: float | None
reserve_mva: float | None # свободная мощность, МВА
reserve_asof: str | None # ISO-дата актуальности резерва
distance_m: float
lat: float | None
lon: float | None
class PowerCapacitySummary(BaseModel):
"""Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки."""
total_power_points: int
# Карта индекс загрузки ('open'|'limited'|'closed'|'unknown') → count.
by_load_index: dict[str, int]
# Ближайший ЦП с положительным резервом (или null, если резерв нигде не задан).
nearest_with_reserve: PowerConnectionPoint | None
class WaterCapacityRow(BaseModel):
"""Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период."""
system_kind: str # 'water'|'sewerage'
system_name: str # ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
reserve_thousand_m3_day: float | None # может быть отрицательным (дефицит)
note: str | None
period: str | None
class ConnectionCapacityResponse(BaseModel):
power_points: list[PowerConnectionPoint]
power_summary: PowerCapacitySummary
water: list[WaterCapacityRow]
# ── 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
# OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций.
# "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана
# objective_lots.price_per_m2_rub через objective_complex_mapping (additive
# fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там.
price_source: str | None = 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
# #1926: машиночитаемый advisory-флаг — score это POI-density-эвристика
# (0..score_max_reference), а не абсолютный инвест-рейтинг. Optional для
# обратной совместимости (старые моки/202-стаб → null).
score_is_advisory: bool | None = None
score_advisory: dict[str, Any] | 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