gendesign/frontend/src/components/concept/ConceptVariantsResult.tsx
Light1YT d350d27325
All checks were successful
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 10m1s
CI / changes (pull_request) Successful in 7s
feat(finmodel): financing overlay (кредит 15%) поверх unlevered DCF (epic #1881 PR-5)
Финансирование как ДОПОЛНИТЕЛЬНЫЙ overlay поверх готового unlevered cashflow:
кредитная линия покрывает накопительный кассовый разрыв, проценты капитализируются
(compound), ставка _CREDIT_RATE_ANNUAL=15% (норматив, ОТДЕЛЬНО от ставки
дисконтирования — разная семантика: стоимость долга vs требуемая доходность).

Backend (financial.py):
- _compute_financing(operating_cf, annual_rate) — чистая (READ-ONLY вход, не
  мутирует): процент на остаток на начало месяца ДО движений → капитализация;
  draw=-cf при оттоке, repay=min(cf,debt) при притоке (долг не уходит в минус).
  Возвращает peak_debt_rub / total_interest_rub / ending_debt_rub.
- compute_financial: вызов после DCF, net_profit_after_financing = net − interest.
- _build_monthly_cashflow и unlevered NPV/IRR/PBP НЕ ТРОНУТЫ → инвариант
  Σ unlevered cashflow == net_profit сохранён (регресс-тест).
- FinancialModel +6 полей (financing_enabled/annual_rate_used/peak_debt_rub/
  total_interest_rub/net_profit_after_financing_rub/financing_is_simplified).

Frontend: ParcelFinancialEstimate +6 полей; adaptFinanceDrawer + concept-каскад
показывают пиковый долг/проценты/чистую прибыль после финанс. (когда enabled).

MVP: только агрегаты, БЕЗ levered IRR (хрупко на хардкод-100%-долге — отдельный
PR). HEAVY caveat: проценты — реальная стоимость (net после < net до); 100%
покрытие разрыва (нет equity/LTC); compound; эскроу не моделируется; unlevered
headline не затронут. financing_is_simplified=True флаг.

mypy strict clean, +9 backend + 3 frontend тестов (compound точное значение,
no-mutation, инвариант-регресс, монотонность, net_after<net), api-types
регенерён авторитетно, deps не тронуты (hand-roll).

Refs #1881
2026-06-24 01:12:47 +05:00

547 lines
19 KiB
TypeScript
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.

"use client";
/**
* ConceptVariantsResult — variant tabs + per-variant placement map, ТЭП KPIs,
* financial model, and export controls. Driven by the ConceptOutput returned
* from POST /concepts. The map is lazy-mounted (Leaflet uses `window`).
*/
import dynamic from "next/dynamic";
import { useState } from "react";
import { KpiCard } from "@/components/analytics/KpiCard";
import { Section } from "@/components/analytics/Section";
import { Badge } from "@/components/ui/Badge";
import {
priceSourceCaption,
STRATEGY_HINTS,
STRATEGY_LABELS,
type ConceptVariant,
type FinancialModel,
} from "@/lib/concept-api";
import type { Polygon } from "geojson";
import { ConceptExportButtons } from "./ConceptExportButtons";
const ConceptResultMap = dynamic(
() =>
import("./ConceptResultMap").then((m) => ({ default: m.ConceptResultMap })),
{
ssr: false,
loading: () => (
<div
style={{
height: 360,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка карты
</div>
),
},
);
// ── Formatters (ru microcopy) ─────────────────────────────────────────────────
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
function formatMoneyCompact(rub: number): string {
const abs = Math.abs(rub);
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
return `${nf.format(Math.round(rub))}`;
}
function formatInt(n: number): string {
return nf.format(Math.round(n));
}
function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`;
}
/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */
function formatPayback(months: number | null): string {
if (months === null) return "не окупается";
return `${months.toFixed(1)} мес`;
}
// ── Variant panel ─────────────────────────────────────────────────────────────
interface PanelProps {
parcel: Polygon;
variant: ConceptVariant;
}
function VariantPanel({ parcel, variant }: PanelProps) {
const { teap, financial } = variant;
const netPositive =
financial.net_profit_rub > 0
? true
: financial.net_profit_rub < 0
? false
: null;
return (
<div>
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
<div
style={{
background: "var(--bg-headline)",
color: "var(--fg-on-dark)",
borderRadius: 12,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 15, fontWeight: 600 }}>
{STRATEGY_LABELS[variant.strategy]}:{" "}
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)}
</div>
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid rgba(255,255,255,0.12)",
fontSize: 12,
color: "var(--fg-on-dark-muted)",
}}
>
{STRATEGY_HINTS[variant.strategy]} NPV{" "}
{formatMoneyCompact(financial.npv_rub)} · IRR{" "}
{formatPct(financial.irr)}
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
{formatPayback(financial.payback_months)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
.
</div>
</div>
{/* Placement map */}
<Section
title="Размещение застройки"
subtitle="Полигон участка (пунктир) и сгенерированные корпуса (заливка)."
>
<ConceptResultMap
parcel={parcel}
buildings={variant.buildings_geojson}
variantKey={variant.strategy}
/>
</Section>
{/* ТЭП — KPI grid */}
<Section
title="ТЭП — технико-экономические показатели"
subtitle="Расчёт движка генерации по выбранной стратегии размещения."
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Площадь застройки"
value={formatInt(teap.built_area_sqm)}
unit="м²"
/>
<KpiCard
label="Общая площадь"
value={formatInt(teap.total_floor_area_sqm)}
unit="м²"
/>
<KpiCard
label="Жилая площадь"
value={formatInt(teap.residential_area_sqm)}
unit="м²"
/>
<KpiCard
label="Квартир"
value={formatInt(teap.apartments_count)}
unit="шт"
/>
<KpiCard
label="Плотность (FAR)"
value={teap.density.toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
/>
<KpiCard
label="Машино-мест"
value={formatInt(teap.parking_spaces)}
unit="шт"
/>
</div>
</Section>
{/* Финмодель */}
<Section
title="Финансовая модель"
subtitle="Девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI; поверх — помесячный DCF (NPV, IRR, окупаемость)."
right={<ConceptExportButtons variant={variant} />}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
/>
<KpiCard
label="Итого затраты"
value={formatMoneyCompact(financial.cost_rub)}
/>
<KpiCard
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
delta={{
value:
netPositive === true
? "Положительная (после НДС и налога на прибыль)"
: netPositive === false
? "Отрицательная (после НДС и налога на прибыль)"
: "Нулевая",
positive: netPositive,
}}
/>
<KpiCard
label="ROI на затраты"
value={formatPct(financial.roi)}
delta={{
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null,
}}
/>
<KpiCard
label="NPV (DCF)"
value={formatMoneyCompact(financial.npv_rub)}
delta={{
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
positive:
financial.npv_rub > 0
? true
: financial.npv_rub < 0
? false
: null,
}}
/>
<KpiCard
label="IRR (DCF, годовой)"
value={formatPct(financial.irr)}
delta={{
value: financial.irr_is_proxy
? "Оценочный (вырожденный поток, не DCF)"
: `Окупаемость ${formatPayback(financial.payback_months)}`,
positive: financial.irr_is_proxy
? null
: financial.irr > financial.discount_rate_used
? true
: false,
}}
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{
value: priceSourceCaption(financial),
positive: null,
}}
/>
</div>
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
<details style={{ marginTop: 12 }}>
<summary
style={{
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-secondary)",
}}
>
Подробнее каскад затрат и расчёт прибыли
</summary>
<div style={{ marginTop: 12 }}>
<FinancialCascadeTable financial={financial} />
<p
style={{
marginTop: 12,
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
NPV / IRR / окупаемость рассчитаны помесячным DCF.{" "}
{financial.schedule_is_default
? `График фаз и темп продаж — типовые допущения (ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт ${formatPct(
financial.discount_rate_used,
)} годовых); метрики рассчитаны DCF по этому графику, точность зависит от графика конкретного проекта. `
: ""}
{financial.irr_is_proxy
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
: ""}
НДС упрощённая оценка (встроенный НДС в положительной марже, не
полная механика входного/выходного НДС НК РФ); привязан к
признанию выручки. Налог на прибыль 25% (с 2025 года). Цена
продажи жилья {" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
{financial.financing_enabled && financial.financing_is_simplified
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
"кредитом по ставке-нормативу, проценты капитализируются, " +
"эскроу не моделируется точно. Проценты — реальная стоимость " +
"(чистая прибыль после финансирования ниже прибыли без него)."
: ""}
</p>
</div>
</details>
</Section>
</div>
);
}
// ── Cost cascade / P&L detail table ────────────────────────────────────────────
function CascadeRow({
label,
value,
emphasis = false,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<tr>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid var(--border-soft)",
color: emphasis ? "var(--fg-primary)" : "var(--fg-secondary)",
fontWeight: emphasis ? 600 : 400,
}}
>
{label}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid var(--border-soft)",
textAlign: "right",
fontWeight: emphasis ? 600 : 400,
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</td>
</tr>
);
}
function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
return (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<tbody>
<CascadeRow
label="Выручка — жильё"
value={formatMoneyCompact(financial.revenue_residential_rub)}
/>
<CascadeRow
label="Выручка — паркинг"
value={formatMoneyCompact(financial.revenue_parking_rub)}
/>
<CascadeRow
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
emphasis
/>
<CascadeRow
label="СМР"
value={formatMoneyCompact(financial.construction_rub)}
/>
<CascadeRow
label="ПИР (проектирование)"
value={formatMoneyCompact(financial.pir_rub)}
/>
<CascadeRow
label="Сети (ТУ)"
value={formatMoneyCompact(financial.networks_rub)}
/>
<CascadeRow
label="Услуги заказчика"
value={formatMoneyCompact(financial.developer_services_rub)}
/>
<CascadeRow
label="Резерв"
value={formatMoneyCompact(financial.contingency_rub)}
/>
<CascadeRow
label="Маркетинг и риэлтор"
value={formatMoneyCompact(financial.marketing_rub)}
/>
<CascadeRow
label="Земля"
value={formatMoneyCompact(financial.land_rub)}
/>
<CascadeRow
label="Итого затраты"
value={formatMoneyCompact(financial.cost_rub)}
emphasis
/>
<CascadeRow
label="Валовая маржа"
value={formatMoneyCompact(financial.gross_margin_rub)}
/>
<CascadeRow
label="НДС (упрощённо)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow
label="Прибыль до налога"
value={formatMoneyCompact(financial.profit_before_tax_rub)}
/>
<CascadeRow
label="Налог на прибыль (25%)"
value={formatMoneyCompact(financial.profit_tax_rub)}
/>
<CascadeRow
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
emphasis
/>
<CascadeRow
label={`NPV (DCF, дисконт ${formatPct(financial.discount_rate_used)})`}
value={formatMoneyCompact(financial.npv_rub)}
/>
<CascadeRow
label={`IRR (годовой)${financial.irr_is_proxy ? ", оценочный" : ""}`}
value={formatPct(financial.irr)}
/>
<CascadeRow
label="Окупаемость (PBP)"
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>
</table>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
parcel: Polygon;
variants: ConceptVariant[];
}
export function ConceptVariantsResult({ parcel, variants }: Props) {
const [active, setActive] = useState(0);
if (variants.length === 0) {
return (
<p style={{ fontSize: 14, color: "var(--fg-secondary)" }}>
Движок не вернул ни одного варианта застройки.
</p>
);
}
const activeVariant = variants[Math.min(active, variants.length - 1)];
return (
<div>
{/* Variant tabs */}
<div
role="tablist"
aria-label="Варианты застройки"
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid var(--border-card)",
marginBottom: 4,
overflowX: "auto",
}}
>
{variants.map((v, i) => {
const isActive = i === active;
return (
<button
key={v.strategy}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActive(i)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "transparent",
border: "none",
borderBottom: isActive
? "2px solid var(--accent)"
: "2px solid transparent",
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
fontSize: 14,
fontWeight: isActive ? 600 : 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{STRATEGY_LABELS[v.strategy]}
<Badge variant={isActive ? "info" : "neutral"} size="sm">
{v.teap.apartments_count > 0
? `${formatInt(v.teap.apartments_count)} кв.`
: "—"}
</Badge>
</button>
);
})}
</div>
<VariantPanel parcel={parcel} variant={activeVariant} />
</div>
);
}