Wire the interactive 3D massing to a LIVE financial recompute inside the Section «7. Концепция» of the light analysis report (epic #1953). - concept-api.ts: add MassingProgram / MassingRecomputeOutput contracts + useRecomputeMassing() (POST /api/v1/concepts/recompute via shared apiFetch), and polygonCentroidWkt() helper for the DB-price fallback hint. - MassingScene.tsx: export MassingModel + add optional onModelChange prop fired on the initial build and every floors/sections change (NOT on the sun/hour slider). No-op when absent — /ptica cockpit + preview/drawer unchanged. - MassingEconomics.tsx (new): light-token KPI strip rendering recomputed ТЭП (GFA, продаваемая, квартиры, КСИТ факт/цель) + финмодель (выручка, себестоимость, прибыль, ROI, NPV, IRR, цена). Debounced ~250ms, latest-wins via monotonic request id, last-good values + subtle note on error, grey-fade skeleton on first load, КСИТ over-cap flag. - Section7Concept.tsx: mount lazy MassingViewer (dynamic ssr:false) + the economics strip; map computeModel → MassingProgram (footprint/floors/sections ← model; site area ← polygon; class/type ← financial_estimate inferred, fallback comfort/mid_rise; land ← egrn cadastral; price/source ← financial_estimate; КСИТ-цель ← coerced nspd_zoning.max_far, fallback 3.5).
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
|
||
* epic #1953).
|
||
*
|
||
* Driven by the interactive 3D MassingScene: every time the user drags the
|
||
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
|
||
* result + the analysis context into a `MassingProgram` and hands it here via
|
||
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
|
||
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
|
||
* прибыль / ROI).
|
||
*
|
||
* Robustness:
|
||
* • debounce — slider drags fire many programs; only the settled one is sent.
|
||
* • latest-wins — an in-flight request is superseded by a newer one via a
|
||
* monotonic request id; a stale response is dropped, never overwriting a
|
||
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
|
||
* • last-good — on a failed recompute we keep the last successful values and
|
||
* show a subtle inline note rather than blanking the panel.
|
||
* • skeleton — a plain grey fade KPI grid while the FIRST recompute is in
|
||
* flight (no shimmer, per ui-conventions).
|
||
*
|
||
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
|
||
* this strip uses the light KPI tokens via the shared KpiCard.
|
||
*/
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { AlertTriangle } from "lucide-react";
|
||
|
||
import { KpiCard } from "@/components/analytics/KpiCard";
|
||
import { Section } from "@/components/analytics/Section";
|
||
import {
|
||
priceSourceCaption,
|
||
useRecomputeMassing,
|
||
type FinancialModel,
|
||
type MassingProgram,
|
||
type MassingRecomputeOutput,
|
||
type Teap,
|
||
} from "@/lib/concept-api";
|
||
|
||
const DEBOUNCE_MS = 250;
|
||
|
||
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
|
||
|
||
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)}%`;
|
||
}
|
||
|
||
function formatFar(far: number): string {
|
||
return far.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
});
|
||
}
|
||
|
||
// ── KPI grid ───────────────────────────────────────────────────────────────────
|
||
|
||
interface KpiGridProps {
|
||
teap: Teap;
|
||
financial: FinancialModel;
|
||
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
|
||
farTarget: number;
|
||
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
|
||
ksitOver: boolean;
|
||
/** Dim the strip while a fresher recompute is in flight (last-good values). */
|
||
stale: boolean;
|
||
}
|
||
|
||
function KpiGrid({
|
||
teap,
|
||
financial,
|
||
farTarget,
|
||
ksitOver,
|
||
stale,
|
||
}: KpiGridProps) {
|
||
const netPositive =
|
||
financial.net_profit_rub > 0
|
||
? true
|
||
: financial.net_profit_rub < 0
|
||
? false
|
||
: null;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
opacity: stale ? 0.55 : 1,
|
||
transition: "opacity 150ms linear",
|
||
}}
|
||
>
|
||
{/* ТЭП */}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<KpiCard
|
||
label="Общая площадь (GFA)"
|
||
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="КСИТ — факт / цель"
|
||
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
|
||
delta={{
|
||
value: ksitOver
|
||
? "Факт превышает регламентный потолок"
|
||
: "В пределах регламента",
|
||
positive: ksitOver ? false : true,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Финмодель */}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
marginTop: 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)"
|
||
: "Дисконтированный денежный поток",
|
||
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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
|
||
|
||
function SkeletonGrid() {
|
||
const cells = Array.from({ length: 7 });
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
{cells.map((_, i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
height: 92,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Component ───────────────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
/**
|
||
* The current massing program (Σ footprint × floors + context), mapped by
|
||
* Section7Concept from the 3D scene's computeModel result. `null` until the
|
||
* scene has fired its first onModelChange (or when geometry is missing).
|
||
*/
|
||
program: MassingProgram | null;
|
||
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
|
||
farTarget: number;
|
||
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
|
||
ksitOver: boolean;
|
||
}
|
||
|
||
export function MassingEconomics({ program, farTarget, ksitOver }: Props) {
|
||
const recompute = useRecomputeMassing();
|
||
|
||
// Last successful result kept locally so a failed/stale recompute never blanks
|
||
// the panel (last-good values stay on screen).
|
||
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
|
||
const [errored, setErrored] = useState(false);
|
||
|
||
// Monotonic request id → latest-wins: a response is applied only if it belongs
|
||
// to the most recently issued request, so out-of-order arrivals are dropped.
|
||
const reqIdRef = useRef(0);
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// Stable JSON key so we only recompute when the program actually changes
|
||
// (not on every parent re-render that hands an equal-but-new object).
|
||
const programKey = program ? JSON.stringify(program) : null;
|
||
|
||
useEffect(() => {
|
||
if (!program) return;
|
||
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => {
|
||
const id = ++reqIdRef.current;
|
||
recompute
|
||
.mutateAsync(program)
|
||
.then((out) => {
|
||
if (id !== reqIdRef.current) return; // stale — a newer request won.
|
||
setResult(out);
|
||
setErrored(false);
|
||
})
|
||
.catch(() => {
|
||
if (id !== reqIdRef.current) return; // stale failure — ignore.
|
||
setErrored(true); // keep last-good `result`.
|
||
});
|
||
}, DEBOUNCE_MS);
|
||
|
||
return () => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
};
|
||
// recompute is a stable mutation object; we key off the serialized program.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [programKey]);
|
||
|
||
// No program yet → nothing to show (parent gates this on geometry anyway).
|
||
if (!program) return null;
|
||
|
||
// First recompute in flight, no last-good value yet → skeleton.
|
||
if (!result) {
|
||
return (
|
||
<Section
|
||
title="Экономика по 3D-модели"
|
||
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
|
||
>
|
||
<SkeletonGrid />
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
// A fresher request is in flight over the last-good values.
|
||
const stale = recompute.isPending;
|
||
|
||
return (
|
||
<Section
|
||
title="Экономика по 3D-модели"
|
||
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
|
||
>
|
||
<KpiGrid
|
||
teap={result.teap}
|
||
financial={result.financial}
|
||
farTarget={farTarget}
|
||
ksitOver={ksitOver}
|
||
stale={stale}
|
||
/>
|
||
|
||
{errored ? (
|
||
<p
|
||
role="status"
|
||
style={{
|
||
margin: "12px 0 0",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
fontSize: 12,
|
||
color: "var(--warn)",
|
||
}}
|
||
>
|
||
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
|
||
Не удалось пересчитать экономику по последнему изменению — показаны
|
||
предыдущие значения. Измените параметры ещё раз для повторного
|
||
расчёта.
|
||
</p>
|
||
) : null}
|
||
</Section>
|
||
);
|
||
}
|