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).
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
/**
|
||
* Concept (Generative Design) API — TanStack Query wrappers.
|
||
*
|
||
* Contract mirrors `backend/app/schemas/concept.py` exactly (Stage 1a frozen
|
||
* interface). Types are defined inline rather than via `npm run codegen`
|
||
* because codegen targets a live OpenAPI on localhost:8000 (`src/lib/
|
||
* api-types.ts`) which is unreachable in this env — same approach as the
|
||
* parcel-chat contract in `site-finder-api.ts`.
|
||
*
|
||
* Endpoint: POST /api/v1/concepts → ConceptOutput.
|
||
* Export endpoints (DXF/PDF) are not in the Stage-1a contract yet; the export
|
||
* buttons fall back to client-side GeoJSON/CSV generation and probe the
|
||
* documented `/concepts/{...}/export` path opportunistically (see
|
||
* ConceptExportButtons).
|
||
*/
|
||
|
||
import { useMutation } from "@tanstack/react-query";
|
||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||
|
||
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||
|
||
// ── Input contract (ConceptInput) ───────────────────────────────────────────
|
||
|
||
export type HousingClass = "econom" | "comfort" | "business";
|
||
export type DevelopmentType = "spot" | "mid_rise" | "high_rise";
|
||
export type ConceptStrategy = "max_area" | "max_insolation" | "balanced";
|
||
|
||
export interface ConceptInput {
|
||
/** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */
|
||
parcel_geojson: Polygon;
|
||
housing_class: HousingClass;
|
||
/** Целевая этажность 1–30. */
|
||
target_floors: number;
|
||
development_type: DevelopmentType;
|
||
/** Стоимость участка (₽) — опционально, для финмодели. */
|
||
land_cost_rub?: number | null;
|
||
}
|
||
|
||
// ── Output contract (ConceptOutput) ─────────────────────────────────────────
|
||
|
||
/** Технико-экономические показатели (ТЭП). */
|
||
export interface Teap {
|
||
built_area_sqm: number;
|
||
total_floor_area_sqm: number;
|
||
residential_area_sqm: number;
|
||
/** Нежилое (коммерция/офисы 1-го этажа), кв.м. Additive к жилой площади. */
|
||
office_area_sqm: number;
|
||
apartments_count: number;
|
||
density: number;
|
||
parking_spaces: number;
|
||
}
|
||
|
||
export interface FinancialModel {
|
||
// Legacy summary (backward-compat)
|
||
revenue_rub: number;
|
||
cost_rub: number;
|
||
gross_margin_rub: number;
|
||
irr: number;
|
||
// Revenue breakdown
|
||
revenue_residential_rub: number;
|
||
revenue_parking_rub: number;
|
||
/** Нежилое (коммерция/офисы 1-го этажа) — additive выручка к GDV. */
|
||
revenue_office_rub: number;
|
||
// Cost cascade
|
||
construction_rub: number;
|
||
pir_rub: number;
|
||
networks_rub: number;
|
||
developer_services_rub: number;
|
||
contingency_rub: number;
|
||
marketing_rub: number;
|
||
land_rub: number;
|
||
// БДР / taxes → net profit
|
||
vat_rub: number;
|
||
profit_before_tax_rub: number;
|
||
profit_tax_rub: number;
|
||
net_profit_rub: number;
|
||
// Metrics
|
||
roi: number;
|
||
margin_pct: number;
|
||
/** false → IRR настоящий (DCF); true → IRR — proxy (вырожденный поток). */
|
||
irr_is_proxy: boolean;
|
||
// DCF / investment metrics (PR-3, эпик #1881)
|
||
/** NPV дисконтированного помесячного cashflow, ₽. */
|
||
npv_rub: number;
|
||
/** Окупаемость, мес (линейная интерполяция); null — не окупается. */
|
||
payback_months: number | null;
|
||
/** Годовая ставка дисконтирования, применённая в NPV. */
|
||
discount_rate_used: number;
|
||
/** true — график фаз/продаж типовой (допущение), не график проекта. */
|
||
schedule_is_default: boolean;
|
||
// Price calibration (PR-2, эпик #1881)
|
||
/** Цена продажи жилья, ₽/м², фактически использованная в выручке. */
|
||
price_per_sqm_used: number;
|
||
/** true — калибровано по рынку; false — норматив класса. */
|
||
price_is_calibrated: boolean;
|
||
/** "objective_district_median" | "district_reference" | "class_norm". */
|
||
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;
|
||
/**
|
||
* IRR на собственные средства (equity) при кредитном плече LTC 70 %. При
|
||
* положительном леверидже > проектной (unlevered) `irr`; при отрицательном —
|
||
* ниже неё или отрицательная (проект не покрывает стоимость долга).
|
||
*/
|
||
levered_irr: number;
|
||
/** true → levered_irr — оценка (roi-proxy / вырожденный equity-поток), не DCF. */
|
||
levered_irr_is_proxy: boolean;
|
||
}
|
||
|
||
export type PriceSource =
|
||
"objective_district_median" | "district_reference" | "class_norm";
|
||
|
||
export interface ConceptVariant {
|
||
strategy: ConceptStrategy;
|
||
/** FeatureCollection полигонов зданий (WGS84). */
|
||
buildings_geojson: FeatureCollection;
|
||
teap: Teap;
|
||
financial: FinancialModel;
|
||
}
|
||
|
||
export interface ConceptOutput {
|
||
variants: ConceptVariant[];
|
||
}
|
||
|
||
// ── Strategy display metadata ────────────────────────────────────────────────
|
||
|
||
export const STRATEGY_LABELS: Record<ConceptStrategy, string> = {
|
||
max_area: "Макс. площадь",
|
||
max_insolation: "Макс. инсоляция",
|
||
balanced: "Баланс",
|
||
};
|
||
|
||
export const STRATEGY_HINTS: Record<ConceptStrategy, string> = {
|
||
max_area: "Максимальная плотность застройки в пределах нормативов.",
|
||
max_insolation: "Приоритет инсоляции и разрывов между корпусами.",
|
||
balanced: "Компромисс между ТЭП и качеством среды.",
|
||
};
|
||
|
||
export const HOUSING_CLASS_LABELS: Record<HousingClass, string> = {
|
||
econom: "Эконом",
|
||
comfort: "Комфорт",
|
||
business: "Бизнес",
|
||
};
|
||
|
||
export const DEVELOPMENT_TYPE_LABELS: Record<DevelopmentType, string> = {
|
||
spot: "Точечная",
|
||
mid_rise: "Среднеэтажная",
|
||
high_rise: "Высотная",
|
||
};
|
||
|
||
/**
|
||
* Honest caption for the sale price source (PR-2). Never passes a class norm off
|
||
* as a market figure — when the price is not calibrated the caption says so.
|
||
*/
|
||
export function priceSourceCaption(financial: FinancialModel): string {
|
||
switch (financial.price_source) {
|
||
case "objective_district_median":
|
||
return "рынок: медиана объявлений Objective по району";
|
||
case "district_reference":
|
||
return "рынок: справочная медиана района (нет свежей выборки Objective)";
|
||
case "class_norm":
|
||
default:
|
||
return "норматив класса (нет рыночных данных по участку)";
|
||
}
|
||
}
|
||
|
||
// ── Hook: useCreateConcept ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* POST /api/v1/concepts — generate building variants for a parcel polygon.
|
||
* Action (not a query) → drive from `useMutation` per the data-layer rule.
|
||
* Uses the shared `apiFetch` (base URL + session header + Content-Type) rather
|
||
* than re-adding fetch config.
|
||
*/
|
||
export function useCreateConcept() {
|
||
return useMutation<ConceptOutput, Error, ConceptInput>({
|
||
mutationFn: (payload) =>
|
||
apiFetch<ConceptOutput>("/api/v1/concepts", {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
}),
|
||
});
|
||
}
|
||
|
||
// ── Live recompute contract (Stage 2b, #1965) ───────────────────────────────
|
||
|
||
/**
|
||
* MassingProgram — the aggregated building program POSTed to
|
||
* `/api/v1/concepts/recompute`. Mirrors `backend/app/schemas/concept.py`
|
||
* (and `components["schemas"]["MassingProgram"]` in api-types.ts): an already
|
||
* FOLDED program from the interactive 3D massing's `computeModel`
|
||
* (Σ footprint × floors), без покомпонентной геометрии секций. The endpoint
|
||
* synthesises ТЭП from it and runs the same `compute_financial` → live economics.
|
||
*/
|
||
export interface MassingProgram {
|
||
/** Суммарное пятно застройки всех секций, кв.м (скаляр). */
|
||
total_footprint_sqm: number;
|
||
/** Этажность программы. */
|
||
floors: number;
|
||
/** Число секций (метаданные программы). */
|
||
sections: number;
|
||
/** Площадь участка для плотности (FAR). */
|
||
site_area_sqm: number;
|
||
housing_class: HousingClass;
|
||
development_type: DevelopmentType;
|
||
/** Стоимость участка для финмодели (опционально). */
|
||
land_cost_rub?: number | null;
|
||
/** Предрезолвленная рыночная цена жилья, ₽/м² (skip backend DB lookup). */
|
||
market_price_per_sqm?: number | null;
|
||
/** Подлинный источник предрезолвленной цены (forward из financial_estimate). */
|
||
price_source?: string | null;
|
||
/** WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback. */
|
||
parcel_centroid_wkt?: string | null;
|
||
}
|
||
|
||
/** Output of `/api/v1/concepts/recompute`: synthesised ТЭП + finmodel. */
|
||
export interface MassingRecomputeOutput {
|
||
teap: Teap;
|
||
financial: FinancialModel;
|
||
}
|
||
|
||
/**
|
||
* POST /api/v1/concepts/recompute — live financial recompute for an interactive
|
||
* massing program. Action (slider-driven) → `useMutation` per the data-layer
|
||
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
|
||
*
|
||
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
|
||
* with latest-wins sequencing handled by the caller (see MassingEconomics).
|
||
*/
|
||
export function useRecomputeMassing() {
|
||
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
||
mutationFn: (payload) =>
|
||
apiFetch<MassingRecomputeOutput>("/api/v1/concepts/recompute", {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
}),
|
||
});
|
||
}
|
||
|
||
// ── Hook: useCadastreGeom ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Resolves a cadastre number to its parcel polygon by POSTing the Site Finder
|
||
* analyze endpoint (`/api/v1/parcels/{cad}/analyze`) and reading `geom_geojson`.
|
||
* Re-uses the existing endpoint rather than adding a new backend route.
|
||
*
|
||
* The analyze endpoint returns 202 while НСПД geometry is still being fetched
|
||
* (see useParcelAnalyzeQuery). For the concept input we keep the UX simple: a
|
||
* 202 surfaces a "try again" error instead of a 2-minute poll loop — the user
|
||
* can re-submit once geometry is cached, or draw the polygon manually.
|
||
*/
|
||
export function useCadastreGeom() {
|
||
return useMutation<Polygon, Error, string>({
|
||
mutationFn: async (cad) => {
|
||
const url = `/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=12`;
|
||
const { status, body } = await apiFetchWithStatus<{
|
||
geom_geojson?: unknown;
|
||
}>(url, { method: "POST" });
|
||
|
||
if (status === 202) {
|
||
throw new Error(
|
||
"Геометрия участка ещё загружается из НСПД. Повторите через несколько секунд.",
|
||
);
|
||
}
|
||
const polygon = extractPolygon(body?.geom_geojson);
|
||
if (!polygon) {
|
||
throw new Error(
|
||
"Не удалось получить геометрию участка по этому номеру.",
|
||
);
|
||
}
|
||
return polygon;
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Polygon helpers ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Narrows an unknown GeoJSON value (e.g. the analyze endpoint's `geom_geojson`)
|
||
* into a Polygon. Accepts a bare Polygon, a Feature wrapping a Polygon, or the
|
||
* first polygon ring of a MultiPolygon. Returns null on any other shape.
|
||
*/
|
||
export function extractPolygon(geom: unknown): Polygon | null {
|
||
if (geom == null || typeof geom !== "object") return null;
|
||
const g = geom as {
|
||
type?: unknown;
|
||
coordinates?: unknown;
|
||
geometry?: unknown;
|
||
};
|
||
|
||
if (g.type === "Feature") {
|
||
return extractPolygon((g as Feature).geometry);
|
||
}
|
||
if (g.type === "Polygon" && Array.isArray(g.coordinates)) {
|
||
return {
|
||
type: "Polygon",
|
||
coordinates: g.coordinates as Polygon["coordinates"],
|
||
};
|
||
}
|
||
if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
|
||
const first = (g.coordinates as Polygon["coordinates"][])[0];
|
||
if (first) return { type: "Polygon", coordinates: first };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Approximate parcel area in m² from a WGS84 polygon (spherical-excess shoelace). */
|
||
export function polygonAreaSqm(polygon: Polygon): number {
|
||
const ring = polygon.coordinates[0];
|
||
if (!ring || ring.length < 4) return 0;
|
||
const R = 6_378_137; // WGS84 equatorial radius (m)
|
||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||
let area = 0;
|
||
for (let i = 0; i < ring.length - 1; i++) {
|
||
const [lon1, lat1] = ring[i];
|
||
const [lon2, lat2] = ring[i + 1];
|
||
area +=
|
||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||
}
|
||
return Math.abs((area * R * R) / 2);
|
||
}
|
||
|
||
/**
|
||
* Centroid of a WGS84 polygon's outer ring as a WKT `POINT(lon lat)` string —
|
||
* used as `parcel_centroid_wkt` so the recompute endpoint can DB-resolve a
|
||
* market price when no pre-resolved one is forwarded. Simple ring-average
|
||
* (good enough as a point hint; not a true area centroid). Returns null on a
|
||
* degenerate ring.
|
||
*/
|
||
export function polygonCentroidWkt(polygon: Polygon): string | null {
|
||
const ring = polygon.coordinates[0];
|
||
if (!ring || ring.length < 4) return null;
|
||
// Skip the closing vertex (== first) when averaging.
|
||
const pts = ring.slice(0, ring.length - 1);
|
||
if (pts.length === 0) return null;
|
||
let sumLon = 0;
|
||
let sumLat = 0;
|
||
for (const [lon, lat] of pts) {
|
||
sumLon += lon;
|
||
sumLat += lat;
|
||
}
|
||
const lon = sumLon / pts.length;
|
||
const lat = sumLat / pts.length;
|
||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||
return `POINT(${lon} ${lat})`;
|
||
}
|