fix(ptica): сноска концепт-PDF печатает фактический срок распродажи, а не «30 мес» (#2464) #2945
2 changed files with 149 additions and 1 deletions
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue