/** * 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 = { max_area: "Макс. площадь", max_insolation: "Макс. инсоляция", balanced: "Баланс", }; export const STRATEGY_HINTS: Record = { max_area: "Максимальная плотность застройки в пределах нормативов.", max_insolation: "Приоритет инсоляции и разрывов между корпусами.", balanced: "Компромисс между ТЭП и качеством среды.", }; export const HOUSING_CLASS_LABELS: Record = { econom: "Эконом", comfort: "Комфорт", business: "Бизнес", }; export const DEVELOPMENT_TYPE_LABELS: Record = { 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({ mutationFn: (payload) => apiFetch("/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({ 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); }