Три honesty/correctness-фикса из audit-issue #1871 (🟠 P2). Все факты подтверждены prod-DB + разведкой. ETL не трогаем (отдельная задача). velocity.py: - _EKB_MEDIAN_FALLBACK 4500→750 (audit-verified медиана ЕКБ 593-766; 4500 завышало знаменатель нормализации 7.6× → занижало velocity_score при DB-error fallback, latent). Консервативно 750 (верх диапазона). - VelocityResult + as_dict() раскрывают: objective_coverage_pct, proxy_used, proxy_sqm_per_deal — теперь видно что объём rosreestr-пути фабрикуется count×45 м²/сделка при низком покрытии Objective (<50%). Проброшено во все 4 конструктора, additive. parcels.py (market_trend producer): - Различать «источник устарел» от «нет сделок». rosreestr_deals MAX(period_start_date)=2026-01-01 (stale 5.5 мес) → recent-окно пусто → раньше голый null. Теперь status ∈ {ok, source_stale, no_deals} + as_of_date + label + recent_deals_count. MAX(deal_date) добавлен в CTE. Consumer scoring читает recent_deals_count защищённо (без регрессии). frontend (types + MarketTrendBlock + VelocityBlock + ptica market-drawer + Section3 verdict + legacy guard): - staleness/no_deals → честный caveat вместо фейкового тренда. - velocity → caption «объём оценочный (прокси N м²/сделка, покрытие P%)». - formatAsOfMonth парсит YYYY-MM напрямую (TZ-safe). - Типы MarketTrend/Velocity расширены (optional, backward-compat; отсутствие status = "ok"). Тесты: +3 velocity unit + extended as_dict contract. 17 velocity passed, 138 frontend vitest passed. Refs #1871
3789 lines
176 KiB
Python
3789 lines
176 KiB
Python
import datetime as _dt
|
||
import json
|
||
import logging
|
||
import math
|
||
import time
|
||
from typing import Annotated, Any, Literal
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
||
from fastapi.concurrency import run_in_threadpool
|
||
from shapely import wkt as _shp_wkt
|
||
from shapely.geometry import Polygon
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.db import get_db
|
||
from app.schemas.parcel import (
|
||
AnalysisRunDetail,
|
||
AnalysisRunListResponse,
|
||
AnalysisRunSummary,
|
||
AnalyzeRequest,
|
||
AnalyzeResponse,
|
||
BestLayoutsRequest,
|
||
BestLayoutsResponse,
|
||
CompetitorsParkingResponse,
|
||
CompetitorsRequest,
|
||
CompetitorsResponse,
|
||
ConnectionPointsResponse,
|
||
OpportunityParcel,
|
||
ParcelBboxResponse,
|
||
ParcelMapMarker,
|
||
ParcelMeta,
|
||
ParcelSearchRequest,
|
||
ParcelSearchResponse,
|
||
RedLine,
|
||
RiskZone,
|
||
)
|
||
from app.services.analysis_runs.repository import (
|
||
ANALYZE_SCHEMA_VERSION,
|
||
get_run,
|
||
latest_run_dates,
|
||
latest_run_for,
|
||
list_runs_for,
|
||
persist_analysis_run,
|
||
)
|
||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||
from app.services.exporters.report_docx import render_report_docx
|
||
from app.services.exporters.report_md import (
|
||
render_report_markdown,
|
||
render_report_telegram_summary,
|
||
)
|
||
from app.services.exporters.report_pdf import export_report_pdf
|
||
from app.services.exporters.report_pptx import render_report_pptx
|
||
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
||
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
|
||
from app.services.site_finder.best_layouts import get_best_layouts
|
||
from app.services.site_finder.cadastre_fetch import (
|
||
cad_exists_in_db,
|
||
find_or_enqueue_fetch,
|
||
)
|
||
from app.services.site_finder.cadastre_fetch import (
|
||
fetch_status as _fetch_status,
|
||
)
|
||
from app.services.site_finder.competitors import get_competitors, get_competitors_parking
|
||
from app.services.site_finder.custom_pois import (
|
||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
||
)
|
||
from app.services.site_finder.developer_attribution import get_developer_attribution
|
||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
||
from app.services.site_finder.poi_score import (
|
||
PoiScoreResponse,
|
||
compute_poi_routing_decay,
|
||
compute_poi_weighted_top7,
|
||
)
|
||
from app.services.site_finder.quarter_dump_lookup import (
|
||
get_connection_points,
|
||
get_quarter_dump_data,
|
||
make_empty_result,
|
||
)
|
||
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
|
||
from app.services.site_finder.saturation import compute_district_saturation
|
||
from app.services.site_finder.velocity import compute_velocity
|
||
from app.services.site_finder.weight_profiles import (
|
||
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
||
)
|
||
from app.services.site_finder.weight_profiles import (
|
||
ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
|
||
)
|
||
from app.services.site_finder.weight_profiles import (
|
||
MAX_WEIGHT as _MAX_WEIGHT,
|
||
)
|
||
from app.services.site_finder.weight_profiles import (
|
||
MIN_WEIGHT as _MIN_WEIGHT,
|
||
)
|
||
from app.services.site_finder.weight_profiles import (
|
||
resolve_weights as _resolve_weights,
|
||
)
|
||
from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulation
|
||
from app.services.weather_cache import (
|
||
get_air_quality_cached,
|
||
get_seasonal_weather_cached,
|
||
get_weather_cached,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# Базовые уровни шума по типу источника (дБ на 10м) — источник: WHO Environmental Noise Guidelines
|
||
NOISE_L_BASE: dict[str, float] = {
|
||
"highway:motorway": 75.0,
|
||
"highway:trunk": 75.0,
|
||
"highway:primary": 70.0,
|
||
"highway:secondary": 65.0,
|
||
"highway:tertiary": 60.0,
|
||
"highway:residential": 55.0,
|
||
"railway": 72.0,
|
||
"industrial": 65.0,
|
||
"aerodrome": 70.0,
|
||
}
|
||
|
||
|
||
def _wind_label(deg: float) -> str:
|
||
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
|
||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||
idx = round(deg / 45) % 8
|
||
return rose[idx]
|
||
|
||
|
||
# Координаты центра ЕКБ — Площадь 1905 года
|
||
EKB_CENTER_LAT: float = 56.838011
|
||
EKB_CENTER_LON: float = 60.597474
|
||
|
||
# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
|
||
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}
|
||
SCORE_MAX_REFERENCE: float = 40.0
|
||
|
||
|
||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||
"""Расстояние по формуле гаверсинуса между двумя точками (км)."""
|
||
earth_r = 6371.0
|
||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||
dphi = math.radians(lat2 - lat1)
|
||
dlam = math.radians(lon2 - lon1)
|
||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||
return 2 * earth_r * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||
|
||
|
||
def _score_label(s: float) -> str:
|
||
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.
|
||
|
||
Шкала: <5 плохо, 5-15 средне, 15-25 хорошо, >=25 отлично.
|
||
"""
|
||
if s < SCORE_THRESHOLDS["плохо"]:
|
||
return "плохо"
|
||
if s < SCORE_THRESHOLDS["средне"]:
|
||
return "средне"
|
||
if s < SCORE_THRESHOLDS["хорошо"]:
|
||
return "хорошо"
|
||
return "отлично"
|
||
|
||
|
||
def _confidence_label(c: float) -> str:
|
||
"""Текстовая интерпретация confidence (0..1).
|
||
|
||
Пороги:
|
||
high — c > 0.75 (плотные актуальные данные)
|
||
medium — 0.4-0.75
|
||
low — c < 0.4 (caveats обязательны)
|
||
"""
|
||
if c >= 0.75:
|
||
return "high"
|
||
if c >= 0.4:
|
||
return "medium"
|
||
return "low"
|
||
|
||
|
||
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
||
_POI_CATEGORY_RU: dict[str, str] = {
|
||
"school": "Школа",
|
||
"kindergarten": "Детсад",
|
||
"pharmacy": "Аптека",
|
||
"hospital": "Больница",
|
||
"shop_mall": "ТЦ",
|
||
"shop_supermarket": "Супермаркет",
|
||
"shop_small": "Магазин",
|
||
"park": "Парк",
|
||
"bus_stop": "Автобус",
|
||
"metro_stop": "Метро",
|
||
"tram_stop": "Трамвай",
|
||
}
|
||
|
||
# Группировка POI по тематическим эшелонам — для stacked-bar % contribution
|
||
# (X1 score breakdown). Расширяй по мере добавления новых категорий.
|
||
_POI_GROUP: dict[str, str] = {
|
||
"school": "Социалка",
|
||
"kindergarten": "Социалка",
|
||
"pharmacy": "Социалка",
|
||
"hospital": "Социалка",
|
||
"shop_mall": "Торговля",
|
||
"shop_supermarket": "Торговля",
|
||
"shop_small": "Торговля",
|
||
"park": "Парки",
|
||
"bus_stop": "Транспорт",
|
||
"metro_stop": "Транспорт",
|
||
"tram_stop": "Шум/трамвай",
|
||
}
|
||
|
||
|
||
def _verbal_for_poi(
|
||
cat: str,
|
||
name: str | None,
|
||
distance_m: float,
|
||
contribution: float,
|
||
) -> str:
|
||
"""Сгенерировать verbal explain для одного POI-вклада.
|
||
|
||
Пример: "Школа №125 в 400м — +0.90 баллов".
|
||
Для отрицательного вклада (трамваи): "Трамвай Ленина в 80м — −0.46 баллов".
|
||
"""
|
||
label = _POI_CATEGORY_RU.get(cat, cat)
|
||
safe_name = (name or "").strip()
|
||
name_part = f" «{safe_name}»" if safe_name and safe_name != "—" else ""
|
||
sign = "+" if contribution >= 0 else "−"
|
||
return f"{label}{name_part} в {round(distance_m)}м — {sign}{abs(contribution):.2f} баллов"
|
||
|
||
|
||
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||
# Добавляй регионы по мере расширения географии продукта.
|
||
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||
66: { # Свердловская обл.
|
||
"seismic_intensity_balls": 5,
|
||
"seismic_label": "минимальная сейсмика (≤5 баллов)",
|
||
"seismic_description": "Обычное строительство без специальных мер.",
|
||
"permafrost": False,
|
||
},
|
||
77: { # Москва
|
||
"seismic_intensity_balls": 4,
|
||
"seismic_label": "практически нет сейсмики",
|
||
"seismic_description": "Обычное строительство.",
|
||
"permafrost": False,
|
||
},
|
||
}
|
||
|
||
|
||
# SF-20 — success_recommendation confidence thresholds
|
||
SUCCESS_REC_MIN_DEALS = 15 # ниже → не показываем (пусто)
|
||
SUCCESS_REC_STRONG_DEALS = 30 # ≥ → data_confidence='strong'; 15-29 → 'weak'
|
||
|
||
# D4 (#36) — pipeline 24mo constants. Размещены в одном месте для тюнинга
|
||
# и аудита; severity пороги матчатся с acceptance #36.
|
||
PIPELINE_RADIUS_M = 5000
|
||
PIPELINE_HORIZON_MONTHS = 24
|
||
PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low
|
||
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
|
||
PIPELINE_TOP_OBJECTS_LIMIT = 10
|
||
# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок
|
||
# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться
|
||
# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию
|
||
# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может
|
||
# не совпадать с центроидом ЗУ → буфер ловит «свой» объект).
|
||
PIPELINE_SELF_EXCLUDE_M = 80
|
||
|
||
|
||
def _coord_round(value: Any) -> float | None:
|
||
"""#999 — привести координату (lat/lon) к float, округлённому до 6 dp.
|
||
|
||
Источник — PostGIS latitude/longitude (float8/numeric → может прийти Decimal).
|
||
None/невалидное значение → None (graceful: объект без координат не ломает
|
||
ответ, фронт просто не рисует маркер). 6 dp ≈ 0.1м точности — достаточно
|
||
для карты.
|
||
"""
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return round(float(value), 6)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _competitor_with_coords(row: Any) -> dict[str, Any]:
|
||
"""#999 (958-B4) — competitor-dict + nullable lat/lon (EPSG:4326).
|
||
|
||
Additive shape: сохраняет ВСЕ существующие ключи competitor_rows через
|
||
{**dict(row)} (distance_m и пр. без изменений), затем перезаписывает lat/lon
|
||
округлёнными float|None (исходные SQL-алиасы lat/lon приходят сырыми
|
||
float/Decimal). Никакие текущие поля не удаляются и не меняются.
|
||
"""
|
||
out = dict(row)
|
||
out["lat"] = _coord_round(out.get("lat"))
|
||
out["lon"] = _coord_round(out.get("lon"))
|
||
return out
|
||
|
||
|
||
def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]:
|
||
"""D4 (#36) — собрать pipeline_24mo aggregate из rows domrf_kn_objects.
|
||
|
||
Метрики:
|
||
- objects_count, flats_total
|
||
- by_class: {economy: int, comfort: int, business: int, unknown: int}
|
||
- by_quarter: {"2026-Q1": {objects: N, flats: M}, ...}
|
||
- severity: low / medium / high (см. PIPELINE_SEVERITY_* пороги)
|
||
- top_objects: PIPELINE_TOP_OBJECTS_LIMIT крупнейших ЖК по flat_count
|
||
|
||
NB: `obj_class` в production часто NULL (см.
|
||
`fixes/Bug_Kn_API_Obj_Class_Always_Null_OPEN`). Pipeline SQL обогащает
|
||
obj_class через objective_lots + objective_complex_mapping (COALESCE
|
||
fallback). Объекты без маппинга остаются "unknown".
|
||
|
||
Используется для UI pipeline-bar и severity badge.
|
||
"""
|
||
if not rows:
|
||
return {
|
||
"objects_count": 0,
|
||
"flats_total": 0,
|
||
"by_class": {},
|
||
"by_quarter": [],
|
||
"severity": "none",
|
||
"top_objects": [],
|
||
"note": (
|
||
f"Нет ЖК в pipeline {PIPELINE_HORIZON_MONTHS}мес в радиусе "
|
||
f"{PIPELINE_RADIUS_M // 1000}км — низкая будущая конкуренция"
|
||
),
|
||
}
|
||
|
||
by_class: dict[str, int] = {}
|
||
by_quarter: dict[str, dict[str, int]] = {}
|
||
flats_total = 0
|
||
|
||
for r in rows:
|
||
cls = (r["obj_class"] or "unknown").lower().strip() or "unknown"
|
||
flats = int(r["flat_count"]) if r["flat_count"] else 0
|
||
flats_total += flats
|
||
by_class[cls] = by_class.get(cls, 0) + flats
|
||
|
||
ready = r["ready_dt"]
|
||
if ready:
|
||
q = (ready.month - 1) // 3 + 1
|
||
key = f"{ready.year}-Q{q}"
|
||
slot = by_quarter.setdefault(key, {"objects": 0, "flats": 0})
|
||
slot["objects"] += 1
|
||
slot["flats"] += flats
|
||
|
||
# Severity (#36 acceptance)
|
||
if flats_total < PIPELINE_SEVERITY_MEDIUM_THRESHOLD:
|
||
severity = "low"
|
||
elif flats_total < PIPELINE_SEVERITY_HIGH_THRESHOLD:
|
||
severity = "medium"
|
||
else:
|
||
severity = "high"
|
||
|
||
severity_label = {
|
||
"low": "низкая",
|
||
"medium": "средняя",
|
||
"high": "высокая",
|
||
}[severity]
|
||
|
||
# Sort quarters chronologically
|
||
quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())]
|
||
|
||
# Top objects — по flat_count desc.
|
||
# Explicit field selection вместо `dict(r)` — иначе CTE `SELECT *` протекает
|
||
# внутренние колонки (latitude/longitude/snapshot_date/region_cd/dev_id и т.д.)
|
||
# в API response. Не security issue, но schema-leak.
|
||
top_rows = sorted(rows, key=lambda r: r.get("flat_count") or 0, reverse=True)[
|
||
:PIPELINE_TOP_OBJECTS_LIMIT
|
||
]
|
||
top_objects: list[dict[str, Any]] = []
|
||
for r in top_rows:
|
||
ready_dt = r.get("ready_dt")
|
||
distance_m = r.get("distance_m")
|
||
top_objects.append(
|
||
{
|
||
"obj_id": r["obj_id"],
|
||
"comm_name": r.get("comm_name"),
|
||
"dev_name": r.get("dev_name"),
|
||
"obj_class": r.get("obj_class"),
|
||
"flat_count": r.get("flat_count"),
|
||
# ISO date string для JSON; distance_m — explicit None guard
|
||
# (centroid-on-building даёт 0.0 — falsy float; raw Decimal иначе
|
||
# упадёт в JSON serialization).
|
||
"ready_dt": ready_dt.isoformat() if ready_dt else None,
|
||
"distance_m": round(float(distance_m)) if distance_m is not None else None,
|
||
# #999 (958-B4): lat/lon (EPSG:4326) для Leaflet-слоёв. Источник —
|
||
# та же geom (latitude/longitude), что и distance_m. Nullable: объект
|
||
# без координат → None (latest_obj фильтрует, но guard на всякий).
|
||
"lat": _coord_round(r.get("lat")),
|
||
"lon": _coord_round(r.get("lon")),
|
||
}
|
||
)
|
||
|
||
return {
|
||
"objects_count": len(rows),
|
||
"flats_total": flats_total,
|
||
"by_class": by_class,
|
||
"by_quarter": quarters_sorted,
|
||
"severity": severity,
|
||
"severity_label": severity_label,
|
||
"top_objects": top_objects,
|
||
"radius_km": PIPELINE_RADIUS_M // 1000,
|
||
"horizon_months": PIPELINE_HORIZON_MONTHS,
|
||
"note": (
|
||
"Будущая конкуренция за покупателя: planned_commissioning от Росреестра "
|
||
"часто оптимистичен (сдвиги по факту). Pressure-балл — относительный."
|
||
),
|
||
}
|
||
|
||
|
||
# P1 (#45) — constants for polygon suitability (строительные нормы Свердл/общие
|
||
# для ЖК; будут править — храним в одном месте)
|
||
_GEOM_MIN_AREA_HA = 0.2 # ниже → area_subscore = 0 (физический минимум)
|
||
_GEOM_AREA_COMFORT_HA = 0.3 # рекомендуемая комфортная площадь МКД (recommendation)
|
||
_GEOM_AREA_SCORE_FULL_HA = 0.5 # ≥ → area_subscore = 1.0 (premium)
|
||
_GEOM_ASPECT_PENALTY_THRESHOLD = 5.0 # выше → вытянутый
|
||
_GEOM_ASPECT_PENALTY = 0.3
|
||
_GEOM_CONVEX_PENALTY_THRESHOLD = 0.65 # ниже → изрезанный
|
||
_GEOM_CONVEX_PENALTY = 0.3
|
||
# Строительный минимум — physical possibility (под penalty)
|
||
_GEOM_MIN_WIDTH_PHYSICAL_M = 30
|
||
_GEOM_NARROW_PENALTY = 0.5
|
||
# Комфорт МКД — recommendation level (помещается типовой корпус 12-16 эт)
|
||
_GEOM_MIN_WIDTH_COMFORT_M = 40
|
||
_GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинируется с penalties)
|
||
_GEOM_LABEL_GOOD = 0.7
|
||
_GEOM_LABEL_MEDIUM = 0.4
|
||
|
||
# ПЗЗ-градрегламент в analyze (#1067): bounded timeout (сек) для live-geoportal вызова
|
||
# на cache-miss. Короткий, чтобы медленный геопортал не вешал hot-path analyze —
|
||
# cache-hit (норма для ЕКБ) сетевых вызовов не делает вообще.
|
||
_ZONE_REGULATION_TIMEOUT_S = 3
|
||
|
||
|
||
def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||
"""P1 (#45) — physical suitability участка по метрикам shape.
|
||
|
||
Метрики:
|
||
- area_ha — площадь в гектарах (locally-projected metres via cos(lat))
|
||
- perimeter_m — периметр
|
||
- aspect_ratio — длина / ширина минимального ограничивающего прямоугольника
|
||
- convex_hull_ratio — площадь / площадь выпуклой оболочки (1.0 = выпуклый, <0.7 изрезанный)
|
||
- min_inscribed_rect_dim_m — длина короткой стороны MABR
|
||
|
||
Suitability score 0..1 — composite (пороги — см. _GEOM_* константы):
|
||
- area_subscore: <_GEOM_MIN_AREA_HA → 0.0, ≥_GEOM_AREA_SCORE_FULL_HA → 1.0, linear
|
||
- −_GEOM_ASPECT_PENALTY если aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD
|
||
- −_GEOM_CONVEX_PENALTY если convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD
|
||
- −_GEOM_NARROW_PENALTY если short_side < _GEOM_MIN_WIDTH_PHYSICAL_M
|
||
|
||
UI label: микро / подходящий / сложная форма / слабо подходит. Label "микро"
|
||
комбинируется с penalties — "микро · узкий" — чтобы пользователь увидел
|
||
обе проблемы сразу.
|
||
"""
|
||
try:
|
||
# Парсим WGS84 polygon (shapely imports теперь module-level)
|
||
poly = _shp_wkt.loads(geom_wkt)
|
||
if poly.is_empty or poly.geom_type not in ("Polygon", "MultiPolygon"):
|
||
return {"data_available": False, "note": "Геометрия не Polygon/MultiPolygon"}
|
||
|
||
# Берём наибольший компонент для MultiPolygon
|
||
if poly.geom_type == "MultiPolygon":
|
||
poly = max(poly.geoms, key=lambda g: g.area)
|
||
assert isinstance(poly, Polygon)
|
||
|
||
# Equirectangular-projection в метры через centroid-anchor.
|
||
# На широте ~57° деформация <1% в радиусе 50км (parcel-scale OK).
|
||
centroid = poly.centroid
|
||
lat_rad = math.radians(centroid.y)
|
||
m_per_deg_lon = 111_320.0 * math.cos(lat_rad)
|
||
m_per_deg_lat = 110_540.0
|
||
ext = list(poly.exterior.coords)
|
||
ext_m = [
|
||
(
|
||
(x - centroid.x) * m_per_deg_lon,
|
||
(y - centroid.y) * m_per_deg_lat,
|
||
)
|
||
for x, y in ext
|
||
]
|
||
poly_m = Polygon(ext_m)
|
||
area_m2 = poly_m.area
|
||
area_ha = area_m2 / 10_000.0
|
||
perimeter_m = poly_m.length
|
||
|
||
# Convex hull ratio
|
||
hull = poly_m.convex_hull
|
||
convex_hull_ratio = area_m2 / hull.area if hull.area > 0 else 1.0
|
||
|
||
# MABR (minimum area bounding rectangle) → aspect_ratio + short side
|
||
try:
|
||
mabr = poly_m.minimum_rotated_rectangle
|
||
mabr_coords = list(mabr.exterior.coords)
|
||
# 4 уникальные точки в MABR (closed ring → 5 points) → две стороны
|
||
side_lens: list[float] = []
|
||
for i in range(4):
|
||
p1 = mabr_coords[i]
|
||
p2 = mabr_coords[i + 1]
|
||
side_lens.append(math.hypot(p2[0] - p1[0], p2[1] - p1[1]))
|
||
short_side = min(side_lens)
|
||
long_side = max(side_lens)
|
||
aspect_ratio = long_side / short_side if short_side > 0 else 1.0
|
||
except Exception as mabr_err:
|
||
logger.debug("MABR computation failed, falling back to sqrt(area): %s", mabr_err)
|
||
short_side = math.sqrt(area_m2)
|
||
aspect_ratio = 1.0
|
||
|
||
# Suitability score composite
|
||
if area_ha >= _GEOM_AREA_SCORE_FULL_HA:
|
||
area_subscore = 1.0
|
||
elif area_ha <= _GEOM_MIN_AREA_HA:
|
||
area_subscore = 0.0
|
||
else:
|
||
# linear: _GEOM_MIN_AREA_HA → 0, _GEOM_AREA_SCORE_FULL_HA → 1.0
|
||
area_subscore = (area_ha - _GEOM_MIN_AREA_HA) / (
|
||
_GEOM_AREA_SCORE_FULL_HA - _GEOM_MIN_AREA_HA
|
||
)
|
||
|
||
suitability = area_subscore
|
||
penalties: list[str] = []
|
||
if aspect_ratio > _GEOM_ASPECT_PENALTY_THRESHOLD:
|
||
suitability -= _GEOM_ASPECT_PENALTY
|
||
penalties.append(f"вытянутый (aspect>{_GEOM_ASPECT_PENALTY_THRESHOLD:.0f})")
|
||
if convex_hull_ratio < _GEOM_CONVEX_PENALTY_THRESHOLD:
|
||
suitability -= _GEOM_CONVEX_PENALTY
|
||
penalties.append(f"изрезанный (convex<{_GEOM_CONVEX_PENALTY_THRESHOLD})")
|
||
if short_side < _GEOM_MIN_WIDTH_PHYSICAL_M:
|
||
suitability -= _GEOM_NARROW_PENALTY
|
||
penalties.append(f"узкий (короткая сторона {short_side:.0f}м)")
|
||
suitability = max(0.0, min(1.0, suitability))
|
||
|
||
# Label — combine "микро" с penalties чтобы UI видел всё
|
||
is_micro = area_ha < _GEOM_LABEL_MICRO_HA
|
||
if suitability >= _GEOM_LABEL_GOOD and not is_micro:
|
||
label = "подходящий"
|
||
elif is_micro:
|
||
# combine с penalties: "микро" + первая penalty (для краткости)
|
||
if penalties:
|
||
label = f"микро, {penalties[0].split(' (')[0]}"
|
||
else:
|
||
label = "микро"
|
||
elif suitability >= _GEOM_LABEL_MEDIUM:
|
||
label = "сложная форма"
|
||
else:
|
||
label = "слабо подходит"
|
||
|
||
return {
|
||
"data_available": True,
|
||
"area_ha": round(area_ha, 3),
|
||
"area_m2": round(area_m2),
|
||
"perimeter_m": round(perimeter_m),
|
||
"aspect_ratio": round(aspect_ratio, 2),
|
||
"convex_hull_ratio": round(convex_hull_ratio, 2),
|
||
"min_inscribed_rect_dim_m": round(short_side),
|
||
"suitability_score": round(suitability, 2),
|
||
"label": label,
|
||
"penalties": penalties,
|
||
"recommendation": (
|
||
f"Строительный минимум короткой стороны — {_GEOM_MIN_WIDTH_PHYSICAL_M}м, "
|
||
f"комфорт типового МКД 12-16 этажей — от {_GEOM_MIN_WIDTH_COMFORT_M}м "
|
||
f"и площадь от {_GEOM_AREA_COMFORT_HA} га."
|
||
),
|
||
"note": (
|
||
"Оценка по форме участка (Shapely). Учитывает площадь, "
|
||
"вытянутость, изрезанность, минимальную ширину MABR."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("polygon suitability failed: %s", e)
|
||
return {
|
||
"data_available": False,
|
||
"note": f"Не удалось проанализировать геометрию: {e}",
|
||
}
|
||
|
||
|
||
# P2 (#46) cost-per-m² sanity filter — кадастровая стоимость иногда
|
||
# содержит 0/None или экстремальные значения (миллиарды). Пороги выбраны
|
||
# эмпирически для ЕКБ.
|
||
_COST_PER_M2_MIN = 1000 # ₽/м² — ниже скорее всего ошибка ввода
|
||
_COST_PER_M2_MAX = 500_000 # ₽/м² — выше скорее всего outlier
|
||
|
||
|
||
def _parse_floors(raw: str | int | None) -> int | None:
|
||
"""cad_buildings.floors — после schema migration #169 теперь INT,
|
||
но historically мог быть TEXT с диапазоном '5-7'. Поддерживаем оба
|
||
для backwards-compat с legacy data + tests.
|
||
|
||
Возвращаем верхнюю границу (более консервативный сосед-высотка).
|
||
NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для
|
||
multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation).
|
||
"""
|
||
if raw is None or raw == "":
|
||
return None
|
||
# Post-migration: INT column → fast path
|
||
if isinstance(raw, int):
|
||
return raw if raw > 0 else None
|
||
raw = raw.strip()
|
||
if not raw:
|
||
return None
|
||
# range like "5-7" → 7
|
||
if "-" in raw:
|
||
parts = raw.split("-")
|
||
try:
|
||
return max(int(p.strip()) for p in parts if p.strip().isdigit())
|
||
except ValueError:
|
||
return None
|
||
# single int
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
# `_neighbors_summary` SQL — module-level чтобы integration-тест (см.
|
||
# `backend/tests/integration/test_analyze_parcels_sql.py`) мог EXPLAIN-нуть его
|
||
# против реальной prod-PG и поймать SQL-уровневые регрессии: PG keyword conflict
|
||
# в CTE (`overlaps` — #1195→#1197), `:x::type` antipattern в psycopg v3,
|
||
# несуществующая колонка/PostGIS-функция. Inline `text(...)` внутри функции — не
|
||
# проверяемый, mock-тест db.execute() ловит только Python-уровень.
|
||
#
|
||
# CTE name `overlap_rows` (НЕ `overlaps`): `OVERLAPS` — PG keyword (binary
|
||
# operator для time-periods), парсер ругается на `overlaps AS (`.
|
||
_NEIGHBORS_SUMMARY_SQL = text("""
|
||
WITH neighbors AS (
|
||
SELECT cad_num,
|
||
building_name,
|
||
floors,
|
||
year_built,
|
||
cost_value,
|
||
area,
|
||
readable_address,
|
||
ST_Distance(
|
||
b.geom::geography,
|
||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
|
||
) AS distance_m
|
||
FROM cad_buildings b
|
||
WHERE ST_DWithin(
|
||
b.geom::geography,
|
||
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
|
||
100
|
||
)
|
||
AND b.cad_num != CAST(:our_cad AS text)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 30
|
||
),
|
||
overlap_rows AS (
|
||
SELECT cad_num,
|
||
building_name,
|
||
floors,
|
||
readable_address,
|
||
ST_Area(
|
||
ST_Intersection(
|
||
ST_Transform(b.geom, 32641),
|
||
ST_Transform(ST_GeomFromText(CAST(:wkt AS text), 4326), 32641)
|
||
)
|
||
) AS overlap_m2
|
||
FROM cad_buildings b
|
||
WHERE ST_Intersects(b.geom, ST_GeomFromText(CAST(:wkt AS text), 4326))
|
||
AND b.cad_num != CAST(:our_cad AS text)
|
||
ORDER BY overlap_m2 DESC NULLS LAST
|
||
LIMIT 5
|
||
)
|
||
SELECT
|
||
COALESCE(
|
||
(SELECT json_agg(row_to_json(n) ORDER BY n.distance_m ASC)
|
||
FROM neighbors n),
|
||
'[]'::json
|
||
) AS neighbors,
|
||
COALESCE(
|
||
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
|
||
FROM overlap_rows o),
|
||
'[]'::json
|
||
) AS overlap_rows
|
||
""")
|
||
|
||
|
||
def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]:
|
||
"""P2 (#46) — cad_buildings соседи в 100м + overlap check.
|
||
|
||
Возвращает aggregate (avg/max floors, median cost/m², count) + плоский
|
||
список соседей для UI + флаг has_existing_buildings (overlap >50 м²).
|
||
|
||
Использует GIST на cad_buildings.geom (уже создан в schema).
|
||
|
||
PR #1130 Phase B: соседи (LIMIT 30) и overlap-check (LIMIT 5) объединены в
|
||
ОДИН SQL-statement через две CTE + scalar-агрегацию в `json_agg`. Это срезает
|
||
один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не
|
||
меняются. Формат возвращаемого dict идентичен прежнему.
|
||
|
||
SQL — module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
|
||
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
|
||
"""
|
||
try:
|
||
row = (
|
||
db.execute(
|
||
_NEIGHBORS_SUMMARY_SQL,
|
||
{"wkt": geom_wkt, "our_cad": our_cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
|
||
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
|
||
except Exception as e:
|
||
logger.warning("neighbors query failed: %s", e)
|
||
return {"data_available": False, "note": f"neighbors query failed: {e}"}
|
||
|
||
# Aggregate floors + cost. Дефенсивный try/except: если cost_value/area
|
||
# придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без
|
||
# этого guard весь endpoint вернёт 500.
|
||
try:
|
||
floors_parsed: list[int] = []
|
||
costs_per_m2: list[float] = []
|
||
for r in neighbor_rows:
|
||
f = _parse_floors(r.get("floors"))
|
||
if f is not None and f > 0:
|
||
floors_parsed.append(f)
|
||
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0:
|
||
cost_per_m2 = float(r["cost_value"]) / float(r["area"])
|
||
if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX:
|
||
costs_per_m2.append(cost_per_m2)
|
||
|
||
avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None
|
||
max_floors = max(floors_parsed) if floors_parsed else None
|
||
median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None
|
||
except (ValueError, TypeError) as e:
|
||
logger.warning("neighbors aggregation failed: %s", e)
|
||
return {
|
||
"data_available": False,
|
||
"note": f"neighbors aggregation failed: {e}",
|
||
}
|
||
|
||
# Overlap check — что-то построено непосредственно на нашем участке.
|
||
# Если хоть один building пересекается с площадью >50 м² — hard warn.
|
||
overlap_buildings = [
|
||
{
|
||
"cad_num": o["cad_num"],
|
||
"building_name": o.get("building_name"),
|
||
"floors": o.get("floors"),
|
||
"readable_address": o.get("readable_address"),
|
||
"overlap_m2": round(float(o["overlap_m2"])) if o.get("overlap_m2") else None,
|
||
}
|
||
for o in overlap_row
|
||
if o.get("overlap_m2") and float(o["overlap_m2"]) > 50
|
||
]
|
||
has_existing = len(overlap_buildings) > 0
|
||
|
||
return {
|
||
"data_available": True,
|
||
"radius_m": 100,
|
||
"count_buildings_100m": len(neighbor_rows),
|
||
"avg_floors_100m": avg_floors,
|
||
"max_floors_100m": max_floors,
|
||
"median_cost_per_m2_100m": median_cost,
|
||
"neighbors": [
|
||
{
|
||
"cad_num": r["cad_num"],
|
||
"building_name": r.get("building_name"),
|
||
"floors": r.get("floors"),
|
||
"floors_parsed": _parse_floors(r.get("floors")),
|
||
"year_built": r.get("year_built"),
|
||
"area_m2": round(float(r["area"])) if r.get("area") else None,
|
||
"cost_per_m2": (
|
||
round(float(r["cost_value"]) / float(r["area"]))
|
||
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
|
||
else None
|
||
),
|
||
"distance_m": round(float(r["distance_m"])),
|
||
"readable_address": r.get("readable_address"),
|
||
}
|
||
for r in neighbor_rows[:20]
|
||
],
|
||
"has_existing_buildings": has_existing,
|
||
"overlap_buildings": overlap_buildings,
|
||
"note": (
|
||
"Cad_buildings 100м radius. Floors хранится как TEXT (диапазоны типа '5-7') — "
|
||
"agg использует верхнюю границу. Cost/m² — кадастровая стоимость, не рыночная."
|
||
),
|
||
}
|
||
|
||
|
||
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||
|
||
industrial_within_500m — proxy для возможного загрязнения почв (без
|
||
реальных шейпов зон загрязнения). Точная геология/гидрогеология требует
|
||
инженерных изысканий (bore-holes).
|
||
"""
|
||
region_data: dict[str, Any] = GEOTECH_BY_REGION.get(
|
||
region_code,
|
||
{
|
||
"seismic_intensity_balls": None,
|
||
"seismic_label": "нет данных в встроенной таблице",
|
||
"seismic_description": "ОСР-2016: уточняйте по карте.",
|
||
"permafrost": False,
|
||
},
|
||
)
|
||
|
||
industrial_close: int = (
|
||
db.execute(
|
||
text("""
|
||
SELECT COUNT(*) AS n
|
||
FROM osm_noise_sources_ekb
|
||
WHERE source_type = 'industrial'
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
500
|
||
)
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
).scalar()
|
||
or 0
|
||
)
|
||
|
||
return {
|
||
**region_data,
|
||
"industrial_within_500m": int(industrial_close),
|
||
"industrial_contamination_flag": int(industrial_close) > 0,
|
||
"note": (
|
||
"Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). "
|
||
"Для строительства обычно достаточно ≤7 баллов без спецмер. "
|
||
"Industrial proximity — proxy для возможного загрязнения почв. "
|
||
"Точная геология/гидрогеология — требует bore-holes (изыскания)."
|
||
),
|
||
}
|
||
|
||
|
||
def _compute_confidence(
|
||
*,
|
||
source: str,
|
||
poi_rows: list[dict[str, Any]],
|
||
district_row: dict[str, Any] | None,
|
||
competitor_rows: list[dict[str, Any]],
|
||
noise_sources_count: int,
|
||
air_q: dict[str, Any] | None,
|
||
weather: dict[str, Any] | None,
|
||
market_trend: dict[str, Any] | None,
|
||
zoning: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
|
||
|
||
Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
|
||
сигналов: POI, район, рынок, конкуренты, источник геометрии) — отдельная
|
||
by-design метрика, НЕ форсайтный §15 `forecasting.confidence_engine`
|
||
(`compute_report_confidence`). §15 оценивает надёжность ПРОГНОЗА
|
||
спроса/цены и работает над форсайт-входами (coverage/confounded/
|
||
special_indices/sales_series), которых в analyze hot-path нет — поэтому
|
||
analyze намеренно держит свою лёгкую coverage-метрику, а не зовёт §15.
|
||
|
||
Композитный балл = avg of subscore'ов; caveats — list конкретных проблем
|
||
для UI ("Нет данных N, score K ненадёжен"). См. #1668 (решение: оставить
|
||
раздельно by-design; «stub до G1/G2/D1/D2» — устаревшая формулировка,
|
||
те эшелоны уже отгружены, их confidence сюда не вплетается намеренно).
|
||
"""
|
||
caveats: list[str] = []
|
||
subscores: dict[str, float] = {}
|
||
|
||
# 1) POI freshness — % POI с last_osm_edit_date в последние 2 года.
|
||
# Для участков с малым числом POI (<5) — снижаем confidence как coverage.
|
||
poi_total = len(poi_rows)
|
||
if poi_total == 0:
|
||
subscores["poi_freshness"] = 0.0
|
||
caveats.append("OSM POI не найдены в радиусе 1км — скоринг неприменим")
|
||
else:
|
||
cutoff = _dt.date.today() - _dt.timedelta(days=730)
|
||
fresh = sum(
|
||
1 for p in poi_rows if p.get("last_osm_edit_date") and p["last_osm_edit_date"] >= cutoff
|
||
)
|
||
ratio = fresh / poi_total
|
||
# coverage penalty: <5 POI слабая статистика
|
||
coverage_factor = min(1.0, poi_total / 10.0)
|
||
subscores["poi_freshness"] = round(ratio * coverage_factor, 2)
|
||
if poi_total < 5:
|
||
caveats.append(f"Мало OSM POI в радиусе 1км ({poi_total}) — социалка-фактор ненадёжен")
|
||
elif ratio < 0.5:
|
||
caveats.append("Большая часть POI (>50%) старше 2 лет — данные OSM требуют обновления")
|
||
|
||
# 2) Geometry source confidence — участок > квартал
|
||
subscores["geom_source"] = 0.9 if source == "cad_building" else 0.6
|
||
if source == "cad_quarter":
|
||
caveats.append(
|
||
"Геометрия quartal-level (нет parcel shape) — окружение усреднено по кварталу"
|
||
)
|
||
|
||
# 3) District context — известен ли район
|
||
subscores["district"] = 1.0 if district_row else 0.3
|
||
if not district_row:
|
||
caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")
|
||
|
||
# 4) Market trend — есть ли rosreestr_deals.
|
||
# Guard `int(... or 0)` — recent_deals_count иногда приходит как non-numeric
|
||
# из external/legacy paths; без guard int() крашнет 500.
|
||
n_recent_raw = (market_trend or {}).get("recent_deals_count")
|
||
try:
|
||
n_recent = int(n_recent_raw) if n_recent_raw is not None else 0
|
||
except (ValueError, TypeError):
|
||
n_recent = 0
|
||
if n_recent > 0:
|
||
# порог 5 сделок за 6 мес — достаточно для тренда
|
||
subscores["market_trend"] = min(1.0, n_recent / 10.0)
|
||
if n_recent < 5:
|
||
caveats.append(f"Мало ДДУ за 6 мес ({n_recent}) — тренд рынка статистически слабый")
|
||
else:
|
||
subscores["market_trend"] = 0.0
|
||
caveats.append("Нет ДДУ в 3км — тренд рынка недоступен")
|
||
|
||
# 5) Competitors coverage
|
||
n_competitors = len(competitor_rows)
|
||
subscores["competitors"] = min(1.0, n_competitors / 5.0)
|
||
if n_competitors == 0:
|
||
caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")
|
||
|
||
# 6) Environmental data freshness
|
||
env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
|
||
subscores["environment"] = env_ok / 3.0
|
||
if noise_sources_count == 0:
|
||
caveats.append("Шумовая карта не загружена — noise score = stub")
|
||
if not air_q:
|
||
caveats.append("Air Quality API недоступен — exposure unknown")
|
||
|
||
# 7) ПЗЗ coverage — placeholder до G1
|
||
has_zoning = bool(zoning.get("data_available")) if zoning else False
|
||
subscores["zoning"] = 1.0 if has_zoning else 0.2
|
||
if not has_zoning:
|
||
caveats.append(
|
||
"ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)"
|
||
)
|
||
|
||
composite = sum(subscores.values()) / len(subscores)
|
||
composite = round(max(0.0, min(1.0, composite)), 2)
|
||
|
||
return {
|
||
"value": composite,
|
||
"label": _confidence_label(composite),
|
||
"breakdown": subscores,
|
||
"caveats": caveats,
|
||
}
|
||
|
||
|
||
# #93 — on-demand cadastre fetch tuning constants.
|
||
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
|
||
#
|
||
# Tradeoff: sync `def analyze_parcel` блокирует один FastAPI threadpool slot
|
||
# на это время. Default threadpool в Starlette/FastAPI — 40 slots (anyio
|
||
# default). При concurrent burst >40 «миссинг cad» запросов будем saturate
|
||
# threadpool — последующие запросы (включая healthcheck) ждут free slot.
|
||
#
|
||
# 15s выбран как баланс: НСПД ~5-15s avg для quarter, ~10-20s для parcel —
|
||
# fast path сработает в ~70% случаев. Остальные 30% получают 202 +
|
||
# polling (без блокировки threadpool).
|
||
_INLINE_FETCH_WAIT_S = 15
|
||
_INLINE_FETCH_POLL_INTERVAL_S = 2
|
||
|
||
# §22-форсайт (3b-ii): schema_version §22-рана в analysis_runs — это "1.0"
|
||
# (SiteFinderReport._SCHEMA_VERSION), отличный от ANALYZE_SCHEMA_VERSION ("analyze-1.0")
|
||
# inline-dict analyze. GET /{cad}/forecast читает именно "1.0".
|
||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||
# Допустимые горизонты прогноза (мес), #995 — иное → 422. ТЗ §12.1 называет
|
||
# 6/12/18/24; 24 добавлен (Q1). Движок уже считает 24 на каждом ране — это первый
|
||
# горизонт в _DEFAULT_HORIZONS=(6,12,18,24) всего §9.x-стека (orchestrator/forecast-
|
||
# task/demand_supply_forecast/scenarios/special_indices/report_assembler); фазированный
|
||
# выход скрытого ПД (_hidden_release_fraction) и взвешивание future_supply по горизонту
|
||
# (_horizon_weight) корректно масштабируются/клампятся на 24 без деградации. Сетка
|
||
# дискретная (а не ge/le-диапазон): UI даёт фиксированные пресет-кнопки.
|
||
_ALLOWED_FORECAST_HORIZONS = frozenset({6, 12, 18, 24})
|
||
|
||
|
||
@router.get("/by-bbox", response_model=ParcelBboxResponse)
|
||
async def get_parcels_by_bbox(
|
||
min_lat: Annotated[float, Query(ge=-90, le=90, description="Южная граница bbox")],
|
||
min_lon: Annotated[float, Query(ge=-180, le=180, description="Западная граница bbox")],
|
||
max_lat: Annotated[float, Query(ge=-90, le=90, description="Северная граница bbox")],
|
||
max_lon: Annotated[float, Query(ge=-180, le=180, description="Восточная граница bbox")],
|
||
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
||
user_id: Annotated[str | None, Query(description="user_id для overlay статуса")] = None,
|
||
db: Annotated[Session, Depends(get_db)] = ...,
|
||
) -> ParcelBboxResponse:
|
||
"""GET /parcels/by-bbox — вернуть участки внутри bounding box для карты.
|
||
|
||
Использует GIST-индекс на cad_parcels.geom (ST_Intersects).
|
||
Если передан user_id — добавляет статус из parcel_user_status (overlay).
|
||
last_analysis_date — реальная дата последнего анализа из v_analysis_runs_latest
|
||
(#994, миграция 127): один batch-запрос на весь bbox, участки без рана → None.
|
||
"""
|
||
if min_lat >= max_lat or min_lon >= max_lon:
|
||
raise HTTPException(status_code=400, detail="Некорректный bbox: min >= max")
|
||
|
||
# Площадь bbox в км² (приблизительно через формулу сферической трапеции)
|
||
lat_mid = (min_lat + max_lat) / 2
|
||
lat_km = (max_lat - min_lat) * 111.32
|
||
lon_km = (max_lon - min_lon) * 111.32 * math.cos(math.radians(lat_mid))
|
||
bbox_area_km2 = round(lat_km * lon_km, 4)
|
||
|
||
# cad_parcels has only (cad_num, geom) — area derived via ST_Area on geography
|
||
# cast for meter accuracy. land_category not stored on this table; returned NULL
|
||
# until enrichment from EGRN is wired (tracking: vault B5 EGRN contract note).
|
||
sql = text("""
|
||
WITH bbox AS (
|
||
SELECT ST_MakeEnvelope(
|
||
CAST(:min_lon AS float),
|
||
CAST(:min_lat AS float),
|
||
CAST(:max_lon AS float),
|
||
CAST(:max_lat AS float),
|
||
4326
|
||
) AS env
|
||
)
|
||
SELECT
|
||
p.cad_num,
|
||
ST_Y(ST_Centroid(p.geom)) AS centroid_lat,
|
||
ST_X(ST_Centroid(p.geom)) AS centroid_lon,
|
||
ST_Area(p.geom::geography) AS area_m2,
|
||
NULL::text AS land_category,
|
||
pus.status AS user_status
|
||
FROM cad_parcels p
|
||
CROSS JOIN bbox
|
||
LEFT JOIN parcel_user_status pus
|
||
ON pus.cad_num = p.cad_num
|
||
AND pus.user_id = CAST(:user_id AS text)
|
||
WHERE p.geom IS NOT NULL
|
||
AND ST_Intersects(p.geom, bbox.env)
|
||
ORDER BY ST_Area(p.geom::geography) DESC NULLS LAST
|
||
LIMIT CAST(:limit AS int)
|
||
""")
|
||
|
||
rows = (
|
||
db.execute(
|
||
sql,
|
||
{
|
||
"min_lat": min_lat,
|
||
"min_lon": min_lon,
|
||
"max_lat": max_lat,
|
||
"max_lon": max_lon,
|
||
"user_id": user_id,
|
||
"limit": limit,
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# #994: дата последнего анализа на участок — один batch-запрос на весь bbox
|
||
# к v_analysis_runs_latest (НЕ N+1). Участки без рана отсутствуют в dict → None.
|
||
# Best-effort: провал чтения (напр. view 127 ещё не применён в deploy-окне, или
|
||
# будущий drift) НЕ должен ронять карту → fallback {} (last_analysis_date=None).
|
||
try:
|
||
last_analysis = latest_run_dates(db, [row["cad_num"] for row in rows])
|
||
except Exception:
|
||
logger.exception("by-bbox: latest_run_dates failed → last_analysis_date=None для всех")
|
||
last_analysis = {}
|
||
|
||
markers: list[ParcelMapMarker] = [
|
||
ParcelMapMarker(
|
||
cad_num=row["cad_num"],
|
||
centroid_lat=float(row["centroid_lat"]) if row["centroid_lat"] is not None else 0.0,
|
||
centroid_lon=float(row["centroid_lon"]) if row["centroid_lon"] is not None else 0.0,
|
||
area_m2=float(row["area_m2"]) if row["area_m2"] is not None else None,
|
||
land_category=row["land_category"],
|
||
status=row["user_status"] if user_id else None,
|
||
last_analysis_date=(
|
||
last_analysis[row["cad_num"]].date().isoformat()
|
||
if row["cad_num"] in last_analysis
|
||
else None
|
||
),
|
||
)
|
||
for row in rows
|
||
]
|
||
|
||
return ParcelBboxResponse(
|
||
parcels=markers,
|
||
count=len(markers),
|
||
limit=limit,
|
||
bbox_area_km2=bbox_area_km2,
|
||
)
|
||
|
||
|
||
@router.post("/search", response_model=ParcelSearchResponse)
|
||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||
"""Search parcels by filters + scoring.
|
||
|
||
TODO Stage 2b: PostGIS query + scorer service.
|
||
"""
|
||
return ParcelSearchResponse(items=[], total=0)
|
||
|
||
|
||
# ── #994 (961-C3, EPIC 961): run-history read endpoints ───────────────────────
|
||
#
|
||
# #251: мёртвый 1-сегментный catch-all `GET /{parcel_id}` (501 Not implemented,
|
||
# Stage 2b так и не наступил, фронт его не звал) удалён. Поэтому 1-сегментного
|
||
# конфликта с `/runs/{run_id}` больше нет. Catch-all на этом router снова заводить
|
||
# НЕ стоит без явной нужды — он перехватывал бы любой 1-сегментный путь.
|
||
|
||
|
||
@router.get("/runs/{run_id}", response_model=AnalysisRunDetail)
|
||
def get_analysis_run(
|
||
run_id: int,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> AnalysisRunDetail:
|
||
"""Полная строка одного рана анализа по id (включая `result`-блоб) — #994.
|
||
|
||
Для re-open / детального просмотра сохранённого анализа. `result` отдаём как
|
||
есть (форма analyze-1.0 ParcelAnalysis или §22 "1.0" SiteFinderReport — модель
|
||
истории её не навязывает).
|
||
|
||
404 (graceful HTTPException), если рана с таким id нет.
|
||
"""
|
||
run = get_run(db, run_id)
|
||
if run is None:
|
||
raise HTTPException(status_code=404, detail=f"analysis run {run_id} not found")
|
||
return AnalysisRunDetail.model_validate(run, from_attributes=True)
|
||
|
||
|
||
@router.get("/{cad_num}/runs", response_model=AnalysisRunListResponse)
|
||
def list_analysis_runs(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
limit: Annotated[
|
||
int,
|
||
Query(ge=1, le=100, description="Сколько недавних ранов вернуть (newest-first)"),
|
||
] = 20,
|
||
) -> AnalysisRunListResponse:
|
||
"""Недавние раны анализа на участок, newest-first (LIGHT-список) — #994.
|
||
|
||
Облегчённый список истории анализов: метаданные ранов БЕЗ тяжёлого `result`-
|
||
блоба (его отдаёт GET /runs/{run_id}). Пустой список (200, НЕ 404), если ранов
|
||
на участок ещё не было.
|
||
"""
|
||
rows = list_runs_for(db, cad_num, limit=limit)
|
||
runs = [AnalysisRunSummary.model_validate(r, from_attributes=True) for r in rows]
|
||
return AnalysisRunListResponse(runs=runs)
|
||
|
||
|
||
@router.get("/{cad_num}/fetch-status")
|
||
def get_fetch_status(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> dict[str, Any]:
|
||
"""Polling endpoint для on-demand cadastre fetch (см. issue #93).
|
||
|
||
Frontend polling каждые 2с после 202 Accepted из /analyze.
|
||
|
||
Returns:
|
||
{
|
||
"status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format",
|
||
"job_id": int | None,
|
||
"error_msg": str | None,
|
||
"eta_seconds": int | None,
|
||
}
|
||
|
||
Frontend поведение:
|
||
- "ready" → автоматически re-trigger POST /analyze
|
||
- "fetching" → continue polling
|
||
- "not_in_nspd" → показать пользователю «cad не найден в НСПД»
|
||
- "failed" → retry button + retry-after message
|
||
- "invalid_format" → подсказка формата
|
||
"""
|
||
return _fetch_status(db, cad_num)
|
||
|
||
|
||
@router.get("/{cad_num}/forecast")
|
||
def get_parcel_forecast(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
response: Response,
|
||
x_authenticated_user: Annotated[
|
||
str | None,
|
||
Header(
|
||
alias="X-Authenticated-User",
|
||
description="Идентичность из Caddy basic_auth (#994, nullable, read-only здесь)",
|
||
),
|
||
] = None,
|
||
) -> dict[str, Any]:
|
||
"""Read-only fetch §22-форсайта участка (3b-ii, #995).
|
||
|
||
Клиент поллит после POST /analyze (тот fire-and-forget enqueue'ит фон-таску,
|
||
считающую §22 SiteFinderReport ~30-180s). Читает ПОСЛЕДНИЙ ран schema_version
|
||
"1.0" (§22-форсайт, НЕ analyze-1.0 inline-dict) через schema-filtered
|
||
`latest_run_for` (3b-i seam).
|
||
|
||
Returns:
|
||
• 200 {"status": "ready", "run_id", "created_at", "report"} — §22 готов
|
||
(report = SiteFinderReport.as_dict() из run.result).
|
||
• 202 {"status": "pending"} — ещё не посчитан, клиент продолжает поллить.
|
||
|
||
Graceful: нет рана → 202 pending (НЕ 404/500 на happy "not yet" path);
|
||
ошибка БД → 202 pending + warning (клиент ретраит, не видит 500).
|
||
"""
|
||
try:
|
||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||
except Exception:
|
||
# БД-сбой на read-only поллинге — не валим клиента 500-кой, отдаём pending.
|
||
logger.warning(
|
||
"forecast read failed for cad=%s — returning pending", cad_num, exc_info=True
|
||
)
|
||
response.status_code = 202
|
||
return {"status": "pending"}
|
||
|
||
if run is not None:
|
||
report = run.result
|
||
# #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт
|
||
# «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом.
|
||
# Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем
|
||
# противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта.
|
||
try:
|
||
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||
gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None
|
||
if (
|
||
isinstance(gate, dict)
|
||
and gate.get("can_build_mkd") is False
|
||
and isinstance(report, dict)
|
||
and isinstance(report.get("product_tz"), dict)
|
||
):
|
||
report["product_tz"]["gate_caveat"] = (
|
||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
||
"рекомендация носит условный характер"
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"gate_caveat enrich failed for forecast cad=%s — report unaffected",
|
||
cad_num,
|
||
exc_info=True,
|
||
)
|
||
return {
|
||
"status": "ready",
|
||
"run_id": run.id,
|
||
"created_at": run.created_at,
|
||
"report": report,
|
||
}
|
||
|
||
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
|
||
response.status_code = 202
|
||
return {"status": "pending"}
|
||
|
||
|
||
@router.get("/{cad_num}/forecast/export")
|
||
def export_parcel_forecast(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
format: Annotated[
|
||
Literal["md", "json", "tg", "docx", "pptx", "pdf"],
|
||
Query(
|
||
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
||
"tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)"
|
||
),
|
||
] = "md",
|
||
) -> Response:
|
||
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
|
||
|
||
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
||
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
||
• format=md → `render_report_markdown(run.result)` — attachment (.md, чистый Markdown).
|
||
• format=json → сырой `run.result` — attachment (.json, download-вариант inline-чтения).
|
||
• format=docx → `render_report_docx(run.result)` — attachment (.docx, Word-документ,
|
||
зеркало содержания md/pdf; python-docx).
|
||
• format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
||
сжатое зеркало содержания docx; python-pptx).
|
||
• format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт,
|
||
зеркало содержания md/docx; WeasyPrint).
|
||
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||
и отправить, а не файл-download).
|
||
|
||
В отличие от read-only поллинга GET /{cad}/forecast (нет рана → 202 pending), это
|
||
export-эндпоинт: нет рана → 404 (graceful HTTPException «прогноз ещё не посчитан»,
|
||
НЕ 500). 3-сегментный путь (`/{cad}/forecast/export`) не конфликтует с 2-сегментными
|
||
`/{cad}/forecast`, `/{cad}/runs`, `/runs/{run_id}` и 1-сегментным `/{parcel_id}`.
|
||
|
||
Args:
|
||
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||
format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".
|
||
|
||
Returns:
|
||
Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
|
||
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||
"""
|
||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||
if run is None:
|
||
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
||
|
||
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
||
if format == "tg":
|
||
return Response(
|
||
content=render_report_telegram_summary(run.result),
|
||
media_type="text/plain; charset=utf-8",
|
||
)
|
||
|
||
cad_safe = cad_num.replace(":", "_")
|
||
today = _dt.date.today().strftime("%Y-%m-%d")
|
||
base_name = f"gendesign_forecast_{cad_safe}_{today}"
|
||
|
||
# docx — байты (Word OOXML), а не строка: отдельной веткой (зеркало tg early-return),
|
||
# чтобы md/json string-ветки остались байт-в-байт прежними.
|
||
if format == "docx":
|
||
return Response(
|
||
content=render_report_docx(run.result),
|
||
media_type=("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||
headers={"Content-Disposition": f'attachment; filename="{base_name}.docx"'},
|
||
)
|
||
|
||
# pptx — байты (PowerPoint OOXML), отдельной веткой (зеркало docx early-return),
|
||
# чтобы md/json string-ветки остались байт-в-байт прежними.
|
||
if format == "pptx":
|
||
return Response(
|
||
content=render_report_pptx(run.result),
|
||
media_type=(
|
||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||
),
|
||
headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
|
||
)
|
||
|
||
# pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт
|
||
# из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними.
|
||
# WeasyPrint-рендер — отдельная зона отказа (шрифты, шаблон, движок): без обёртки
|
||
# ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
|
||
# traceback причины 500 (#1739), пользователю — безопасное RU-сообщение.
|
||
if format == "pdf":
|
||
try:
|
||
pdf_bytes = export_report_pdf(run.result)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception("forecast PDF render failed for %s", cad_num)
|
||
raise HTTPException(status_code=500, detail="Ошибка генерации PDF-форсайта") from exc
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'attachment; filename="{base_name}.pdf"'},
|
||
)
|
||
|
||
if format == "json":
|
||
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
||
media_type = "application/json"
|
||
filename = f"{base_name}.json"
|
||
else:
|
||
payload = render_report_markdown(run.result)
|
||
media_type = "text/markdown; charset=utf-8"
|
||
filename = f"{base_name}.md"
|
||
|
||
return Response(
|
||
content=payload,
|
||
media_type=media_type,
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
|
||
|
||
@router.post("/{cad_num}/analyze", response_model=AnalyzeResponse)
|
||
def analyze_parcel(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
response: Response,
|
||
profile_id: Annotated[
|
||
int | None,
|
||
Query(ge=1, description="Переопределить веса POI через конкретный weight profile"),
|
||
] = None,
|
||
profile_user_id: Annotated[
|
||
str | None,
|
||
Query(description="user_id для fallback на default-профиль пользователя"),
|
||
] = None,
|
||
horizon: Annotated[
|
||
int,
|
||
Query(description="Горизонт прогноза, мес (#995, ТЗ §12.1): один из {6, 12, 18, 24}"),
|
||
] = 12,
|
||
body: Annotated[
|
||
AnalyzeRequest | None,
|
||
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
|
||
] = None,
|
||
x_session_id: Annotated[
|
||
str | None,
|
||
Header(description="Session ID пользователя для custom POI scoring (#254)"),
|
||
] = None,
|
||
x_authenticated_user: Annotated[
|
||
str | None,
|
||
Header(
|
||
alias="X-Authenticated-User",
|
||
description="Идентичность из Caddy basic_auth → created_by рана (#994, nullable)",
|
||
),
|
||
] = None,
|
||
) -> dict[str, Any]:
|
||
"""Анализ участка: близость к социалке + district context + конкуренты.
|
||
|
||
Порядок поиска геометрии: cad_quarters_geom → cad_buildings → cad_parcels_geom.
|
||
|
||
Issue #93 — Graceful fallback при отсутствии geometry:
|
||
- Не возвращаем 404 сразу. Вместо: enqueue NSPD on-demand fetch, ждём
|
||
inline до _INLINE_FETCH_WAIT_S (~15с). Если за это время геометрия
|
||
появилась в БД — продолжаем analyze (fast path).
|
||
- Иначе → 202 Accepted + {status, job_id, eta_seconds} для polling
|
||
через GET /fetch-status.
|
||
- Дедупликация (через `find_active_on_demand_job`): параллельные запросы
|
||
на тот же cad → один Celery job, оба клиента ждут.
|
||
|
||
§22-форсайт (3b-ii, #995): после успешного persist рана best-effort enqueue'им
|
||
`forecast_site_finder_report.delay(cad_num, horizon, ...)` — fire-and-forget,
|
||
провал Celery/Redis НЕ валит ответ (он уже успешен). `horizon` ∈ {6, 12, 18, 24}
|
||
(ТЗ §12.1; иначе 422). Результат добавляется в ответ как `forecast` stub (additive); сам
|
||
§22-отчёт клиент забирает через GET /{cad}/forecast (202 pending → 200 ready).
|
||
"""
|
||
# Валидация горизонта прогноза (#995) — до любой работы с БД: иное → 422.
|
||
if horizon not in _ALLOWED_FORECAST_HORIZONS:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=(
|
||
f"horizon должен быть одним из {sorted(_ALLOWED_FORECAST_HORIZONS)}, "
|
||
f"получено: {horizon}"
|
||
),
|
||
)
|
||
|
||
# 1) Получить геометрию участка — GeoJSON строка через ST_AsGeoJSON
|
||
row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
|
||
g.geom AS geom_wkb,
|
||
'cad_quarter' AS source
|
||
FROM cad_quarters_geom g
|
||
WHERE g.cad_number = :c AND g.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(b.geom) AS geom_geojson,
|
||
b.geom AS geom_wkb,
|
||
'cad_building' AS source
|
||
FROM cad_buildings b
|
||
WHERE b.cad_num = :c AND b.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(p.geom) AS geom_geojson,
|
||
p.geom AS geom_wkb,
|
||
'cad_parcel' AS source
|
||
FROM cad_parcels_geom p
|
||
WHERE p.cad_num = :c AND p.geom IS NOT NULL
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
# NB: geom IS NOT NULL во всех ветках — участок с meta-строкой, но NULL-geometry
|
||
# (964 таких в cad_parcels_geom) НЕ должен «найтись» с пустой геометрией (иначе
|
||
# ST_Centroid→NULL→float(None)→500). Без geom он попадает в #93 graceful fallback
|
||
# ниже (enqueue NSPD fetch → 202), как и участок, которого нет в БД вовсе.
|
||
if not row:
|
||
# #93 — graceful fallback: enqueue NSPD fetch, await inline до 15s
|
||
# (см. _INLINE_FETCH_WAIT_S — снижено с 25 для threadpool safety).
|
||
status, job_id, error_msg = find_or_enqueue_fetch(db, cad_num)
|
||
if status == "invalid_format":
|
||
raise HTTPException(status_code=400, detail=error_msg)
|
||
if status == "not_in_nspd":
|
||
raise HTTPException(status_code=404, detail=error_msg)
|
||
if status == "failed":
|
||
# 503 — НСПД временно недоступен (rate-limit / WAF)
|
||
response.headers["Retry-After"] = "60"
|
||
raise HTTPException(status_code=503, detail=error_msg)
|
||
# status == "fetching" → inline await fast path
|
||
deadline = time.monotonic() + _INLINE_FETCH_WAIT_S
|
||
while time.monotonic() < deadline:
|
||
time.sleep(_INLINE_FETCH_POLL_INTERVAL_S)
|
||
if cad_exists_in_db(db, cad_num):
|
||
# Re-fetch row для analyze
|
||
row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_AsGeoJSON(g.geom) AS geom_geojson,
|
||
g.geom AS geom_wkb,
|
||
'cad_quarter' AS source
|
||
FROM cad_quarters_geom g
|
||
WHERE g.cad_number = :c AND g.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(b.geom), b.geom, 'cad_building'
|
||
FROM cad_buildings b
|
||
WHERE b.cad_num = :c AND b.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT ST_AsGeoJSON(p.geom), p.geom, 'cad_parcel'
|
||
FROM cad_parcels_geom p
|
||
WHERE p.cad_num = :c AND p.geom IS NOT NULL
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if row:
|
||
break
|
||
if not row:
|
||
# Timeout — frontend будет poll'ить /fetch-status.
|
||
response.status_code = 202
|
||
return {
|
||
"status": "fetching",
|
||
"cad_num": cad_num,
|
||
"job_id": job_id,
|
||
"eta_seconds": 15,
|
||
"message": ("Геометрия загружается из НСПД. Обычно занимает 15-30 секунд."),
|
||
}
|
||
|
||
geom_geojson: str = row["geom_geojson"]
|
||
source: str = row["source"]
|
||
|
||
# Используем ST_AsText для передачи геометрии в последующие запросы.
|
||
# Все PostGIS-запросы принимают текстовый WKT через ST_GeomFromText.
|
||
geom_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_AsText(g.geom) AS wkt
|
||
FROM (
|
||
SELECT g.geom FROM cad_quarters_geom g
|
||
WHERE g.cad_number = :c AND g.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT b.geom FROM cad_buildings b
|
||
WHERE b.cad_num = :c AND b.geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT p.geom FROM cad_parcels_geom p
|
||
WHERE p.cad_num = :c AND p.geom IS NOT NULL
|
||
) g
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
geom_wkt: str = geom_row["wkt"] # type: ignore[index]
|
||
|
||
# 2) District context — ближайший район ЕКБ
|
||
# median_price_per_m2: предпочитаем median_12m из mv_quarter_price_per_m2 (12 мес),
|
||
# fallback на ekb_districts.median_price_per_m2 (24 мес).
|
||
district_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT d.district_name,
|
||
COALESCE(mq.median_12m, d.median_price_per_m2) AS median_price_per_m2,
|
||
ST_Distance(
|
||
d.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS dist_to_center
|
||
FROM ekb_districts d
|
||
LEFT JOIN mv_quarter_price_per_m2 mq
|
||
ON mq.quarter_cad_number =
|
||
REGEXP_REPLACE(:cad_num, '^([^:]+:[^:]+:[^:]+).*$', '\\1')
|
||
WHERE ST_DWithin(
|
||
d.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
5000
|
||
)
|
||
ORDER BY dist_to_center ASC
|
||
LIMIT 1
|
||
"""),
|
||
{"wkt": geom_wkt, "cad_num": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
# 3) POI в радиусе 1 км — список с distance_m
|
||
poi_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT category,
|
||
name,
|
||
lat,
|
||
lon,
|
||
ST_Distance(
|
||
p.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m,
|
||
last_osm_edit_date
|
||
FROM osm_poi_ekb p
|
||
WHERE ST_DWithin(
|
||
p.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
1000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 3b) Resolve effective POI weights (inline → profile → user default → system)
|
||
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
|
||
|
||
if _inline_weights is not None:
|
||
# Validate inline weights: keys и диапазон значений (#201)
|
||
bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES
|
||
if bad_keys:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=(
|
||
f"Неизвестные POI-категории: {sorted(bad_keys)}. "
|
||
f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}"
|
||
),
|
||
)
|
||
out_of_range = {
|
||
k: v
|
||
for k, v in _inline_weights.items()
|
||
if not math.isfinite(v) or v < _MIN_WEIGHT or v > _MAX_WEIGHT
|
||
}
|
||
if out_of_range:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=(
|
||
f"Веса за пределами допустимого диапазона "
|
||
f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}"
|
||
),
|
||
)
|
||
# Inline weights applied — merge поверх системных defaults (partial override)
|
||
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
|
||
_weights_source = "inline"
|
||
else:
|
||
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||
_weights_source = (
|
||
"profile"
|
||
if profile_id is not None
|
||
else ("user_default" if profile_user_id is not None else "system")
|
||
)
|
||
|
||
# 4) Scoring: weighted sum с distance decay
|
||
score = 0.0
|
||
by_category: dict[str, list[dict[str, Any]]] = {}
|
||
# X1 (#47): per-POI breakdown с verbal explain для UI
|
||
factors_detailed: list[dict[str, Any]] = []
|
||
for idx, p in enumerate(poi_rows):
|
||
cat: str = p["category"]
|
||
w = _effective_weights.get(cat, _POI_WEIGHTS.get(cat, 0.0))
|
||
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
||
distance_m = float(p["distance_m"])
|
||
decay = max(0.0, 1.0 - distance_m / 1000.0)
|
||
contribution = w * decay
|
||
score += contribution
|
||
by_category.setdefault(cat, []).append(
|
||
{
|
||
"name": p["name"],
|
||
"distance_m": round(distance_m),
|
||
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||
"last_edit": (
|
||
p["last_osm_edit_date"].isoformat() if p["last_osm_edit_date"] else None
|
||
),
|
||
}
|
||
)
|
||
# Skip факторы с нулевым вкладом (POI дальше 1км) — UI шуму не нужен.
|
||
if abs(contribution) < 0.01:
|
||
continue
|
||
factors_detailed.append(
|
||
{
|
||
# Include idx чтобы избежать React key collision: два POI одной
|
||
# категории на одинаково округлённом расстоянии иначе дали бы
|
||
# дубль (например, two аптеки 450м в плотном районе).
|
||
"factor": f"{cat}_{round(distance_m)}m_{idx}",
|
||
"category": cat,
|
||
"category_ru": _POI_CATEGORY_RU.get(cat, cat),
|
||
"group": _POI_GROUP.get(cat, "Прочее"),
|
||
"value": round(distance_m, 1),
|
||
"weight": w,
|
||
"contribution": round(contribution, 2),
|
||
"verbal": _verbal_for_poi(cat, p["name"], distance_m, contribution),
|
||
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||
}
|
||
)
|
||
|
||
# 5) Конкуренты в радиусе 3 км из DOM.РФ с ценами из objective_lots.
|
||
# OBJ-3: обогащаем каждый ЖК данными objective_lots через маппинг
|
||
# domrf_kn_objects.obj_id → objective_complex_mapping.domrf_obj_id
|
||
# → objective_lots.project_name.
|
||
# Агрегат: avg_price_per_m2_rub (81% coverage), avg_area_pd, units_sold,
|
||
# units_available — для UI-блока «Конкуренты».
|
||
# LEFT JOIN — ЖК без маппинга остаются в выдаче (поля = NULL).
|
||
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
||
# latest snapshot, иначе дубликаты ЖК в выдаче.
|
||
competitor_rows = (
|
||
db.execute(
|
||
text("""
|
||
WITH latest_obj AS (
|
||
SELECT DISTINCT ON (obj_id) *
|
||
FROM domrf_kn_objects
|
||
WHERE latitude IS NOT NULL
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
),
|
||
nearby_obj AS (
|
||
-- Ближние конкуренты (3 км) — единственные obj_id, чьи цены реально
|
||
-- попадают в выдачу. Считаем геофильтр один раз, переиспользуем в
|
||
-- obj_pricing (pushdown scope) и в финальном SELECT.
|
||
SELECT *
|
||
FROM latest_obj o
|
||
WHERE ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
3000
|
||
)
|
||
),
|
||
obj_pricing AS (
|
||
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id.
|
||
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
|
||
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
|
||
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
|
||
SELECT
|
||
cm.domrf_obj_id,
|
||
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
||
ROUND(AVG(ol.area_pd)::numeric, 1) AS avg_area_pd,
|
||
COUNT(*) FILTER (WHERE ol.is_sold) AS units_sold,
|
||
COUNT(*) FILTER (WHERE NOT ol.is_sold) AS units_available,
|
||
COUNT(*) FILTER (
|
||
WHERE ol.price_per_m2_rub IS NOT NULL
|
||
) AS lots_with_price
|
||
FROM objective_complex_mapping cm
|
||
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
|
||
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)
|
||
GROUP BY cm.domrf_obj_id
|
||
)
|
||
SELECT o.obj_id,
|
||
o.comm_name,
|
||
o.dev_name,
|
||
o.obj_class,
|
||
o.flat_count,
|
||
o.district_name,
|
||
o.site_status,
|
||
o.ready_dt,
|
||
o.latitude AS lat,
|
||
o.longitude AS lon,
|
||
p.avg_price_per_m2_rub,
|
||
p.avg_area_pd,
|
||
p.units_sold,
|
||
p.units_available,
|
||
p.lots_with_price,
|
||
ST_Distance(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM nearby_obj o
|
||
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
|
||
ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
|
||
distance_m ASC
|
||
LIMIT 20
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
|
||
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
|
||
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
|
||
# NB: full seq scan на ~3000 строк OK; при росте — нужен GIST/index на
|
||
# (latitude, longitude) — отдельный issue для database-expert.
|
||
pipeline_rows = (
|
||
db.execute(
|
||
text("""
|
||
WITH latest_obj AS (
|
||
SELECT DISTINCT ON (obj_id) *
|
||
FROM domrf_kn_objects
|
||
WHERE latitude IS NOT NULL
|
||
AND ready_dt IS NOT NULL
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
)
|
||
SELECT obj_id,
|
||
comm_name,
|
||
dev_name,
|
||
COALESCE(
|
||
obj_class,
|
||
(SELECT DISTINCT ol.class
|
||
FROM objective_lots ol
|
||
JOIN objective_complex_mapping m
|
||
ON m.objective_complex_name = ol.project_name
|
||
WHERE m.domrf_obj_id = o.obj_id
|
||
AND ol.class IS NOT NULL
|
||
LIMIT 1)
|
||
) AS obj_class,
|
||
flat_count,
|
||
ready_dt,
|
||
o.latitude AS lat,
|
||
o.longitude AS lon,
|
||
ST_Distance(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM latest_obj o
|
||
WHERE ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
:radius_m
|
||
)
|
||
-- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке
|
||
-- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом.
|
||
AND NOT ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_GeomFromText(:wkt, 4326)::geography,
|
||
:self_exclude_m
|
||
)
|
||
AND ready_dt >= CURRENT_DATE
|
||
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
|
||
ORDER BY ready_dt ASC
|
||
"""),
|
||
{
|
||
"wkt": geom_wkt,
|
||
"radius_m": PIPELINE_RADIUS_M,
|
||
"self_exclude_m": PIPELINE_SELF_EXCLUDE_M,
|
||
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 6) Centroid координаты для внешних API (air quality / wind)
|
||
centroid_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_X(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lon,
|
||
ST_Y(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lat
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
# Defensive: centroid_row может существовать, но с NULL lat/lon (вырожденная/
|
||
# невалидная геометрия → ST_Centroid NULL). Проверяем именно значения, а не только
|
||
# наличие строки — иначе float(None) → 500. Fallback = центр ЕКБ.
|
||
centroid_lat: float = (
|
||
float(centroid_row["lat"]) if centroid_row and centroid_row["lat"] is not None else 56.838
|
||
)
|
||
centroid_lon: float = (
|
||
float(centroid_row["lon"]) if centroid_row and centroid_row["lon"] is not None else 60.605
|
||
)
|
||
|
||
# 6b) Distance to EKB center + center bonus
|
||
dist_to_center_km = _haversine_km(centroid_lat, centroid_lon, EKB_CENTER_LAT, EKB_CENTER_LON)
|
||
if dist_to_center_km < 5:
|
||
center_bonus = 3.0
|
||
elif dist_to_center_km < 10:
|
||
center_bonus = 1.5
|
||
elif dist_to_center_km < 15:
|
||
center_bonus = 0.5
|
||
else:
|
||
center_bonus = 0.0
|
||
|
||
# X1 (#47): centrality как отдельный synthetic factor в breakdown.
|
||
# NB: для centrality decay не применяется (bonus IS the value), поэтому
|
||
# weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus.
|
||
if center_bonus > 0:
|
||
factors_detailed.append(
|
||
{
|
||
"factor": f"center_bonus_{round(dist_to_center_km)}km",
|
||
"category": "centrality",
|
||
"category_ru": "Центральность",
|
||
"group": "Локация",
|
||
"value": round(dist_to_center_km, 2),
|
||
"weight": 1.0,
|
||
"contribution": round(center_bonus, 2),
|
||
"verbal": (
|
||
f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — "
|
||
f"+{center_bonus:.2f} баллов"
|
||
),
|
||
"lat": None,
|
||
"lon": None,
|
||
}
|
||
)
|
||
|
||
# 7) Noise score — шумовые источники в радиусе 2 км
|
||
noise_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT source_type, road_class, name,
|
||
ST_Distance(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_noise_sources_ekb n
|
||
WHERE ST_DWithin(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
2000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 30
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
noise_db_max = 0.0
|
||
nearby_noise_sources: list[dict[str, Any]] = []
|
||
for nr in noise_rows:
|
||
src = nr["source_type"]
|
||
key = f"{src}:{nr['road_class']}" if src == "highway" and nr["road_class"] else src
|
||
base_db = NOISE_L_BASE.get(key, 50.0)
|
||
d = max(float(nr["distance_m"]), 10.0)
|
||
noise_db = base_db - 20.0 * math.log10(d / 10.0)
|
||
noise_db_max = max(noise_db_max, noise_db)
|
||
if noise_db >= 50.0: # WHO порог дискомфорта
|
||
nearby_noise_sources.append(
|
||
{
|
||
"source_type": src,
|
||
"road_class": nr["road_class"],
|
||
"name": nr["name"],
|
||
"distance_m": round(d),
|
||
"estimated_db": round(noise_db, 1),
|
||
}
|
||
)
|
||
|
||
# noise_score: 0..1, чем тише тем лучше. 45 dB=1.0 (тихо), 75 dB=0.0 (шумно).
|
||
noise_score = max(0.0, min(1.0, (75.0 - noise_db_max) / 30.0))
|
||
|
||
if noise_db_max < 50.0:
|
||
noise_level = "тихо"
|
||
elif noise_db_max < 65.0:
|
||
noise_level = "умеренный"
|
||
else:
|
||
noise_level = "шумно"
|
||
|
||
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
||
# PR #1130 Phase B: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
|
||
air_q = get_air_quality_cached(centroid_lat, centroid_lon)
|
||
|
||
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||
# PR #1130 Phase A: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py).
|
||
weather = get_weather_cached(centroid_lat, centroid_lon)
|
||
|
||
# 9b) Seasonal weather — 30-летние нормали Climate API (TTL 7 суток).
|
||
seasonal_weather = get_seasonal_weather_cached(centroid_lat, centroid_lon)
|
||
|
||
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||
hydrology: dict[str, Any] | None = None
|
||
try:
|
||
with db.begin_nested():
|
||
hydro_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT source_type, road_class, name,
|
||
ST_Distance(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_noise_sources_ekb n
|
||
WHERE source_type = 'water'
|
||
AND ST_DWithin(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
2000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 10
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
hydrology = {
|
||
"nearest": [
|
||
{
|
||
"subtype": r["road_class"],
|
||
"name": r["name"],
|
||
"distance_m": round(float(r["distance_m"])),
|
||
}
|
||
for r in hydro_rows[:5]
|
||
],
|
||
"flood_risk_flag": any(
|
||
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
|
||
for r in hydro_rows
|
||
),
|
||
"note": (
|
||
"Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
|
||
"зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
|
||
"подтопления') через ФГИС ТП."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("hydrology query failed for %s: %s", cad_num, e)
|
||
hydrology = None
|
||
|
||
# 9d) Utilities — power lines + pipelines из OSM (магистральные сети)
|
||
utilities: dict[str, Any] | None = None
|
||
try:
|
||
with db.begin_nested():
|
||
util_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT road_class, name,
|
||
ST_Distance(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_noise_sources_ekb n
|
||
WHERE source_type = 'utility'
|
||
AND ST_DWithin(
|
||
n.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
2000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 10
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
# Группировка по типу для compactness. util_rows отсортированы по
|
||
# distance_m ASC → первый встреченный road_class = ближайший.
|
||
by_subtype: dict[str, dict[str, Any]] = {}
|
||
for r in util_rows:
|
||
sub = r["road_class"] or "other"
|
||
if sub not in by_subtype:
|
||
by_subtype[sub] = {
|
||
"subtype": sub,
|
||
"nearest_m": round(float(r["distance_m"])),
|
||
"name": r["name"],
|
||
"count_within_2km": 0,
|
||
}
|
||
by_subtype[sub]["count_within_2km"] += 1
|
||
|
||
# I3 (#44): именованные расстояния до ближайшей инж.сети по типу.
|
||
# road_class сопоставлен в noise_loader._NOISE_QUERIES. None если в 2 км
|
||
# нет ни одного объекта данного типа.
|
||
def _nearest(*subtypes: str) -> int | None:
|
||
cands = [by_subtype[s]["nearest_m"] for s in subtypes if s in by_subtype]
|
||
return min(cands) if cands else None
|
||
|
||
# Водопровод: магистраль (pipeline=water) ИЛИ узловой объект ВКХ.
|
||
nearest_water_main_m = _nearest("water_main", "water_works", "water_tower")
|
||
# Электроснабжение: подстанция ИЛИ трансформатор.
|
||
nearest_substation_m = _nearest("substation", "transformer")
|
||
nearest_gas_m = _nearest("gas_pipeline")
|
||
nearest_heat_m = _nearest("heat_substation")
|
||
|
||
utilities = {
|
||
"summary": list(by_subtype.values()),
|
||
"nearest_water_main_m": nearest_water_main_m,
|
||
"nearest_substation_m": nearest_substation_m,
|
||
"nearest_gas_m": nearest_gas_m,
|
||
"nearest_heat_m": nearest_heat_m,
|
||
"power_line_охранная_зона_flag": any(
|
||
float(r["distance_m"]) < 25 and r["road_class"] == "power_line" for r in util_rows
|
||
),
|
||
"note": (
|
||
"OSM-покрытие инж.сетей в РФ неполное (особенно подземные трубы) — "
|
||
"nearest_*_m нижняя оценка по доступным OSM-объектам. "
|
||
"Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). "
|
||
"В зоне ОЗ нельзя строить капитальные объекты. "
|
||
"Точная классификация напряжения / магистральности — ЗОУИТ "
|
||
"5 (ОЗ ЛЭП) и 9 (ОЗ газопровода) через ФГИС ТП."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("utilities query failed for %s: %s", cad_num, e)
|
||
utilities = None
|
||
|
||
# 9e) NSPD quarter dump — ПЗЗ зона + ЗОУИТ + инженерные сооружения из кеша
|
||
try:
|
||
nspd_dump_data = get_quarter_dump_data(db, cad_num, geom_wkt)
|
||
except Exception as e:
|
||
logger.warning("nspd quarter dump lookup failed for %s: %s", cad_num, e)
|
||
# Independent dict per request — never mutate module singleton.
|
||
nspd_dump_data = make_empty_result()
|
||
|
||
# 9e-bis) Реальный ПЗЗ-градрегламент зоны → merge в nspd_zoning (Route 2, #1067).
|
||
# Резолвим числовые предельные параметры (КСИТ/max_far, высота, этажность,
|
||
# %застройки, min площадь ЗУ) по centroid через get_or_fetch_zone_regulation:
|
||
# cache-first (кэш прогрет для ЕКБ), live-geoportal только на miss и закапан
|
||
# коротким timeout'ом (_ZONE_REGULATION_TIMEOUT_S), чтобы медленный геопортал не
|
||
# вешал analyze. Hot-path-safe: любой сбой/таймаут → поля остаются None, analyze
|
||
# не падает. Существующие ключи nspd_zoning (zone_code/zone_name/source/raw_props)
|
||
# не трогаем — только ДОБАВЛЯЕМ regulation-поля.
|
||
_nspd_zoning = nspd_dump_data.get("nspd_zoning")
|
||
if (
|
||
settings.enable_zoning_regulation_in_analyze
|
||
and _nspd_zoning is not None
|
||
and centroid_lat is not None
|
||
and centroid_lon is not None
|
||
):
|
||
try:
|
||
_client = EKBGeoportalClient(timeout=_ZONE_REGULATION_TIMEOUT_S)
|
||
_regulation = get_or_fetch_zone_regulation(
|
||
db, centroid_lon, centroid_lat, client=_client
|
||
)
|
||
except Exception as e:
|
||
# Никогда не роняем analyze из-за регламента — деградируем в None.
|
||
logger.warning("zone regulation resolve failed for %s: %s", cad_num, e)
|
||
_regulation = None
|
||
if _regulation is not None:
|
||
_nspd_zoning["max_far"] = _regulation.get("max_far")
|
||
_nspd_zoning["max_height_m"] = _regulation.get("max_height_m")
|
||
_nspd_zoning["max_floors"] = _regulation.get("max_floors")
|
||
_nspd_zoning["max_building_pct"] = _regulation.get("max_building_pct")
|
||
_nspd_zoning["min_parcel_area_m2"] = _regulation.get("min_parcel_area_m2")
|
||
_nspd_zoning["regulation_zone_index"] = _regulation.get("zone_index")
|
||
_nspd_zoning["regulation_source"] = "ekb-geoportal-urbancard"
|
||
logger.debug(
|
||
"zone regulation hit for %s: zone_index=%s max_far=%s",
|
||
cad_num,
|
||
_regulation.get("zone_index"),
|
||
_regulation.get("max_far"),
|
||
)
|
||
else:
|
||
logger.debug("zone regulation miss for %s (no resolve)", cad_num)
|
||
|
||
# 9f) Parcel meta — ВРИ и кадастровые метаданные из cad_parcels (#29 G2)
|
||
parcel_meta: ParcelMeta | None = None
|
||
try:
|
||
with db.begin_nested():
|
||
pm_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT permitted_use_established_by_document AS permitted_use,
|
||
land_record_category_type AS land_category,
|
||
land_record_subtype AS land_subtype,
|
||
cost_value AS cad_cost
|
||
FROM cad_parcels
|
||
WHERE cad_num = CAST(:c AS text)
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if pm_row:
|
||
parcel_meta = ParcelMeta(
|
||
permitted_use=pm_row["permitted_use"],
|
||
land_category=pm_row["land_category"],
|
||
land_subtype=pm_row["land_subtype"],
|
||
cad_cost=float(pm_row["cad_cost"]) if pm_row["cad_cost"] is not None else None,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("parcel_meta query failed for %s: %s", cad_num, e)
|
||
|
||
# B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
|
||
egrn_block: dict[str, Any] = {}
|
||
try:
|
||
with db.begin_nested():
|
||
egrn_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT cost_value AS cadastral_value_rub,
|
||
cost_index AS cost_index_per_m2,
|
||
land_record_category_type AS land_category,
|
||
permitted_use_established_by_document AS permitted_use_text,
|
||
cost_registration_date AS last_egrn_update_date,
|
||
land_record_area AS area_m2,
|
||
ownership_type,
|
||
right_type,
|
||
status,
|
||
readable_address,
|
||
registration_date
|
||
FROM cad_parcels
|
||
WHERE cad_num = CAST(:c AS text)
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if egrn_row:
|
||
_cad_val = (
|
||
float(egrn_row["cadastral_value_rub"])
|
||
if egrn_row["cadastral_value_rub"] is not None
|
||
else None
|
||
)
|
||
_area_m2 = float(egrn_row["area_m2"]) if egrn_row["area_m2"] is not None else None
|
||
_idx = egrn_row["cost_index_per_m2"]
|
||
_cad_per_m2: float | None = None
|
||
if _idx is not None:
|
||
_cad_per_m2 = float(_idx)
|
||
elif _cad_val is not None and _area_m2 and _area_m2 > 0:
|
||
_cad_per_m2 = round(_cad_val / _area_m2, 2)
|
||
egrn_block = {
|
||
"cadastral_value_rub": _cad_val,
|
||
"cadastral_value_per_m2": _cad_per_m2,
|
||
"land_category": egrn_row["land_category"],
|
||
"permitted_use_text": egrn_row["permitted_use_text"],
|
||
"last_egrn_update_date": (
|
||
egrn_row["last_egrn_update_date"].isoformat()
|
||
if egrn_row["last_egrn_update_date"] is not None
|
||
else None
|
||
),
|
||
"area_m2": _area_m2,
|
||
"ownership_type": egrn_row["ownership_type"],
|
||
"right_type": egrn_row["right_type"],
|
||
"parcel_status": egrn_row["status"],
|
||
"address": egrn_row["readable_address"],
|
||
"registration_date": (
|
||
egrn_row["registration_date"].isoformat()
|
||
if egrn_row["registration_date"] is not None
|
||
else None
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("egrn_block query failed for %s: %s", cad_num, e)
|
||
|
||
# B5-2) Encumbrance block — ЗОУИТ из cad_zouit (SF-B5)
|
||
encumbrance_block: dict[str, Any] = {
|
||
"has_zouit": False,
|
||
"zouit_types": [],
|
||
"zouit_count": 0,
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
zouit_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT type_zone, name_by_doc
|
||
FROM cad_zouit
|
||
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
|
||
ORDER BY id
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
if zouit_rows:
|
||
_zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
|
||
encumbrance_block = {
|
||
"has_zouit": True,
|
||
"zouit_types": _zouit_types,
|
||
"zouit_count": len(zouit_rows),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("encumbrance_block query failed for %s: %s", cad_num, e)
|
||
|
||
# B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
|
||
red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
|
||
try:
|
||
with db.begin_nested():
|
||
rl_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT COUNT(*) AS cnt
|
||
FROM cad_red_lines
|
||
WHERE ST_Intersects(
|
||
geom::geometry,
|
||
ST_GeomFromText(:wkt, 4326)
|
||
)
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if rl_row:
|
||
_rl_cnt = int(rl_row["cnt"])
|
||
red_lines_block = {
|
||
"intersects": _rl_cnt > 0,
|
||
"count": _rl_cnt,
|
||
}
|
||
except Exception as e:
|
||
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
|
||
|
||
# B5-4) Metro — ближайшие 3 станции метро к участку.
|
||
# Данные уже в osm_poi_ekb (category='metro_stop', грузятся poi_loader.py:44),
|
||
# скрапер не нужен — прямой KNN-запрос по geom <-> centroid участка.
|
||
# nearest_top3=[] (НЕ None) когда метро не найдено: отличаем "посчитано, рядом
|
||
# нет" от "не реализовано". None только при ошибке запроса.
|
||
metro_block: dict[str, Any] = {"nearest_top3": []}
|
||
try:
|
||
with db.begin_nested():
|
||
metro_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT name,
|
||
ST_Distance(
|
||
m.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS dist_m
|
||
FROM osm_poi_ekb m
|
||
WHERE m.category = 'metro_stop'
|
||
ORDER BY m.geom <-> ST_Centroid(ST_GeomFromText(:wkt, 4326))
|
||
LIMIT 3
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
metro_block = {
|
||
"nearest_top3": [
|
||
{
|
||
"name": mr["name"],
|
||
"distance_m": round(float(mr["dist_m"])) if mr["dist_m"] is not None else None,
|
||
}
|
||
for mr in metro_rows
|
||
]
|
||
}
|
||
except Exception as e:
|
||
logger.warning("metro_block query failed for %s: %s", cad_num, e)
|
||
metro_block = {"nearest_top3": None}
|
||
|
||
# B5-5) District price ranges из objective_lots (SF-B5)
|
||
district_price_block: dict[str, Any] = {
|
||
"district_price_per_m2_min": None,
|
||
"district_price_per_m2_max": None,
|
||
"district_price_per_m2_median": None,
|
||
"district_price_sample_size": None,
|
||
}
|
||
if district_row and district_row["district_name"]:
|
||
try:
|
||
with db.begin_nested():
|
||
dp_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT
|
||
MIN(price_per_m2_rub) AS price_min,
|
||
MAX(price_per_m2_rub) AS price_max,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
||
ORDER BY price_per_m2_rub
|
||
) AS price_median,
|
||
COUNT(*) AS sample_size
|
||
FROM objective_lots
|
||
WHERE district = CAST(:dn AS text)
|
||
AND price_per_m2_rub IS NOT NULL
|
||
AND price_per_m2_rub BETWEEN 30000 AND 600000
|
||
"""),
|
||
{"dn": district_row["district_name"]},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0:
|
||
district_price_block = {
|
||
"district_price_per_m2_min": (
|
||
round(float(dp_row["price_min"])) if dp_row["price_min"] else None
|
||
),
|
||
"district_price_per_m2_max": (
|
||
round(float(dp_row["price_max"])) if dp_row["price_max"] else None
|
||
),
|
||
"district_price_per_m2_median": (
|
||
round(float(dp_row["price_median"])) if dp_row["price_median"] else None
|
||
),
|
||
"district_price_sample_size": int(dp_row["sample_size"]),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("district_price_block query failed for %s: %s", cad_num, e)
|
||
|
||
# B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5)
|
||
risks_block: dict[str, Any] = {
|
||
"flood_zone": False,
|
||
"noise_score": round(noise_score, 2),
|
||
"geology_risk_label": None,
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
flood_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT COUNT(*) AS cnt
|
||
FROM cad_risk_zones
|
||
WHERE ST_Intersects(
|
||
geom::geometry,
|
||
ST_GeomFromText(:wkt, 4326)
|
||
)
|
||
AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%'
|
||
OR risk_type ILIKE '%затоп%')
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
_flood = bool(flood_row and int(flood_row["cnt"]) > 0)
|
||
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
|
||
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
|
||
_has_flood = _flood or _geo_flood
|
||
# geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low
|
||
if _has_flood:
|
||
_geo_label: str | None = "high"
|
||
elif noise_db_max >= 65.0:
|
||
_geo_label = "medium"
|
||
else:
|
||
_geo_label = "low"
|
||
risks_block = {
|
||
"flood_zone": _has_flood,
|
||
"noise_score": round(noise_score, 2),
|
||
"geology_risk_label": _geo_label,
|
||
}
|
||
except Exception as e:
|
||
logger.warning("risks_block query failed for %s: %s", cad_num, e)
|
||
|
||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||
market_trend: dict[str, Any] | None = None
|
||
try:
|
||
with db.begin_nested():
|
||
trend_row = (
|
||
db.execute(
|
||
text("""
|
||
WITH district_deals AS (
|
||
SELECT d.period_start_date AS deal_date,
|
||
d.price_per_sqm AS price_per_m2
|
||
FROM rosreestr_deals d
|
||
WHERE d.region_code = 66
|
||
AND d.doc_type = 'ДДУ'
|
||
AND d.realestate_type_code = '002001003000'
|
||
AND d.price_per_sqm BETWEEN 30000 AND 500000
|
||
AND d.period_start_date > NOW() - INTERVAL '12 months'
|
||
AND ST_DWithin(
|
||
(SELECT ST_Centroid(geom)
|
||
FROM cad_quarters_geom
|
||
WHERE cad_number = d.quarter_cad_number)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
3000
|
||
)
|
||
)
|
||
SELECT
|
||
AVG(price_per_m2)
|
||
FILTER (WHERE deal_date > NOW() - INTERVAL '6 months')
|
||
AS recent_avg,
|
||
AVG(price_per_m2)
|
||
FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
|
||
AND NOW() - INTERVAL '6 months')
|
||
AS prior_avg,
|
||
COUNT(*)
|
||
FILTER (WHERE deal_date > NOW() - INTERVAL '6 months')
|
||
AS recent_n,
|
||
COUNT(*)
|
||
FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months'
|
||
AND NOW() - INTERVAL '6 months')
|
||
AS prior_n,
|
||
MAX(deal_date) AS max_period
|
||
FROM district_deals
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
# SF#1871 P2: честность staleness. Источник сделок (rosreestr) может встать —
|
||
# тогда recent-окно (0-6 мес) пусто → recent_avg=NULL. Раньше market_trend
|
||
# обнулялся в голый None, и фронт/скоринг не могли отличить «нет данных рядом»
|
||
# от «источник устарел». Теперь явный status. (ETL-фикс — вне scope.)
|
||
_raw_max_period = trend_row["max_period"] if trend_row else None
|
||
# period_start_date — DATE; нормализуем на случай timestamp (.date()), чтобы
|
||
# сравнение/форматирование не падало на datetime vs date.
|
||
max_period: _dt.date | None
|
||
if isinstance(_raw_max_period, _dt.datetime):
|
||
max_period = _raw_max_period.date()
|
||
elif isinstance(_raw_max_period, _dt.date):
|
||
max_period = _raw_max_period
|
||
else:
|
||
max_period = None
|
||
recent_n_val = int(trend_row["recent_n"]) if trend_row and trend_row["recent_n"] else 0
|
||
# Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня.
|
||
stale_cutoff = _dt.date.today() - _dt.timedelta(days=90)
|
||
|
||
if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]:
|
||
# Happy path — есть данные в обоих окнах.
|
||
recent_p = float(trend_row["recent_avg"])
|
||
prior_p = float(trend_row["prior_avg"])
|
||
# 6-месячное изменение; ×2 даёт годовой эквивалент
|
||
delta_6m_pct = round((recent_p - prior_p) / prior_p * 100, 1)
|
||
if delta_6m_pct > 8:
|
||
perspective_label = "Сильный рост — рынок растёт быстрее инфляции"
|
||
elif delta_6m_pct > 0:
|
||
perspective_label = "Умеренный рост — стабильный спрос"
|
||
elif delta_6m_pct > -5:
|
||
perspective_label = "Стагнация — рынок остыл"
|
||
else:
|
||
perspective_label = "Падение — риск переоценки"
|
||
market_trend = {
|
||
"status": "ok",
|
||
"recent_avg_price_per_m2": round(recent_p),
|
||
"prior_avg_price_per_m2": round(prior_p),
|
||
"delta_6m_pct": delta_6m_pct,
|
||
"recent_deals_count": int(trend_row["recent_n"]),
|
||
"prior_deals_count": int(trend_row["prior_n"]),
|
||
"label": perspective_label,
|
||
"radius_km": 3,
|
||
}
|
||
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
|
||
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
|
||
market_trend = {
|
||
"status": "source_stale",
|
||
"as_of_date": max_period.isoformat(),
|
||
"label": (
|
||
"Источник сделок устарел "
|
||
f"(данные до {max_period.strftime('%m.%Y')})"
|
||
),
|
||
"recent_deals_count": 0,
|
||
}
|
||
else:
|
||
# Источник свежий (или вообще нет данных в радиусе), но по объекту 0 сделок
|
||
# в recent-окне → честно «нет сделок», а не подмена под устаревание.
|
||
market_trend = {
|
||
"status": "no_deals",
|
||
"label": "Нет сделок в радиусе за период",
|
||
"recent_deals_count": 0,
|
||
}
|
||
except Exception as e:
|
||
logger.warning("market_trend query failed for %s: %s", cad_num, e)
|
||
market_trend = None
|
||
|
||
# 10b) Zoning — территориальная зона ПЗЗ.
|
||
# NB: Росреестр PKK6 API закрыт в 2024 → редирект на NSPD (anti-bot WAF).
|
||
# Open-data shapefile ПЗЗ ЕКБ публично не выкладывается (data.midural.ru
|
||
# содержит только metadata). Реальный per-parcel zoning требует либо
|
||
# ручного импорта shapefile из ГИС ЕКБ (с авторизацией) либо платный
|
||
# API (egrn.reestr.net). Здесь — fallback на pzz_zones_ekb (если есть)
|
||
# + deep-links на публичные геопорталы для drill-down.
|
||
zoning: dict[str, Any] = {
|
||
"zone_code": None,
|
||
"zone_name": None,
|
||
"description": None,
|
||
"data_available": False,
|
||
"note": (
|
||
"Автоматический per-parcel zoning недоступен: Росреестр PKK6 API "
|
||
"закрыт (2024), NSPD блокирует bot-доступ. Используй внешние "
|
||
"геопорталы для определения зоны вручную."
|
||
),
|
||
"links": {
|
||
"nspd_portal": f"https://nspd.gov.ru/map?lat={centroid_lat}&lng={centroid_lon}&z=17",
|
||
"ekb_geoportal": "https://xn--80acgfbsl1azdqr.xn--p1ai/",
|
||
"midural_data": "https://data.midural.ru/",
|
||
},
|
||
"lat": centroid_lat,
|
||
"lon": centroid_lon,
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
zoning_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT zone_code, zone_name, description, rosreestr_id
|
||
FROM pzz_zones_ekb
|
||
WHERE ST_Within(
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326)),
|
||
geom
|
||
)
|
||
LIMIT 1
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if zoning_row:
|
||
zoning.update(
|
||
{
|
||
"zone_code": zoning_row["zone_code"],
|
||
"zone_name": zoning_row["zone_name"],
|
||
"description": zoning_row["description"],
|
||
"rosreestr_id": zoning_row["rosreestr_id"],
|
||
"data_available": True,
|
||
"source": "rosreestr-pkk6-cached",
|
||
}
|
||
)
|
||
except Exception as e:
|
||
logger.warning("zoning query failed for %s: %s", cad_num, e)
|
||
|
||
# 10c) Success recommendation — топ квартирография по district из v_bucket_success_score
|
||
# SF-20: адаптивный порог — MIN_DEALS=15 (показываем), STRONG=30 (confidence='strong').
|
||
# Если n_deals всех строк < STRONG → data_confidence='weak' + UI badge «слабые данные».
|
||
success_recommendation: dict[str, Any] | None = None
|
||
if district_row:
|
||
try:
|
||
with db.begin_nested():
|
||
success_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2,
|
||
velocity_z, price_z, area_z
|
||
FROM v_bucket_success_score
|
||
WHERE district_name = :dn
|
||
ORDER BY success_score DESC
|
||
LIMIT 5
|
||
"""),
|
||
{"dn": district_row["district_name"]},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
# Фильтруем строки ниже абсолютного минимума (< SUCCESS_REC_MIN_DEALS)
|
||
valid_rows = [r for r in success_rows if int(r["n_deals"]) >= SUCCESS_REC_MIN_DEALS]
|
||
if valid_rows:
|
||
max_deals = max(int(r["n_deals"]) for r in valid_rows)
|
||
data_confidence = "strong" if max_deals >= SUCCESS_REC_STRONG_DEALS else "weak"
|
||
success_recommendation = {
|
||
"district": district_row["district_name"],
|
||
"data_confidence": data_confidence,
|
||
"ranking": [
|
||
{
|
||
"bucket": r["bucket"],
|
||
"success_score": round(float(r["success_score"]), 2),
|
||
"n_deals": int(r["n_deals"]),
|
||
"avg_price_per_m2": (
|
||
int(r["avg_price_per_m2"]) if r["avg_price_per_m2"] else None
|
||
),
|
||
"avg_area_m2": (
|
||
round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None
|
||
),
|
||
}
|
||
for r in valid_rows
|
||
],
|
||
"top_bucket": dict(valid_rows[0]),
|
||
"note": (
|
||
"Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 "
|
||
"- area×0.2. Min 15 сделок в группе за 24 мес. "
|
||
"Используй для квартирографии проекта."
|
||
),
|
||
}
|
||
except Exception as e:
|
||
logger.warning("success_recommendation query failed for %s: %s", cad_num, e)
|
||
success_recommendation = None
|
||
|
||
# 10d-pre) Market price — ценовая статистика квартала из mv_quarter_price_per_m2 (#33)
|
||
# quarter_cad_number — первые три части кад. номера: "66:41:0204016:10" → "66:41:0204016"
|
||
_cad_parts = cad_num.split(":")
|
||
_quarter_cad = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else cad_num
|
||
market_price: dict[str, Any]
|
||
try:
|
||
with db.begin_nested():
|
||
mp_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT p25, median, p75, mean, deals_count,
|
||
median_6m, median_12m, median_24m, last_deal_date
|
||
FROM mv_quarter_price_per_m2
|
||
WHERE quarter_cad_number = :q
|
||
"""),
|
||
{"q": _quarter_cad},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if mp_row:
|
||
market_price = {
|
||
"p25": float(mp_row["p25"]) if mp_row["p25"] is not None else None,
|
||
"median": float(mp_row["median"]) if mp_row["median"] is not None else None,
|
||
"p75": float(mp_row["p75"]) if mp_row["p75"] is not None else None,
|
||
"mean": float(mp_row["mean"]) if mp_row["mean"] is not None else None,
|
||
"deals_count": int(mp_row["deals_count"]),
|
||
"median_6m": (
|
||
float(mp_row["median_6m"]) if mp_row["median_6m"] is not None else None
|
||
),
|
||
"median_12m": (
|
||
float(mp_row["median_12m"]) if mp_row["median_12m"] is not None else None
|
||
),
|
||
"median_24m": (
|
||
float(mp_row["median_24m"]) if mp_row["median_24m"] is not None else None
|
||
),
|
||
"last_deal_date": (
|
||
mp_row["last_deal_date"].isoformat()
|
||
if mp_row["last_deal_date"] is not None
|
||
else None
|
||
),
|
||
"source": "quarter_mv",
|
||
}
|
||
else:
|
||
market_price = {"deals_count": 0, "source": "no_data"}
|
||
except Exception as e:
|
||
logger.warning("market_price query failed for %s: %s", cad_num, e)
|
||
market_price = {"deals_count": 0, "source": "no_data"}
|
||
|
||
# 10d-pre2) #105 Phase 5: РНС/РВЭ в квартале (cad_num quarter prefix match).
|
||
# Spatial filter через geom недоступен до Phase 3 (geocoding → geom).
|
||
# Используем quarter prefix (первые 3 сегмента кад. номера) как прокси.
|
||
# После Phase 3: заменить на ST_DWithin(..., 500) по geom.
|
||
recent_permits: list[dict[str, Any]] = []
|
||
permits_summary: dict[str, Any] = {
|
||
"rns_count": 0,
|
||
"rve_count": 0,
|
||
"rns_total_area_sqm": 0.0,
|
||
"by_developer": [],
|
||
}
|
||
try:
|
||
with db.begin_nested():
|
||
permits_rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT
|
||
permit_type, permit_number, issue_date,
|
||
developer_name, developer_inn, object_name, object_type,
|
||
construction_address, total_area_sqm
|
||
FROM ekburg_construction_permits
|
||
WHERE LEFT(cadastral_number, LENGTH(CAST(:q AS text))) = CAST(:q AS text)
|
||
AND issue_date > NOW() - INTERVAL '24 months'
|
||
ORDER BY issue_date DESC
|
||
LIMIT 50
|
||
"""),
|
||
{"q": _quarter_cad},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
recent_permits = [
|
||
{
|
||
"permit_type": r["permit_type"],
|
||
"permit_number": r["permit_number"],
|
||
"issue_date": r["issue_date"].isoformat() if r["issue_date"] else None,
|
||
"developer_name": r["developer_name"],
|
||
"developer_inn": r["developer_inn"],
|
||
"object_name": r["object_name"],
|
||
"object_type": r["object_type"],
|
||
"construction_address": r["construction_address"],
|
||
"total_area_sqm": float(r["total_area_sqm"]) if r["total_area_sqm"] else None,
|
||
}
|
||
for r in permits_rows
|
||
]
|
||
rns_list = [p for p in recent_permits if p["permit_type"] == "RNS"]
|
||
by_dev: dict[str, int] = {}
|
||
for p in recent_permits:
|
||
if p["developer_name"]:
|
||
by_dev[p["developer_name"]] = by_dev.get(p["developer_name"], 0) + 1
|
||
permits_summary = {
|
||
"rns_count": len(rns_list),
|
||
"rve_count": len([p for p in recent_permits if p["permit_type"] == "RVE"]),
|
||
"rns_total_area_sqm": sum(p["total_area_sqm"] or 0.0 for p in rns_list),
|
||
"by_developer": [
|
||
{"name": name, "permits_count": cnt}
|
||
for name, cnt in sorted(by_dev.items(), key=lambda x: -x[1])[:3]
|
||
],
|
||
}
|
||
except Exception as e:
|
||
logger.warning("ekburg permits query failed for %s: %s", cad_num, e)
|
||
|
||
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
|
||
karpinsky_url = (
|
||
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
||
f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12"
|
||
)
|
||
efgi_url = "https://efgi.ru/"
|
||
geology: dict[str, Any] = {
|
||
"data_available": False,
|
||
"note": (
|
||
"Подробная геология (типы пород, грунты, мощность ОС) "
|
||
"доступна только через ВСЕГЕИ-200/1000 шейпы — требуется "
|
||
"ручной импорт в PostGIS (multi-day задача). Для drill-down "
|
||
"используй внешние ссылки ниже."
|
||
),
|
||
"links": {
|
||
"karpinsky_webgis": karpinsky_url,
|
||
"efgi_federal_registry": efgi_url,
|
||
},
|
||
"lat": centroid_lat,
|
||
"lon": centroid_lon,
|
||
}
|
||
|
||
# 4b) #254: Custom POI scoring — user-defined points с произвольным весом.
|
||
# Добавляем после основного POI loop, не трогаем OSM логику.
|
||
# user_id берём из X-Session-Id header (workaround до #67 NextAuth).
|
||
custom_poi_items: list[dict[str, Any]] = []
|
||
_session_id = x_session_id.strip() if x_session_id and x_session_id.strip() else None
|
||
if _session_id:
|
||
try:
|
||
_custom_overlaps = _get_custom_poi_overlaps(
|
||
db, geom_wkt, _session_id, parcel_cad=cad_num
|
||
)
|
||
for cp in _custom_overlaps:
|
||
_distance_m = cp["distance_m"]
|
||
_decay = max(0.0, 1.0 - _distance_m / 1000.0)
|
||
_contribution = cp["weight"] * _decay
|
||
score += _contribution
|
||
_item: dict[str, Any] = {
|
||
"source": "custom",
|
||
"id": cp["id"],
|
||
"label": cp["name"],
|
||
"category": cp["category"],
|
||
"weight": cp["weight"],
|
||
"distance_m": round(_distance_m),
|
||
"contribution": round(_contribution, 3),
|
||
"lat": cp["lat"],
|
||
"lon": cp["lon"],
|
||
}
|
||
custom_poi_items.append(_item)
|
||
if abs(_contribution) >= 0.01:
|
||
factors_detailed.append(
|
||
{
|
||
"factor": f"custom_{cp['id']}_{round(_distance_m)}m",
|
||
"category": f"custom_{cp['category'] or 'poi'}",
|
||
"category_ru": f"Custom: {cp['name']}",
|
||
"group": "Custom POI",
|
||
"value": round(_distance_m, 1),
|
||
"weight": cp["weight"],
|
||
"contribution": round(_contribution, 2),
|
||
"verbal": (
|
||
f"Custom POI '{cp['name']}' ({round(_distance_m)}м) — "
|
||
f"{'+' if _contribution >= 0 else ''}{round(_contribution, 2)} б."
|
||
),
|
||
"lat": cp["lat"],
|
||
"lon": cp["lon"],
|
||
}
|
||
)
|
||
if _custom_overlaps:
|
||
logger.debug(
|
||
"custom POI scoring: user=%s cad=%s poi_count=%d",
|
||
_session_id,
|
||
cad_num,
|
||
len(_custom_overlaps),
|
||
)
|
||
except Exception as _cpe:
|
||
logger.warning("custom POI scoring failed cad=%s: %s", cad_num, _cpe)
|
||
|
||
score_final = score + center_bonus
|
||
|
||
# X1 (#47): расчёт contribution_pct + top-3 / by-group для UI.
|
||
# Базис для процентов — сумма абсолютных значений всех факторов; это даёт
|
||
# стабильное соотношение независимо от знака и не делится на 0.
|
||
abs_total = sum(abs(f["contribution"]) for f in factors_detailed) or 1.0
|
||
for f in factors_detailed:
|
||
f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1)
|
||
|
||
factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True)
|
||
# Convention: оба top-list'а отсортированы "dominant first":
|
||
# positives → most-positive first (factors_sorted desc → [:3])
|
||
# negatives → most-negative first (sort negatives asc → [:3])
|
||
score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
|
||
negatives_only = [f for f in factors_sorted if f["contribution"] < 0]
|
||
score_top_3_negatives = sorted(negatives_only, key=lambda x: x["contribution"])[:3]
|
||
|
||
# By-group totals — для stacked-bar в UI. count это int, contribution* — float.
|
||
group_totals: dict[str, dict[str, float | int]] = {}
|
||
for f in factors_detailed:
|
||
g = group_totals.setdefault(
|
||
f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0}
|
||
)
|
||
g["contribution"] += f["contribution"]
|
||
g["count"] += 1
|
||
group_abs_total = sum(abs(g["contribution"]) for g in group_totals.values()) or 1.0
|
||
for g_val in group_totals.values():
|
||
g_val["contribution"] = round(g_val["contribution"], 2)
|
||
g_val["contribution_pct"] = round(100.0 * abs(g_val["contribution"]) / group_abs_total, 1)
|
||
score_by_group = [
|
||
{"group": k, **v}
|
||
for k, v in sorted(group_totals.items(), key=lambda kv: -abs(kv[1]["contribution"]))
|
||
]
|
||
|
||
# X2 (#48): composite confidence + caveats
|
||
confidence_info = _compute_confidence(
|
||
source=source,
|
||
poi_rows=[dict(p) for p in poi_rows],
|
||
district_row=dict(district_row) if district_row else None,
|
||
competitor_rows=[dict(c) for c in competitor_rows],
|
||
noise_sources_count=len(noise_rows),
|
||
air_q=air_q,
|
||
weather=weather,
|
||
market_trend=market_trend,
|
||
zoning=zoning,
|
||
)
|
||
|
||
# D4 (#36): aggregate pipeline_24mo
|
||
pipeline_24mo = _aggregate_pipeline(pipeline_rows)
|
||
|
||
# D2 (#34): velocity-score — темп продаж конкурентов вокруг участка.
|
||
# SAVEPOINT защищает outer transaction если velocity SQL падает —
|
||
# иначе следующие queries (_geotech_risk и пр.) крашатся
|
||
# с InFailedSqlTransaction.
|
||
# SF#17: передаём cad_quarter для rosreestr_fallback (первые 3 сегмента cad_num).
|
||
_cad_parts = cad_num.split(":")
|
||
_cad_quarter = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else None
|
||
velocity_data: dict[str, Any] | None = None
|
||
try:
|
||
with db.begin_nested():
|
||
v_result = compute_velocity(
|
||
db,
|
||
parcel_geom_wkt=geom_wkt,
|
||
radius_km=3.0,
|
||
cad_quarter=_cad_quarter,
|
||
)
|
||
if v_result is not None:
|
||
velocity_data = v_result.as_dict()
|
||
except Exception as _ve:
|
||
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
|
||
|
||
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
|
||
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
|
||
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
|
||
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
|
||
_competitors_total = len(competitor_rows)
|
||
_competitors_priced = len(_competitors_with_price)
|
||
if _competitors_with_price:
|
||
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
|
||
_market_avg_price = round(sum(_prices) / len(_prices))
|
||
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
|
||
_with_sales = [
|
||
c
|
||
for c in _competitors_with_price
|
||
if c["units_sold"] is not None and int(c["units_sold"]) > 0
|
||
]
|
||
_top_sellers = sorted(
|
||
_with_sales,
|
||
key=lambda c: int(c["units_sold"]),
|
||
reverse=True,
|
||
)[:5]
|
||
_top_sellers_list = [
|
||
{
|
||
"obj_id": c["obj_id"],
|
||
"comm_name": c["comm_name"],
|
||
"dev_name": c["dev_name"],
|
||
"units_sold": int(c["units_sold"]),
|
||
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
|
||
}
|
||
for c in _top_sellers
|
||
]
|
||
else:
|
||
_market_avg_price = None
|
||
_top_sellers_list = []
|
||
|
||
_coverage_pct = (
|
||
round(_competitors_priced * 100.0 / _competitors_total, 1)
|
||
if _competitors_total > 0
|
||
else 0.0
|
||
)
|
||
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
|
||
# ЖК с objective_corpus_room_month данными (non-null by construction).
|
||
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
|
||
market_pulse: dict[str, Any] = {
|
||
"avg_velocity_m2": _avg_velocity,
|
||
"market_avg_price_per_m2": _market_avg_price,
|
||
"competitors_total": _competitors_total,
|
||
"competitors_with_price": _competitors_priced,
|
||
"coverage_pct": _coverage_pct,
|
||
"top_sellers": _top_sellers_list,
|
||
}
|
||
|
||
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
|
||
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
|
||
# только если у участка резолвится район; ошибка/нет демографии → None (не роняет
|
||
# analyze). SAVEPOINT изолирует от outer-сессии (как success_recommendation выше).
|
||
saturation_block: dict[str, Any] | None = None
|
||
if district_row and district_row["district_name"]:
|
||
try:
|
||
with db.begin_nested():
|
||
saturation_block = compute_district_saturation(db, district_row["district_name"])
|
||
except Exception as e:
|
||
logger.warning(
|
||
"saturation block failed for %s (%s): %s",
|
||
cad_num,
|
||
district_row["district_name"],
|
||
e,
|
||
)
|
||
|
||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner.
|
||
# Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740)
|
||
# пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное).
|
||
gate_verdict = compute_gate_verdict(
|
||
nspd_zoning=nspd_dump_data["nspd_zoning"],
|
||
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
|
||
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
||
nspd_dump=nspd_dump_data["nspd_dump"],
|
||
)
|
||
|
||
# #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт
|
||
# квартирографию — не удаляем рекомендацию (строгость = продуктовое решение),
|
||
# а делаем противоречие явным через caveat-флаг. success_recommendation —
|
||
# analyze-рекомендатор (топ-квартирография по district).
|
||
if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
|
||
success_recommendation["gate_caveat"] = (
|
||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
||
"рекомендация носит условный характер"
|
||
)
|
||
|
||
result_payload: dict[str, Any] = {
|
||
"cad_num": cad_num,
|
||
"source": source,
|
||
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
||
"district": dict(district_row) if district_row else None,
|
||
# #42: per-capita обеспеченность соцобъектами района (saturation) + влияние
|
||
# на POI-score (score_multiplier). None если район не резолвится / нет демографии.
|
||
"infra": {"saturation": saturation_block},
|
||
"score": round(score_final, 2),
|
||
"score_without_center": round(score, 2),
|
||
"score_label": _score_label(score_final),
|
||
"score_max_reference": SCORE_MAX_REFERENCE,
|
||
"score_explanation": (
|
||
"Сумма close-distance POI (школы/сады/парки +, трамваи -) + center_bonus. "
|
||
">40 = редко, типичный город. центр 15-30."
|
||
),
|
||
"score_breakdown": by_category,
|
||
# X1 (#47): per-factor контрибуции с verbal explain + top-3 / by-group.
|
||
"score_breakdown_detailed": factors_sorted,
|
||
"score_top_3_positives": score_top_3_positives,
|
||
"score_top_3_negatives": score_top_3_negatives,
|
||
"score_by_group": score_by_group,
|
||
"poi_count": len(poi_rows),
|
||
"location": {
|
||
"distance_to_center_km": round(dist_to_center_km, 2),
|
||
"center_bonus": center_bonus,
|
||
"ekb_center": {"lat": EKB_CENTER_LAT, "lon": EKB_CENTER_LON},
|
||
"note": "Бонус к score: <5км +3.0, 5-10км +1.5, 10-15км +0.5, >15км 0",
|
||
},
|
||
# #999 (958-B4): competitors несут lat/lon (EPSG:4326) для Leaflet-слоёв.
|
||
# Additive — все существующие поля сохранены через {**dict(c)}; lat/lon
|
||
# округлены до 6 dp (float|None). latest_obj фильтрует latitude IS NOT NULL,
|
||
# поэтому здесь координаты обычно заполнены, но _coord_round graceful к None.
|
||
"competitors": [_competitor_with_coords(c) for c in competitor_rows],
|
||
# OBJ-3 fix: aggregate market metrics — только non-null competitors.
|
||
# ЖК без objective_mapping остаются на карте (competitors list),
|
||
# но исключены из avg/velocity/top_sellers расчётов.
|
||
"market_pulse": market_pulse,
|
||
"market_avg_price_per_m2": market_pulse["market_avg_price_per_m2"],
|
||
"market_data_coverage_pct": market_pulse["coverage_pct"],
|
||
# D4 (#36): 24-month pipeline competition
|
||
"pipeline_24mo": pipeline_24mo,
|
||
# D2 (#34): velocity-score из objective_corpus_room_month (OBJ-3 migrated)
|
||
"velocity": velocity_data,
|
||
"noise": {
|
||
"score": round(noise_score, 2),
|
||
"estimated_db": round(noise_db_max, 1),
|
||
"level": noise_level,
|
||
"sources": nearby_noise_sources[:10],
|
||
},
|
||
"air_quality": air_q,
|
||
"weather": weather,
|
||
"seasonal_weather": seasonal_weather,
|
||
"wind": (weather or {}).get("wind") if weather else None, # backward compat
|
||
"geology": geology,
|
||
"hydrology": hydrology,
|
||
"utilities": utilities,
|
||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||
# P1 (#45) — physical suitability участка
|
||
"geometry_suitability": _polygon_suitability(geom_wkt),
|
||
# P2 (#46) — соседи-здания + overlap check
|
||
"neighbors_summary": _neighbors_summary(db, geom_wkt, cad_num),
|
||
"market_trend": market_trend,
|
||
# #33 D2: квартальная ценовая статистика из mv_quarter_price_per_m2
|
||
"market_price": market_price,
|
||
# #29 G2: кадастровые метаданные участка (ВРИ, категория, кад. стоимость)
|
||
"parcel_meta": parcel_meta.model_dump() if parcel_meta is not None else None,
|
||
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
|
||
"recent_permits_in_quarter": recent_permits,
|
||
"permits_summary": permits_summary,
|
||
"zoning": zoning,
|
||
"success_recommendation": success_recommendation,
|
||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
||
# X2 (#48) — confidence indicator
|
||
"confidence": confidence_info["value"],
|
||
"confidence_label": confidence_info["label"],
|
||
"confidence_breakdown": confidence_info["breakdown"],
|
||
"confidence_caveats": confidence_info["caveats"],
|
||
# Sprint 1.1 item #4 — NSPD quarter dump fields
|
||
# nspd_zoning: ПЗЗ зона из territorial_zones кеша (G1)
|
||
# nspd_zouit_overlaps: ЗОУИТ пересечения (G3)
|
||
# nspd_engineering_nearby: инженерные сооружения в 200м (I3)
|
||
# nspd_risk_zones: TIER 3 risk zones (#94) — подтопление, эрозия, гари, оползни
|
||
# nspd_opportunity_parcels: TIER 4 opportunity ЗУ (#94 PR2)
|
||
# nspd_red_lines: TIER 4 красные линии застройки (#94 PR2, #54 Generative)
|
||
# nspd_dump: freshness metadata — available, stale, harvest_triggered
|
||
"nspd_zoning": nspd_dump_data["nspd_zoning"],
|
||
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
||
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
||
"nspd_risk_zones": [RiskZone(**rz) for rz in nspd_dump_data.get("nspd_risk_zones", [])],
|
||
"nspd_opportunity_parcels": [
|
||
OpportunityParcel(**op) for op in nspd_dump_data.get("nspd_opportunity_parcels", [])
|
||
],
|
||
"nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
|
||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
|
||
"gate_verdict": gate_verdict,
|
||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||
"weights_profile": {
|
||
"source": _weights_source,
|
||
"profile_id": profile_id,
|
||
"user_id": profile_user_id,
|
||
"weights_applied": _effective_weights,
|
||
"inline_weights": _inline_weights,
|
||
},
|
||
# #254: custom POI scoring — user-defined points (via X-Session-Id header).
|
||
"custom_poi_score_items": custom_poi_items,
|
||
# SF-B5: EGRN + encumbrance + red_lines + metro + district prices + risks
|
||
"egrn": egrn_block,
|
||
"encumbrance": encumbrance_block,
|
||
"red_lines": red_lines_block,
|
||
"metro": metro_block,
|
||
"district_price_per_m2_min": district_price_block["district_price_per_m2_min"],
|
||
"district_price_per_m2_max": district_price_block["district_price_per_m2_max"],
|
||
"district_price_per_m2_median": district_price_block["district_price_per_m2_median"],
|
||
"district_price_sample_size": district_price_block["district_price_sample_size"],
|
||
"risks": risks_block,
|
||
}
|
||
|
||
# #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
|
||
# иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
|
||
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
|
||
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
|
||
|
||
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
|
||
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
|
||
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
|
||
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
|
||
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
|
||
# передаётся в persist by-reference; если enqueue провалится после persist —
|
||
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
|
||
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
|
||
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||
|
||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
|
||
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
|
||
if settings.enable_ird_analyze:
|
||
try:
|
||
result_payload["ird"] = build_ird_analyze_block(
|
||
db, geom_wkt, centroid_lon, centroid_lat, cad_num
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"ird block failed for cad=%s — analyze response unaffected",
|
||
cad_num,
|
||
exc_info=True,
|
||
)
|
||
|
||
# РИАСУРТ Свердл gate (#108, multi-city scaling): для участков АГЛОМЕРАЦИИ ЕКБ
|
||
# (окраины — Берёзовский/В.Пышма/Среднеуральск/Арамиль/Сысерть), НЕ ЕКБ-сити (66:41).
|
||
# Flag-gated (default off): таблица riasurt_sverdl наполняется post-deploy harvest'ом на
|
||
# уточнённых bbox МО. Полностью graceful (parcel_riasurt_gate сам отдаёт пустой gate при
|
||
# отсутствии таблицы / вне агломерации) — сбой не меняет успех/форму ответа (extra="allow").
|
||
# Ключ добавляется только когда gate применим (applicable=True) — иначе не шумим.
|
||
if settings.enable_riasurt_gate:
|
||
try:
|
||
_riasurt_gate = parcel_riasurt_gate(db, geom_wkt, cad_num)
|
||
if _riasurt_gate.get("applicable"):
|
||
result_payload.setdefault("gate", {})["riasurt"] = _riasurt_gate
|
||
except Exception:
|
||
logger.warning(
|
||
"riasurt gate failed for cad=%s — analyze response unaffected",
|
||
cad_num,
|
||
exc_info=True,
|
||
)
|
||
|
||
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
|
||
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
|
||
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
|
||
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
|
||
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
|
||
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
|
||
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
|
||
try:
|
||
_dev_attr = get_developer_attribution(db, cad_num)
|
||
if _dev_attr is not None:
|
||
result_payload["developer_attribution"] = _dev_attr.model_dump()
|
||
except Exception:
|
||
logger.warning(
|
||
"developer_attribution block failed for cad=%s — analyze response unaffected",
|
||
cad_num,
|
||
exc_info=True,
|
||
)
|
||
|
||
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
|
||
# forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
|
||
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
|
||
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
||
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
||
# без JSON-разбора); confidence — отчётный уровень high/medium/low из
|
||
# confidence_label (нормализуется под CHECK в repository). schema_version —
|
||
# ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
|
||
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
|
||
_district_name = (
|
||
result_payload["district"].get("district_name")
|
||
if isinstance(result_payload.get("district"), dict)
|
||
else None
|
||
)
|
||
persist_analysis_run(
|
||
db,
|
||
cad_num=cad_num,
|
||
result=result_payload,
|
||
params={
|
||
"profile_id": profile_id,
|
||
"profile_user_id": profile_user_id,
|
||
"inline_weights": _inline_weights,
|
||
"weights_source": _weights_source,
|
||
"x_session_id": _session_id,
|
||
},
|
||
district=_district_name,
|
||
confidence=confidence_info["label"],
|
||
status="complete",
|
||
schema_version=ANALYZE_SCHEMA_VERSION,
|
||
created_by=x_authenticated_user,
|
||
)
|
||
|
||
# §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
|
||
# единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
|
||
# должен случиться только после того, как ран закоммичен: иначе фоновая таска
|
||
# `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
|
||
# ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
|
||
# Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
|
||
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
|
||
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
||
try:
|
||
from app.workers.tasks.forecast import forecast_site_finder_report
|
||
|
||
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
|
||
except Exception:
|
||
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
|
||
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
|
||
# persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
|
||
logger.warning(
|
||
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
|
||
cad_num,
|
||
horizon,
|
||
exc_info=True,
|
||
)
|
||
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
|
||
|
||
return result_payload
|
||
|
||
|
||
@router.get(
|
||
"/{cad_num}/connection-points",
|
||
response_model=ConnectionPointsResponse,
|
||
summary="Точки подключения к инженерным сетям + охранные зоны (issue #115)",
|
||
)
|
||
def get_parcel_connection_points(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
radius_m: Annotated[int, Query(ge=50, le=2000)] = 500,
|
||
) -> ConnectionPointsResponse:
|
||
"""Инженерные структуры (ТП, ЦТП, ЛЭП) и охранные зоны коммуникаций вблизи участка.
|
||
|
||
Источник данных: nspd_quarter_dumps (НСПД cat 36328 / 37578).
|
||
Если дамп для квартала ещё не загружен → dump_available=false,
|
||
пустые массивы (harvest запускается автоматически).
|
||
Если участок не найден в БД (нет geom) → 404.
|
||
|
||
Query params:
|
||
radius_m: радиус поиска инженерных структур, 50–2000 м (default 500).
|
||
"""
|
||
try:
|
||
data = get_connection_points(db, cad_num, radius_m)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
|
||
return ConnectionPointsResponse(
|
||
engineering_structures=data["engineering_structures"],
|
||
zouit_engineering_overlaps=data["zouit_engineering_overlaps"],
|
||
summary=data["summary"],
|
||
dump_available=data["dump_available"],
|
||
dump_fetched_at=data["dump_fetched_at"],
|
||
)
|
||
|
||
|
||
_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
|
||
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
|
||
|
||
|
||
@router.get("/{cad_num}/isochrones")
|
||
def get_isochrones(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
mode: str = "foot-walking",
|
||
times_min: Annotated[list[int], Query()] = [10, 15], # noqa: B006
|
||
) -> dict[str, Any]:
|
||
"""Изохроны доступности от центроида участка через OpenRouteService.
|
||
|
||
Modes: foot-walking | cycling-regular | driving-car.
|
||
times_min — список минут (1-60), например ?times_min=10×_min=15.
|
||
Возвращает GeoJSON FeatureCollection для рендера на карте.
|
||
"""
|
||
if not settings.openrouteservice_api_key:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail=(
|
||
"OPENROUTESERVICE_API_KEY не задан в env. "
|
||
"Получи free key на https://openrouteservice.org/dev/#/signup "
|
||
"и пропиши в backend/.env.runtime"
|
||
),
|
||
)
|
||
|
||
if mode not in _ORS_VALID_MODES:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"Недопустимый mode '{mode}'. Допустимые: {sorted(_ORS_VALID_MODES)}",
|
||
)
|
||
|
||
invalid_times = [t for t in times_min if not (1 <= t <= 60)]
|
||
if invalid_times:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=f"times_min значения вне диапазона 1-60: {invalid_times}",
|
||
)
|
||
|
||
# Получить координаты центроида из доступных геометрий участка.
|
||
# `geom IS NOT NULL` во ВСЕХ ветках UNION (#1201): cad_parcels_geom — VIEW
|
||
# над cad_parcels с nullable geom, 964 prod-строки. Без фильтра NULL-строка
|
||
# ранней ветки скрывает валидную из поздней + ST_Centroid(NULL) → NULL → 500.
|
||
# Зеркало паттерна analyze (parcels.py:1418-1438).
|
||
coord_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_X(ST_Centroid(g.geom)) AS lon,
|
||
ST_Y(ST_Centroid(g.geom)) AS lat
|
||
FROM (
|
||
SELECT geom FROM cad_quarters_geom
|
||
WHERE cad_number = :c AND geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT geom FROM cad_buildings
|
||
WHERE cad_num = :c AND geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT geom FROM cad_parcels_geom
|
||
WHERE cad_num = :c AND geom IS NOT NULL
|
||
) g
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
# belt-and-suspenders: row может вернуться с NULL lat/lon только если в БД
|
||
# есть строка с НЕ-NULL geom но битый ST_Centroid (топологически невалидный
|
||
# polygon). Граница `not coord_row` truthy-проверка на RowMapping её бы НЕ
|
||
# поймала (RowMapping с None-полями всё ещё truthy) → float(None) → 500.
|
||
if not coord_row or coord_row.get("lat") is None or coord_row.get("lon") is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Геометрия для {cad_num} не найдена.",
|
||
)
|
||
|
||
lat = float(coord_row["lat"])
|
||
lon = float(coord_row["lon"])
|
||
|
||
# OpenRouteService isochrones API — POST с JSON body
|
||
url = f"{_ORS_BASE}/{mode}"
|
||
body: dict[str, Any] = {
|
||
"locations": [[lon, lat]],
|
||
"range": [t * 60 for t in times_min], # ORS ожидает секунды
|
||
"range_type": "time",
|
||
"units": "m",
|
||
}
|
||
headers = {
|
||
"Authorization": settings.openrouteservice_api_key,
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/geo+json",
|
||
}
|
||
|
||
try:
|
||
with httpx.Client(timeout=10) as client:
|
||
resp = client.post(url, json=body, headers=headers)
|
||
resp.raise_for_status()
|
||
geojson = resp.json()
|
||
except httpx.HTTPStatusError as exc:
|
||
if exc.response.status_code == 429:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail="OpenRouteService daily limit (2000 req) exceeded.",
|
||
) from exc
|
||
logger.error("ORS HTTP error for %s: %s — %s", cad_num, exc.response.status_code, exc)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"ORS error {exc.response.status_code}: {exc.response.text[:200]}",
|
||
) from exc
|
||
except Exception as exc:
|
||
logger.error("ORS request failed for %s: %s", cad_num, exc)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"Isochrones fetch failed: {exc}",
|
||
) from exc
|
||
|
||
return {
|
||
"cad_num": cad_num,
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"mode": mode,
|
||
"times_min": times_min,
|
||
"geojson": geojson,
|
||
"source": "openrouteservice.org",
|
||
"note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.",
|
||
}
|
||
|
||
|
||
@router.get(
|
||
"/{cad_num}/poi-score",
|
||
response_model=PoiScoreResponse,
|
||
summary="POI weighted top-7 (B6)",
|
||
)
|
||
def get_poi_score(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
radius_m: Annotated[int, Query(ge=100, le=5000)] = 2000,
|
||
decay: Annotated[str, Query(pattern="^(straight|routing)$")] = "straight",
|
||
) -> PoiScoreResponse:
|
||
"""Вернуть top-7 ближайших POI для участка.
|
||
|
||
decay=straight (default, B6): weight = (1 / (distance_m + 100)) * category_weight.
|
||
decay=routing (#41): время пешком (ORS /matrix) + per-category radius (минут) +
|
||
per-category decay-кривая. За порогом доступности категории POI выбывает.
|
||
ORS недоступен → graceful fallback на straight-line оценку времени.
|
||
|
||
POI берутся из osm_poi_ekb в заданном радиусе (default 2000м), отсортированы
|
||
по weight DESC.
|
||
"""
|
||
# Получить координаты центроида участка из геометрических таблиц.
|
||
# См. парный комментарий в get_isochrones — `geom IS NOT NULL` во ВСЕХ ветках
|
||
# UNION (#1201): NULL-строка ранней ветки скрывает валидную из поздней +
|
||
# ST_Centroid(NULL) → NULL → float(None) → 500. Зеркало analyze (parcels.py:1418-1438).
|
||
coord_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_X(ST_Centroid(g.geom)) AS lon,
|
||
ST_Y(ST_Centroid(g.geom)) AS lat
|
||
FROM (
|
||
SELECT geom FROM cad_quarters_geom
|
||
WHERE cad_number = :c AND geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT geom FROM cad_buildings
|
||
WHERE cad_num = :c AND geom IS NOT NULL
|
||
UNION ALL
|
||
SELECT geom FROM cad_parcels_geom
|
||
WHERE cad_num = :c AND geom IS NOT NULL
|
||
) g
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
# belt-and-suspenders: см. get_isochrones — RowMapping с None-полями truthy.
|
||
if not coord_row or coord_row.get("lat") is None or coord_row.get("lon") is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Геометрия для {cad_num} не найдена.",
|
||
)
|
||
|
||
lat = float(coord_row["lat"])
|
||
lon = float(coord_row["lon"])
|
||
|
||
if decay == "routing":
|
||
return compute_poi_routing_decay(db, cad_num, lat, lon, radius_m=radius_m)
|
||
return compute_poi_weighted_top7(db, cad_num, lat, lon, radius_m=radius_m)
|
||
|
||
|
||
@router.post("/{cad_num}/competitors", response_model=CompetitorsResponse)
|
||
async def get_parcel_competitors(
|
||
cad_num: str,
|
||
body: CompetitorsRequest,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> CompetitorsResponse:
|
||
"""Активные конкуренты ЖК в радиусе от участка (Issue #112).
|
||
|
||
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
||
участка с рассчитанным velocity_per_month за указанный time_window.
|
||
"""
|
||
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
|
||
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
|
||
try:
|
||
return await run_in_threadpool(get_competitors, db=db, cad_num=cad_num, request=body)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
logger.error("competitors endpoint failed for %s: %s", cad_num, exc)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Ошибка расчёта конкурентов",
|
||
) from exc
|
||
|
||
|
||
@router.post("/{cad_num}/competitors-parking", response_model=CompetitorsParkingResponse)
|
||
def get_parcel_competitors_parking(
|
||
cad_num: str,
|
||
body: CompetitorsRequest,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> CompetitorsParkingResponse:
|
||
"""parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze.
|
||
|
||
Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через
|
||
PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio.
|
||
Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы
|
||
секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после
|
||
отрисовки списка конкурентов.
|
||
|
||
sync `def` (не async): premises_lookup изолирует НСПД-async через
|
||
asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI
|
||
исполняет sync-handler в threadpool, не блокируя event loop.
|
||
"""
|
||
try:
|
||
return get_competitors_parking(db=db, cad_num=cad_num, request=body)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
logger.error("competitors-parking endpoint failed for %s: %s", cad_num, exc)
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Ошибка расчёта паркинга конкурентов",
|
||
) from exc
|
||
|
||
|
||
@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
|
||
async def get_parcel_best_layouts(
|
||
cad_num: str,
|
||
body: BestLayoutsRequest,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> BestLayoutsResponse:
|
||
"""Top layouts (rooms × area_bin) у конкурентов с ranking по velocity.
|
||
|
||
Issue #113 Phase 2.1: "Анализ лучших планировок конкурентов → ТЗ на проектирование".
|
||
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
|
||
× objective_complex_mapping).
|
||
"""
|
||
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
|
||
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
|
||
try:
|
||
return await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
logger.error("best_layouts endpoint failed for %s: %s", cad_num, exc)
|
||
raise HTTPException(status_code=500, detail="Internal server error") from exc
|
||
|
||
|
||
@router.post("/{cad_num}/best-layouts/pdf")
|
||
async def get_parcel_best_layouts_pdf(
|
||
cad_num: str,
|
||
body: BestLayoutsRequest,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> Response:
|
||
"""ТЗ на проектирование (PDF) — генерируется из /best-layouts данных.
|
||
|
||
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
|
||
"""
|
||
# sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
|
||
# write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
|
||
try:
|
||
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||
pdf_bytes = await run_in_threadpool(
|
||
render_layout_tz_pdf,
|
||
response,
|
||
cad_num=cad_num,
|
||
radius_km=body.radius_km,
|
||
time_window=body.time_window,
|
||
)
|
||
today = _dt.date.today().strftime("%Y-%m-%d")
|
||
cad_safe = cad_num.replace(":", "-")
|
||
filename = f"tz-layout-{cad_safe}-{today}.pdf"
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
logger.error("best_layouts PDF endpoint failed for %s: %s", cad_num, exc)
|
||
raise HTTPException(status_code=500, detail="Internal server error") from exc
|
||
|
||
|
||
def _filter_competitors_by_dev(
|
||
rows: list[dict[str, Any]], exclude_dev: str | None
|
||
) -> list[dict[str, Any]]:
|
||
"""Отфильтровать строки-конкуренты по имени застройщика (dev_name).
|
||
|
||
`exclude_dev` — CSV нормализованных (casefold) имён застройщиков, которых
|
||
нужно скрыть из блока «Конкуренты» PDF-снимка. Сравнение регистронезависимо.
|
||
Пустой/None `exclude_dev` → список возвращается без изменений. Строки с
|
||
пустым/None dev_name НЕ исключаются (исключаем только явные совпадения).
|
||
"""
|
||
if not exclude_dev:
|
||
return rows
|
||
excluded = {s.strip().casefold() for s in exclude_dev.split(",") if s.strip()}
|
||
if not excluded:
|
||
return rows
|
||
kept = [r for r in rows if (r.get("dev_name") or "").strip().casefold() not in excluded]
|
||
dropped = len(rows) - len(kept)
|
||
if dropped:
|
||
logger.info(
|
||
"snapshot_pdf: excluded %d competitors by dev_name filter %s",
|
||
dropped,
|
||
sorted(excluded),
|
||
)
|
||
return kept
|
||
|
||
|
||
@router.get(
|
||
"/{cad_num}/snapshot.pdf",
|
||
summary="1-page PDF snapshot участка (НСПД + POI + конкуренты)",
|
||
)
|
||
def parcel_snapshot_pdf(
|
||
cad_num: str,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
exclude_dev: Annotated[
|
||
str | None,
|
||
Query(
|
||
description=(
|
||
"Скрыть конкурентов этих застройщиков (dev_name) из блока «Конкуренты». "
|
||
"Несколько — через запятую. Регистронезависимо. Пример: exclude_dev=Практика"
|
||
)
|
||
),
|
||
] = None,
|
||
) -> Response:
|
||
"""Генерирует одностраничный PDF-снимок участка (A4).
|
||
|
||
Содержимое:
|
||
- Header: кадастровый номер, адрес, район, площадь
|
||
- Block 1: 5 KPI (площадь, кадастровая стоимость, категория, ВРИ, дата обновления)
|
||
- Block 2: Топ-7 POI по взвешенному баллу (из osm_poi_ekb, радиус 1 км)
|
||
- Block 3: Топ-5 конкурентов (из domrf_kn_objects, радиус 3 км)
|
||
- Footer: gendsgn.ru + дата генерации
|
||
|
||
Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
|
||
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
|
||
"""
|
||
# 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты +
|
||
# district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS-
|
||
# ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
|
||
# traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии
|
||
# геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500).
|
||
try:
|
||
# 1) Получить метаданные участка из cad_parcels
|
||
parcel_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT readable_address AS address,
|
||
land_record_area AS area_m2,
|
||
land_record_category_type AS land_category,
|
||
permitted_use_established_by_document AS vri,
|
||
cost_value AS cadastral_cost,
|
||
updated_at AS last_update
|
||
FROM cad_parcels
|
||
WHERE cad_num = CAST(:c AS text)
|
||
LIMIT 1
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
|
||
if not parcel_row:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=(
|
||
f"Участок {cad_num} не найден в БД. " "Используйте POST /analyze для загрузки."
|
||
),
|
||
)
|
||
|
||
# 2) Получить геометрию (WKT) для POI / competitor queries
|
||
geom_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT ST_AsText(COALESCE(
|
||
(SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
|
||
(SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1)
|
||
)) AS wkt
|
||
"""),
|
||
{"c": cad_num},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
geom_wkt: str | None = geom_row["wkt"] if geom_row else None
|
||
|
||
# #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/
|
||
# района). 422 — клиентский кейс «данные неполны», не серверная 500.
|
||
if not geom_wkt:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail=(
|
||
f"Геометрия участка {cad_num} отсутствует в БД — "
|
||
"PDF-снимок недоступен. Запустите POST /analyze для дозагрузки."
|
||
),
|
||
)
|
||
|
||
# 3) POI в радиусе 1 км
|
||
poi_rows: list[dict[str, Any]] = [
|
||
dict(r)
|
||
for r in db.execute(
|
||
text("""
|
||
SELECT category,
|
||
name,
|
||
ST_Distance(
|
||
p.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM osm_poi_ekb p
|
||
WHERE ST_DWithin(
|
||
p.geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
1000
|
||
)
|
||
ORDER BY distance_m ASC
|
||
LIMIT 50
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
]
|
||
|
||
# 4) Конкуренты в радиусе 3 км
|
||
competitor_rows: list[dict[str, Any]] = [
|
||
dict(r)
|
||
for r in db.execute(
|
||
text("""
|
||
WITH latest_obj AS (
|
||
SELECT DISTINCT ON (obj_id) *
|
||
FROM domrf_kn_objects
|
||
WHERE latitude IS NOT NULL
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
)
|
||
SELECT obj_id,
|
||
comm_name,
|
||
dev_name,
|
||
obj_class,
|
||
flat_count,
|
||
ST_Distance(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM latest_obj o
|
||
WHERE ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
3000
|
||
)
|
||
ORDER BY flat_count DESC NULLS LAST
|
||
LIMIT 20
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
]
|
||
|
||
# 4b) Опционально скрыть конкурентов конкретных застройщиков (client-кейс:
|
||
# убрать оффер девелопера X из отчёта для девелопера Y). Без exclude_dev —
|
||
# список идентичен прежнему (zero behavior change).
|
||
competitor_rows = _filter_competitors_by_dev(competitor_rows, exclude_dev)
|
||
|
||
# 5) Получить district (через пересечение с ekb_districts)
|
||
district: str | None = None
|
||
district_row = (
|
||
db.execute(
|
||
text("""
|
||
SELECT d.district_name
|
||
FROM ekb_districts d
|
||
WHERE ST_Contains(d.geom, ST_Centroid(ST_GeomFromText(:wkt, 4326)))
|
||
LIMIT 1
|
||
"""),
|
||
{"wkt": geom_wkt},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if district_row:
|
||
district = district_row["district_name"]
|
||
|
||
# 6) Форматировать last_update
|
||
raw_update = parcel_row["last_update"]
|
||
last_update_str: str | None = None
|
||
if raw_update is not None:
|
||
try:
|
||
last_update_str = raw_update.strftime("%d.%m.%Y")
|
||
except AttributeError:
|
||
last_update_str = str(raw_update)[:10]
|
||
except HTTPException:
|
||
# 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500.
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception("snapshot data fetch failed for %s", cad_num)
|
||
raise HTTPException(status_code=500, detail="Ошибка сбора данных для PDF-снимка") from exc
|
||
|
||
# 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты,
|
||
# шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739).
|
||
try:
|
||
pdf_bytes = generate_snapshot_pdf(
|
||
cad_num=cad_num,
|
||
address=parcel_row["address"],
|
||
district=district,
|
||
area_m2=float(parcel_row["area_m2"]) if parcel_row["area_m2"] is not None else None,
|
||
cadastral_cost_rub=(
|
||
float(parcel_row["cadastral_cost"])
|
||
if parcel_row["cadastral_cost"] is not None
|
||
else None
|
||
),
|
||
land_category=parcel_row["land_category"],
|
||
vri=parcel_row["vri"],
|
||
last_update=last_update_str,
|
||
poi_rows=poi_rows,
|
||
competitor_rows=competitor_rows,
|
||
competitors_limit=5,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("snapshot PDF render failed for %s", cad_num)
|
||
raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc
|
||
|
||
cad_safe = cad_num.replace(":", "-")
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'attachment; filename="snapshot-{cad_safe}.pdf"'},
|
||
)
|