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 # Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без # LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL). # ADDITIVE (default False) — старые клиенты не ломаются. protection_zones_truncated: bool = False structures_truncated: bool = False 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] # Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же # ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200). # ADDITIVE (default False) — старые клиенты не ломаются. features_truncated: bool = False 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. + счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО «Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4. """ # Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если # таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD). city_grs: list[GasGrsRow] # Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет). total_free_th_m3_h: float | None # Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level — # см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица # gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4. # ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). outlets_total: int = 0 # всего точек выхода в последнем срезе outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0) outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт class GasOutletPoint(BaseModel): """Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4.""" outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст) consumer_type: str | None # котельная / ТЭЦ / площадка … # Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть # <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True). free_capacity_mln_m3: float | None needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт distance_m: float # расстояние от центроида участка, м lat: float | None lon: 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 NearbyNetworkZone(BaseModel): """Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3. Охранная зона сети = коридор, где физически проходит сеть → «теплотрасса в 24 м, врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0. """ # Вид сети: thermal/electricity/gas/water/communication/pipeline/general. # general дочищен по name_by_doc (инж.коммуникации на проде часто реально тепло/газ). net_kind: str distance_m: int # расстояние от участка до охранной зоны, м (0 = пересекает участок) name_by_doc: str | None # сырое детальное имя из cad_zouit (для тултипа/дебага) # Гуманизированная подпись (C3): «Тепловая сеть (по охранной зоне), кад. № …» / # «Ввод 10 кВ от ТП 3164 (по охранной зоне)» / «Инженерные коммуникации (вид не уточнён)». human_label: str class ConnectionCapacityResponse(BaseModel): power_points: list[PowerConnectionPoint] power_summary: PowerCapacitySummary water: list[WaterCapacityRow] # Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран. gas: GasCapacityBlock | None = None # Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2 # PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе. gas_outlet_points: list[GasOutletPoint] = [] # Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран. heat: HeatCapacityBlock | None = None # Позитив-разрез (Phase C2/C3): сетевые охранные зоны cad_zouit в радиусе 300 м — # до 2 ближайших на net_kind, гуманизированы. ADDITIVE (default []): [] если сеть # рядом не найдена / нет geom участка. nearby_network_zones: list[NearbyNetworkZone] = [] # ── Full PDF report (эпик #2259 PR-D) ──────────────────────────────────────── class ReportBuildResponse(BaseModel): """Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D). status="building" (202) — таска поставлена в очередь, PDF собирается в фоне; status="ready" (200) — готовый кэш (тот же analyze+forecast ран) уже на диске, качать сразу через /report/download. """ status: Literal["building", "ready"] # Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт. analyze_run_at: str | None = None forecast_run_at: str | None = None # Дата генерации готового PDF (ISO) — только при status="ready" (кэш-хит); на # building-ветке None (файла ещё нет). Даёт fast-path кнопке дату без GET /status. report_generated_at: str | None = None class ReportStatusResponse(BaseModel): """Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D). status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске), "building" (есть analyze-ран, но готового отчёта нет — вероятно собирается / ещё не запускался), "none" (нет даже analyze-рана — отчётировать нечего). """ status: Literal["ready", "building", "none"] # Даты базовых ранов (ISO) — по каким данным собран/будет собран отчёт. analyze_run_at: str | None = None forecast_run_at: str | None = None # Дата генерации готового PDF (ISO) — только при status="ready". report_generated_at: str | 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 SupplyOnlyLayoutRow(BaseModel): """Строка блока «по предложению» (#2177 шаг 3). Структура текущего предложения (комнатность × площадь × цена) для комплексов, которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже. """ project_name: str room_bucket: str # {studio,1,2,3,4+} — вокабуляр velocity-стороны area_bin: str lots_count: int median_price_m2: float | None # NULL если цены не прошли price-sanity median_area_m2: float | None # Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж». source: str = "objective_lots" 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 # Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» — # сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в # продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием. groups_with_supply_only: 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 # Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» — # структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity. # Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию. supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list) # ── 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 permits_nearby: 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