import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from fastapi.concurrency import run_in_threadpool from shapely.geometry import Point from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db from app.schemas.concept import ( ConceptInput, ConceptOutput, HouseTypeCatalog, HouseTypeCatalogItem, MassingProgram, MassingRecomputeOutput, ) from app.services.generative import geometry from app.services.generative.catalog import HOUSE_TYPES, available_section_types from app.services.generative.financial import compute_financial from app.services.generative.geometry import ParcelGeometryError, _parse_polygon from app.services.generative.teap import synthesize_teap_from_program logger = logging.getLogger(__name__) router = APIRouter() # Минимум объявлений Objective в районе для надёжной медианы цены продажи жилья. # n>=10: с меньшей выборкой медиана шумна (1-2 нетипичных лота её перекашивают) и # выдавать её за «рыночную цену района» нечестно. При n<10 честнее откатиться на # справочную median_price_per_m2 района (ekb_districts, 24-мес окно), а при её # отсутствии — на норматив класса. Порог зеркалит «осторожную выборку» из других # district-агрегатов проекта (см. parcels.py district_price_block). _MIN_OBJECTIVE_SAMPLE: int = 10 # Радиус поиска района от центроида участка (м). Зеркалит ST_DWithin 5 км из # parcels.py district-context — участок может лежать у границы полигона района. _DISTRICT_SEARCH_RADIUS_M: int = 5000 # Санитарные границы цены продажи, руб/кв.м — отсекают мусорные лоты Objective # (price_per_m2_rub — это РУБЛИ, не тысячи). Те же пороги, что в parcels.py. _PRICE_MIN_RUB: int = 30_000 _PRICE_MAX_RUB: int = 600_000 # Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда # фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне # (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median. _DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median" # Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены. # Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён). # ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT. _DISTRICT_FOR_POINT_SQL = text( """ SELECT d.district_name, d.median_price_per_m2 FROM ekb_districts d WHERE d.geom IS NOT NULL AND ST_DWithin( d.geom::geography, ST_GeomFromText(CAST(:wkt AS text), 4326)::geography, CAST(:radius AS double precision) ) ORDER BY ST_Distance( d.geom::geography, ST_GeomFromText(CAST(:wkt AS text), 4326)::geography ) ASC LIMIT 1 """ ) # Медиана цены продажи жилья из объявлений Objective по району + размер выборки. # price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3. # # #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на # один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE # были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON # (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий # physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт # district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует # ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district # В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает. # Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем # ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL. _OBJECTIVE_MEDIAN_SQL = text( """ WITH latest AS ( SELECT DISTINCT ON ( project_name, corpus_name, section, floor, lot_number ) price_per_m2_rub FROM objective_lots WHERE district = CAST(:dn AS text) ORDER BY project_name, corpus_name, section, floor, lot_number, snapshot_date DESC, id DESC ) SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2, COUNT(*) AS sample_size FROM latest WHERE price_per_m2_rub IS NOT NULL AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric) """ ) def _parcel_centroid_wkt(payload: ConceptInput) -> str: """Центроид полигона участка как WKT-точка (WGS84) для spatial-lookup.""" polygon = _parse_polygon(payload.parcel_geojson) c = polygon.centroid wkt: str = Point(c.x, c.y).wkt return wkt def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str]: """Рыночная цена продажи жилья, руб/кв.м, для участка по его центроиду + источник. Алгоритм (от точного к общему): 1. Центроид → ближайший район ЕКБ (ekb_districts, ST_DWithin 5 км). 2. Медиана Objective по этому району (price_per_m2_rub, n>=:_MIN_OBJECTIVE_SAMPLE) → ("objective_district_median"). Свежайший рыночный сигнал. 3. Иначе справочная ekb_districts.median_price_per_m2 → ("district_reference"). 4. Иначе (нет района / нет данных) → (None, "class_norm") — финмодель возьмёт норматив класса. Никогда не роняет генерацию концепции: ЛЮБАЯ ошибка (вне ЕКБ, нет PostGIS, сбой БД, нет таблицы) логируется warning'ом и деградирует в (None, "class_norm"). Read-only — SAVEPOINT не нужен. """ try: district_row = ( db.execute( _DISTRICT_FOR_POINT_SQL, {"wkt": wkt_point, "radius": _DISTRICT_SEARCH_RADIUS_M}, ) .mappings() .first() ) except Exception as exc: logger.warning("concept market-price: district lookup failed, fallback class_norm: %s", exc) return None, "class_norm" if not district_row or not district_row["district_name"]: logger.info("concept market-price: parcel outside ЕКБ districts → class_norm") return None, "class_norm" district_name: str = district_row["district_name"] district_reference = district_row["median_price_per_m2"] # 2) Свежайший сигнал — медиана объявлений Objective по району (n>=порог). try: obj_row = ( db.execute( _OBJECTIVE_MEDIAN_SQL, {"dn": district_name, "lo": _PRICE_MIN_RUB, "hi": _PRICE_MAX_RUB}, ) .mappings() .first() ) except Exception as exc: logger.warning( "concept market-price: objective median failed (district=%s), " "trying district reference: %s", district_name, exc, ) obj_row = None if ( obj_row and obj_row["sample_size"] and int(obj_row["sample_size"]) >= _MIN_OBJECTIVE_SAMPLE and obj_row["median_ppm2"] ): price = float(obj_row["median_ppm2"]) logger.info( "concept market-price: objective median %.0f руб/м² (district=%s, n=%d)", price, district_name, int(obj_row["sample_size"]), ) return price, "objective_district_median" # 3) Справочная медиана района (24-мес окно ekb_districts). if district_reference is not None: price = float(district_reference) logger.info( "concept market-price: district reference %.0f руб/м² (district=%s, n<%d)", price, district_name, _MIN_OBJECTIVE_SAMPLE, ) return price, "district_reference" logger.info( "concept market-price: district=%s has no price reference → class_norm", district_name ) return None, "class_norm" @router.get("/house-types", response_model=HouseTypeCatalog) async def list_house_types() -> HouseTypeCatalog: """Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера. Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так фронт НЕ хардкодит габариты/этажности — они приходят отсюда. """ return HouseTypeCatalog( house_types=[ HouseTypeCatalogItem( section_type=ht.section_type, label_ru=ht.label_ru, footprint_w_m=ht.footprint_w_m, footprint_d_m=ht.footprint_d_m, footprint_sqm=ht.footprint_sqm, default_floors=ht.default_floors, housing_class=ht.housing_class, ) for ht in HOUSE_TYPES ] ) @router.post("", response_model=ConceptOutput) async def create_concept( payload: ConceptInput, db: Annotated[Session, Depends(get_db)], ) -> ConceptOutput: """Generate 3 building variants for the given parcel polygon. Stage 1a: Shapely parse + buildable area (setback) + placement grid. Stage 1b: greedy section placement with STRtree collisions (3 strategies). Stage 1c: real ТЭП + financial model attached to each variant. The housing sale price is calibrated to real market data (Objective / district reference) when available, falling back to the per-class norm with an honest source flag (PR-2, эпик #1881). A degenerate parcel (setback consumes everything, malformed geometry) yields a 422 rather than empty variants — that is a bad request, not a valid empty result. Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный ``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested. """ # Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный # section_type это bad request (422), а не 500 из KeyError в глубине placement. if payload.building_program: known = available_section_types() unknown = sorted({item.section_type for item in payload.building_program} - known) if unknown: raise HTTPException( status_code=422, detail=( f"unknown house type(s): {', '.join(unknown)}; " f"available: {', '.join(sorted(known))}" ), ) # Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех # стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не # блокировать event loop. Падение lookup'а не должно ронять генерацию. market_price: float | None = None price_source: str = "class_norm" try: wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload) market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point) except ParcelGeometryError: # Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422). pass except Exception as exc: # Защитный пояс: любая иная ошибка lookup'а → норматив класса, не 500. logger.warning("concept market-price: unexpected lookup error, class_norm: %s", exc) try: # geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через # run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py). variants = await run_in_threadpool( geometry.generate, payload, market_price_per_sqm=market_price, price_source=price_source, ) except ParcelGeometryError as exc: logger.warning("concept generation rejected parcel: %s", exc) raise HTTPException(status_code=422, detail=str(exc)) from exc return ConceptOutput(variants=variants) @router.post("/recompute", response_model=MassingRecomputeOutput) async def recompute_massing( payload: MassingProgram, db: Annotated[Session, Depends(get_db)], ) -> MassingRecomputeOutput: """Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга. Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый ``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность), мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` — те же нормативные константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial`` → пересчитанный ТЭП + финмодель. Цена продажи жилья: * ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) — фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из financial_estimate — objective_geo_radius / objective_district_median / district_reference / class_norm); если фронт его не прислал — дефолтный лейбл. НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены. * иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy не должен блокировать event loop). Падение lookup'а → норматив класса, не 500. * иначе (нет ни цены, ни центроида) → норматив класса (``price_source="class_norm"``). """ # ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ─────── market_price: float | None = payload.market_price_per_sqm price_source: str = "class_norm" if market_price is not None: # FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke. # Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если # его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно # форсит class_norm, но сюда мы попадаем только при заданной цене.) price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE elif payload.parcel_centroid_wkt: # Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept). # Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри # _lookup_market_price, защитный пояс на непредвиденное оставляем тут. try: market_price, price_source = await run_in_threadpool( _lookup_market_price, db, payload.parcel_centroid_wkt ) except Exception as exc: logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc) market_price, price_source = None, "class_norm" teap = synthesize_teap_from_program( total_footprint_sqm=payload.total_footprint_sqm, floors=payload.floors, site_area_sqm=payload.site_area_sqm, housing_class=payload.housing_class, sections=payload.sections, ) # compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в # event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется # внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно. financial = compute_financial( teap=teap, housing_class=payload.housing_class, land_cost_rub=payload.land_cost_rub, market_price_per_sqm=market_price, price_source=price_source, development_type=payload.development_type, ) return MassingRecomputeOutput(teap=teap, financial=financial)