From a0232e22f17d6e4b90a6b79383c87f105be47573 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 28 Jun 2026 01:01:53 +0500 Subject: [PATCH] =?UTF-8?q?feat(concept):=20live=203D=20massing=20?= =?UTF-8?q?=E2=86=92=20financial=20recompute=20in=20=C2=AB7.=20=D0=9A?= =?UTF-8?q?=D0=BE=D0=BD=D1=86=D0=B5=D0=BF=D1=86=D0=B8=D1=8F=C2=BB=20(#1965?= =?UTF-8?q?=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../site-finder/analysis/MassingEconomics.tsx | 352 ++++++++++++++++++ .../site-finder/analysis/Section7Concept.tsx | 138 ++++++- .../ptica/massing/MassingScene.tsx | 29 +- frontend/src/lib/concept-api.ts | 84 ++++- 4 files changed, 593 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/MassingEconomics.tsx diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx new file mode 100644 index 00000000..59fb7687 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx @@ -0,0 +1,352 @@ +"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 ( +
+ {/* ТЭП */} +
+ + + + +
+ + {/* Финмодель */} +
+ + + + + 0 + ? true + : financial.npv_rub < 0 + ? false + : null, + }} + /> + financial.discount_rate_used + ? true + : false, + }} + /> + +
+
+ ); +} + +// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── + +function SkeletonGrid() { + const cells = Array.from({ length: 7 }); + return ( +