fix(ptica): сноска концепт-PDF печатает фактический срок распродажи, а не «30 мес» (#2464) #2945

Merged
bot-backend merged 1 commit from fix/2464-concept-pdf-sales-window into main 2026-08-19 17:39:38 +00:00
2 changed files with 149 additions and 1 deletions

View file

@ -163,6 +163,29 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str:
)
def _sales_phrase(financial: FinancialModel) -> str:
"""Фраза о сроке распродажи для методической сноски. PURE.
#2464: срок был зашит числом «30 мес» — при том, что ставка дисконта в той же
строке берётся из расчёта. 30 это ФОЛБЭК (`financial._SALES_DURATION_MONTHS`),
применяемый только когда рыночная скорость абсорбции не передана. Иначе окно
считается как площадь/скорость и клампится в [6, 120] мес, то есть сноска обещала
читателю не тот срок, по которому посчитан NPV.
Оба нужных поля уже есть в схеме: `sales_duration_months` (реализованное окно) и
`schedule_is_default` (честный флаг «норматив, а не рынок»). Отчёт Site Finder флаг
уже читает full_report_html.py:1335 и full_report_docx.py:855; игнорировал его
только этот экспортёр.
getattr с дефолтом тот же оборонительный приём, что у соседних полей: старый
сериализованный вариант без новых ключей не должен ронять экспорт.
"""
months = getattr(financial, "sales_duration_months", None)
if getattr(financial, "schedule_is_default", True) or months is None:
return "распродажа 30 мес (нормативный темп)"
return f"распродажа {months:.0f} мес (по рыночной абсорбции)"
def _build_html(variants: Sequence[ConceptVariant]) -> str:
if not variants:
return (
@ -172,6 +195,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
)
disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%"
sales_phrase = _sales_phrase(variants[0].financial)
return (
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
f"<h1>{html.escape(_TITLE)}</h1>"
@ -179,7 +203,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
f"{_teap_table(variants)}"
f"{_financial_table(variants)}"
"<p class='sub'>NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). "
f"(ПИР 6 мес → СМР по типу застройки → {sales_phrase}, дисконт {disc_pct} годовых). "
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "

View file

@ -0,0 +1,124 @@
"""#2464: методическая сноска концепт-PDF обещала не тот срок распродажи.
В сноске стояло «распродажа 30 мес» числом, при том что ставка дисконта в той же
строке берётся из расчёта (`variants[0].financial.discount_rate_used`).
30 месяцев это ФОЛБЭК `financial._SALES_DURATION_MONTHS`, применяемый только когда
рыночная скорость абсорбции не передана. Иначе окно считается как
`residential_area_sqm / market_velocity` и клампится в [6, 120] мес. То есть читатель
экспортированного документа получал срок, по которому NPV НЕ считался.
Оба нужных поля давно есть в схеме `sales_duration_months` и честный флаг
`schedule_is_default`. Отчёт Site Finder их читает (full_report_html.py:1335,
full_report_docx.py:855); игнорировал только этот экспортёр.
"""
from __future__ import annotations
from types import SimpleNamespace
# `_sales_phrase` импортируем ВНУТРИ тестов, а не в шапке: на origin/main её ещё нет,
# и импорт здесь уронил бы СБОРКУ всего модуля — тогда поведенческая проверка внизу
# не исполнилась бы вовсе, а красное означало бы «функции нет», а не «в документе
# не тот срок».
def test_market_driven_window_is_reported_as_measured() -> None:
"""Окно построено по рынку → в сноске стоит ФАКТИЧЕСКОЕ число, а не 30."""
from app.services.generative.exporters.pdf import _sales_phrase
fin = SimpleNamespace(schedule_is_default=False, sales_duration_months=54.0)
phrase = _sales_phrase(fin)
assert "54" in phrase
assert "30" not in phrase
assert "рыночной абсорбции" in phrase
def test_default_schedule_says_it_is_a_norm() -> None:
"""Окно дефолтное → 30 мес, но с явной пометкой «нормативный», а не как факт."""
from app.services.generative.exporters.pdf import _sales_phrase
fin = SimpleNamespace(schedule_is_default=True, sales_duration_months=30.0)
phrase = _sales_phrase(fin)
assert "30" in phrase
assert "норматив" in phrase
def test_missing_window_falls_back_to_the_norm() -> None:
"""Контроль: старый сериализованный вариант без поля не роняет экспорт."""
from app.services.generative.exporters.pdf import _sales_phrase
assert "30" in _sales_phrase(SimpleNamespace())
assert "30" in _sales_phrase(SimpleNamespace(schedule_is_default=False))
def test_market_window_is_rounded_not_truncated_to_zero() -> None:
"""Дробное окно печатается целым числом месяцев, а не '54.0' и не '0'."""
from app.services.generative.exporters.pdf import _sales_phrase
phrase = _sales_phrase(SimpleNamespace(schedule_is_default=False, sales_duration_months=53.6))
assert "54 мес" in phrase
# ── поведенческая проверка на уровне готового документа ────────────────────────
#
# Проверки выше падают на origin/main с ImportError — там нет самой функции, то есть
# красное значит «возможности нет», а не «в документе не то». Тест ниже красный ПО
# СОДЕРЖИМОМУ: собирает HTML тем же кодом, что уходит в PDF, и требует, чтобы
# фактический срок стоял в сноске.
def _variant(*, schedule_is_default: bool, sales_months: float) -> SimpleNamespace:
"""Минимальный вариант: только поля, которые читают таблицы и сноска."""
financial = SimpleNamespace(
discount_rate_used=0.18,
schedule_is_default=schedule_is_default,
sales_duration_months=sales_months,
irr=0.21,
irr_is_proxy=False,
npv_rub=1.0,
payback_months=24.0,
margin_pct=15.0,
cost_rub=1.0,
land_rub=1.0,
pir_rub=1.0,
construction_rub=1.0,
networks_rub=1.0,
marketing_rub=1.0,
developer_services_rub=1.0,
contingency_rub=1.0,
gross_margin_rub=1.0,
profit_before_tax_rub=1.0,
net_profit_rub=1.0,
profit_tax_rub=1.0,
vat_rub=1.0,
revenue_rub=1.0,
revenue_residential_rub=1.0,
revenue_parking_rub=1.0,
revenue_office_rub=1.0,
roi=0.1,
)
teap = SimpleNamespace(
built_area_sqm=1.0,
total_floor_area_sqm=1.0,
residential_area_sqm=1.0,
apartments_count=1,
parking_spaces=1,
density=1.0,
)
return SimpleNamespace(strategy="balanced", teap=teap, financial=financial)
def test_document_carries_the_measured_window_not_the_norm() -> None:
from app.services.generative.exporters.pdf import _build_html
html = _build_html([_variant(schedule_is_default=False, sales_months=54.0)])
assert (
"распродажа 54 мес" in html
), "в методической сноске стоит не тот срок, по которому посчитан NPV"
assert "распродажа 30 мес," not in html