gendesign/frontend/src/lib/concept-api.ts
Light1YT 6b4b081c44
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m4s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / backend-tests (pull_request) Successful in 11m10s
feat(financial): surface office revenue in PDF + concept UI (offices presentation)
Office tranche was computed but invisible in both export surfaces (жильё+паркинг
< GDV with an unexplained gap). Add «Выручка — нежилое (1-й этаж)» row to the PDF
financial table + concept cascade; relabel PDF VAT row «НДС (нежилое: паркинг +
коммерция)». Add office_area_sqm/revenue_office_rub to hand-maintained
concept-api types; make teap_synth.office_area_sqm optional in site-finder types
(dev mock on main lacks it). Fix ptica finance fixture for required
revenue_office_rub.

Refs #1881
2026-06-25 16:27:17 +05:00

282 lines
12 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.

/**
* 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;
/** Целевая этажность 130. */
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),
}),
});
}
// ── 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);
}