From 41b1302e1e46d763d22e7334dec4d217f38a5ea8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 22:19:27 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D1=81=D0=BD=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B5=D0=BF=D1=82-PDF=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=B0=D0=B5=D1=82=20=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=81?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=20=D1=80=D0=B0=D1=81=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=B6=D0=B8,=20=D0=B0=20=D0=BD=D0=B5=20=C2=AB30=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=C2=BB=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В методической сноске стояло «распродажа 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. --- .../app/services/generative/exporters/pdf.py | 26 +++- .../test_2464_concept_pdf_sales_window.py | 124 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py diff --git a/backend/app/services/generative/exporters/pdf.py b/backend/app/services/generative/exporters/pdf.py index 7ec34e11..8703dee5 100644 --- a/backend/app/services/generative/exporters/pdf.py +++ b/backend/app/services/generative/exporters/pdf.py @@ -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"

{_DASH} нет вариантов для отображения

" ) disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%" + sales_phrase = _sales_phrase(variants[0].financial) return ( f"" f"

{html.escape(_TITLE)}

" @@ -179,7 +203,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str: f"{_teap_table(variants)}" f"{_financial_table(variants)}" "

NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз " - f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). " + f"(ПИР 6 мес → СМР по типу застройки → {sales_phrase}, дисконт {disc_pct} годовых). " "График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; " "точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток " "вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " diff --git a/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py new file mode 100644 index 00000000..7104d93a --- /dev/null +++ b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py @@ -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 -- 2.45.3