gendesign/backend/tests/services/generative/test_exporters.py
Light1YT d4068a255b
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / backend-tests (pull_request) Successful in 9m54s
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
feat(finmodel): monthly DCF — real NPV/IRR/PBP, replace IRR-proxy (epic #1881 PR-3)
Hand-rolled детерминированный DCF (без numpy-financial/новых deps): _npv /
_irr_monthly (bisection с bracket-guard) / _payback_months над помесячным
cashflow, построенным из статического каскада (PR-1) + калиброванной цены
(PR-2) по дефолтным нормативам фаз (ПИР 6мес → СМР по development_type
24/36/48 → распродажа, дисконт 15%).

- Налоги прорейтятся на признание выручки → Σ недисконтированного cashflow ==
  net_profit (ИНВАРИАНТ, тест — гарантия что DCF не разошёлся со статикой).
- IRR настоящий когда есть смена знака; вырожденный поток → fallback на
  roi-proxy (irr_is_proxy=True). Честно помечено.
- График продаж привязан к завершению стройки: sales_end = max(sales_start +
  длительность, construction_end) — продажи не заканчиваются раньше ввода
  (РФ ДДУ/эскроу). Без этого high_rise распродавался за 12 мес ДО ввода →
  двойная смена знака → profitable-проект молча падал в proxy + инверсия NPV.
- FinancialModel +npv_rub/payback_months/discount_rate_used/schedule_is_default
  (additive). development_type проброшен через placement.
- UI/PDF: NPV/IRR/PBP + честный caveat «график — типовое допущение, точность
  зависит от него». api-types регенерён авторитетно.

mypy strict clean (generative.*), +15 тестов (DCF helpers на учебных значениях,
ИНВАРИАНТ, IRR real-vs-proxy обе ветки, PBP, sales≥construction для всех типов,
profitable high_rise → настоящий IRR). 48 passed.

Refs #1881
2026-06-23 21:52:04 +05:00

130 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Stage 1c tests — DXF and PDF exporters.
DXF is asserted by a binary round-trip (re-read with ezdxf, check layers/entities).
The PDF render is skipped when WeasyPrint's native libraries are unavailable (dev
boxes без libgobject); the HTML-build step is always exercised.
"""
from __future__ import annotations
import importlib.util
import os
import tempfile
from collections import Counter
import ezdxf
import pytest
from app.schemas.concept import ConceptInput
from app.services.generative import geometry
from app.services.generative.exporters import dxf, pdf
_PARCEL_COORDS = [
[60.60, 56.830],
[60.6045, 56.830],
[60.6045, 56.8328],
[60.60, 56.8328],
[60.60, 56.830],
]
def _payload() -> ConceptInput:
return ConceptInput(
parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]},
housing_class="comfort",
target_floors=9,
development_type="mid_rise",
land_cost_rub=150_000_000.0,
)
def _weasyprint_available() -> bool:
"""WeasyPrint импортируется только с нативными библиотеками (libgobject и т.д.)."""
if importlib.util.find_spec("weasyprint") is None:
return False
try:
import weasyprint # noqa: F401
except OSError:
return False
return True
def test_dxf_export_round_trips_with_layers() -> None:
payload = _payload()
parcel = geometry.parse_parcel(payload)
variant = geometry.generate(payload)[0]
data = dxf.export_concept_dxf(parcel, variant)
assert data.startswith(b"AutoCAD Binary DXF")
with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as fh:
fh.write(data)
path = fh.name
try:
doc = ezdxf.readfile(path)
finally:
os.unlink(path)
msp = doc.modelspace()
by_layer = Counter(e.dxf.layer for e in msp)
# Участок и пятно застройки нарисованы.
assert by_layer["PARCEL"] == 1
assert by_layer["BUILDABLE"] == 1
# Секции нарисованы (по одному polyline на секцию + подписи).
n_sections = len(variant.buildings_geojson["features"])
assert n_sections > 0
assert by_layer["BUILDINGS"] >= n_sections
def test_dxf_building_footprints_have_metric_area() -> None:
from shapely.geometry import Polygon
payload = _payload()
parcel = geometry.parse_parcel(payload)
variant = geometry.generate(payload)[0]
data = dxf.export_concept_dxf(parcel, variant)
with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as fh:
fh.write(data)
path = fh.name
try:
doc = ezdxf.readfile(path)
finally:
os.unlink(path)
areas = []
for e in doc.modelspace():
if e.dxftype() == "LWPOLYLINE" and e.dxf.layer == "BUILDINGS":
pts = [(p[0], p[1]) for p in e.get_points()]
areas.append(Polygon(pts).area)
assert areas, "no building polylines found"
# Площади секций — десятки/сотни кв.м (метры), не доли (градусы).
for area in areas:
assert 50.0 < area < 2000.0
def test_pdf_html_build_contains_tables() -> None:
variants = geometry.generate(_payload())
html = pdf._build_html(variants)
assert "Технико-экономические показатели" in html
assert "Финансовая модель" in html
# PR-3: DCF-строки заменили IRR-proxy в таблице финмодели.
assert "NPV (DCF), млн руб" in html
assert "IRR (DCF, годовой)" in html
assert "Окупаемость (PBP)" in html
def test_pdf_html_build_graceful_on_empty() -> None:
html = pdf._build_html([])
assert "нет вариантов" in html
@pytest.mark.skipif(
not _weasyprint_available(),
reason="WeasyPrint native libs unavailable on this host",
)
def test_pdf_export_produces_pdf_bytes() -> None:
variants = geometry.generate(_payload())
data = pdf.export_concept_pdf(variants)
assert data.startswith(b"%PDF-")
assert len(data) > 1000