From e7881cf45c7d8a683be40a18fb3c1b22627a87d1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 10:10:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(exporters):=20PURE=20HTML=20=C2=A74?= =?UTF-8?q?=E2=80=93=C2=A77=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20PDF-=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20(#2259=20PR-B=20S?= =?UTF-8?q?ections)=20(#2285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/exporters/full_report_html.py | 605 +++++++++++++++++- .../exporters/test_full_report_html_part_b.py | 569 ++++++++++++++++ 2 files changed, 1172 insertions(+), 2 deletions(-) create mode 100644 backend/tests/services/exporters/test_full_report_html_part_b.py 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, каскад затрат) ' + "считается по концепции застройки — она ещё не рассчитана. Ниже — только " + "рыночный контекст цены из прогноза.
" + ) + return f""" +
+

{html.escape(_TITLE_S5)}

+ {note} + {affordability or _no_data()} +
+""" + + strategy_label = _strategy_ru(_variant_strategy(variant)) + return f""" +
+

{html.escape(_TITLE_S5)}

+

Опорный вариант концепции (макс. NPV): {html.escape(strategy_label)}.

+ +

Ключевые показатели (DCF)

+ {_build_financial_headline(financial)} + +

Каскад затрат и расчёт прибыли

+ {_build_financial_cascade(financial)} + + {affordability} +
+""" + + +# ── §6 «Риски и дефицит» ──────────────────────────────────────────────────────── + + +def _build_deficit_by_horizon(forecast: dict[str, Any]) -> str: + """Прогноз дефицита/затоварки по горизонтам: спрос / предложение / индекс / months.""" + future = _fc_as_dict(forecast.get("future_market")) + forecasts = _as_list(future.get("forecasts_by_horizon")) + + rows: list[list[Any]] = [] + for f in forecasts: + if not isinstance(f, dict): + continue + rows.append( + [ + f.get("horizon_months"), + _fmt_int_ru(f.get("projected_demand_units")), + _fmt_int_ru(f.get("projected_supply_units")), + f.get("deficit_index"), + f.get("months_of_inventory"), + _fc_level_ru(f.get("confidence")), + ] + ) + headers = ["Горизонт, мес", "Спрос", "Предложение", "Индекс дефицита", "Мес. запаса", "Увер."] + return _data_table(headers, rows) + + +def _build_supply_pressure(forecast: dict[str, Any]) -> str: + """Давление будущего предложения (future_supply): открытый/скрытый сток, поглощение.""" + future = _fc_as_dict(forecast.get("future_market")) + pairs = _fc_future_supply_pairs(future.get("future_supply")) + kv = [(k, v) for k, v in pairs.items() if v not in (None, "", _DASH)] + return _kv_table(kv) + + +def _build_risk_indices(forecast: dict[str, Any]) -> str: + """Риск-индексы дефицита/ошибки: белые пятна, цена ошибки, окно запуска, каннибализация.""" + special = _fc_as_dict(_fc_as_dict(forecast.get("scoring")).get("special_indices")) + indices = _fc_as_dict(special.get("indices")) + + rows: list[list[Any]] = [] + for _key, payload in indices.items(): + data = _fc_as_dict(payload) + if not data: + continue + rows.append([data.get("key"), data.get("label"), data.get("value")]) + return _data_table(["Индекс", "Метка", "Значение"], rows) + + +def _build_scenarios_honesty(forecast: dict[str, Any]) -> str: + """Честный блок сценариев: разброс дефицита ИЛИ плашка-объяснение при коллапсе.""" + scenarios = _fc_as_dict(forecast.get("scenarios")) + if scenarios.get("scenarios_collapsed"): + reason = scenarios.get("scenarios_collapse_reason") + reason_text = ( + reason + if isinstance(reason, str) and reason + else "Сценарная дифференциация недоступна на текущих данных." + ) + return ( + '
Сценарная дифференциация недоступна: ' + f"{html.escape(reason_text)}
" + ) + + 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} секций — участок ' + "вмещает меньше, чем в заданной программе; ТЭП и финмодель — по фактически " + "размещённым корпусам.
" + ) + + return f""" +
+
{html.escape(strategy_label)}
+ {partial} +

ТЭП

+ {_kv_table(teap_pairs)} +

Финансовый итог

+ {_kv_table(fin_pairs)} + {_build_concept_program(variant)} +
+""" + + +def _build_section_7(concept: dict[str, Any] | None) -> str: + """§7 «Концепция»: footprint-план (плейсхолдер PR-C) + ТЭП/финитог/программа вариантов. + + `concept` = сериализованный `ConceptOutput` (POST /concepts) или None. None / пустые + варианты → честная заметка «концепция не рассчитана» (без падения). + """ + concept_dict = _as_dict(concept) + variants = [v for v in _as_list(concept_dict.get("variants")) if isinstance(v, dict)] + + if not variants: + return f""" +
+

{html.escape(_TITLE_S7)}

+
Концепция застройки не рассчитана — раздел появится после генерации + вариантов концепции для участка.
+
+""" + + cards = "".join(_build_concept_variant(v) for v in variants) + return f""" +
+

{html.escape(_TITLE_S7)}

+
{MAP_CONCEPT_PLACEHOLDER}
+ {cards} +
+""" + + +def build_full_report_html_part_b( + forecast_result: dict[str, Any], + concept_result: dict[str, Any] | None, + *, + cad: str, +) -> str: + """Собрать HTML Part B полного отчёта: §4 «Рынок» + §5 «Финмодель» + §6 «Риски» + §7. + + PURE (без WeasyPrint / БД / сети). §4–§6 читают ПЕРСИСТНУТЫЙ форсайт-ран + (`analysis_runs.result`, schema §22-форсайта "1.0" = `SiteFinderReport.as_dict()`) — + нормализуется теми же `_fc_*`-хелперами, что и PDF-рендерер форсайта. §7 читает + сериализованный `ConceptOutput` (POST /concepts). Каждый ключ — через `.get()` с + дефолтом; отсутствующая секция → «нет данных»; ВСЕ строки payload проходят + `html.escape`. Карта footprint-плана §7 — плейсхолдер `{{MAP_CONCEPT}}` (PR-C). + + Args: + forecast_result: `analysis_runs.result` (schema §22-форсайта "1.0") или любой + dict/инстанс с `as_dict()`. Не-dict / None → пустой форсайт (секции → «нет + данных»). + concept_result: сериализованный `ConceptOutput` или None. None / без вариантов → + §7 рисует честную заметку «концепция не рассчитана» (§5 — только рыночный + контекст цены). + cad: кадастровый номер участка (для логов; в HTML приходит через каркас). + + Returns: + HTML-фрагмент Part B (четыре `
`), готовый как `part_b_html` + для `build_full_report_html`. + """ + forecast = _fc_normalize(forecast_result) + part_b = ( + _build_section_4(forecast) + + _build_section_5(forecast, _as_dict(concept_result)) + + _build_section_6(forecast) + + _build_section_7(concept_result) + ) + logger.info( + "build_full_report_html_part_b: cad=%s forecast_keys=%d concept_variants=%d", + cad, + len(forecast), + len(_as_list(_as_dict(concept_result).get("variants"))), + ) + return part_b + + # ────────────────────────────────────────────────────────────────────────────── # Публичный API PR-A: сборка Part A (§1–§3) + общий каркас документа. # ────────────────────────────────────────────────────────────────────────────── @@ -750,14 +1344,21 @@ def build_full_report_html_part_a(analyze_result: dict[str, Any], *, cad: str) - def _build_toc(has_part_b: bool) -> str: - """Оглавление-якоря. Part B опционален (PR-B) → его пункт скрываем, если None. PURE.""" + """Оглавление-якоря. Part B опционален (PR-B) → его пункты скрываем, если None. PURE.""" items = [ f'
  • {html.escape(_TITLE_S1)}
  • ', f'
  • {html.escape(_TITLE_S2)}
  • ', f'
  • {html.escape(_TITLE_S3)}
  • ', ] if has_part_b: - items.append('
  • §4–§7. Рынок, продукт, концепция
  • ') + items.extend( + [ + f'
  • {html.escape(_TITLE_S4)}
  • ', + f'
  • {html.escape(_TITLE_S5)}
  • ', + f'
  • {html.escape(_TITLE_S6)}
  • ', + f'
  • {html.escape(_TITLE_S7)}
  • ', + ] + ) return f'' diff --git a/backend/tests/services/exporters/test_full_report_html_part_b.py b/backend/tests/services/exporters/test_full_report_html_part_b.py new file mode 100644 index 00000000..fe44ed0f --- /dev/null +++ b/backend/tests/services/exporters/test_full_report_html_part_b.py @@ -0,0 +1,569 @@ +"""Unit-тесты PURE HTML-агрегатора §4–§7 (эпик #2259 PR-B). + +Чистые тесты БЕЗ native libs (WeasyPrint не импортируется) и БЕЗ БД/сети: агрегатор +только ПОТРЕБЛЯЕТ уже-персистнутый форсайт-ран (`analysis_runs.result`, schema §22- +форсайта "1.0" = `SiteFinderReport.as_dict()`) + сериализованный `ConceptOutput`. +Фикстур — СИНТЕТИЧЕСКИЙ (структура зеркалит прод-payload, без реальных прод-строк). + +Покрываем: + • реалистичный форсайт+концепция → все четыре секции §4–§7 + якоря; + • §4 velocity/тренд/конкуренты/покрытие; §5 DCF NPV/IRR/ROI + каскад; + • §6 дефицит по горизонтам + давление предложения + риск-индексы + сценарии; + • §7 ТЭП/финитог/программа вариантов + плейсхолдер `{{MAP_CONCEPT}}`; + • concept_result=None → §7 деградирует «концепция не рассчитана», без падения; + • None / не-dict / непредвиденные типы payload → валидный HTML, без падения; + • HTML-escape динамических строк payload (XSS-вектор через внешние источники); + • каркас `build_full_report_html` с обеими частями (A+B) вместе. + +DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.py) — на случай +side-effect'ов импорта пакета app. +""" + +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.exporters.full_report_html import ( + _ANCHOR_S1, + _ANCHOR_S4, + _ANCHOR_S5, + _ANCHOR_S6, + _ANCHOR_S7, + _NO_DATA, + MAP_CONCEPT_PLACEHOLDER, + build_full_report_html, + build_full_report_html_part_a, + build_full_report_html_part_b, +) + +# ── Синтетические фикстуры (структура прод-payload, без реальных прод-строк) ───── + + +def _synthetic_forecast() -> dict[str, Any]: + """Реалистичный форсайт-ран: §22-форсайт (SiteFinderReport.as_dict(), schema "1.0").""" + return { + "schema_version": "1.0", + "meta": { + "cad_num": "00:00:0000000:0000", + "district": "Тестовый район", + "horizons": [6, 12, 18, 24], + }, + # §4 — рынок сейчас + "market_now": { + "summary": "Текущий рынок: абсорбция ~231 ед./мес, средняя цена ~150 тыс.", + "market_metrics": { + "district": "Тестовый район", + "premise_kind": "квартира", + "obj_count": 64, + "n_lots": 44116, + "n_sold": 23405, + "n_available": 20711, + "unit_velocity": 231.33, + "area_velocity": 11959.9, + "window_months": 6, + "absorption_rate": 0.0112, + "months_of_supply": 89.5, + "overstock_index": 0.711, + "sell_through_pct": 53.1, + }, + "competitors": [ + { + "comm_name": "Тестовый ЖК А", + "dev_name": "Тестовый девелопер", + "obj_class": "Комфорт", + "distance_m": 1224.01, + "flat_count": 227, + }, + { + "comm_name": "Тестовый ЖК Б", + "dev_name": "Другой девелопер", + "obj_class": "Бизнес", + "distance_m": 915.4, + "flat_count": 411, + }, + ], + }, + # §6 — будущий рынок / дефицит + "future_market": { + "summary": "Прогноз: затоварка, индекс дефицита −1.0 на целевом горизонте.", + "future_supply": { + "index": 1.0, + "confidence": "low", + "breakdown": { + "open_units": 20720, + "hidden_units": 0, + "months_of_pressure": 23.8, + "future_units_by_horizon": 5503.0, + "monthly_absorption_units": 231.33, + }, + }, + "scenarios_summary": {"base": -1.0, "aggressive": -1.0, "conservative": -1.0}, + "forecasts_by_horizon": [ + { + "horizon_months": 6, + "projected_demand_units": 989.9, + "projected_supply_units": 18499.1, + "deficit_index": -1.0, + "months_of_inventory": 118.1, + "confidence": "low", + }, + { + "horizon_months": 12, + "projected_demand_units": 1980.0, + "projected_supply_units": 19000.0, + "deficit_index": -1.0, + "months_of_inventory": 95.0, + "confidence": "low", + }, + ], + }, + # §6 — сценарии (в этом фикстуре НЕ коллапснуты → разброс дефицита) + "scenarios": { + "summary": "Сценарии (3): conservative, base, aggressive.", + "scenarios_collapsed": False, + "scenarios_collapse_reason": None, + "scenarios_summary": {"base": -1.0, "aggressive": -0.8, "conservative": -1.0}, + }, + # §4 покрытие + §6 риск-индексы + "confidence": { + "level": "low", + "rationale": "Low потому что коротка история и мало покрытие ДОМ.РФ.", + "factors": { + "deal_count": { + "label": "Сделки", + "level": "high", + "note": "1388 сделок за 6 мес — достаточно", + }, + "domrf_coverage": { + "label": "Покрытие ДОМ.РФ", + "level": "low", + "note": "Планировки известны у 15% будущих проектов (мало)", + }, + "history_months": { + "label": "История", + "level": "low", + "note": "6 мес истории (мало)", + }, + }, + }, + "scoring": { + "overall": 0.366, + "special_indices": { + "indices": { + "product_void": { + "key": "product_void", + "label": "нет белых пятен", + "value": 0.0, + }, + "cost_of_error": { + "key": "cost_of_error", + "label": "риск дорогой ошибки", + "value": 0.369, + "detail": { + "ref_area_m2": 50.0, + "price_per_m2": 155937.0, + "avg_ticket_rub": 7796826.0, + "oversupply_risk": 0.711, + }, + }, + "launch_window": { + "key": "launch_window", + "label": "6 мес", + "value": 0.0, + }, + } + }, + }, + } + + +def _synthetic_financial() -> dict[str, Any]: + """Финмодель варианта концепции (ConceptVariant.financial → FinancialModel).""" + return { + "revenue_rub": 2_400_000_000.0, + "cost_rub": 1_800_000_000.0, + "gross_margin_rub": 600_000_000.0, + "irr": 0.184, + "revenue_residential_rub": 2_200_000_000.0, + "revenue_parking_rub": 150_000_000.0, + "revenue_office_rub": 50_000_000.0, + "construction_rub": 1_200_000_000.0, + "pir_rub": 90_000_000.0, + "networks_rub": 60_000_000.0, + "developer_services_rub": 80_000_000.0, + "contingency_rub": 70_000_000.0, + "marketing_rub": 50_000_000.0, + "land_rub": 250_000_000.0, + "vat_rub": 25_000_000.0, + "profit_before_tax_rub": 480_000_000.0, + "profit_tax_rub": 120_000_000.0, + "net_profit_rub": 360_000_000.0, + "roi": 0.2, + "margin_pct": 0.15, + "irr_is_proxy": False, + "npv_rub": 145_000_000.0, + "payback_months": 28.5, + "discount_rate_used": 0.16, + "schedule_is_default": True, + "financing_enabled": True, + "peak_debt_rub": 900_000_000.0, + "total_interest_rub": 120_000_000.0, + "net_profit_after_financing_rub": 240_000_000.0, + "levered_irr": 0.31, + "levered_irr_is_proxy": False, + "price_per_sqm_used": 155937.0, + "price_is_calibrated": True, + "price_source": "objective_district_median", + } + + +def _synthetic_teap() -> dict[str, Any]: + """ТЭП варианта концепции (ConceptVariant.teap → TEAP).""" + return { + "built_area_sqm": 8000.0, + "total_floor_area_sqm": 64000.0, + "office_area_sqm": 2000.0, + "residential_area_sqm": 55000.0, + "apartments_count": 920, + "density": 2.34, + "parking_spaces": 460, + } + + +def _1b_feature(section_id: int, floors: int, strategy: str) -> dict[str, Any]: + """1b-стратегия footprint: properties как в placement._footprints_to_geojson. + + БЕЗ `section_type` (у 1b-стратегий каталожного типа нет) → таблица программы для + таких вариантов не рисуется. + """ + return { + "type": "Feature", + "properties": { + "section_id": section_id, + "floors": floors, + "footprint_sqm": 432.0, + "strategy": strategy, + }, + } + + +def _program_feature(section_id: int, section_type: str, floors: int) -> dict[str, Any]: + """Program footprint: properties как в placement._program_footprints_to_geojson. + + Несёт `section_type` + СВОИ `floors`; `strategy == "program"` (маркер program-режима). + """ + return { + "type": "Feature", + "properties": { + "section_id": section_id, + "floors": floors, + "footprint_sqm": 450.0, + "section_type": section_type, + "strategy": "program", + }, + } + + +def _synthetic_concept() -> dict[str, Any]: + """Сериализованный ConceptOutput: два варианта (1b-стратегия + program). + + Структура `buildings_geojson.features[].properties` зеркалит реального продюсера + (placement.py): 1b-стратегия кладёт `floors`+`strategy` без `section_type`; program- + режим — `section_type`+`floors`+`strategy="program"`. `building_program` НЕ впрыскиваем + (выходной ConceptVariant это поле не несёт — состав восстанавливается из фич). + """ + fin_low = dict(_synthetic_financial()) + fin_low["npv_rub"] = 90_000_000.0 + return { + "variants": [ + { + "strategy": "max_area", + "buildings_geojson": { + "type": "FeatureCollection", + "features": [ + _1b_feature(1, 12, "max_area"), + _1b_feature(2, 12, "max_area"), + ], + }, + "teap": _synthetic_teap(), + "financial": _synthetic_financial(), + }, + { + # program-вариант рапортует под слотом "balanced", но фичи помечены + # strategy="program" → каркас подписывает «Ваша программа». + "strategy": "balanced", + "buildings_geojson": { + "type": "FeatureCollection", + "features": [ + _program_feature(1, "tower_a", 25), + _program_feature(2, "tower_a", 25), + _program_feature(3, "plate_b", 16), + ], + }, + "teap": _synthetic_teap(), + "financial": fin_low, + # partial-fit: попросили 4 секции, разместилось 3. + "placed_count": 3, + "requested_count": 4, + }, + ] + } + + +# ── 1. Полный форсайт+концепция → все четыре секции + якоря ────────────────────── + + +def test_all_sections_and_anchors_present() -> None: + html = build_full_report_html_part_b( + _synthetic_forecast(), _synthetic_concept(), cad="00:00:0000000:0000" + ) + for anchor in (_ANCHOR_S4, _ANCHOR_S5, _ANCHOR_S6, _ANCHOR_S7): + assert f'id="{anchor}"' in html + + +# ── 2. §4 Рынок: velocity / тренд / конкуренты / покрытие ──────────────────────── + + +def test_section_4_market_velocity_competitors_coverage() -> None: + html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X") + # velocity / метрики темпа + assert "Темп продаж (velocity)" in html + assert "231.33" in html # unit_velocity + # тренд-резюме + assert "абсорбция" in html + # конкуренты + assert "Тестовый ЖК А" in html + assert "Тестовый девелопер" in html + # покрытие данными (factors) + assert "Покрытие ДОМ.РФ" in html + assert "1388 сделок" in html + + +# ── 3. §5 Финмодель: DCF / NPV / IRR / ROI + каскад затрат ─────────────────────── + + +def test_section_5_financial_dcf_and_cascade() -> None: + html = build_full_report_html_part_b(_synthetic_forecast(), _synthetic_concept(), cad="X") + # DCF headline (₽ округлены до млрд/млн) + assert "NPV (DCF)" in html + assert "145 млн ₽" in html # npv_rub 145_000_000 + assert "18.4%" in html # irr 0.184 → 18.4% + assert "20.0%" in html # roi 0.2 + assert "28.5 мес" in html # payback + # каскад затрат — статьи + assert "СМР" in html + assert "Налог на прибыль" in html + assert "Чистая прибыль" in html + # источник цены (калиброван по рынку) — RU-фраза + assert "медиана объявлений Objective" in html + # опорный вариант = макс. NPV (max_area, 145 млн > balanced 90 млн) + assert "Макс. площадь" in html + + +def test_section_5_financing_overlay_rows() -> None: + html = build_full_report_html_part_b(_synthetic_forecast(), _synthetic_concept(), cad="X") + # financing_enabled=True → строки долга в каскаде + assert "Пиковый долг" in html + assert "Проценты по кредиту" in html + assert "IRR на собственные средства (LTC)" in html + + +# ── 4. §6 Риски/дефицит: горизонты + давление + индексы + сценарии ─────────────── + + +def test_section_6_deficit_pressure_indices_scenarios() -> None: + html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X") + # дефицит по горизонтам + assert "Индекс дефицита" in html + assert "Мес. запаса" in html + # давление будущего предложения + assert "Месяцев давления" in html + # риск-индексы + assert "риск дорогой ошибки" in html + # сценарии НЕ коллапснуты → разброс (значения base/aggressive/conservative) + assert "aggressive" in html + assert "Сценарная дифференциация недоступна" not in html + + +def test_section_6_scenarios_collapsed_honest_note() -> None: + forecast = _synthetic_forecast() + forecast["scenarios"] = { + "scenarios_collapsed": True, + "scenarios_collapse_reason": "Чувствительность спроса к ставке не оценена.", + } + html = build_full_report_html_part_b(forecast, None, cad="X") + assert "Сценарная дифференциация недоступна" in html + assert "Чувствительность спроса к ставке не оценена" in html + + +# ── 5. §7 Концепция: ТЭП/финитог/программа + {{MAP_CONCEPT}} ───────────────────── + + +def test_section_7_concept_teap_program_map() -> None: + html = build_full_report_html_part_b(_synthetic_forecast(), _synthetic_concept(), cad="X") + # плейсхолдер footprint-плана + assert MAP_CONCEPT_PLACEHOLDER in html + # ТЭП + assert "Плотность (FAR)" in html + assert "2.34" in html # density + # обе стратегии подписаны (program-вариант → «Ваша программа») + assert "Макс. площадь" in html + assert "Ваша программа" in html + # программа застройки восстановлена из фич (section_type в properties) + assert "Программа застройки" in html + assert "tower_a" in html + assert "plate_b" in html + # partial-fit честная заметка (placed 3 < requested 4) + assert "Размещено 3 из 4 секций" in html + + +def test_section_7_program_grouped_from_features() -> None: + """Состав программы группируется из features[].properties (section_type × floors → count). + + Гарантирует, что таблица «Программа застройки» строится из РАЗМЕЩЁННЫХ фич, а не из + несуществующего на выходе `building_program` (регресс-гард: без section_type в + properties таблица бы молча не рисовалась). + """ + concept = _synthetic_concept() + html = build_full_report_html_part_b(_synthetic_forecast(), concept, cad="X") + # tower_a: 2 секции @25 эт; plate_b: 1 @16 — сгруппировано по (тип, этажность). + prog_start = html.index("Программа застройки") + prog = html[prog_start : prog_start + 800] + assert "tower_a" in prog + assert "plate_b" in prog + assert ">25<" in prog # этажность tower_a + assert ">16<" in prog # этажность plate_b + assert ">2<" in prog # count tower_a (сгруппировано) + + +def test_section_7_1b_strategy_has_no_program_table() -> None: + """1b-стратегия (features без section_type) → таблица программы НЕ рисуется. + + max_area/max_insolation/balanced кладут только floors/strategy без section_type — + у них нет каталожного состава, честно НЕ показываем пустую/фиктивную программу. + """ + concept: dict[str, Any] = { + "variants": [ + { + "strategy": "max_area", + "buildings_geojson": { + "type": "FeatureCollection", + "features": [_1b_feature(1, 12, "max_area"), _1b_feature(2, 12, "max_area")], + }, + "teap": _synthetic_teap(), + "financial": _synthetic_financial(), + } + ] + } + html = build_full_report_html_part_b(_synthetic_forecast(), concept, cad="X") + # ТЭП есть, а таблицы программы — нет (нет каталожного состава секций). + assert "Плотность (FAR)" in html + assert "Программа застройки" not in html + + +# ── 6. concept_result=None → §7 деградирует без падения ────────────────────────── + + +def test_section_7_concept_none_degrades() -> None: + html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X") + assert f'id="{_ANCHOR_S7}"' in html + assert "Концепция застройки не рассчитана" in html + # §5 без концепции → рыночный контекст цены + честная заметка, без падения + assert f'id="{_ANCHOR_S5}"' in html + assert "считается по концепции застройки" in html + # плейсхолдер карты не рисуется, когда вариантов нет + assert MAP_CONCEPT_PLACEHOLDER not in html + + +def test_concept_empty_variants_degrades() -> None: + for empty in ({}, {"variants": []}, {"variants": None}): + html = build_full_report_html_part_b(_synthetic_forecast(), empty, cad="X") + assert "Концепция застройки не рассчитана" in html + + +# ── 7. None / не-dict / непредвиденные типы → валидный HTML, без падения ───────── + + +def test_none_and_junk_payloads_do_not_crash() -> None: + for junk in (None, [], "строка", 42): + html = build_full_report_html_part_b(junk, None, cad="X") # type: ignore[arg-type] + # Все четыре секции присутствуют, «нет данных» где пусто. + assert f'id="{_ANCHOR_S4}"' in html + assert f'id="{_ANCHOR_S7}"' in html + assert _NO_DATA in html + + +def test_none_fields_within_sections() -> None: + forecast: dict[str, Any] = { + "market_now": {"summary": None, "market_metrics": None, "competitors": None}, + "future_market": {"summary": None, "forecasts_by_horizon": None, "future_supply": None}, + "confidence": {"level": None, "factors": None, "rationale": None}, + "scoring": {"special_indices": {"indices": None}}, + "scenarios": {"scenarios_collapsed": False, "scenarios_summary": None}, + } + concept: dict[str, Any] = {"variants": [{"teap": None, "financial": None}]} + html = build_full_report_html_part_b(forecast, concept, cad="X") + assert f'id="{_ANCHOR_S4}"' in html + assert _NO_DATA in html + + +# ── 8. HTML-escape динамических строк payload (XSS-вектор) ─────────────────────── + + +def test_dynamic_strings_are_escaped() -> None: + forecast: dict[str, Any] = { + "market_now": { + "summary": "", + "competitors": [{"comm_name": "ЖК", "dev_name": "dev"}], + }, + "confidence": { + "rationale": "", + "factors": {"f": {"label": "lbl", "level": "low", "note": "n"}}, + }, + } + concept: dict[str, Any] = { + "variants": [ + { + "strategy": "", + "buildings_geojson": {"features": []}, + "teap": {"apartments_count": 100}, + "financial": {"npv_rub": 1.0}, + } + ] + } + html = build_full_report_html_part_b(forecast, concept, cad="X") + assert "