Closes #105 (Phase 3 geocoding — отдельный issue). Phase 4: 'ekburg-permits-monthly' crontab(0 2 1 * *) → tasks.refresh_all Phase 5: /analyze returns engineering.recent_permits_in_quarter + permits_summary (rns/rve counts/area/units, by_developer top-3) Filter: quarter prefix (3-segment cadastral match). Spatial 500m radius — после Phase 3 geocoding. Tests: test_analyze_recent_permits.py (3 mock), comment updates на inline_weights/market_price (shifted execute ordering).
286 lines
8.9 KiB
Python
286 lines
8.9 KiB
Python
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
|
||
units_count: int | None
|
||
|
||
|
||
class PermitsSummary(BaseModel):
|
||
"""Агрегированная сводка по разрешениям в квартале."""
|
||
|
||
rns_count: int
|
||
rve_count: int
|
||
rns_total_area_sqm: float
|
||
rve_total_units: int
|
||
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
|
||
|
||
|
||
# ── 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
|
||
|
||
|
||
# ── 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 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."
|
||
),
|
||
)
|