feat(exporters): PURE HTML §4-§7 полного PDF-отчёта (#2259 PR-B Sections)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 14m48s

build_full_report_html_part_b(forecast_result, concept_result, cad):
§4 рынок (market_now/velocity/конкуренты/confidence), §5 финмодель
(DCF из ConceptOutput.variants[].financial, опорный вариант = max NPV;
рыночный контекст из scoring.special_indices), §6 риски/дефицит
(future_market + честная плашка при scenarios_collapsed), §7 концепция
(ТЭП + программа, переисточенная из buildings_geojson.features[].properties
— поле building_program на выходном ConceptVariant НЕ существует;
1b-стратегии без section_type таблицу программы не рисуют). Плейсхолдер
{{MAP_CONCEPT}} для PR-C; concept=None → «не рассчитана» без падения.
Нормализация forecast-словаря — импорт _normalize/_as_dict/
_future_supply_pairs/_level_ru из report_pdf (без третьего набора
форматтеров). Один общий <style>.

ВАЖНО для PR-D: реальная schema форсайта = "1.0" (не forecast-1.0);
концепция НЕ персистится — server-side вызов POST /concepts обязателен,
без него §5 деградирует в рыночный контекст, §7 в заглушку.

Refs #2259
This commit is contained in:
bot-backend 2026-07-03 14:54:46 +05:00
parent c0c18f4ecc
commit 704d886a67
2 changed files with 1172 additions and 2 deletions

View file

@ -35,10 +35,23 @@ import html
import logging import logging
from typing import Any 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__) logger = logging.getLogger(__name__)
# ── Плейсхолдер карты (подставится в PR-C) ───────────────────────────────────── # ── Плейсхолдер карты (подставится в PR-C) ─────────────────────────────────────
MAP_PARCEL_PLACEHOLDER = "{{MAP_PARCEL}}" MAP_PARCEL_PLACEHOLDER = "{{MAP_PARCEL}}"
# Плейсхолдер footprint-плана концепции §7 (реальная подстановка карты — PR-C).
MAP_CONCEPT_PLACEHOLDER = "{{MAP_CONCEPT}}"
# ── Микрокопия / заглушки ────────────────────────────────────────────────────── # ── Микрокопия / заглушки ──────────────────────────────────────────────────────
_DASH = "" _DASH = ""
@ -57,6 +70,34 @@ _ANCHOR_S2 = "section-2"
_ANCHOR_S3 = "section-3" _ANCHOR_S3 = "section-3"
_ANCHOR_ALT = "section-alternatives" _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). # RU-метки источников цены варианта программы (зеркало SectionAlternatives.tsx).
_PRICE_SOURCE_RU: dict[str, str] = { _PRICE_SOURCE_RU: dict[str, str] = {
"objective_district_median": "рынок: медиана объявлений Objective по району", "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'<p class="verdict">{_esc(summary)}</p>' if summary else ""
return f"""
<div class="section" id="{_ANCHOR_S4}">
<h2>{html.escape(_TITLE_S4)}</h2>
{summary_html}
<h3>Метрики рынка (темп, ликвидность, цена)</h3>
{_build_market_metrics(forecast)}
<h3>Конкуренты рядом</h3>
{_build_market_competitors(forecast)}
<h3>Покрытие данными и уверенность</h3>
{_build_market_coverage(forecast)}
</div>
"""
# ── §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'<div class="caveat">{html.escape(c)}</div>' 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"<h3>Рыночный контекст цены</h3>{_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 = (
'<div class="caveat">Финансовая модель (DCF, NPV/IRR/ROI, каскад затрат) '
"считается по концепции застройки — она ещё не рассчитана. Ниже — только "
"рыночный контекст цены из прогноза.</div>"
)
return f"""
<div class="section" id="{_ANCHOR_S5}">
<h2>{html.escape(_TITLE_S5)}</h2>
{note}
{affordability or _no_data()}
</div>
"""
strategy_label = _strategy_ru(_variant_strategy(variant))
return f"""
<div class="section" id="{_ANCHOR_S5}">
<h2>{html.escape(_TITLE_S5)}</h2>
<p class="alt-meta">Опорный вариант концепции (макс. NPV): {html.escape(strategy_label)}.</p>
<h3>Ключевые показатели (DCF)</h3>
{_build_financial_headline(financial)}
<h3>Каскад затрат и расчёт прибыли</h3>
{_build_financial_cascade(financial)}
{affordability}
</div>
"""
# ── §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 (
'<div class="caveat">Сценарная дифференциация недоступна: '
f"{html.escape(reason_text)}</div>"
)
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'<p class="verdict">{_esc(summary)}</p>' if summary else ""
return f"""
<div class="section" id="{_ANCHOR_S6}">
<h2>{html.escape(_TITLE_S6)}</h2>
{summary_html}
<h3>Дефицит / затоварка по горизонтам</h3>
{_build_deficit_by_horizon(forecast)}
<h3>Давление будущего предложения</h3>
{_build_supply_pressure(forecast)}
<h3>Риск-индексы</h3>
{_build_risk_indices(forecast)}
<h3>Сценарии</h3>
{_build_scenarios_honesty(forecast)}
</div>
"""
# ── §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 (
"<h3>Программа застройки</h3>" 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'<div class="caveat">Размещено {placed} из {requested} секций — участок '
"вмещает меньше, чем в заданной программе; ТЭП и финмодель — по фактически "
"размещённым корпусам.</div>"
)
return f"""
<div class="alt-card">
<div class="alt-title">{html.escape(strategy_label)}</div>
{partial}
<h3>ТЭП</h3>
{_kv_table(teap_pairs)}
<h3>Финансовый итог</h3>
{_kv_table(fin_pairs)}
{_build_concept_program(variant)}
</div>
"""
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"""
<div class="section" id="{_ANCHOR_S7}">
<h2>{html.escape(_TITLE_S7)}</h2>
<div class="caveat">Концепция застройки не рассчитана раздел появится после генерации
вариантов концепции для участка.</div>
</div>
"""
cards = "".join(_build_concept_variant(v) for v in variants)
return f"""
<div class="section" id="{_ANCHOR_S7}">
<h2>{html.escape(_TITLE_S7)}</h2>
<div class="map-placeholder">{MAP_CONCEPT_PLACEHOLDER}</div>
{cards}
</div>
"""
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 (четыре `<div class="section">`), готовый как `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) + общий каркас документа. # Публичный 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: def _build_toc(has_part_b: bool) -> str:
"""Оглавление-якоря. Part B опционален (PR-B) → его пункт скрываем, если None. PURE.""" """Оглавление-якоря. Part B опционален (PR-B) → его пункты скрываем, если None. PURE."""
items = [ items = [
f'<li><a href="#{_ANCHOR_S1}">{html.escape(_TITLE_S1)}</a></li>', f'<li><a href="#{_ANCHOR_S1}">{html.escape(_TITLE_S1)}</a></li>',
f'<li><a href="#{_ANCHOR_S2}">{html.escape(_TITLE_S2)}</a></li>', f'<li><a href="#{_ANCHOR_S2}">{html.escape(_TITLE_S2)}</a></li>',
f'<li><a href="#{_ANCHOR_S3}">{html.escape(_TITLE_S3)}</a></li>', f'<li><a href="#{_ANCHOR_S3}">{html.escape(_TITLE_S3)}</a></li>',
] ]
if has_part_b: if has_part_b:
items.append('<li><a href="#section-4">§4§7. Рынок, продукт, концепция</a></li>') items.extend(
[
f'<li><a href="#{_ANCHOR_S4}">{html.escape(_TITLE_S4)}</a></li>',
f'<li><a href="#{_ANCHOR_S5}">{html.escape(_TITLE_S5)}</a></li>',
f'<li><a href="#{_ANCHOR_S6}">{html.escape(_TITLE_S6)}</a></li>',
f'<li><a href="#{_ANCHOR_S7}">{html.escape(_TITLE_S7)}</a></li>',
]
)
return f'<nav class="toc"><h3>Содержание</h3><ul>{"".join(items)}</ul></nav>' return f'<nav class="toc"><h3>Содержание</h3><ul>{"".join(items)}</ul></nav>'

View file

@ -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": "<script>alert('xss')</script>",
"competitors": [{"comm_name": "<b>ЖК</b>", "dev_name": "<i>dev</i>"}],
},
"confidence": {
"rationale": "<img src=x onerror=alert(1)>",
"factors": {"f": {"label": "<code>lbl</code>", "level": "low", "note": "n"}},
},
}
concept: dict[str, Any] = {
"variants": [
{
"strategy": "<script>s</script>",
"buildings_geojson": {"features": []},
"teap": {"apartments_count": 100},
"financial": {"npv_rub": 1.0},
}
]
}
html = build_full_report_html_part_b(forecast, concept, cad="X")
assert "<script>alert" not in html
assert "&lt;script&gt;alert" in html
assert "<img src=x onerror" not in html
assert "&lt;img src=x onerror" in html
assert "&lt;b&gt;ЖК&lt;/b&gt;" in html
# ── 9. Каркас документа: Part A + Part B вместе ──────────────────────────────────
def test_document_frame_with_both_parts() -> None:
part_a = build_full_report_html_part_a({}, cad="00:00:0000000:0000")
part_b = build_full_report_html_part_b(
_synthetic_forecast(), _synthetic_concept(), cad="00:00:0000000:0000"
)
doc = build_full_report_html(
part_a,
part_b,
cad="00:00:0000000:0000",
address="улица Примерная, 1",
generated_at="03.07.2026",
)
assert doc.startswith("<!DOCTYPE html>")
# Part A (§1) и Part B (§4§7) оба встроены.
assert f'id="{_ANCHOR_S1}"' in doc
assert f'id="{_ANCHOR_S4}"' in doc
assert f'id="{_ANCHOR_S7}"' in doc
# Оглавление содержит пункты §4§7 (has_part_b=True).
assert f'href="#{_ANCHOR_S4}"' in doc
assert f'href="#{_ANCHOR_S7}"' in doc
# Ровно один общий <style> (не дублируется на Part B).
assert doc.count("<style>") == 1