Merge pull request 'feat(finmodel): financing overlay (кредит 15%) поверх unlevered DCF (epic #1881 PR-5)' (#1886) from feat/finmodel-financing into main
All checks were successful
All checks were successful
This commit is contained in:
commit
f0b689318e
10 changed files with 364 additions and 3 deletions
|
|
@ -91,6 +91,17 @@ class FinancialModel(BaseModel):
|
||||||
# проекта). True → UI/PDF показывают caveat «график — типовое допущение».
|
# проекта). True → UI/PDF показывают caveat «график — типовое допущение».
|
||||||
schedule_is_default: bool = True
|
schedule_is_default: bool = True
|
||||||
|
|
||||||
|
# ── Financing overlay (PR-5, эпик #1881) ───────────────────────────────────
|
||||||
|
# OVERLAY поверх unlevered cashflow: кредит покрывает кассовый разрыв, проценты
|
||||||
|
# капитализируются (compound). Unlevered NPV/IRR (headline) НЕ затронуты.
|
||||||
|
financing_enabled: bool = False # True когда overlay посчитан
|
||||||
|
annual_rate_used: float = 0.0 # _CREDIT_RATE_ANNUAL (норматив, отдельно от дисконта)
|
||||||
|
peak_debt_rub: float = 0.0 # «задолженность по кредиту» Excel — пик долга
|
||||||
|
total_interest_rub: float = 0.0 # «поток по финдеятельности» Excel — Σ процентов
|
||||||
|
net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest
|
||||||
|
# honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно.
|
||||||
|
financing_is_simplified: bool = True
|
||||||
|
|
||||||
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
# ── Price calibration (PR-2, эпик #1881) ───────────────────────────────────
|
||||||
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
# Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо
|
||||||
# калиброванная по рынку (Objective / district reference), либо норматив класса.
|
# калиброванная по рынку (Objective / district reference), либо норматив класса.
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,34 @@ flag the source honestly (``price_source="class_norm"``) so the UI/PDF never
|
||||||
passes a class norm off as a market figure. Construction cost (СМР) is *not*
|
passes a class norm off as a market figure. Construction cost (СМР) is *not*
|
||||||
calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms.
|
calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms.
|
||||||
|
|
||||||
|
FINANCING — OVERLAY (PR-5)
|
||||||
|
--------------------------
|
||||||
|
Unlevered (project) DCF — NPV / IRR / PBP — остаётся HEADLINE и НЕ меняется:
|
||||||
|
это интринсивная доходность проекта, инвариант Σ cashflow == net_profit
|
||||||
|
сохраняется. Финансирование (кредит/проектное финансирование) — это
|
||||||
|
ДОПОЛНИТЕЛЬНЫЙ overlay ПОВЕРХ готового unlevered cashflow; он его читает
|
||||||
|
(READ-ONLY) и НЕ пересобирает.
|
||||||
|
|
||||||
|
* **Проценты — реальная стоимость долга**, поэтому ``net_profit_after_financing
|
||||||
|
< net_profit`` для любого проекта с кассовым разрывом.
|
||||||
|
* **Ставка ``annual_rate_used`` — норматив** (``_CREDIT_RATE_ANNUAL``), отдельный
|
||||||
|
от ставки дисконтирования (см. константы), а не выведенная из проекта величина.
|
||||||
|
* **100% покрытие разрыва** — кредитная линия покрывает весь накопительный
|
||||||
|
кассовый разрыв; equity / LTC / лимиты банка НЕ моделируются (нет доли
|
||||||
|
собственных средств).
|
||||||
|
* **Проценты капитализируются (compound)** — начисляются на остаток на начало
|
||||||
|
месяца ДО движений и добавляются в тело долга.
|
||||||
|
* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов
|
||||||
|
по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это
|
||||||
|
упрощено до единой капитализируемой ставки на кассовый разрыв.
|
||||||
|
* **Levered IRR НЕ считается** в этом PR (хрупко на хардкод-100%-долге — отдельный
|
||||||
|
PR). MVP раскрывает только агрегаты: ``peak_debt_rub`` / ``total_interest_rub``
|
||||||
|
/ ``net_profit_after_financing_rub``; debt_schedule в схему НЕ выносится.
|
||||||
|
* **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же
|
||||||
|
интринсивная доходность. График наследует неточность ``schedule_is_default``:
|
||||||
|
финансирование считается по тому же дефолт-графику фаз, поэтому
|
||||||
|
``financing_is_simplified=True`` (honest-флаг для UI/PDF).
|
||||||
|
|
||||||
This function stays **pure**: it does no DB lookup itself (the API layer does the
|
This function stays **pure**: it does no DB lookup itself (the API layer does the
|
||||||
lookup and passes the result in). Детерминированно, без LLM / внешних API / БД.
|
lookup and passes the result in). Детерминированно, без LLM / внешних API / БД.
|
||||||
"""
|
"""
|
||||||
|
|
@ -160,6 +188,11 @@ _PROJECT_YEARS: float = 3.0
|
||||||
|
|
||||||
# Годовая ставка дисконтирования (Excel-эталон девелоперской модели).
|
# Годовая ставка дисконтирования (Excel-эталон девелоперской модели).
|
||||||
_DISCOUNT_RATE_ANNUAL: float = 0.15
|
_DISCOUNT_RATE_ANNUAL: float = 0.15
|
||||||
|
# Ставка по кредиту/проектному финансированию (Excel-эталон). ОТДЕЛЬНА от
|
||||||
|
# _DISCOUNT_RATE_ANNUAL: дисконт = требуемая доходность инвестора (NPV), кредитная
|
||||||
|
# ставка = стоимость долга (проценты). Совпадение значения (0.15) — калибровка,
|
||||||
|
# НЕ тождество; не сливать в одну константу.
|
||||||
|
_CREDIT_RATE_ANNUAL: float = 0.15
|
||||||
# Длительность ПИР (проектирование/экспертиза), мес.
|
# Длительность ПИР (проектирование/экспертиза), мес.
|
||||||
_PIR_DURATION_MONTHS: int = 6
|
_PIR_DURATION_MONTHS: int = 6
|
||||||
# Длительность СМР по типу застройки, мес (точечная быстрее высотной).
|
# Длительность СМР по типу застройки, мес (точечная быстрее высотной).
|
||||||
|
|
@ -258,6 +291,42 @@ def _payback_months(cf: list[float]) -> float | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_financing(operating_cf: list[float], annual_rate: float) -> dict[str, float]:
|
||||||
|
"""OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.
|
||||||
|
|
||||||
|
``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
|
||||||
|
Кредитная линия покрывает накопительный кассовый разрыв на 100%; проценты
|
||||||
|
КАПИТАЛИЗИРУЮТСЯ в долг (compound) — упрощение под эскроу-логику РФ. Порядок
|
||||||
|
внутри месяца: процент НАЧИСЛЯЕМ на остаток на начало месяца ДО draw/repay,
|
||||||
|
затем выборка (cf<0) либо погашение притоком (cf>0, не уходя ниже 0).
|
||||||
|
|
||||||
|
Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
|
||||||
|
(корректно: финансирование не требуется).
|
||||||
|
|
||||||
|
Возвращает агрегаты (без debt_schedule — только итоговые числа для MVP):
|
||||||
|
``peak_debt_rub`` — пиковая задолженность, ``total_interest_rub`` — суммарные
|
||||||
|
капитализированные проценты, ``ending_debt_rub`` — остаток долга на конец.
|
||||||
|
"""
|
||||||
|
monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
|
||||||
|
debt = 0.0
|
||||||
|
peak_debt = 0.0
|
||||||
|
total_interest = 0.0
|
||||||
|
for cf in operating_cf:
|
||||||
|
interest = debt * monthly_rate # процент на остаток на начало месяца (до движений)
|
||||||
|
debt += interest
|
||||||
|
total_interest += interest
|
||||||
|
if cf < 0:
|
||||||
|
debt += -cf # выборка на покрытие оттока
|
||||||
|
else:
|
||||||
|
debt -= min(cf, debt) # гасим долг притоком (не уходим в минус)
|
||||||
|
peak_debt = max(peak_debt, debt)
|
||||||
|
return {
|
||||||
|
"peak_debt_rub": round(peak_debt, 2),
|
||||||
|
"total_interest_rub": round(total_interest, 2),
|
||||||
|
"ending_debt_rub": round(debt, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]:
|
def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]:
|
||||||
"""Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``.
|
"""Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``.
|
||||||
|
|
||||||
|
|
@ -457,6 +526,13 @@ def compute_financial(
|
||||||
irr = max(-1.0, min(1.0, roi / _PROJECT_YEARS))
|
irr = max(-1.0, min(1.0, roi / _PROJECT_YEARS))
|
||||||
irr_is_proxy = True
|
irr_is_proxy = True
|
||||||
|
|
||||||
|
# ── Финансирование: OVERLAY поверх unlevered cashflow (PR-5) ───────────────
|
||||||
|
# Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта:
|
||||||
|
# overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость
|
||||||
|
# долга, режут прибыль: net_after < net. Levered IRR НЕ считаем (отдельный PR).
|
||||||
|
financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL)
|
||||||
|
net_profit_after_financing = net_profit - financing["total_interest_rub"]
|
||||||
|
|
||||||
model = FinancialModel(
|
model = FinancialModel(
|
||||||
# legacy summary
|
# legacy summary
|
||||||
revenue_rub=round(revenue, 2),
|
revenue_rub=round(revenue, 2),
|
||||||
|
|
@ -488,6 +564,13 @@ def compute_financial(
|
||||||
payback_months=(round(payback, 1) if payback is not None else None),
|
payback_months=(round(payback, 1) if payback is not None else None),
|
||||||
discount_rate_used=_DISCOUNT_RATE_ANNUAL,
|
discount_rate_used=_DISCOUNT_RATE_ANNUAL,
|
||||||
schedule_is_default=True,
|
schedule_is_default=True,
|
||||||
|
# financing overlay (PR-5)
|
||||||
|
financing_enabled=True,
|
||||||
|
annual_rate_used=_CREDIT_RATE_ANNUAL,
|
||||||
|
peak_debt_rub=financing["peak_debt_rub"],
|
||||||
|
total_interest_rub=financing["total_interest_rub"],
|
||||||
|
net_profit_after_financing_rub=round(net_profit_after_financing, 2),
|
||||||
|
financing_is_simplified=True,
|
||||||
# price calibration (PR-2)
|
# price calibration (PR-2)
|
||||||
price_per_sqm_used=round(sale_price, 2),
|
price_per_sqm_used=round(sale_price, 2),
|
||||||
price_is_calibrated=price_is_calibrated,
|
price_is_calibrated=price_is_calibrated,
|
||||||
|
|
@ -495,7 +578,8 @@ def compute_financial(
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
|
"financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
|
||||||
"irr=%.3f%s pbp=%s price=%.0f/m2 source=%s",
|
"irr=%.3f%s pbp=%s price=%.0f/m2 source=%s "
|
||||||
|
"peak_debt=%.0f total_interest=%.0f net_after_fin=%.0f",
|
||||||
model.revenue_rub,
|
model.revenue_rub,
|
||||||
model.cost_rub,
|
model.cost_rub,
|
||||||
model.net_profit_rub,
|
model.net_profit_rub,
|
||||||
|
|
@ -506,6 +590,9 @@ def compute_financial(
|
||||||
model.payback_months,
|
model.payback_months,
|
||||||
model.price_per_sqm_used,
|
model.price_per_sqm_used,
|
||||||
model.price_source,
|
model.price_source,
|
||||||
|
model.peak_debt_rub,
|
||||||
|
model.total_interest_rub,
|
||||||
|
model.net_profit_after_financing_rub,
|
||||||
)
|
)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,13 @@ def test_concepts_response_matches_contract_keys() -> None:
|
||||||
"payback_months",
|
"payback_months",
|
||||||
"discount_rate_used",
|
"discount_rate_used",
|
||||||
"schedule_is_default",
|
"schedule_is_default",
|
||||||
|
# financing overlay (PR-5)
|
||||||
|
"financing_enabled",
|
||||||
|
"annual_rate_used",
|
||||||
|
"peak_debt_rub",
|
||||||
|
"total_interest_rub",
|
||||||
|
"net_profit_after_financing_rub",
|
||||||
|
"financing_is_simplified",
|
||||||
# price calibration (PR-2)
|
# price calibration (PR-2)
|
||||||
"price_per_sqm_used",
|
"price_per_sqm_used",
|
||||||
"price_is_calibrated",
|
"price_is_calibrated",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.schemas.concept import TEAP
|
||||||
from app.services.generative import financial
|
from app.services.generative import financial
|
||||||
from app.services.generative.financial import (
|
from app.services.generative.financial import (
|
||||||
_build_monthly_cashflow,
|
_build_monthly_cashflow,
|
||||||
|
_compute_financing,
|
||||||
_irr_monthly,
|
_irr_monthly,
|
||||||
_npv,
|
_npv,
|
||||||
_payback_months,
|
_payback_months,
|
||||||
|
|
@ -255,3 +256,96 @@ def test_unknown_development_type_defaults_to_mid_rise() -> None:
|
||||||
)
|
)
|
||||||
assert none_type.npv_rub == explicit_mid.npv_rub
|
assert none_type.npv_rub == explicit_mid.npv_rub
|
||||||
assert none_type.irr == explicit_mid.irr
|
assert none_type.irr == explicit_mid.irr
|
||||||
|
|
||||||
|
|
||||||
|
# ── FINANCING — OVERLAY (PR-5) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_does_not_mutate_operating_cashflow() -> None:
|
||||||
|
# OVERLAY: _compute_financing читает unlevered поток READ-ONLY, НЕ мутирует.
|
||||||
|
cf = [-1000.0, -500.0, 0.0, 800.0, 900.0]
|
||||||
|
snapshot = list(cf)
|
||||||
|
_compute_financing(cf, 0.15)
|
||||||
|
assert cf == snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_draws_debt_on_cash_gap() -> None:
|
||||||
|
# Ранний отток / поздняя выручка → нужна кредитная линия → peak_debt>0, %>0.
|
||||||
|
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||||
|
res = _compute_financing(cf, 0.15)
|
||||||
|
assert res["peak_debt_rub"] > 0.0
|
||||||
|
assert res["total_interest_rub"] > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_zero_when_no_cash_gap() -> None:
|
||||||
|
# Поток без минуса → финансирование не требуется → debt=0, interest=0.
|
||||||
|
cf = [100.0, 200.0, 300.0]
|
||||||
|
res = _compute_financing(cf, 0.15)
|
||||||
|
assert res["peak_debt_rub"] == 0.0
|
||||||
|
assert res["total_interest_rub"] == 0.0
|
||||||
|
assert res["ending_debt_rub"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_interest_capitalises_compound() -> None:
|
||||||
|
# Долг 1000 в мес 0, дальше ноль → проценты КАПИТАЛИЗИРУЮТСЯ (начисляются на
|
||||||
|
# растущий остаток). За мес 1 и 2 суммарный % > 2× месячного % от 1000 (compound).
|
||||||
|
cf = [-1000.0, 0.0, 0.0]
|
||||||
|
res = _compute_financing(cf, 0.15)
|
||||||
|
monthly_rate = (1.0 + 0.15) ** (1.0 / 12.0) - 1.0
|
||||||
|
# Мес 0: % с нуля = 0, выборка 1000. Мес 1: % = 1000·r. Мес 2: % = (1000+1000·r)·r.
|
||||||
|
expected = 1000.0 * monthly_rate + (1000.0 + 1000.0 * monthly_rate) * monthly_rate
|
||||||
|
assert abs(res["total_interest_rub"] - round(expected, 2)) < 0.5
|
||||||
|
# Compound строго больше простого 2× месячного % от исходного тела долга.
|
||||||
|
assert res["total_interest_rub"] > 2.0 * 1000.0 * monthly_rate
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_interest_monotonic_in_rate() -> None:
|
||||||
|
# Монотонность: выше ставка → выше суммарные проценты при том же потоке.
|
||||||
|
cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
|
||||||
|
low = _compute_financing(cf, 0.10)
|
||||||
|
high = _compute_financing(cf, 0.20)
|
||||||
|
assert high["total_interest_rub"] > low["total_interest_rub"]
|
||||||
|
assert high["peak_debt_rub"] >= low["peak_debt_rub"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_profit_after_financing_equals_net_minus_interest() -> None:
|
||||||
|
# Тождество модели: net_after == net_profit − total_interest (float-tol).
|
||||||
|
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
|
||||||
|
)
|
||||||
|
expected = model.net_profit_rub - model.total_interest_rub
|
||||||
|
assert abs(model.net_profit_after_financing_rub - expected) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_profit_after_financing_below_net_for_project_with_gap() -> None:
|
||||||
|
# Проект с кассовым разрывом: проценты режут прибыль → net_after < net.
|
||||||
|
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
|
||||||
|
)
|
||||||
|
assert model.total_interest_rub > 0.0
|
||||||
|
assert model.net_profit_after_financing_rub < model.net_profit_rub
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_overlay_does_not_touch_unlevered_invariant() -> None:
|
||||||
|
# РЕГРЕСС: overlay не трогает unlevered cashflow → инвариант Σ cf == net_profit
|
||||||
|
# держится как до PR-5 (financing — поверх готового потока, не пересборка).
|
||||||
|
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
|
||||||
|
)
|
||||||
|
cf = _rebuild_cashflow(model, construction_months=36)
|
||||||
|
assert abs(sum(cf) - model.net_profit_rub) < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_financing_enabled_on_realistic_parcel() -> None:
|
||||||
|
# Интеграция compute_financial: реалистичный участок → overlay посчитан.
|
||||||
|
t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
|
||||||
|
model = financial.compute_financial(
|
||||||
|
teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
|
||||||
|
)
|
||||||
|
assert model.financing_enabled is True
|
||||||
|
assert model.financing_is_simplified is True
|
||||||
|
assert model.annual_rate_used == financial._CREDIT_RATE_ANNUAL
|
||||||
|
assert model.peak_debt_rub > 0.0
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,12 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
: "норматив класса (нет рыночных данных по участку)"}
|
: "норматив класса (нет рыночных данных по участку)"}
|
||||||
; себестоимость СМР и цена паркинга — нормативные ориентиры.
|
; себестоимость СМР и цена паркинга — нормативные ориентиры.
|
||||||
Коммерческие и офисные площади не учитываются.
|
Коммерческие и офисные площади не учитываются.
|
||||||
|
{financial.financing_enabled && financial.financing_is_simplified
|
||||||
|
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
|
||||||
|
"кредитом по ставке-нормативу, проценты капитализируются, " +
|
||||||
|
"эскроу не моделируется точно. Проценты — реальная стоимость " +
|
||||||
|
"(чистая прибыль после финансирования ниже прибыли без него)."
|
||||||
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -436,6 +442,29 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
||||||
label="Окупаемость (PBP)"
|
label="Окупаемость (PBP)"
|
||||||
value={formatPayback(financial.payback_months)}
|
value={formatPayback(financial.payback_months)}
|
||||||
/>
|
/>
|
||||||
|
{/* #1881 PR-5 — финансирование (кредит). Показываем только когда движок
|
||||||
|
смоделировал финансирование (financing_enabled). */}
|
||||||
|
{financial.financing_enabled ? (
|
||||||
|
<>
|
||||||
|
<CascadeRow
|
||||||
|
label="Пиковый долг"
|
||||||
|
value={formatMoneyCompact(financial.peak_debt_rub)}
|
||||||
|
/>
|
||||||
|
<CascadeRow
|
||||||
|
label={`Проценты по кредиту (ставка ${formatPct(
|
||||||
|
financial.annual_rate_used,
|
||||||
|
)} годовых)`}
|
||||||
|
value={formatMoneyCompact(financial.total_interest_rub)}
|
||||||
|
/>
|
||||||
|
<CascadeRow
|
||||||
|
label="Чистая прибыль после финансирования"
|
||||||
|
value={formatMoneyCompact(
|
||||||
|
financial.net_profit_after_financing_rub,
|
||||||
|
)}
|
||||||
|
emphasis
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,13 @@ function financialFixture(): ParcelFinancialEstimate {
|
||||||
apartments_count: 520,
|
apartments_count: 520,
|
||||||
},
|
},
|
||||||
caveat: "Расчёт по максимальной застройке НСПД, а не реальной концепции.",
|
caveat: "Расчёт по максимальной застройке НСПД, а не реальной концепции.",
|
||||||
|
// #1881 PR-5 — финансирование (кредит).
|
||||||
|
financing_enabled: true,
|
||||||
|
annual_rate_used: 0.18,
|
||||||
|
peak_debt_rub: 1_800_000_000,
|
||||||
|
total_interest_rub: 320_000_000,
|
||||||
|
net_profit_after_financing_rub: 780_000_000,
|
||||||
|
financing_is_simplified: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,9 +189,39 @@ describe("ptica-adapt finance (#1881 PR-4)", () => {
|
||||||
const fin = financialFixture();
|
const fin = financialFixture();
|
||||||
const d = adaptFinanceDrawer(fin);
|
const d = adaptFinanceDrawer(fin);
|
||||||
expect(d.rows.every((r) => r.field.isReal === true)).toBe(true);
|
expect(d.rows.every((r) => r.field.isReal === true)).toBe(true);
|
||||||
expect(d.summary).toBe(fin.caveat);
|
|
||||||
const keys = d.rows.map((r) => r.k);
|
const keys = d.rows.map((r) => r.k);
|
||||||
expect(keys).toContain("IRR");
|
expect(keys).toContain("IRR");
|
||||||
expect(keys).toContain("ТЭП · квартир");
|
expect(keys).toContain("ТЭП · квартир");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #1881 PR-5 — финансирование (кредит)
|
||||||
|
it("adaptFinanceDrawer: financing_enabled → debt/interest/net-after rows + caption", () => {
|
||||||
|
const fin = financialFixture(); // financing_enabled: true
|
||||||
|
const d = adaptFinanceDrawer(fin);
|
||||||
|
const keys = d.rows.map((r) => r.k);
|
||||||
|
expect(keys).toContain("Пиковый долг");
|
||||||
|
expect(keys).toContain("Проценты по кредиту");
|
||||||
|
expect(keys).toContain("Чистая прибыль после финанс.");
|
||||||
|
const interest = d.rows.find((r) => r.k === "Проценты по кредиту");
|
||||||
|
expect(interest?.field.isReal).toBe(true);
|
||||||
|
expect(interest?.field.caption).toContain("18");
|
||||||
|
expect(interest?.field.caption).toContain("годовых");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adaptFinanceDrawer: financing_is_simplified → caveat appended to summary", () => {
|
||||||
|
const d = adaptFinanceDrawer(financialFixture());
|
||||||
|
expect(d.summary).toContain("упрощен");
|
||||||
|
expect(d.summary).toContain("эскроу");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adaptFinanceDrawer: financing_enabled=false → no financing rows", () => {
|
||||||
|
const d = adaptFinanceDrawer({
|
||||||
|
...financialFixture(),
|
||||||
|
financing_enabled: false,
|
||||||
|
});
|
||||||
|
const keys = d.rows.map((r) => r.k);
|
||||||
|
expect(keys).not.toContain("Пиковый долг");
|
||||||
|
expect(keys).not.toContain("Проценты по кредиту");
|
||||||
|
expect(keys).not.toContain("Чистая прибыль после финанс.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1725,9 +1725,46 @@ export function adaptFinanceDrawer(
|
||||||
)} мес)`
|
)} мес)`
|
||||||
: "не окупается в горизонте модели";
|
: "не окупается в горизонте модели";
|
||||||
|
|
||||||
|
// #1881 PR-5 — финансирование (кредит). Показываем строки только когда
|
||||||
|
// financing_enabled; проценты — реальная стоимость (чистая прибыль после
|
||||||
|
// финансирования < чистой прибыли до). financing_is_simplified → честный
|
||||||
|
// caveat в summary, чтобы финдиректор не принял упрощение за точную модель.
|
||||||
|
const financingRows: DrawerKvRow[] = financial.financing_enabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
k: "Пиковый долг",
|
||||||
|
field: real(`${formatBln(financial.peak_debt_rub)} млрд ₽`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
k: "Проценты по кредиту",
|
||||||
|
field: {
|
||||||
|
value: `${formatBln(financial.total_interest_rub)} млрд ₽`,
|
||||||
|
isReal: true,
|
||||||
|
caption: `ставка ${formatPctFromFraction(
|
||||||
|
financial.annual_rate_used,
|
||||||
|
)} % годовых`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
k: "Чистая прибыль после финанс.",
|
||||||
|
field: real(
|
||||||
|
`${formatBln(financial.net_profit_after_financing_rub)} млрд ₽`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
financial.financing_enabled && financial.financing_is_simplified
|
||||||
|
? `${financial.caveat} Финансирование упрощено: весь кассовый разрыв ` +
|
||||||
|
"покрыт кредитом по ставке-нормативу, проценты капитализируются, эскроу " +
|
||||||
|
"не моделируется точно."
|
||||||
|
: financial.caveat;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: financial.caveat,
|
summary,
|
||||||
rows: [
|
rows: [
|
||||||
|
...financingRows,
|
||||||
{ k: "GDV (выручка)", field: real(`${formatBln(financial.revenue_rub)} млрд ₽`) },
|
{ k: "GDV (выручка)", field: real(`${formatBln(financial.revenue_rub)} млрд ₽`) },
|
||||||
{
|
{
|
||||||
k: "Совокупные затраты",
|
k: "Совокупные затраты",
|
||||||
|
|
|
||||||
|
|
@ -3569,6 +3569,36 @@ export interface components {
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
schedule_is_default: boolean;
|
schedule_is_default: boolean;
|
||||||
|
/**
|
||||||
|
* Financing Enabled
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
financing_enabled: boolean;
|
||||||
|
/**
|
||||||
|
* Annual Rate Used
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
annual_rate_used: number;
|
||||||
|
/**
|
||||||
|
* Peak Debt Rub
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
peak_debt_rub: number;
|
||||||
|
/**
|
||||||
|
* Total Interest Rub
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
total_interest_rub: number;
|
||||||
|
/**
|
||||||
|
* Net Profit After Financing Rub
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
net_profit_after_financing_rub: number;
|
||||||
|
/**
|
||||||
|
* Financing Is Simplified
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
financing_is_simplified: boolean;
|
||||||
/** Price Per Sqm Used */
|
/** Price Per Sqm Used */
|
||||||
price_per_sqm_used: number;
|
price_per_sqm_used: number;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,22 @@ export interface FinancialModel {
|
||||||
price_is_calibrated: boolean;
|
price_is_calibrated: boolean;
|
||||||
/** "objective_district_median" | "district_reference" | "class_norm". */
|
/** "objective_district_median" | "district_reference" | "class_norm". */
|
||||||
price_source: PriceSource;
|
price_source: PriceSource;
|
||||||
|
// Financing / кредит (PR-5, эпик #1881)
|
||||||
|
/** false → финансирование не моделировалось (строки кредита не показываем). */
|
||||||
|
financing_enabled: boolean;
|
||||||
|
/** Годовая ставка кредита, доля (0.15 = 15% годовых). */
|
||||||
|
annual_rate_used: number;
|
||||||
|
/** Пиковый долг по телу кредита, ₽. */
|
||||||
|
peak_debt_rub: number;
|
||||||
|
/** Суммарные проценты по кредиту за проект, ₽. */
|
||||||
|
total_interest_rub: number;
|
||||||
|
/** Чистая прибыль за вычетом процентов по кредиту, ₽. */
|
||||||
|
net_profit_after_financing_rub: number;
|
||||||
|
/**
|
||||||
|
* true — упрощённая модель: весь кассовый разрыв покрыт кредитом по
|
||||||
|
* ставке-нормативу, проценты капитализируются, эскроу не моделируется точно.
|
||||||
|
*/
|
||||||
|
financing_is_simplified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PriceSource =
|
export type PriceSource =
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,19 @@ export interface ParcelFinancialEstimate {
|
||||||
apartments_count: number;
|
apartments_count: number;
|
||||||
};
|
};
|
||||||
caveat: string;
|
caveat: string;
|
||||||
|
// #1881 PR-5 — финансирование (кредит) в финмодели. financing_enabled=false →
|
||||||
|
// финансирование не моделировалось (UI не показывает строки кредита).
|
||||||
|
// annual_rate_used — годовая ставка кредита (доля 0..1). peak_debt_rub —
|
||||||
|
// пиковый долг по телу кредита, ₽. total_interest_rub — суммарные проценты, ₽.
|
||||||
|
// net_profit_after_financing_rub — чистая прибыль за вычетом процентов, ₽.
|
||||||
|
// financing_is_simplified=true → упрощённая модель (100% покрытие кассового
|
||||||
|
// разрыва, проценты капитализируются, эскроу не моделируется точно) — caveat.
|
||||||
|
financing_enabled: boolean;
|
||||||
|
annual_rate_used: number;
|
||||||
|
peak_debt_rub: number;
|
||||||
|
total_interest_rub: number;
|
||||||
|
net_profit_after_financing_rub: number;
|
||||||
|
financing_is_simplified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue