diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py
index c82b3fd1..96a2a154 100644
--- a/backend/app/services/exporters/full_report_html.py
+++ b/backend/app/services/exporters/full_report_html.py
@@ -35,10 +35,23 @@ import html
import logging
from typing import Any
+# §4–§7 нормализуют forecast-словарь (schema §22-форсайта "1.0" = SiteFinderReport.
+# as_dict()) теми же PURE-хелперами, что уже гоняет PDF-рендерер форсайта — импортируем,
+# НЕ копируем (ревью PR-A уже отметило дублирование). WeasyPrint внутри report_pdf
+# импортируется ЛОКАЛЬНО (в export_report_pdf), поэтому импорт модуля тут дешёвый и
+# native-libs не тянет. Алиасим под _fc_ («forecast»), чтобы не спутать с локальными
+# каркас-хелперами §1–§3 (у них своя семантика _fmt: округление ₽ до млн против 3 знаков).
+from app.services.exporters.report_pdf import _as_dict as _fc_as_dict
+from app.services.exporters.report_pdf import _future_supply_pairs as _fc_future_supply_pairs
+from app.services.exporters.report_pdf import _level_ru as _fc_level_ru
+from app.services.exporters.report_pdf import _normalize as _fc_normalize
+
logger = logging.getLogger(__name__)
# ── Плейсхолдер карты (подставится в PR-C) ─────────────────────────────────────
MAP_PARCEL_PLACEHOLDER = "{{MAP_PARCEL}}"
+# Плейсхолдер footprint-плана концепции §7 (реальная подстановка карты — PR-C).
+MAP_CONCEPT_PLACEHOLDER = "{{MAP_CONCEPT}}"
# ── Микрокопия / заглушки ──────────────────────────────────────────────────────
_DASH = "—"
@@ -57,6 +70,34 @@ _ANCHOR_S2 = "section-2"
_ANCHOR_S3 = "section-3"
_ANCHOR_ALT = "section-alternatives"
+# ── §4–§7 (Part B): заголовки секций + якоря ────────────────────────────────────
+_TITLE_S4 = "§4. Рынок"
+_TITLE_S5 = "§5. Финансовая модель"
+_TITLE_S6 = "§6. Риски и дефицит"
+_TITLE_S7 = "§7. Концепция застройки"
+
+_ANCHOR_S4 = "section-4"
+_ANCHOR_S5 = "section-5"
+_ANCHOR_S6 = "section-6"
+_ANCHOR_S7 = "section-7"
+
+# RU-метка стратегии генерации концепции (зеркало STRATEGY_LABELS в concept-api.ts).
+# «program»-вариант каркас распознаёт по флагу и подписывает «Ваша программа».
+_STRATEGY_RU: dict[str, str] = {
+ "max_area": "Макс. площадь",
+ "max_insolation": "Макс. инсоляция",
+ "balanced": "Баланс",
+ "program": "Ваша программа",
+}
+
+# RU-фраза источника цены продажи финмодели (зеркало priceSourceCaption в concept-api.ts).
+_PRICE_SOURCE_FIN_RU: dict[str, str] = {
+ "objective_district_median": "рынок: медиана объявлений Objective по району",
+ "objective_geo_radius": "рынок: медиана новостроек в радиусе 3 км",
+ "district_reference": "рынок: справочная медиана района (нет свежей выборки Objective)",
+ "class_norm": "норматив класса (нет рыночных данных по участку)",
+}
+
# RU-метки источников цены варианта программы (зеркало SectionAlternatives.tsx).
_PRICE_SOURCE_RU: dict[str, str] = {
"objective_district_median": "рынок: медиана объявлений Objective по району",
@@ -714,6 +755,559 @@ def _build_section_3(result: dict[str, Any]) -> str:
"""
+# ──────────────────────────────────────────────────────────────────────────────
+# §4–§7 (Part B): forecast-ран (schema "1.0" §22-форсайта = SiteFinderReport.as_dict())
+# + концепция (ConceptOutput.variants). PURE, graceful — как §1–§3. Нормализация
+# forecast-словаря — импортированными _fc_* хелперами report_pdf (НЕ дублируем).
+# ──────────────────────────────────────────────────────────────────────────────
+
+
+def _price_source_fin_ru(value: Any) -> str:
+ """RU-фраза источника цены финмодели, иначе значение как есть. PURE."""
+ if isinstance(value, str) and value in _PRICE_SOURCE_FIN_RU:
+ return _PRICE_SOURCE_FIN_RU[value]
+ return _fmt(value)
+
+
+def _strategy_ru(value: Any) -> str:
+ """RU-метка стратегии генерации концепции, иначе значение как есть. PURE."""
+ if isinstance(value, str) and value in _STRATEGY_RU:
+ return _STRATEGY_RU[value]
+ return _fmt(value)
+
+
+def _variant_strategy(variant: dict[str, Any]) -> Any:
+ """Стратегия варианта: program-вариант помечен на первом footprint'е `strategy`.
+
+ Движок кладёт `properties.strategy == "program"` на пятна program-режима, но
+ сам вариант рапортует под слотом `balanced` — распознаём program по первой фиче,
+ чтобы не подписать пользовательскую программу «Балансом» (зеркало isProgramVariant
+ во фронте). Иначе — верхнеуровневый `strategy` варианта. PURE.
+ """
+ features = _as_list(_as_dict(variant.get("buildings_geojson")).get("features"))
+ first = features[0] if features else None
+ if isinstance(first, dict) and _as_dict(first.get("properties")).get("strategy") == "program":
+ return "program"
+ return variant.get("strategy")
+
+
+# ── §4 «Рынок» ──────────────────────────────────────────────────────────────────
+
+
+def _build_market_metrics(forecast: dict[str, Any]) -> str:
+ """Метрики рынка сейчас: темп/абсорбция, ликвидность/затоварка, цена, покрытие."""
+ market_now = _fc_as_dict(forecast.get("market_now"))
+ metrics = _fc_as_dict(market_now.get("market_metrics"))
+
+ pairs: list[tuple[str, Any]] = [
+ ("Район", metrics.get("district")),
+ ("Тип помещений", metrics.get("premise_kind")),
+ ("Объектов-аналогов", metrics.get("obj_count")),
+ ("Лотов в выборке", _fmt_int_ru(metrics.get("n_lots"))),
+ ("Продано за период", _fmt_int_ru(metrics.get("n_sold"))),
+ ("В продаже (доступно)", _fmt_int_ru(metrics.get("n_available"))),
+ ("Темп продаж (velocity), ед./мес", metrics.get("unit_velocity")),
+ ("Темп продаж (площадь), м²/мес", metrics.get("area_velocity")),
+ ("Окно расчёта, мес", metrics.get("window_months")),
+ ("Ставка абсорбции", metrics.get("absorption_rate")),
+ ("Месяцев запаса (months of supply)", metrics.get("months_of_supply")),
+ ("Индекс затоварки (overstock)", metrics.get("overstock_index")),
+ ("Sell-through, %", metrics.get("sell_through_pct")),
+ ]
+ pairs = [(k, v) for k, v in pairs if v not in (None, "", _DASH)]
+ return _kv_table(pairs)
+
+
+def _build_market_competitors(forecast: dict[str, Any]) -> str:
+ """Конкуренты рынка сейчас: ЖК / девелопер / класс / расстояние / лотов."""
+ market_now = _fc_as_dict(forecast.get("market_now"))
+ competitors = _as_list(market_now.get("competitors"))
+
+ rows: list[list[Any]] = []
+ for c in competitors:
+ if not isinstance(c, dict):
+ continue
+ rows.append(
+ [
+ c.get("comm_name"),
+ c.get("dev_name"),
+ c.get("obj_class"),
+ _fmt_int_ru(c.get("distance_m")),
+ _fmt_int_ru(c.get("flat_count")),
+ ]
+ )
+ return _data_table(["ЖК", "Девелопер", "Класс", "Расстояние, м", "Лотов"], rows)
+
+
+def _build_market_coverage(forecast: dict[str, Any]) -> str:
+ """Покрытие данными: факторы уверенности (сделки / история / ДОМ.РФ-покрытие)."""
+ confidence = _fc_as_dict(forecast.get("confidence"))
+ factors = _fc_as_dict(confidence.get("factors"))
+
+ rows: list[list[Any]] = []
+ for _key, payload in factors.items():
+ data = _fc_as_dict(payload)
+ if not data:
+ continue
+ rows.append(
+ [
+ data.get("label") or data.get("note"),
+ _fc_level_ru(data.get("level")),
+ data.get("note"),
+ ]
+ )
+ coverage_table = _data_table(["Фактор", "Уровень", "Комментарий"], rows)
+
+ level_pairs: list[tuple[str, Any]] = [
+ ("Итоговая уверенность отчёта", _fc_level_ru(confidence.get("level"))),
+ ("Обоснование", confidence.get("rationale")),
+ ]
+ level_pairs = [(k, v) for k, v in level_pairs if v not in (None, "", _DASH)]
+ return _kv_table(level_pairs) + coverage_table
+
+
+def _build_section_4(forecast: dict[str, Any]) -> str:
+ """§4 «Рынок»: резюме + метрики (velocity/тренд) + конкуренты + покрытие данными."""
+ market_now = _fc_as_dict(forecast.get("market_now"))
+ summary = market_now.get("summary")
+ summary_html = f'
{_esc(summary)}
' if summary else ""
+ return f"""
+
+
{html.escape(_TITLE_S4)}
+ {summary_html}
+
+
Метрики рынка (темп, ликвидность, цена)
+ {_build_market_metrics(forecast)}
+
+
Конкуренты рядом
+ {_build_market_competitors(forecast)}
+
+
Покрытие данными и уверенность
+ {_build_market_coverage(forecast)}
+
+"""
+
+
+# ── §5 «Финансовая модель» ──────────────────────────────────────────────────────
+
+
+def _best_variant(concept: dict[str, Any]) -> dict[str, Any]:
+ """Опорный вариант концепции для §5-финмодели: с максимальным NPV, иначе первый.
+
+ §5 показывает ОДНУ финмодель — берём вариант с наибольшим NPV (лучший экономически),
+ при равенстве / отсутствии NPV — первый валидный. Пустой concept → {}. PURE.
+ """
+ variants = [v for v in _as_list(concept.get("variants")) if isinstance(v, dict)]
+ if not variants:
+ return {}
+
+ def _npv(v: dict[str, Any]) -> float:
+ npv = _as_dict(v.get("financial")).get("npv_rub")
+ if isinstance(npv, bool) or not isinstance(npv, int | float):
+ return float("-inf")
+ return float(npv)
+
+ return max(variants, key=_npv)
+
+
+def _build_financial_headline(financial: dict[str, Any]) -> str:
+ """Ключевые числа финмодели: DCF (NPV / IRR / окупаемость) + ROI / прибыль."""
+ payback = financial.get("payback_months")
+ payback_str = f"{_fmt(payback)} мес" if payback is not None else "не окупается"
+ pairs: list[tuple[str, Any]] = [
+ ("Выручка (GDV), ₽", _fmt_money_signed(financial.get("revenue_rub"))),
+ ("Итого затраты, ₽", _fmt_money_signed(financial.get("cost_rub"))),
+ ("Чистая прибыль, ₽", _fmt_money_signed(financial.get("net_profit_rub"))),
+ ("ROI на затраты", _fmt_pct(financial.get("roi"))),
+ ("Чистая маржа на выручку", _fmt_pct(financial.get("margin_pct"))),
+ ("NPV (DCF), ₽", _fmt_money_signed(financial.get("npv_rub"))),
+ ("IRR (DCF, годовой)", _fmt_pct(financial.get("irr"))),
+ ("Ставка дисконтирования", _fmt_pct(financial.get("discount_rate_used"))),
+ ("Окупаемость (PBP)", payback_str),
+ (
+ "Цена продажи жилья, ₽/м²",
+ _fmt_int_ru(financial.get("price_per_sqm_used")),
+ ),
+ ("Источник цены", _price_source_fin_ru(financial.get("price_source"))),
+ ]
+ pairs = [(k, v) for k, v in pairs if v not in (None, "", _DASH)]
+ caveats: list[str] = []
+ if financial.get("irr_is_proxy"):
+ caveats.append(
+ "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), "
+ "показан аннуализированный ROI вместо DCF-IRR."
+ )
+ if financial.get("schedule_is_default"):
+ caveats.append(
+ "График фаз и темп продаж — типовые нормативные допущения (ПИР → СМР → "
+ "распродажа); точность метрик зависит от графика конкретного проекта."
+ )
+ caveat_html = "".join(f'
{html.escape(c)}
' for c in caveats)
+ return _kv_table(pairs) + caveat_html
+
+
+def _build_financial_cascade(financial: dict[str, Any]) -> str:
+ """Каскад затрат и БДР: выручка по статьям → затраты → НДС/налог → чистая прибыль."""
+ rows: list[list[Any]] = [
+ ["Выручка — жильё", _fmt_money_signed(financial.get("revenue_residential_rub"))],
+ ["Выручка — паркинг", _fmt_money_signed(financial.get("revenue_parking_rub"))],
+ ["Выручка — нежилое (1-й этаж)", _fmt_money_signed(financial.get("revenue_office_rub"))],
+ ["Выручка (GDV)", _fmt_money_signed(financial.get("revenue_rub"))],
+ ["СМР", _fmt_money_signed(financial.get("construction_rub"))],
+ ["ПИР (проектирование)", _fmt_money_signed(financial.get("pir_rub"))],
+ ["Сети (ТУ)", _fmt_money_signed(financial.get("networks_rub"))],
+ ["Услуги заказчика", _fmt_money_signed(financial.get("developer_services_rub"))],
+ ["Резерв", _fmt_money_signed(financial.get("contingency_rub"))],
+ ["Маркетинг и риэлтор", _fmt_money_signed(financial.get("marketing_rub"))],
+ ["Земля", _fmt_money_signed(financial.get("land_rub"))],
+ ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
+ ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
+ ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
+ ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
+ ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
+ ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
+ ]
+ if financial.get("financing_enabled"):
+ rows.extend(
+ [
+ ["Пиковый долг", _fmt_money_signed(financial.get("peak_debt_rub"))],
+ ["Проценты по кредиту", _fmt_money_signed(financial.get("total_interest_rub"))],
+ [
+ "Чистая прибыль после финансирования",
+ _fmt_money_signed(financial.get("net_profit_after_financing_rub")),
+ ],
+ ["IRR на собственные средства (LTC)", _fmt_pct(financial.get("levered_irr"))],
+ ]
+ )
+ return _data_table(["Статья", "Значение"], rows)
+
+
+def _build_market_affordability(forecast: dict[str, Any]) -> str:
+ """Рыночный контекст цены/платёжеспособности §5: цена/ticket из cost_of_error-индекса."""
+ indices = _fc_as_dict(
+ _fc_as_dict(_fc_as_dict(forecast.get("scoring")).get("special_indices")).get("indices")
+ )
+ cost_of_error = _fc_as_dict(indices.get("cost_of_error"))
+ detail = _fc_as_dict(cost_of_error.get("detail"))
+ if not detail:
+ return ""
+ pairs: list[tuple[str, Any]] = [
+ ("Рыночная цена, ₽/м²", _fmt_int_ru(detail.get("price_per_m2"))),
+ ("Средний чек лота, ₽", _fmt_money_signed(detail.get("avg_ticket_rub"))),
+ ("Референс-площадь, м²", detail.get("ref_area_m2")),
+ ("Индекс избытка предложения", detail.get("oversupply_risk")),
+ ]
+ pairs = [(k, v) for k, v in pairs if v not in (None, "", _DASH)]
+ if not pairs:
+ return ""
+ return f"
Рыночный контекст цены
{_kv_table(pairs)}"
+
+
+def _build_section_5(forecast: dict[str, Any], concept: dict[str, Any]) -> str:
+ """§5 «Финмодель»: DCF/NPV/IRR/ROI + каскад затрат (из концепции) + рыночный контекст.
+
+ Финмодель (DCF) живёт в КОНЦЕПЦИИ (ConceptVariant.financial) — берём опорный вариант
+ (макс. NPV). Концепция не рассчитана → показываем только рыночный контекст цены из
+ forecast (cost_of_error) + честную заметку, без падения.
+ """
+ variant = _best_variant(concept)
+ financial = _as_dict(variant.get("financial"))
+ affordability = _build_market_affordability(forecast)
+
+ if not financial:
+ note = (
+ '
Финансовая модель (DCF, NPV/IRR/ROI, каскад затрат) '
+ "считается по концепции застройки — она ещё не рассчитана. Ниже — только "
+ "рыночный контекст цены из прогноза.
"
+ )
+
+ summary = _fc_as_dict(scenarios.get("scenarios_summary"))
+ if not summary:
+ summary = _fc_as_dict(_fc_as_dict(forecast.get("future_market")).get("scenarios_summary"))
+ pairs = [(str(name), value) for name, value in summary.items()]
+ return _kv_table(pairs)
+
+
+def _build_section_6(forecast: dict[str, Any]) -> str:
+ """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы."""
+ future = _fc_as_dict(forecast.get("future_market"))
+ summary = future.get("summary")
+ summary_html = f'
{_esc(summary)}
' if summary else ""
+ return f"""
+
+
{html.escape(_TITLE_S6)}
+ {summary_html}
+
+
Дефицит / затоварка по горизонтам
+ {_build_deficit_by_horizon(forecast)}
+
+
Давление будущего предложения
+ {_build_supply_pressure(forecast)}
+
+
Риск-индексы
+ {_build_risk_indices(forecast)}
+
+
Сценарии
+ {_build_scenarios_honesty(forecast)}
+
+"""
+
+
+# ── §7 «Концепция застройки» ────────────────────────────────────────────────────
+
+
+def _build_concept_program(variant: dict[str, Any]) -> str:
+ """Программа застройки варианта из размещённых фич (тип дома × этажность → секций).
+
+ ВЫХОДНОЙ `ConceptVariant` (schemas/concept.py) НЕ несёт `building_program` — это поле
+ только на входном `ConceptInput`. Реальный состав программы восстанавливаем из
+ размещённых footprint'ов: в program-режиме `buildings_geojson.features[].properties`
+ несут `section_type` + `floors` (см. `_program_footprints_to_geojson` в placement.py).
+ 1b-стратегии (max_area / max_insolation / balanced) кладут только `floors` без
+ `section_type` → таблица программы не рисуется (у них нет каталожного состава).
+ Группируем по (тип, этажность) → count; порядок групп — по первому появлению
+ (стабилен). PURE.
+ """
+ features = _as_list(_as_dict(variant.get("buildings_geojson")).get("features"))
+ groups: dict[tuple[Any, Any], int] = {}
+ order: list[tuple[Any, Any]] = []
+ for feat in features:
+ props = _as_dict(_as_dict(feat).get("properties"))
+ section_type = props.get("section_type")
+ if section_type in (None, ""):
+ continue
+ key = (section_type, props.get("floors"))
+ if key not in groups:
+ order.append(key)
+ groups[key] = groups.get(key, 0) + 1
+
+ if not order:
+ return ""
+ rows = [[stype, floors, groups[(stype, floors)]] for stype, floors in order]
+ return (
+ "
Программа застройки
" f"{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}"
+ )
+
+
+def _build_concept_variant(variant: dict[str, Any]) -> str:
+ """Карточка одного варианта концепции: ТЭП + финитог + программа."""
+ teap = _as_dict(variant.get("teap"))
+ financial = _as_dict(variant.get("financial"))
+ strategy_label = _strategy_ru(_variant_strategy(variant))
+
+ features = _as_list(_as_dict(variant.get("buildings_geojson")).get("features"))
+ corpus_count = len(features)
+
+ teap_pairs: list[tuple[str, Any]] = [
+ ("Корпусов", corpus_count if corpus_count else None),
+ ("Площадь застройки, м²", _fmt_int_ru(teap.get("built_area_sqm"))),
+ ("Общая площадь (GFA), м²", _fmt_int_ru(teap.get("total_floor_area_sqm"))),
+ ("Жилая площадь, м²", _fmt_int_ru(teap.get("residential_area_sqm"))),
+ ("Нежилое (1-й этаж), м²", _fmt_int_ru(teap.get("office_area_sqm"))),
+ ("Квартир", _fmt_int_ru(teap.get("apartments_count"))),
+ ("Плотность (FAR)", teap.get("density")),
+ ("Машино-мест", _fmt_int_ru(teap.get("parking_spaces"))),
+ ]
+ teap_pairs = [(k, v) for k, v in teap_pairs if v not in (None, "", _DASH)]
+
+ fin_pairs: list[tuple[str, Any]] = [
+ ("Выручка (GDV), ₽", _fmt_money_signed(financial.get("revenue_rub"))),
+ ("Итого затраты, ₽", _fmt_money_signed(financial.get("cost_rub"))),
+ ("Чистая прибыль, ₽", _fmt_money_signed(financial.get("net_profit_rub"))),
+ ("NPV (DCF), ₽", _fmt_money_signed(financial.get("npv_rub"))),
+ ("ROI", _fmt_pct(financial.get("roi"))),
+ ("IRR", _fmt_pct(financial.get("irr"))),
+ ]
+ fin_pairs = [(k, v) for k, v in fin_pairs if v not in (None, "", _DASH)]
+
+ partial = ""
+ placed = variant.get("placed_count")
+ requested = variant.get("requested_count")
+ if (
+ isinstance(placed, int)
+ and isinstance(requested, int)
+ and not isinstance(placed, bool)
+ and not isinstance(requested, bool)
+ and placed < requested
+ ):
+ partial = (
+ f'
Размещено {placed} из {requested} секций — участок '
+ "вмещает меньше, чем в заданной программе; ТЭП и финмодель — по фактически "
+ "размещённым корпусам.