import datetime as dt from typing import Any, Literal from pydantic import BaseModel, 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 class ParcelDetail(ParcelSummary): geometry_geojson: dict[str, Any] enrichment: dict[str, Any] # ── 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) 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 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 # ── 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 мес., 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." ), )