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
130 lines
4.1 KiB
Python
130 lines
4.1 KiB
Python
"""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
|