import datetime as dt from typing import Any, Literal from pydantic import BaseModel, Field # ── 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 # ── 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 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 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 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