feat(exporters): PURE HTML §4–§7 полного PDF-отчёта (#2259 PR-B Sections) #2285
2 changed files with 1172 additions and 2 deletions
|
|
@ -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'<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) + общий каркас документа.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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'<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_S3}">{html.escape(_TITLE_S3)}</a></li>',
|
||||
]
|
||||
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>'
|
||||
|
||||
|
||||
|
|
|
|||
569
backend/tests/services/exporters/test_full_report_html_part_b.py
Normal file
569
backend/tests/services/exporters/test_full_report_html_part_b.py
Normal 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 "<script>alert" in html
|
||||
assert "<img src=x onerror" not in html
|
||||
assert "<img src=x onerror" in html
|
||||
assert "<b>ЖК</b>" 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
|
||||
Loading…
Add table
Reference in a new issue