feat(site-finder): оптимизатор программ — «Как участок сходится» при отрицательном вердикте (#2181) (#2222)
All checks were successful
All checks were successful
This commit is contained in:
parent
ac19c4f7b3
commit
ae483468c9
7 changed files with 940 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
229
backend/app/services/site_finder/program_optimizer.py
Normal file
229
backend/app/services/site_finder/program_optimizer.py
Normal file
|
|
@ -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"]
|
||||
236
backend/tests/services/site_finder/test_program_optimizer.py
Normal file
236
backend/tests/services/site_finder/test_program_optimizer.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""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 одинакова);
|
||||
• сбой одного комбо не валит грид;
|
||||
• полный вызов быстрый (грубый порог 1 с — защита от деградации на порядки).
|
||||
|
||||
Детерминированно, без 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_fast_enough_for_sync_request() -> None:
|
||||
"""Полный грид достаточно быстр для синхронного вызова в теле запроса.
|
||||
|
||||
Локально 12 комбо ≈ 15 мс; порог 1000 мс намеренно грубый — ловим
|
||||
деградацию НА ПОРЯДКИ (случайный I/O, БД, квадратичный взрыв), а не
|
||||
миллисекунды: на нагруженном shared CI-раннере тот же вызов занимал
|
||||
199 мс и валил жёсткий порог 100 мс (flaky, run 3917).
|
||||
"""
|
||||
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 < 1000.0, f"optimize_program took {elapsed_ms:.1f} ms (>1000 ms)"
|
||||
|
|
@ -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",
|
||||
}}
|
||||
>
|
||||
<AnalysisSidebarNav />
|
||||
<AnalysisSidebarNav
|
||||
hasAlternatives={
|
||||
(analysis.program_alternatives?.variants?.length ?? 0) > 0
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* ── Main scroll area ──────────────────────────────────────────── */}
|
||||
|
|
@ -222,6 +227,12 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
Seeded из анализа (geom + financial_estimate), считается по
|
||||
запросу; result lazy-mounted (Leaflet). */}
|
||||
<Section7Concept analysis={analysis} />
|
||||
|
||||
{/* «Как участок сходится» (#2181) — авто-ранжир вариантов программы
|
||||
(класс × этажность) по NPV при отрицательном фин-вердикте. Backend
|
||||
кладёт program_alternatives только тогда; иначе блок не рендерится.
|
||||
Не дубль §7: без карт/3D, статичный топ-3. */}
|
||||
<SectionAlternatives alternatives={analysis.program_alternatives} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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<string>(TOP_LEVEL_IDS[0]);
|
||||
const [activeId, setActiveId] = useState<string>(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 (
|
||||
<nav
|
||||
|
|
@ -488,7 +528,7 @@ function AnalysisSidebarNav() {
|
|||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{NAV_GROUPS.map((group, gi) => (
|
||||
{navGroups.map((group, gi) => (
|
||||
<div key={group.label}>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* SectionAlternatives — «Как участок сходится» (#2181).
|
||||
*
|
||||
* Компактный автоматический ранжир вариантов программы застройки
|
||||
* (класс × этажность) по NPV. Backend кладёт `program_alternatives` в ответ
|
||||
* /analyze ТОЛЬКО при отрицательном базовом фин-вердикте (program_optimizer.py);
|
||||
* на позитивных участках поле null/отсутствует и блок НЕ рендерится вовсе.
|
||||
*
|
||||
* Это НЕ дубль §7 «Концепция»: там ручная интерактивная концепция с картой/3D,
|
||||
* здесь — статичный топ-3 без геометрии, отвечающий на вопрос «а вообще эта
|
||||
* земля сходится при какой-нибудь программе?». Оценка по МАКСИМАЛЬНОЙ застройке
|
||||
* зоны (синтез ТЭП из предельных параметров), НЕ инвест-рекомендация — тон
|
||||
* advisory, честный caveat из ответа показываем полным предложением.
|
||||
*/
|
||||
|
||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import {
|
||||
DEVELOPMENT_TYPE_LABELS,
|
||||
HOUSING_CLASS_LABELS,
|
||||
type DevelopmentType,
|
||||
type HousingClass,
|
||||
} from "@/lib/concept-api";
|
||||
import type { ProgramAlternatives, ProgramVariant } from "@/types/site-finder";
|
||||
|
||||
const SCROLL_MARGIN = 72;
|
||||
const TOP_N = 3;
|
||||
|
||||
// ── Formatters (ru microcopy, Unicode-минус) ──────────────────────────────────
|
||||
|
||||
const MINUS = "−"; // U+2212 — типографский минус, не hyphen-minus.
|
||||
|
||||
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||
|
||||
/**
|
||||
* Деньги округлением до млн со знаком: «+42 млн ₽» / «−216 млн ₽». Ниже 1 млн —
|
||||
* точная сумма (редко, но честно). Знак «+» показываем только для положительных.
|
||||
*/
|
||||
function formatMoneySigned(rub: number): string {
|
||||
const abs = Math.abs(rub);
|
||||
const sign = rub > 0 ? "+" : rub < 0 ? MINUS : "";
|
||||
if (abs >= 1e9) return `${sign}${(abs / 1e9).toFixed(1)} млрд ₽`;
|
||||
if (abs >= 1e6) return `${sign}${Math.round(abs / 1e6)} млн ₽`;
|
||||
return `${sign}${nf.format(Math.round(abs))} ₽`;
|
||||
}
|
||||
|
||||
/** ₽/м² с пробелами-разрядами: «142 500 ₽/м²». */
|
||||
function formatPricePerSqm(rub: number): string {
|
||||
return `${nf.format(Math.round(rub))} ₽/м²`;
|
||||
}
|
||||
|
||||
/** Проценты доли: 0.184 → «18.4%». Отрицательные — с Unicode-минусом. */
|
||||
function formatPct(fraction: number): string {
|
||||
const v = (fraction * 100).toFixed(1);
|
||||
return v.startsWith("-") ? `${MINUS}${v.slice(1)}%` : `${v}%`;
|
||||
}
|
||||
|
||||
function formatInt(n: number): string {
|
||||
return nf.format(Math.round(n));
|
||||
}
|
||||
|
||||
// ── Narrowing (backend отдаёт класс/тип/источник строкой, extra=allow) ─────────
|
||||
|
||||
const HOUSING_CLASSES: readonly HousingClass[] = [
|
||||
"econom",
|
||||
"comfort",
|
||||
"business",
|
||||
];
|
||||
const DEVELOPMENT_TYPES: readonly DevelopmentType[] = [
|
||||
"spot",
|
||||
"mid_rise",
|
||||
"high_rise",
|
||||
];
|
||||
function housingClassLabel(value: string): string {
|
||||
return (HOUSING_CLASSES as readonly string[]).includes(value)
|
||||
? HOUSING_CLASS_LABELS[value as HousingClass]
|
||||
: value;
|
||||
}
|
||||
|
||||
function developmentTypeLabel(value: string): string {
|
||||
return (DEVELOPMENT_TYPES as readonly string[]).includes(value)
|
||||
? DEVELOPMENT_TYPE_LABELS[value as DevelopmentType]
|
||||
: value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Честная подпись источника цены (тот же набор, что priceSourceCaption в
|
||||
* concept-api + objective_geo_radius из parcel_financial). Вариант несёт
|
||||
* price_source строкой, а не FinancialModel-объект, поэтому берём каноничные
|
||||
* фразы по значению — норматив класса НИКОГДА не выдаётся за рыночную цену,
|
||||
* и наоборот: рыночный источник не должен падать в норматив-фразу (инверсия
|
||||
* честности; неизвестный источник показываем как есть, не как норматив).
|
||||
*/
|
||||
function priceSourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case "objective_district_median":
|
||||
return "рынок: медиана объявлений Objective по району";
|
||||
case "objective_geo_radius":
|
||||
return "рынок: медиана новостроек в радиусе 3 км";
|
||||
case "district_reference":
|
||||
return "рынок: справочная медиана района";
|
||||
case "class_norm":
|
||||
return "норматив класса (нет рыночных данных по участку)";
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
/** program_alternatives из ответа /analyze (null/пусто → блок не рендерится). */
|
||||
alternatives?: ProgramAlternatives | null;
|
||||
}
|
||||
|
||||
// ── Variant card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function VariantCard({
|
||||
variant,
|
||||
isBest,
|
||||
}: {
|
||||
variant: ProgramVariant;
|
||||
isBest: boolean;
|
||||
}) {
|
||||
const npvPositive = variant.npv_rub > 0;
|
||||
const npvColor = npvPositive
|
||||
? "var(--success)"
|
||||
: variant.npv_rub < 0
|
||||
? "var(--danger)"
|
||||
: "var(--fg-primary)";
|
||||
|
||||
const title = `${housingClassLabel(variant.housing_class)} · ${variant.floors} эт · ${developmentTypeLabel(variant.development_type)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-card)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
padding: "16px 18px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* Титул + бейдж «лучший» */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{isBest && (
|
||||
<Badge variant={npvPositive ? "success" : "neutral"} size="sm">
|
||||
лучший
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NPV — крупно */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
NPV
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: npvColor,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{formatMoneySigned(variant.npv_rub)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ROI / IRR строкой */}
|
||||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||
ROI {formatPct(variant.roi)} · IRR {formatPct(variant.irr)}
|
||||
</div>
|
||||
|
||||
{/* Жилая S · квартиры */}
|
||||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||
Жилая {formatInt(variant.residential_sqm)} м² ·{" "}
|
||||
{formatInt(variant.apartments_count)} квартир
|
||||
</div>
|
||||
|
||||
{/* Цена м² + источник */}
|
||||
<div style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||||
Цена {formatPricePerSqm(variant.price_per_sqm_used)} (
|
||||
{priceSourceLabel(variant.price_source)})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SectionAlternatives ───────────────────────────────────────────────────────
|
||||
|
||||
export function SectionAlternatives({ alternatives }: Props) {
|
||||
// Рендер-guard: нет поля / пустой список → блок не рендерится вовсе.
|
||||
const variants = alternatives?.variants ?? [];
|
||||
if (!alternatives || variants.length === 0) return null;
|
||||
|
||||
const topVariants = variants.slice(0, TOP_N);
|
||||
const { any_viable, grid_size, caveat } = alternatives;
|
||||
|
||||
const subtitle = any_viable
|
||||
? "Базовая оценка отрицательна, но участок сходится при другой программе — топ вариантов по NPV:"
|
||||
: `При текущих вводных ни один из ${grid_size} вариантов программы (класс × этажность) не выходит в плюс. Ниже — наименее убыточные:`;
|
||||
|
||||
// «Лучший» = вариант с максимальным NPV. Список уже отсортирован по NPV убыв.
|
||||
// на backend, но берём индекс явно (устойчиво к любому порядку прихода).
|
||||
let bestIndex = 0;
|
||||
for (let i = 1; i < topVariants.length; i++) {
|
||||
if (topVariants[i].npv_rub > topVariants[bestIndex].npv_rub) bestIndex = i;
|
||||
}
|
||||
|
||||
// Honest-caveat одним предложением: caveat ответа + допущение по графику, если
|
||||
// хоть один показанный вариант считался по типовому графику строительства.
|
||||
const scheduleIsDefault = topVariants.some((v) => v.schedule_is_default);
|
||||
const caveatText = scheduleIsDefault
|
||||
? `${caveat} График строительства — типовое допущение, не график проекта.`
|
||||
: caveat;
|
||||
|
||||
return (
|
||||
<section
|
||||
id="section-alternatives"
|
||||
style={{ scrollMarginTop: SCROLL_MARGIN }}
|
||||
>
|
||||
<HeadlineBar title="Как участок сходится" subtitle={subtitle} />
|
||||
|
||||
{/* Карточки топ-3 — auto-fit грид как KPI-ряды. */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{topVariants.map((variant, i) => (
|
||||
<VariantCard
|
||||
key={`${variant.housing_class}-${variant.floors}-${variant.development_type}`}
|
||||
variant={variant}
|
||||
isBest={i === bestIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Один честный caveat — полным предложением, --fg-tertiary. */}
|
||||
<p
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
fontSize: 12,
|
||||
lineHeight: "18px",
|
||||
color: "var(--fg-tertiary)",
|
||||
}}
|
||||
>
|
||||
{caveatText}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -546,6 +546,53 @@ export interface ParcelAnalysis {
|
|||
// Эпик #1881 PR-4 — финмодель из /analyze (extra=allow на backend, hand-typed
|
||||
// здесь). null когда нельзя строить МКД / нет зонинга / нет площади.
|
||||
financial_estimate?: ParcelFinancialEstimate | null;
|
||||
// #2181 — топ-N вариантов программы (класс × этажность) по NPV. Backend кладёт
|
||||
// это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py);
|
||||
// на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend).
|
||||
program_alternatives?: ProgramAlternatives | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Один вариант программы застройки (класс × этажность), посчитанный
|
||||
* program_optimizer.optimize_program (#2181) через ту же compute_financial, что
|
||||
* и основной financial_estimate. Класс/тип застройки приходят строкой (backend
|
||||
* extra=allow) — на UI сужаются к HOUSING_CLASS_LABELS / DEVELOPMENT_TYPE_LABELS.
|
||||
*/
|
||||
export interface ProgramVariant {
|
||||
// "econom" | "comfort" | "business" (Literal HousingClass на backend).
|
||||
housing_class: string;
|
||||
floors: number;
|
||||
// "spot" | "mid_rise" | "high_rise" (по этажности комбо).
|
||||
development_type: string;
|
||||
// NPV дисконтированного cashflow, ₽ (ключ ранжирования; может быть отрицательным).
|
||||
npv_rub: number;
|
||||
irr: number;
|
||||
roi: number;
|
||||
net_profit_rub: number;
|
||||
residential_sqm: number;
|
||||
apartments_count: number;
|
||||
// Цена продажи жилья, ₽/м², фактически использованная в выручке комбо.
|
||||
price_per_sqm_used: number;
|
||||
// "objective_district_median" | "district_reference" | "class_norm" — тот же
|
||||
// honest-набор источников, что у financial_estimate.price_source.
|
||||
price_source: string;
|
||||
// true — график фаз/продаж типовой (допущение), не график проекта.
|
||||
schedule_is_default: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок «Как участок сходится» (#2181) — ранжир вариантов программы. Присутствует
|
||||
* только при отрицательном базовом фин-вердикте; иначе null / отсутствует.
|
||||
*/
|
||||
export interface ProgramAlternatives {
|
||||
// Топ-N вариантов по NPV убыв. (тай-брейк ROI). При all-negative — «наименее убыточные».
|
||||
variants: ProgramVariant[];
|
||||
// true — хотя бы один вариант с NPV > 0 (участок сходится при другой программе).
|
||||
any_viable: boolean;
|
||||
// Сколько комбо (класс × этажность) реально посчиталось в переборе.
|
||||
grid_size: number;
|
||||
// HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке зоны).
|
||||
caveat: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue