gendesign/frontend/src/lib/concept-api.ts
lekss361 76c3f5c110
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m12s
Deploy Trade-In / deploy (push) Successful in 1m4s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m9s
chore(frontend): фикстуры макета не по умолчанию + удаление осиротевших компонентов (#2747)
МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми).

Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария.

Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет.
2026-08-06 18:59:50 +00:00

505 lines
22 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, useQuery } from "@tanstack/react-query";
import type {
Feature,
FeatureCollection,
MultiPolygon,
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";
/**
* Один пункт программы застройки (Stage 3a/3b, #1965): «поставить `count`
* секций каталожного типа `section_type` этажностью `floors`». Габариты пятна
* берёт бэк из каталога по ключу. None-программа → жадная max-FAR раскладка.
* Зеркалит `BuildingProgramItem` в backend/app/schemas/concept.py
* (`components["schemas"]["BuildingProgramItem"]` в api-types.ts).
*/
export interface BuildingProgramItem {
/** Ключ типа дома из каталога house-types (см. useHouseTypes). */
section_type: string;
/** Этажность группы секций, 140. */
floors: number;
/** Сколько секций этого типа разместить, 150. */
count: number;
/**
* Опциональное ручное пятно секции (ширина, м), 4120. None / не задано →
* габариты берёт бэк из каталога по `section_type`. Должно задаваться вместе с
* `footprint_d_m` (частичное задание бэк игнорирует). ADDITIVE (Stage 3c).
*/
footprint_w_m?: number | null;
/** Опциональное ручное пятно секции (глубина, м), 4120. См. `footprint_w_m`. */
footprint_d_m?: number | null;
}
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;
/**
* Stage 3b (#1965): опциональная программа типовых домов. Не задана / пусто →
* жадная max-FAR раскладка (3 стратегии, без изменений). Задана → бэк кладёт
* РОВНО эту программу (один вариант) и возвращает partial-fit сигнал.
*/
building_program?: BuildingProgramItem[] | 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;
/**
* Stage 3b (#1965) — partial-fit сигнал program-режима: `requested_count` —
* сколько секций просили (Σ count), `placed_count` — сколько реально влезло.
* placed < requested → участок вмещает меньше (честная заметка, не ошибка).
* Оба null в greedy-режиме (программа не задана) — backward-compat.
*/
placed_count?: number | null;
requested_count?: number | null;
}
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),
}),
});
}
// ── House-type catalog (Stage 3b, #1965) ─────────────────────────────────────
/**
* Один тип дома каталога, отдаваемый `GET /concepts/house-types`. Зеркалит
* `HouseTypeCatalogItem` в backend/app/schemas/concept.py
* (`components["schemas"]["HouseTypeCatalogItem"]` в api-types.ts). Каталог —
* единый источник истины: фронт НЕ хардкодит габариты/этажности.
*/
export interface HouseTypeCatalogItem {
/** Ключ типа — значение для BuildingProgramItem.section_type. */
section_type: string;
/** Человекочитаемый русский лейбл. */
label_ru: string;
/** Ширина пятна секции, м. */
footprint_w_m: number;
/** Глубина пятна секции, м. */
footprint_d_m: number;
/** Площадь пятна секции, м² (ширина × глубина). */
footprint_sqm: number;
/** Дефолтная этажность типа (UI подставляет; пользователь меняет в 140). */
default_floors: number;
/** Подходящий класс жилья. */
housing_class: HousingClass;
}
interface HouseTypeCatalog {
house_types: HouseTypeCatalogItem[];
}
/**
* GET /api/v1/concepts/house-types — каталог типовых домов для пикера Stage 3b.
* Read-only справочник (статичен на бэке) → `useQuery` per data-layer rule;
* `staleTime: Infinity`, т.к. каталог не меняется в рамках сессии. Возвращает
* массив пунктов каталога (разворачиваем `{house_types}` в `select`).
*/
export function useHouseTypes() {
return useQuery<HouseTypeCatalogItem[]>({
queryKey: ["concept", "house-types"],
queryFn: async () => {
const data = await apiFetch<HouseTypeCatalog>(
"/api/v1/concepts/house-types",
);
return data.house_types;
},
staleTime: Infinity,
});
}
// ── 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.
*/
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 single Polygon — the geometry POSTed as `parcel_geojson` to /concepts.
* Accepts a bare Polygon, a Feature wrapping one, or a MultiPolygon (collapsed to
* its LARGEST-area sub-polygon, NOT `coordinates[0]`: for a multi-part parcel the
* first part can be a sliver, so the generative corpus must sit on the dominant
* part). Returns null on any other shape.
*
* NB: the massing SCENE (outline / projection origin / OSM tile) must instead use
* the WHOLE geometry via `extractParcelGeometry`, so it shares the frame the
* backend neighbour endpoint anchors context to.
*/
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 parts = g.coordinates as Polygon["coordinates"][];
let best: Polygon["coordinates"] | null = null;
let bestArea = -Infinity;
for (const part of parts) {
if (!Array.isArray(part) || part.length === 0) continue;
const area = polygonAreaSqm({ type: "Polygon", coordinates: part });
if (area > bestArea) {
bestArea = area;
best = part;
}
}
if (best) return { type: "Polygon", coordinates: best };
}
return null;
}
/**
* Narrows an unknown GeoJSON value into the WHOLE parcel geometry — a Polygon or a
* MultiPolygon preserving ALL sub-polygons (unlike `extractPolygon`, which drops
* to one part for the /concepts request). The §7 massing scene feeds this to
* derive its projection origin, OSM ground-tile bounds and drawn outline over
* every part, so neighbour context (anchored by the backend to the entire parcel
* geometry) lands geo-aligned instead of offset from part 0. Returns null on any
* non-polygonal shape.
*/
export function extractParcelGeometry(
geom: unknown,
): Polygon | MultiPolygon | null {
if (geom == null || typeof geom !== "object") return null;
const g = geom as {
type?: unknown;
coordinates?: unknown;
geometry?: unknown;
};
if (g.type === "Feature") {
return extractParcelGeometry((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)) {
return {
type: "MultiPolygon",
coordinates: g.coordinates as MultiPolygon["coordinates"],
};
}
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})`;
}