fix(ptica): сноска концепт-PDF печатает фактический срок распродажи, а не «30 мес» (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 2m18s
CI / backend-tests (pull_request) Successful in 17m8s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 2m18s
CI / backend-tests (pull_request) Successful in 17m8s
В методической сноске стояло «распродажа 30 мес» — числом, при том что ставка дисконта в ТОЙ ЖЕ строке берётся из расчёта (variants[0].financial. discount_rate_used). 30 месяцев — это фолбэк financial._SALES_DURATION_MONTHS, применяемый ТОЛЬКО когда рыночная скорость абсорбции не передана. Иначе окно считается как residential_area_sqm / market_velocity и клампится в [6, 120] мес. То есть читатель экспортированного документа получал срок, по которому NPV НЕ считался. Оба нужных поля давно есть в схеме: sales_duration_months (реализованное окно sales_end − sales_start) и schedule_is_default — честный флаг «норматив, а не рынок», заведённый ровно для этого. Отчёт Site Finder их читает (full_report_html.py:1335 и full_report_docx.py:855); игнорировал только этот экспортёр. Теперь: при schedule_is_default → «распродажа 30 мес (нормативный темп)», иначе → «распродажа N мес (по рыночной абсорбции)». Норматив остаётся, но перестаёт выдавать себя за измерение. Логика вынесена в чистую _sales_phrase: собирать полный ConceptVariant ради одной строки — хрупко, а так проверка не зависит от двух десятков несвязанных полей. Тест на уровне ДОКУМЕНТА красный на origin/main по содержимому: «AssertionError: в методической сноске стоит не тот срок, по которому посчитан NPV». Импорт новой функции перенесён внутрь тестов — в шапке он ронял сборку модуля, и тогда красное означало бы «функции нет», а не «в документе не то». pytest tests/services/exporters + tests/services/generative: 373 passed, 8 skipped, rc=0 Прогон повторён после правок pre-commit.
This commit is contained in:
parent
43c71a006d
commit
41b1302e1e
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:
|
def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
||||||
if not variants:
|
if not variants:
|
||||||
return (
|
return (
|
||||||
|
|
@ -172,6 +195,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
||||||
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
|
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
|
||||||
)
|
)
|
||||||
disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%"
|
disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%"
|
||||||
|
sales_phrase = _sales_phrase(variants[0].financial)
|
||||||
return (
|
return (
|
||||||
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
|
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
|
||||||
f"<h1>{html.escape(_TITLE)}</h1>"
|
f"<h1>{html.escape(_TITLE)}</h1>"
|
||||||
|
|
@ -179,7 +203,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
||||||
f"{_teap_table(variants)}"
|
f"{_teap_table(variants)}"
|
||||||
f"{_financial_table(variants)}"
|
f"{_financial_table(variants)}"
|
||||||
"<p class='sub'>NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
|
"<p class='sub'>NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
|
||||||
f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). "
|
f"(ПИР 6 мес → СМР по типу застройки → {sales_phrase}, дисконт {disc_pct} годовых). "
|
||||||
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
|
"График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; "
|
||||||
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
||||||
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-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