diff --git a/backend/app/api/v1/concepts.py b/backend/app/api/v1/concepts.py index 579212b3..3e5dda47 100644 --- a/backend/app/api/v1/concepts.py +++ b/backend/app/api/v1/concepts.py @@ -1,32 +1,211 @@ import logging +from typing import Annotated -from fastapi import APIRouter, HTTPException +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 from app.services.generative import geometry -from app.services.generative.geometry import ParcelGeometryError +from app.services.generative.geometry import ParcelGeometryError, _parse_polygon 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 + +# Район ЕКБ (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. +_OBJECTIVE_MEDIAN_SQL = text( + """ + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2, + 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 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.post("", response_model=ConceptOutput) -async def create_concept(payload: ConceptInput) -> 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. + 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. """ + # Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех + # стратегий). 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) + 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 diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index 5b765519..31e3ed05 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -70,6 +70,15 @@ class FinancialModel(BaseModel): margin_pct: float # net profit / revenue irr_is_proxy: bool = True # frontend caveat: not a real DCF IRR (см. PR-3) + # ── Price calibration (PR-2, эпик #1881) ─────────────────────────────────── + # Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо + # калиброванная по рынку (Objective / district reference), либо норматив класса. + # ``price_source`` honest-flag для UI/PDF: не выдавать норматив за рынок. + price_per_sqm_used: float + price_is_calibrated: bool = False + # Значения: "objective_district_median" | "district_reference" | "class_norm". + price_source: str = "class_norm" + class ConceptVariant(BaseModel): strategy: Literal["max_area", "max_insolation", "balanced"] diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index 56bc5d9d..fe3228fa 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -39,8 +39,17 @@ OFFICES / COMMERCIAL — SKIPPED TEAP carries no office/commercial area (only residential + parking), so commercial revenue/cost is intentionally **not** modelled here. Add when TEAP gains the field. -Prices/costs of housing & СМР stay as the existing hardcoded per-class proxies -(calibration is PR-2). Детерминированно, без LLM / внешних API / БД. +PRICE CALIBRATION — PR-2 +------------------------ +The housing **sale price** can be calibrated to real market data: callers pass +``market_price_per_sqm`` (руб/кв.м) computed from Objective listings / district +reference at the API layer. When ``None`` we fall back to the per-class norm and +flag the source honestly (``price_source="class_norm"``) so the UI/PDF never +passes a class norm off as a market figure. Construction cost (СМР) is *not* +calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms. + +This function stays **pure**: it does no DB lookup itself (the API layer does the +lookup and passes the result in). Детерминированно, без LLM / внешних API / БД. """ from __future__ import annotations @@ -108,6 +117,8 @@ def compute_financial( teap: TEAP, housing_class: HousingClass, land_cost_rub: float | None, + market_price_per_sqm: float | None = None, + price_source: str = "class_norm", ) -> FinancialModel: """Свести ТЭП + класс + стоимость земли в полный статический :class:`FinancialModel`. @@ -116,12 +127,30 @@ def compute_financial( налогом на прибыль → чистая прибыль + ROI. НЕ DCF (PR-3); НДС — упрощение (см. docstring модуля); офисов нет в TEAP — пропущены. + Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой + (lookup рынка) и прокидывает сюда через ``market_price_per_sqm``. + Args: teap: Stage 1c ТЭП (residential_area_sqm, total_floor_area_sqm, parking_spaces). - housing_class: задаёт цену продажи и себестоимость СМР. + housing_class: задаёт цену продажи (fallback) и себестоимость СМР. land_cost_rub: стоимость участка (опционально); None -> 0 в затратах. + market_price_per_sqm: калиброванная рыночная цена продажи жилья, руб/кв.м. + ``None`` → fallback на норматив класса ``_SALE_PRICE_PER_SQM``. Калибруем + ТОЛЬКО цену жилья; цена паркинга и себестоимость СМР остаются нормативом. + price_source: метка источника цены для honest-флага в UI/PDF — одно из + "objective_district_median" / "district_reference" / "class_norm". + Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``. """ - sale_price = _SALE_PRICE_PER_SQM[housing_class] + # Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback + # на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок). + if market_price_per_sqm is not None: + sale_price = market_price_per_sqm + price_is_calibrated = True + resolved_price_source = price_source + else: + sale_price = _SALE_PRICE_PER_SQM[housing_class] + price_is_calibrated = False + resolved_price_source = "class_norm" construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class] # ── Выручка (GDV) ────────────────────────────────────────────────────────── @@ -185,9 +214,14 @@ def compute_financial( roi=round(roi, 4), margin_pct=round(margin_pct, 4), irr_is_proxy=True, + # price calibration (PR-2) + price_per_sqm_used=round(sale_price, 2), + price_is_calibrated=price_is_calibrated, + price_source=resolved_price_source, ) logger.info( - "financial: revenue=%.0f cost=%.0f gross=%.0f vat=%.0f tax=%.0f net=%.0f roi=%.3f", + "financial: revenue=%.0f cost=%.0f gross=%.0f vat=%.0f tax=%.0f net=%.0f roi=%.3f " + "price=%.0f/m2 source=%s", model.revenue_rub, model.cost_rub, model.gross_margin_rub, @@ -195,6 +229,8 @@ def compute_financial( model.profit_tax_rub, model.net_profit_rub, model.roi, + model.price_per_sqm_used, + model.price_source, ) return model diff --git a/backend/app/services/generative/geometry.py b/backend/app/services/generative/geometry.py index 7609aa7c..6c282950 100644 --- a/backend/app/services/generative/geometry.py +++ b/backend/app/services/generative/geometry.py @@ -291,18 +291,32 @@ def parse_parcel( ) -def generate(payload: ConceptInput) -> list[ConceptVariant]: +def generate( + payload: ConceptInput, + *, + market_price_per_sqm: float | None = None, + price_source: str = "class_norm", +) -> list[ConceptVariant]: """Public orchestrator: Stage 1a -> 1b -> 1c -> 3 filled :class:`ConceptVariant`. Deterministic end-to-end. On a degenerate parcel (setback eats everything, bad geometry) we *log and re-raise* :class:`ParcelGeometryError` — the API layer maps it to 4xx; silently returning zero-variants would hide a bad request. + + ``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены продажи + жилья, PR-2) прокидываются в Stage 1c (финмодель). Lookup рынка делает API-слой + (он один знает БД); сюда приходит уже посчитанная цена. ``None`` → норматив класса. """ # Local import to avoid a module-level import cycle (placement imports geometry). from app.services.generative import placement parcel = parse_parcel(payload) - variants = placement.place_all_strategies(parcel, payload) + variants = placement.place_all_strategies( + parcel, + payload, + market_price_per_sqm=market_price_per_sqm, + price_source=price_source, + ) logger.info("generated %d concept variants", len(variants)) return variants diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py index 55441b7b..e66edc7b 100644 --- a/backend/app/services/generative/placement.py +++ b/backend/app/services/generative/placement.py @@ -191,6 +191,9 @@ def place_strategy( parcel: Parcel, payload: ConceptInput, spec: StrategySpec, + *, + market_price_per_sqm: float | None = None, + price_source: str = "class_norm", ) -> ConceptVariant | None: """Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant. @@ -198,6 +201,10 @@ def place_strategy( участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный — ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`. + + ``market_price_per_sqm`` / ``price_source`` прокидываются в :func:`compute_financial` + для калибровки цены продажи жилья по рынку (PR-2). Lookup делает API-слой; здесь + только проброс. ``None`` → норматив класса. """ floors = _resolve_floors(payload.target_floors, spec.floors_factor) coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP) @@ -221,6 +228,8 @@ def place_strategy( teap=teap_result, housing_class=payload.housing_class, land_cost_rub=payload.land_cost_rub, + market_price_per_sqm=market_price_per_sqm, + price_source=price_source, ) buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec) @@ -233,18 +242,36 @@ def place_strategy( ) -def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]: +def place_all_strategies( + parcel: Parcel, + payload: ConceptInput, + *, + market_price_per_sqm: float | None = None, + price_source: str = "class_norm", +) -> list[ConceptVariant]: """Stage 1b entry: построить три варианта (max_area / max_insolation / balanced). Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают только варианты с реальными секциями. Если ни одна стратегия не легла (участок не вмещает даже самую компактную секцию), это вырожденный участок: поднимаем :class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ. + + ``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2) + прокидываются неизменными в каждую стратегию — цена едина для участка. """ variants = [ variant for spec in _STRATEGIES - if (variant := place_strategy(parcel, payload, spec)) is not None + if ( + variant := place_strategy( + parcel, + payload, + spec, + market_price_per_sqm=market_price_per_sqm, + price_source=price_source, + ) + ) + is not None ] if not variants: raise ParcelGeometryError( diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py index 16f59db2..9e22b941 100644 --- a/backend/tests/services/generative/test_api_concepts.py +++ b/backend/tests/services/generative/test_api_concepts.py @@ -7,10 +7,35 @@ degenerate parcel yields 422 rather than empty variants. from __future__ import annotations +from collections.abc import Iterator + +import pytest from fastapi.testclient import TestClient +from app.core.db import get_db from app.main import app + +class _NoDbSession: + """Заглушка Session: любой execute падает → market-price lookup деградирует в + class_norm без реальной БД. Тесты API не должны зависеть от живого Postgres.""" + + def execute(self, *args: object, **kwargs: object) -> object: + raise RuntimeError("no DB in unit test") + + +@pytest.fixture(autouse=True) +def _override_db() -> Iterator[None]: + """get_db → заглушка без БД на время API-тестов концепции (conftest чистит overrides).""" + + def _fake_get_db() -> Iterator[_NoDbSession]: + yield _NoDbSession() + + app.dependency_overrides[get_db] = _fake_get_db + yield + app.dependency_overrides.pop(get_db, None) + + _PARCEL = { "type": "Polygon", "coordinates": [ @@ -57,6 +82,10 @@ def test_concepts_returns_three_filled_variants() -> None: assert teap["parking_spaces"] > 0 assert fin["revenue_rub"] > 0 assert fin["cost_rub"] > 0 + # PR-2: без БД market-price lookup честно падает в норматив класса. + assert fin["price_is_calibrated"] is False + assert fin["price_source"] == "class_norm" + assert fin["price_per_sqm_used"] > 0 # GeoJSON застройки непустой. fc = v["buildings_geojson"] assert fc["type"] == "FeatureCollection" @@ -133,4 +162,8 @@ def test_concepts_response_matches_contract_keys() -> None: "roi", "margin_pct", "irr_is_proxy", + # price calibration (PR-2) + "price_per_sqm_used", + "price_is_calibrated", + "price_source", } diff --git a/backend/tests/services/generative/test_market_price_lookup.py b/backend/tests/services/generative/test_market_price_lookup.py new file mode 100644 index 00000000..0ec05908 --- /dev/null +++ b/backend/tests/services/generative/test_market_price_lookup.py @@ -0,0 +1,129 @@ +"""PR-2 (эпик #1881) — unit tests for the market-price lookup in the concept API. + +``_lookup_market_price`` is the only DB-touching piece of the price-calibration +chain (``compute_financial`` itself stays pure). We mock the SQLAlchemy session so +the tests assert the SELECTION LOGIC — objective median vs district reference vs +class norm — and the graceful-degradation contract (never raise), without a live DB. +""" + +from __future__ import annotations + +from typing import Any + +from app.api.v1 import concepts + + +class _Result: + """Мини-заглушка результата execute(): .mappings().first() → заданный row.""" + + def __init__(self, row: dict[str, Any] | None) -> None: + self._row = row + + def mappings(self) -> _Result: + return self + + def first(self) -> dict[str, Any] | None: + return self._row + + +class _FakeSession: + """Сессия, отдающая заранее заданные строки в порядке вызовов execute(). + + ``rows`` — список (row-dict | None | Exception). Exception → execute поднимает её + (моделируем сбой конкретного запроса). Порядок: [district_lookup, objective_median]. + """ + + def __init__(self, rows: list[dict[str, Any] | None | Exception]) -> None: + self._rows = list(rows) + self.calls = 0 + + def execute(self, *_args: object, **_kwargs: object) -> _Result: + item = self._rows[self.calls] if self.calls < len(self._rows) else None + self.calls += 1 + if isinstance(item, Exception): + raise item + return _Result(item) + + +_WKT = "POINT (60.6 56.83)" + + +def test_objective_median_selected_when_sample_large_enough() -> None: + # Район найден + Objective n>=10 → выбирается медиана Objective. + db = _FakeSession( + [ + {"district_name": "Кировский", "median_price_per_m2": 130_000}, + {"median_ppm2": 175_000.0, "sample_size": 42}, + ] + ) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price == 175_000.0 + assert source == "objective_district_median" + + +def test_district_reference_when_objective_sample_too_small() -> None: + # Район найден, но Objective n<10 → справочная медиана района. + db = _FakeSession( + [ + {"district_name": "Кировский", "median_price_per_m2": 130_000}, + {"median_ppm2": 175_000.0, "sample_size": 4}, + ] + ) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price == 130_000.0 + assert source == "district_reference" + + +def test_district_reference_when_no_objective_rows() -> None: + # Район найден, Objective вернул пусто (n=0/None) → справочная медиана. + db = _FakeSession( + [ + {"district_name": "Кировский", "median_price_per_m2": 128_500}, + {"median_ppm2": None, "sample_size": 0}, + ] + ) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price == 128_500.0 + assert source == "district_reference" + + +def test_class_norm_when_district_has_no_reference_and_no_objective() -> None: + # Район найден, но ни Objective (n<порог), ни справочной цены нет → class_norm. + db = _FakeSession( + [ + {"district_name": "Кировский", "median_price_per_m2": None}, + {"median_ppm2": None, "sample_size": 2}, + ] + ) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price is None + assert source == "class_norm" + + +def test_class_norm_when_parcel_outside_ekb() -> None: + # Центроид вне 8 полигонов ЕКБ (район не найден) → class_norm, без обращения к Objective. + db = _FakeSession([None]) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price is None + assert source == "class_norm" + + +def test_class_norm_when_district_query_raises() -> None: + # SQL-ошибка на district-lookup (нет PostGIS/таблицы) → graceful class_norm, без краха. + db = _FakeSession([RuntimeError("relation ekb_districts does not exist")]) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price is None + assert source == "class_norm" + + +def test_district_reference_when_objective_query_raises() -> None: + # District найден, но objective-median падает → откат на справочную цену района. + db = _FakeSession( + [ + {"district_name": "Кировский", "median_price_per_m2": 140_000}, + RuntimeError("objective_lots query failed"), + ] + ) + price, source = concepts._lookup_market_price(db, _WKT) # type: ignore[arg-type] + assert price == 140_000.0 + assert source == "district_reference" diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index d48a8222..97921410 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -187,3 +187,66 @@ def test_financial_backward_compat_fields_present() -> None: assert isinstance(model.cost_rub, float) assert isinstance(model.gross_margin_rub, float) assert isinstance(model.irr, float) + + +# ── PR-2: рыночная калибровка цены продажи жилья ─────────────────────────────── + + +def test_financial_no_market_price_uses_class_norm() -> None: + # Без market_price_per_sqm — норматив класса comfort (145_000), флаг не калибровано. + t = _teap(residential=1000.0, gfa=1300.0) + model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) + assert model.price_per_sqm_used == 145_000.0 + assert model.price_is_calibrated is False + assert model.price_source == "class_norm" + # Выручка жилья считается по нормативу. + assert model.revenue_residential_rub == 1000.0 * 145_000.0 + + +def test_financial_market_price_overrides_class_norm() -> None: + # market_price_per_sqm передан → используется он, флаг калибровано, source проброшен. + t = _teap(residential=1000.0, gfa=1300.0) + model = financial.compute_financial( + teap=t, + housing_class="comfort", + land_cost_rub=None, + market_price_per_sqm=180_000.0, + price_source="objective_district_median", + ) + assert model.price_per_sqm_used == 180_000.0 + assert model.price_is_calibrated is True + assert model.price_source == "objective_district_median" + # Выручка жилья считается по рыночной цене, НЕ по нормативу класса. + assert model.revenue_residential_rub == 1000.0 * 180_000.0 + + +def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None: + # Калибруем ТОЛЬКО цену жилья: паркинг (1.9М) и себестоимость СМР (88_000) — норма. + t = _teap(residential=1000.0, gfa=1300.0, parking=10) + model = financial.compute_financial( + teap=t, + housing_class="comfort", + land_cost_rub=None, + market_price_per_sqm=200_000.0, + price_source="district_reference", + ) + # Паркинг — норматив (10 * 1_900_000), не зависит от рыночной цены жилья. + assert model.revenue_parking_rub == 10 * 1_900_000.0 + # СМР — норматив класса comfort (GFA*88_000 + паркинг*1_800_000). + assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0 + + +def test_financial_none_market_price_forces_class_norm_source() -> None: + # Даже если source передан, при market_price_per_sqm=None он форсится в class_norm — + # нельзя выдавать норматив за рыночные данные. + t = _teap(residential=1000.0, gfa=1300.0) + model = financial.compute_financial( + teap=t, + housing_class="comfort", + land_cost_rub=None, + market_price_per_sqm=None, + price_source="objective_district_median", + ) + assert model.price_is_calibrated is False + assert model.price_source == "class_norm" + assert model.price_per_sqm_used == 145_000.0 diff --git a/frontend/src/components/concept/ConceptExportButtons.tsx b/frontend/src/components/concept/ConceptExportButtons.tsx index 1cd67405..93fdf761 100644 --- a/frontend/src/components/concept/ConceptExportButtons.tsx +++ b/frontend/src/components/concept/ConceptExportButtons.tsx @@ -19,7 +19,11 @@ import { Download, FileCode, FileText, Map as MapIcon } from "lucide-react"; import { API_BASE_URL } from "@/lib/api"; import { triggerDownload } from "@/lib/download"; -import { STRATEGY_LABELS, type ConceptVariant } from "@/lib/concept-api"; +import { + priceSourceCaption, + STRATEGY_LABELS, + type ConceptVariant, +} from "@/lib/concept-api"; // ── CSV helpers (mirrors site-finder ExportButtons) ─────────────────────────── @@ -54,6 +58,8 @@ function buildCsvRows(variant: ConceptVariant): string[][] { ["Затраты, ₽", String(f.cost_rub)], ["Валовая прибыль, ₽", String(f.gross_margin_rub)], ["IRR", String(f.irr)], + ["Цена продажи жилья, ₽/м²", String(f.price_per_sqm_used)], + ["Источник цены", priceSourceCaption(f)], ]; } diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 6e5429bc..28428785 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -13,6 +13,7 @@ import { KpiCard } from "@/components/analytics/KpiCard"; import { Section } from "@/components/analytics/Section"; import { Badge } from "@/components/ui/Badge"; import { + priceSourceCaption, STRATEGY_HINTS, STRATEGY_LABELS, type ConceptVariant, @@ -222,6 +223,14 @@ function VariantPanel({ parcel, variant }: PanelProps) { positive: null, }} /> + {/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */} @@ -252,8 +261,12 @@ function VariantPanel({ parcel, variant }: PanelProps) { {financial.irr_is_proxy ? "— оценочный (аннуализированный чистый ROI), не настоящий IRR." : "."}{" "} - Цены и себестоимость — рыночные ориентиры, не калиброванная - модель. Коммерческие и офисные площади не учитываются. + Цена продажи жилья —{" "} + {financial.price_is_calibrated + ? `калибрована по рынку (${priceSourceCaption(financial)})` + : "норматив класса (нет рыночных данных по участку)"} + ; себестоимость СМР и цена паркинга — нормативные ориентиры. + Коммерческие и офисные площади не учитываются.

diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 5f5f8a02..03945257 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -19,7 +19,10 @@ export interface paths { * * 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. + * 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. @@ -3540,6 +3543,18 @@ export interface components { * @default true */ irr_is_proxy: boolean; + /** Price Per Sqm Used */ + price_per_sqm_used: number; + /** + * Price Is Calibrated + * @default false + */ + price_is_calibrated: boolean; + /** + * Price Source + * @default class_norm + */ + price_source: string; }; /** * GroundedIn diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index f1ca6257..8bcd9a05 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -74,8 +74,20 @@ export interface FinancialModel { roi: number; margin_pct: number; irr_is_proxy: boolean; + // Price calibration (PR-2, эпик #1881) + /** Цена продажи жилья, ₽/м², фактически использованная в выручке. */ + price_per_sqm_used: number; + /** true — калибровано по рынку; false — норматив класса. */ + price_is_calibrated: boolean; + /** "objective_district_median" | "district_reference" | "class_norm". */ + price_source: PriceSource; } +export type PriceSource = + | "objective_district_median" + | "district_reference" + | "class_norm"; + export interface ConceptVariant { strategy: ConceptStrategy; /** FeatureCollection полигонов зданий (WGS84). */ @@ -114,6 +126,22 @@ export const DEVELOPMENT_TYPE_LABELS: Record = { high_rise: "Высотная", }; +/** + * Honest caption for the sale price source (PR-2). Never passes a class norm off + * as a market figure — when the price is not calibrated the caption says so. + */ +export function priceSourceCaption(financial: FinancialModel): string { + switch (financial.price_source) { + case "objective_district_median": + return "рынок: медиана объявлений Objective по району"; + case "district_reference": + return "рынок: справочная медиана района (нет свежей выборки Objective)"; + case "class_norm": + default: + return "норматив класса (нет рыночных данных по участку)"; + } +} + // ── Hook: useCreateConcept ──────────────────────────────────────────────────── /**