diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 7b7489d5..601b834b 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -77,12 +77,22 @@ from app.services.site_finder.osrm_client_local import ( OsrmLocalUnavailableError, get_road_distances_m, ) -from app.services.site_finder.parcel_financial import synthesize_parcel_financial +from app.services.site_finder.parcel_financial import ( + _as_float as _fin_as_float, +) +from app.services.site_finder.parcel_financial import ( + _as_int as _fin_as_int, +) +from app.services.site_finder.parcel_financial import ( + select_calibrated_price, + synthesize_parcel_financial, +) from app.services.site_finder.poi_score import ( PoiScoreResponse, compute_poi_routing_decay, compute_poi_weighted_top7, ) +from app.services.site_finder.program_optimizer import optimize_program from app.services.site_finder.quarter_dump_lookup import ( get_connection_points, get_quarter_dump_data, @@ -3533,6 +3543,40 @@ def analyze_parcel( logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e) financial_estimate = None + # #2181: оптимизатор программы — при ОТРИЦАТЕЛЬНОМ фин-вердикте (NPV<0 или ROI<0) + # честно перебираем сетку (класс × этажность) той же compute_financial и отдаём топ-3 + # варианта + флаг any_viable (есть ли ВООБЩЕ плюс на этом участке). Позитивные отчёты + # не засоряем (program_alternatives=None). Быстро (≈15 мс/12 комбо) → синхронно, без + # Celery. Тот же «пояс безопасности», что у financial_estimate: любой сбой → None, + # analyze НЕ падает. Единая откалиброванная цена во все комбо (DRY через + # select_calibrated_price) — иначе business ложно выиграет за счёт норматива 210k. + program_alternatives: dict[str, Any] | None = None + try: + _fin_negative = financial_estimate is not None and ( + (_fin_as_float(financial_estimate.get("npv_rub")) or 0.0) < 0 + or (_fin_as_float(financial_estimate.get("roi")) or 0.0) < 0 + ) + if _fin_negative: + _opt_price, _opt_price_source = select_calibrated_price( + market_price=market_price, + geo_radius_price_median=geo_radius_price.get("median"), + district_price_median=district_price_block["district_price_per_m2_median"], + ) + _opt_zoning = nspd_dump_data["nspd_zoning"] or {} + program_alternatives = optimize_program( + area_m2=_fin_area_m2, + max_far=_fin_as_float(_opt_zoning.get("max_far")), + max_building_pct=_fin_as_float(_opt_zoning.get("max_building_pct")), + max_floors_allowed=_fin_as_int(_opt_zoning.get("max_floors")), + land_cost_rub=_fin_cad_value, + market_price_per_sqm=_opt_price, + price_source=_opt_price_source, + velocity_sqm_per_month=_velocity_absorption, + ) + except Exception: + logger.exception("program_optimizer failed for %s", cad_num) + program_alternatives = None + # #1960: карточка «Медиана рынка» (district.median_price_per_m2) должна быть # КОНСИСТЕНТНА с остальной страницей, построенной на NEWBUILD Objective/DOM.РФ # basis (geo_radius_price.median, market_avg ≈197k), а не на загрязнённой @@ -3692,6 +3736,10 @@ def analyze_parcel( # застройке зоны (синтез ТЭП из buildability). dict|None (None когда gate # против / нет чисел зоны / нет площади). caveat внутри. См. parcel_financial. "financial_estimate": financial_estimate, + # #2181: при ОТРИЦАТЕЛЬНОМ фин-вердикте — топ-3 варианта программы (класс × + # этажность) по NPV + флаг any_viable (существует ли ВООБЩЕ плюс). dict|None + # (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри. + "program_alternatives": program_alternatives, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py index 03f9d8f3..0e207ca4 100644 --- a/backend/app/services/site_finder/parcel_financial.py +++ b/backend/app/services/site_finder/parcel_financial.py @@ -87,6 +87,37 @@ def _infer_development_type(max_floors: int | None) -> str: return "high_rise" +def select_calibrated_price( + *, + market_price: dict[str, Any] | None, + geo_radius_price_median: float | None, + district_price_median: float | None, +) -> tuple[float | None, str]: + """Выбрать откалиброванную цену продажи жилья + её источник (single source of truth). + + Приоритет от точного рынка к нормативу (совпадает с исходной логикой + :func:`synthesize_parcel_financial`, вынесен сюда для переиспользования оптимизатором + программы, чтобы выбор цены не разошёлся): + + 1. ``market_price.median`` — квартальная MV перепродаж → ``objective_district_median``. + 2. ``geo_radius_price_median`` — медиана новостроек Objective в гео-радиусе → + ``objective_geo_radius``. + 3. ``district_price_median`` — name-match медиана админ-района → ``district_reference``. + 4. ничего из рынка → ``(None, "class_norm")`` — caller использует норматив класса. + + Returns: + ``(price, price_source)`` — ``price`` может быть ``None`` (тогда source + ``"class_norm"``), что сигналит caller-у про fallback на норматив класса. + """ + if market_price and _as_float(market_price.get("median")) is not None: + return _as_float(market_price.get("median")), "objective_district_median" + if _as_float(geo_radius_price_median) is not None: + return _as_float(geo_radius_price_median), "objective_geo_radius" + if district_price_median is not None: + return float(district_price_median), "district_reference" + return None, "class_norm" + + def synthesize_teap_from_buildability( *, area_m2: float | None, @@ -238,18 +269,13 @@ def synthesize_parcel_financial( # ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─ # Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV - # и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ. - price: float | None = None - price_source = "class_norm" - if market_price and _as_float(market_price.get("median")) is not None: - price = _as_float(market_price.get("median")) - price_source = "objective_district_median" - elif _as_float(geo_radius_price_median) is not None: - price = _as_float(geo_radius_price_median) - price_source = "objective_geo_radius" - elif district_price_median is not None: - price = float(district_price_median) - price_source = "district_reference" + # и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ. Логика + # вынесена в общий helper (single-source-of-truth с program_optimizer). + price, price_source = select_calibrated_price( + market_price=market_price, + geo_radius_price_median=geo_radius_price_median, + district_price_median=district_price_median, + ) housing_class = _infer_housing_class(price) development_type = _infer_development_type(max_floors) @@ -331,6 +357,7 @@ def _as_int(value: Any) -> int | None: __all__ = [ + "select_calibrated_price", "synthesize_parcel_financial", "synthesize_teap_from_buildability", ] diff --git a/backend/app/services/site_finder/program_optimizer.py b/backend/app/services/site_finder/program_optimizer.py new file mode 100644 index 00000000..0f631e9d --- /dev/null +++ b/backend/app/services/site_finder/program_optimizer.py @@ -0,0 +1,229 @@ +"""Оптимизатор программы застройки — топ-N вариантов (класс × этажность) по NPV (#2181). + +Когда финмодель участка (``financial_estimate`` в ``/analyze``) даёт ОТРИЦАТЕЛЬНЫЙ +вердикт (NPV < 0 или ROI < 0), девелоперу мало «не выгодно» — важно честно показать, +существует ли ВООБЩЕ прибыльная раскладка на этом участке при других параметрах +(класс жилья + этажность), и если нет — какие варианты «наименее убыточные». + +Этот модуль перебирает небольшую сетку (класс × этажность), прогоняет каждый комбо +через ту же :func:`compute_financial`, что и основная финмодель, и возвращает топ-N по +NPV. Всё детерминированно и **быстро** (≈15 мс на 12 комбо → считается СИНХРОННО в теле +запроса, без Celery), и **чисто** — без БД / LLM / внешних API. Данные участка +(площадь, предельные параметры зоны, откалиброванная цена, стоимость земли, абсорбция) +прокидывает caller — ровно те же входы, что использованы для основного +``financial_estimate`` (см. ``synthesize_parcel_financial``). + +КРИТИЧНО — ЕДИНАЯ ЦЕНА ВО ВСЕХ КОМБО +------------------------------------ +Когда рыночная цена известна, ОДНА И ТА ЖЕ откалиброванная ``market_price_per_sqm`` идёт +во все комбо (класс двигает ТОЛЬКО себестоимость/эффективность/паркинг/ср.лот, но НЕ +цену продажи). Иначе «business» ложно выиграл бы за счёт норматива продажи 210k против +110k у эконома — сравнивали бы цены, а не экономику застройки. Норматив класса +(``_SALE_PRICE_PER_SQM``) применяется ТОЛЬКО когда рынка нет — то же поведение, что в +:func:`synthesize_parcel_financial`. + +HONEST CAVEAT +------------- +Как и основная финмодель — это оценка по МАКСИМАЛЬНОЙ застройке зоны (синтез ТЭП из +предельных параметров градрегламента), а НЕ по реальной проектной раскладке. Caveat +(``_FINANCIAL_CAVEAT``) кладётся в результат и обязан показываться в UI/PDF. +""" + +from __future__ import annotations + +import logging +from typing import Any, get_args + +from app.services.generative.financial import HousingClass, compute_financial +from app.services.site_finder.parcel_financial import ( + _FINANCIAL_CAVEAT, + _infer_development_type, + synthesize_teap_from_buildability, +) + +logger = logging.getLogger(__name__) + +# Классы жилья для перебора — весь набор (econom / comfort / business). Берём из +# Literal-типа HousingClass, чтобы сетка не разошлась с финмоделью при добавлении класса. +_CLASSES_GRID: tuple[HousingClass, ...] = get_args(HousingClass) + +# Опорная сетка этажности (кламп по max_floors_allowed зоны). Значения — типовые +# высотные пороги РФ (5 — среднеэтажка, 9 — «хрущёвочный» потолок, 14 — высотка, +# 25 — башня); двигают GFA и development_type (порог spot/mid_rise/high_rise в +# _infer_development_type → длительность СМР в DCF-графике). +_FLOORS_GRID: tuple[int, ...] = (5, 9, 14, 25) + +_DEFAULT_TOP_N: int = 3 + + +def _build_floors_grid(max_floors_allowed: int | None) -> list[int]: + """Сетка этажности, клампнутая по предельной этажности зоны. + + * ``max_floors_allowed`` не задан (``None``) → весь опорный набор (зона неизвестна, + перебираем всё). + * задан → оставляем только этажи ``<= max_floors_allowed``. + * после клампа пусто (предел ниже минимального опорного, напр. 3) → берём сам + ``max_floors_allowed`` (единственная физически допустимая этажность). + """ + if max_floors_allowed is None: + return list(_FLOORS_GRID) + clamped = [f for f in _FLOORS_GRID if f <= max_floors_allowed] + if not clamped: + # Предел ниже минимального опорного (напр. 3 этажа) — считаем по самому пределу. + return [max_floors_allowed] + return clamped + + +def optimize_program( + *, + area_m2: float | None, + max_far: float | None, + max_building_pct: float | None, + max_floors_allowed: int | None, + land_cost_rub: float | None, + market_price_per_sqm: float | None, + price_source: str, + velocity_sqm_per_month: float | None, + top_n: int = _DEFAULT_TOP_N, +) -> dict[str, Any] | None: + """Перебрать сетку (класс × этажность) → топ-N вариантов программы по NPV. + + Прогоняет каждый комбо через :func:`compute_financial` (та же финмодель, что и + основной ``financial_estimate``) и ранжирует по NPV убыв. (тай-брейк — ROI убыв.). + + Args: + area_m2: площадь участка, кв.м. ``None``/<=0 → ``None`` (нечего считать). + max_far: КСИТ/max_far зоны (руб/кв.м GFA). Задаёт GFA при синтезе ТЭП. Если + ``None``, GFA выводится из ``max_building_pct × floors`` (по каждому комбо + своя этажность из сетки). + max_building_pct: % застройки зоны. Нужен вместе с этажностью, если нет ``max_far``. + max_floors_allowed: предельная этажность зоны — кламп сетки этажности. ``None`` → + весь опорный набор. + land_cost_rub: стоимость участка (обычно кадастровая) → land_cost. ``None`` → 0. + market_price_per_sqm: ЕДИНАЯ откалиброванная рыночная цена продажи жилья во ВСЕ + комбо. ``None`` → каждый комбо использует норматив своего класса + (``_SALE_PRICE_PER_SQM``) — то же поведение, что в основной финмодели. + price_source: honest-метка источника цены (из ``select_calibrated_price``). + Игнорируется финмоделью, если ``market_price_per_sqm is None`` (форсится + ``class_norm``). + velocity_sqm_per_month: темп абсорбции района (м²/мес) — окно продаж DCF. ``None`` + → дефолт-30-мес график (``schedule_is_default``). + top_n: сколько лучших вариантов вернуть (по умолчанию 3). + + Returns: + ``dict`` с ключами ``variants`` (список топ-N), ``any_viable`` (хоть один вариант + с NPV > 0), ``grid_size`` (сколько комбо реально посчиталось), ``caveat``. Либо + ``None``, если считать нельзя (нет площади / ни один комбо не дал ТЭП). + """ + if area_m2 is None or area_m2 <= 0: + return None + + floors_grid = _build_floors_grid(max_floors_allowed) + + variants: list[dict[str, Any]] = [] + for housing_class in _CLASSES_GRID: + for floors in floors_grid: + variant = _evaluate_combo( + housing_class=housing_class, + floors=floors, + area_m2=area_m2, + max_far=max_far, + max_building_pct=max_building_pct, + land_cost_rub=land_cost_rub, + market_price_per_sqm=market_price_per_sqm, + price_source=price_source, + velocity_sqm_per_month=velocity_sqm_per_month, + ) + if variant is not None: + variants.append(variant) + + if not variants: + # Ни один комбо не дал ТЭП (нет ни far, ни pct+floors) — считать нечего. + return None + + # Ранжир по NPV убыв.; тай-брейк — ROI убыв. (при равном NPV предпочитаем + # эффективность капитала). При all-negative топ-N — «наименее убыточные». + variants.sort(key=lambda v: (v["npv_rub"], v["roi"]), reverse=True) + + any_viable = any(v["npv_rub"] > 0 for v in variants) + + return { + "variants": variants[:top_n], + "any_viable": any_viable, + "grid_size": len(variants), + # HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке + # зоны, НЕ по реальной раскладке). Обязан показываться в UI/PDF. + "caveat": _FINANCIAL_CAVEAT, + } + + +def _evaluate_combo( + *, + housing_class: HousingClass, + floors: int, + area_m2: float, + max_far: float | None, + max_building_pct: float | None, + land_cost_rub: float | None, + market_price_per_sqm: float | None, + price_source: str, + velocity_sqm_per_month: float | None, +) -> dict[str, Any] | None: + """Посчитать один комбо (класс × этажность) → компактный вариант программы. + + Сбой одного комбо (например, ТЭП не вывелся) НЕ валит весь грид — логируется и + возвращается ``None`` (caller скипает). Это тот же «пояс безопасности», что у + ``financial_estimate`` в analyze-эндпоинте. + """ + try: + # development_type по ЭТАЖНОСТИ комбо (не по зоне) — этаж двигает график СМР в DCF. + development_type = _infer_development_type(floors) + + teap = synthesize_teap_from_buildability( + area_m2=area_m2, + max_far=max_far, + max_building_pct=max_building_pct, + max_floors=floors, + housing_class=housing_class, + ) + if teap is None: + return None + + fin = compute_financial( + teap=teap, + housing_class=housing_class, + land_cost_rub=land_cost_rub, + # ЕДИНАЯ цена во все комбо (когда рынок есть) — класс НЕ двигает цену продажи. + market_price_per_sqm=market_price_per_sqm, + price_source=price_source, + development_type=development_type, + market_velocity_sqm_per_month=velocity_sqm_per_month, + ) + + return { + "housing_class": housing_class, + "floors": floors, + "development_type": development_type, + "npv_rub": fin.npv_rub, + "irr": fin.irr, + "roi": fin.roi, + "net_profit_rub": fin.net_profit_rub, + "residential_sqm": teap.residential_area_sqm, + "apartments_count": teap.apartments_count, + # honest-поля источника/графика цены — те же флаги, что в основной финмодели. + "price_per_sqm_used": fin.price_per_sqm_used, + "price_source": fin.price_source, + "schedule_is_default": fin.schedule_is_default, + } + except Exception: + # Один сбойный комбо не валит грид — логируем и скипаем (не глотаем молча). + logger.exception( + "program_optimizer combo failed: class=%s floors=%d area=%.0f", + housing_class, + floors, + area_m2, + ) + return None + + +__all__ = ["optimize_program"] diff --git a/backend/tests/services/site_finder/test_program_optimizer.py b/backend/tests/services/site_finder/test_program_optimizer.py new file mode 100644 index 00000000..0418b1c0 --- /dev/null +++ b/backend/tests/services/site_finder/test_program_optimizer.py @@ -0,0 +1,230 @@ +"""Unit-тесты оптимизатора программы застройки (#2181). + +Тестируем чистую функцию program_optimizer.optimize_program (без БД, без app.main): + • ранжир вариантов по NPV убыв. (тай-брейк ROI); + • any_viable=False когда все комбо в минусе — и всё равно топ-N «наименее убыточных»; + • кламп floors_grid по max_floors_allowed (10 → [5, 9]); + • ЕДИНАЯ откалиброванная цена во все комбо (price_per_sqm_used одинакова); + • сбой одного комбо не валит грид; + • полный вызов быстрый (<100 мс). + +Детерминированно, без LLM / БД. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import time +from unittest.mock import patch + +from app.services.site_finder.program_optimizer import ( + _build_floors_grid, + optimize_program, +) + + +def _big_profitable_kwargs() -> dict: + """Крупный участок + высокая цена → как минимум часть комбо прибыльна.""" + return { + "area_m2": 12_000.0, + "max_far": 3.0, + "max_building_pct": 40.0, + "max_floors_allowed": 25, + "land_cost_rub": 50_000_000.0, + "market_price_per_sqm": 180_000.0, + "price_source": "objective_geo_radius", + "velocity_sqm_per_month": 900.0, + } + + +# ── floors-grid кламп ───────────────────────────────────────────────────────── + + +def test_floors_grid_clamped_to_max_allowed() -> None: + """max_floors_allowed=10 → [5, 9] (14 и 25 отсечены).""" + assert _build_floors_grid(10) == [5, 9] + + +def test_floors_grid_none_returns_full_set() -> None: + """Предел неизвестен → весь опорный набор.""" + assert _build_floors_grid(None) == [5, 9, 14, 25] + + +def test_floors_grid_below_min_falls_back_to_limit() -> None: + """Предел ниже минимального опорного (3) → сам предел единственной этажностью.""" + assert _build_floors_grid(3) == [3] + + +def test_optimize_respects_floors_clamp() -> None: + """max_floors_allowed=10 → в вариантах только этажи из [5, 9].""" + kwargs = _big_profitable_kwargs() + kwargs["max_floors_allowed"] = 10 + result = optimize_program(**kwargs) + assert result is not None + floors_seen = {v["floors"] for v in result["variants"]} + assert floors_seen <= {5, 9} + # 3 класса × 2 этажа = 6 комбо всего в гриде. + assert result["grid_size"] == 6 + + +# ── ранжир по NPV ───────────────────────────────────────────────────────────── + + +def test_variants_sorted_by_npv_desc() -> None: + """Топ-N отсортирован по npv_rub убыв.""" + result = optimize_program(**_big_profitable_kwargs()) + assert result is not None + npvs = [v["npv_rub"] for v in result["variants"]] + assert npvs == sorted(npvs, reverse=True) + assert len(result["variants"]) == 3 # top_n=3 по умолчанию + + +def test_profitable_site_any_viable_true() -> None: + """Крупный участок + высокая цена → хотя бы один вариант в плюсе.""" + result = optimize_program(**_big_profitable_kwargs()) + assert result is not None + assert result["any_viable"] is True + assert any(v["npv_rub"] > 0 for v in result["variants"]) + + +# ── all-negative: any_viable=False, но топ-N всё равно есть ──────────────────── + + +def test_all_negative_any_viable_false_still_returns_top_n() -> None: + """Крошечный участок + мизерная цена → все в минусе, any_viable=False, топ-3 есть.""" + result = optimize_program( + area_m2=600.0, + max_far=1.5, + max_building_pct=30.0, + max_floors_allowed=9, + land_cost_rub=200_000_000.0, # неадекватно дорогая земля топит все комбо + market_price_per_sqm=40_000.0, # мизерная цена + price_source="objective_geo_radius", + velocity_sqm_per_month=100.0, + ) + assert result is not None + assert result["any_viable"] is False + assert all(v["npv_rub"] <= 0 for v in result["variants"]) + # «Наименее убыточные» — всё равно топ-3 и отсортированы по NPV убыв. + assert len(result["variants"]) == 3 + npvs = [v["npv_rub"] for v in result["variants"]] + assert npvs == sorted(npvs, reverse=True) + + +# ── ЕДИНАЯ цена во все комбо ─────────────────────────────────────────────────── + + +def test_fixed_price_across_all_combos() -> None: + """market_price_per_sqm един во всех комбо: price_per_sqm_used одинакова везде.""" + kwargs = _big_profitable_kwargs() + kwargs["market_price_per_sqm"] = 165_000.0 + result = optimize_program(**kwargs) + assert result is not None + prices = {v["price_per_sqm_used"] for v in result["variants"]} + assert prices == {165_000.0} + sources = {v["price_source"] for v in result["variants"]} + assert sources == {"objective_geo_radius"} + + +def test_no_market_price_uses_class_norm() -> None: + """Рынка нет → каждый комбо на нормативе своего класса (цены РАЗНЫЕ по классам).""" + kwargs = _big_profitable_kwargs() + kwargs["market_price_per_sqm"] = None + kwargs["price_source"] = "class_norm" + result = optimize_program(**kwargs) + assert result is not None + # Норматив зависит от класса → в гриде минимум 2 разных цены (эконом 110k vs бизнес 210k). + all_prices = {v["price_per_sqm_used"] for v in result["variants"]} + assert all(v["price_source"] == "class_norm" for v in result["variants"]) + assert len(all_prices) >= 1 # top-3 может оказаться одним классом; проверяем норму ниже + for v in result["variants"]: + assert v["price_per_sqm_used"] in (110_000.0, 145_000.0, 210_000.0) + + +# ── устойчивость: сбой одного комбо не валит грид ───────────────────────────── + + +def test_one_combo_failure_does_not_break_grid() -> None: + """compute_financial кидает на 'business' → остальные комбо считаются, грид не падает.""" + real_compute = None + from app.services.site_finder import program_optimizer as mod + + real_compute = mod.compute_financial + + def _flaky(*args, **kwargs): # type: ignore[no-untyped-def] + if kwargs.get("housing_class") == "business": + raise ValueError("boom") + return real_compute(*args, **kwargs) + + with patch.object(mod, "compute_financial", side_effect=_flaky): + result = optimize_program(**_big_profitable_kwargs()) + + assert result is not None + # business (× 4 этажа) выпал → в гриде остались только econom + comfort. + assert all(v["housing_class"] != "business" for v in result["variants"]) + assert result["grid_size"] > 0 # грид выжил + + +def test_no_area_returns_none() -> None: + """Нет площади → None (нечего считать).""" + kwargs = _big_profitable_kwargs() + kwargs["area_m2"] = None + assert optimize_program(**kwargs) is None + + +def test_no_zoning_numbers_returns_none() -> None: + """Ни far, ни (pct+floors) → ни один комбо не даёт ТЭП → None.""" + result = optimize_program( + area_m2=8000.0, + max_far=None, + max_building_pct=None, + max_floors_allowed=None, + land_cost_rub=0.0, + market_price_per_sqm=150_000.0, + price_source="objective_geo_radius", + velocity_sqm_per_month=None, + ) + assert result is None + + +# ── caveat / метаданные ─────────────────────────────────────────────────────── + + +def test_result_carries_caveat_and_metadata() -> None: + """Результат несёт honest-caveat + grid_size + структуру каждого варианта.""" + result = optimize_program(**_big_profitable_kwargs()) + assert result is not None + assert isinstance(result["caveat"], str) and result["caveat"] + assert result["grid_size"] == 12 # 3 класса × 4 этажа + v = result["variants"][0] + for key in ( + "housing_class", + "floors", + "development_type", + "npv_rub", + "irr", + "roi", + "net_profit_rub", + "residential_sqm", + "apartments_count", + "price_per_sqm_used", + "price_source", + "schedule_is_default", + ): + assert key in v + + +# ── производительность ──────────────────────────────────────────────────────── + + +def test_full_grid_under_100ms() -> None: + """Полный вызов (12 комбо) укладывается в <100 мс (синхронно в теле запроса).""" + kwargs = _big_profitable_kwargs() + start = time.perf_counter() + result = optimize_program(**kwargs) + elapsed_ms = (time.perf_counter() - start) * 1000.0 + assert result is not None + assert elapsed_ms < 100.0, f"optimize_program took {elapsed_ms:.1f} ms (>100 ms)" diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index fff7b7ee..5eae665b 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; @@ -14,6 +14,7 @@ import { Section4Estimate } from "@/components/site-finder/analysis/Section4Esti import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere"; import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; +import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives"; import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; import type { ParcelAnalysis } from "@/types/site-finder"; @@ -169,7 +170,11 @@ export function AnalysisPageContent({ cad }: Props) { alignSelf: "start", }} > - + 0 + } + /> {/* ── Main scroll area ──────────────────────────────────────────── */} @@ -222,6 +227,12 @@ export function AnalysisPageContent({ cad }: Props) { Seeded из анализа (geom + financial_estimate), считается по запросу; result lazy-mounted (Leaflet). */} + + {/* «Как участок сходится» (#2181) — авто-ранжир вариантов программы + (класс × этажность) по NPV при отрицательном фин-вердикте. Backend + кладёт program_alternatives только тогда; иначе блок не рендерится. + Не дубль §7: без карт/3D, статичный топ-3. */} + @@ -406,12 +417,23 @@ const NAV_GROUPS: NavGroup[] = [ }, ]; -// Top-level section anchors in scroll order — observed by the scrollspy. -// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit -// while the user is anywhere inside the section, which reads cleaner. -const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) => - g.items.map((i) => i.id), -); +// «Альтернативы» (#2181) — навигационный пункт для блока «Как участок сходится». +// Добавляется в конец группы «Стройка и рынок» ТОЛЬКО когда backend вернул +// program_alternatives (иначе якоря section-alternatives на странице нет). +const ALTERNATIVES_NAV_ITEM: NavItem = { + id: "section-alternatives", + label: "Альтернативы программы", +}; + +/** NAV_GROUPS с опциональным пунктом «Альтернативы» в хвосте (#2181). */ +function buildNavGroups(hasAlternatives: boolean): NavGroup[] { + if (!hasAlternatives) return NAV_GROUPS; + return NAV_GROUPS.map((g) => + g.label === "Стройка и рынок" + ? { ...g, items: [...g.items, ALTERNATIVES_NAV_ITEM] } + : g, + ); +} // Sticky chrome offset (header + breadcrumb). The jump lands the section title // just below this; the scrollspy uses the same line to pick the active section. @@ -429,12 +451,30 @@ function scrollToSection(id: string) { window.scrollTo({ top, behavior: "auto" }); } -function AnalysisSidebarNav() { +function AnalysisSidebarNav({ + hasAlternatives = false, +}: { + hasAlternatives?: boolean; +}) { + const navGroups = useMemo( + () => buildNavGroups(hasAlternatives), + [hasAlternatives], + ); + // Top-level section anchors in scroll order — observed by the scrollspy. + // Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit + // while the user is anywhere inside the section, which reads cleaner. Rebuilt + // only when «Альтернативы» (#2181) appears/disappears so the effect's dep is + // stable (not a fresh array each render). + const topLevelIds = useMemo( + () => navGroups.flatMap((g) => g.items.map((i) => i.id)), + [navGroups], + ); + // Scrollspy: the active section is the LAST one whose top has scrolled past the // offset line (deterministic, never "empty" — unlike a narrow IntersectionObserver // band, which left the last/at-bottom section un-lit). Default to the first so // something is lit before any scroll. - const [activeId, setActiveId] = useState(TOP_LEVEL_IDS[0]); + const [activeId, setActiveId] = useState(topLevelIds[0]); useEffect(() => { if (typeof window === "undefined") return; @@ -448,12 +488,12 @@ function AnalysisSidebarNav() { window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2; if (atBottom) { - setActiveId(TOP_LEVEL_IDS[TOP_LEVEL_IDS.length - 1]); + setActiveId(topLevelIds[topLevelIds.length - 1]); return; } // Otherwise: the last section whose top has passed the offset line. - let current = TOP_LEVEL_IDS[0]; - for (const id of TOP_LEVEL_IDS) { + let current = topLevelIds[0]; + for (const id of topLevelIds) { const el = document.getElementById(id); if (el && el.getBoundingClientRect().top - SCROLL_OFFSET <= 1) { current = id; @@ -474,7 +514,7 @@ function AnalysisSidebarNav() { window.removeEventListener("scroll", onScroll); if (raf) window.cancelAnimationFrame(raf); }; - }, []); + }, [topLevelIds]); return (