846 lines
38 KiB
Python
846 lines
38 KiB
Python
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
|
||
# Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК»
|
||
# (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1.
|
||
reserve_note: str | None = None
|
||
# Единица reserve_mva/installed_capacity_mva: 'МВА' (Россети) или 'МВт'
|
||
# (ЕЭСК ПС 35-220). district — адм. район ЕКБ (из xlsx ЕЭСК; NULL у Россети).
|
||
# ADDITIVE (default None). #2119 Phase B2.
|
||
reserve_unit: str | None = None
|
||
district: str | None = 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 GasGrsRow(BaseModel):
|
||
"""Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1."""
|
||
|
||
grs_name: str
|
||
design_capacity_th_m3_h: float | None # проектная (сумма по выходам), тыс. м³/ч
|
||
free_capacity_th_m3_h: float | None # свободная (сумма по выходам), тыс. м³/ч
|
||
free_capacity_pct: float | None # средняя свободная, %
|
||
upgrade_due: str | None # срок расшивки (макс. по выходам, свободный текст)
|
||
outputs_count: int # число строк-выходов ГРС, вошедших в агрегацию
|
||
|
||
|
||
class GasCapacityBlock(BaseModel):
|
||
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1."""
|
||
|
||
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
|
||
# таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||
city_grs: list[GasGrsRow]
|
||
# Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
|
||
total_free_th_m3_h: float | None
|
||
|
||
|
||
class HeatSystemRow(BaseModel):
|
||
"""Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2."""
|
||
|
||
org: str # организация-держатель (ЕТК / Екатеринбургэнерго)
|
||
system_name: str # имя системы теплоснабжения (напр. «СТ №54»)
|
||
reserve_gcal_h: float | None # свободная мощность, Гкал/ч (может быть <0 — дефицит)
|
||
period: str | None # напр. '2026-Q2'
|
||
|
||
|
||
class HeatCapacityBlock(BaseModel):
|
||
"""Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2."""
|
||
|
||
# Строки heat_system_reserves за MAX(period) ПО КАЖДОЙ организации. Пусто, если
|
||
# таблица heat_system_reserves ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||
systems: list[HeatSystemRow]
|
||
# Суммарная свободная тепловая мощность, Гкал/ч (None если данных нет).
|
||
total_reserve_gcal_h: float | None
|
||
|
||
|
||
class ConnectionCapacityResponse(BaseModel):
|
||
power_points: list[PowerConnectionPoint]
|
||
power_summary: PowerCapacitySummary
|
||
water: list[WaterCapacityRow]
|
||
# Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
|
||
gas: GasCapacityBlock | None = None
|
||
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
|
||
heat: HeatCapacityBlock | None = 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
|
||
# 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).
|
||
|
||
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
|
||
domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
|
||
дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
|
||
знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
|
||
комплексов → «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
|
||
считают комплексы; raw_objects_total — старый счёт obj_id для прозрачности.
|
||
"""
|
||
|
||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
||
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
|
||
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
|
||
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
|
||
groups_matched_by_core: int = 0
|
||
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 мес., 30K–800K руб/м², 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: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
|
||
# вложенный offer_trend (динамика цен предложения из objective_lots_history) и
|
||
# status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
|
||
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
|