gendesign/frontend/src/components/concept/ConceptExportButtons.tsx
Light1YT b1332d6b55
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 10m6s
feat(generative): calibrate finmodel sale price from Objective market data (epic #1881 PR-2)
Заменяет хардкод-цену продажи жилья (класс-норма) на реальную рыночную
медиану из Objective по локации участка. compute_financial остаётся ЧИСТЫМ
(без БД) — DB-lookup в API-слое, цена прокидывается параметром.

- compute_financial: +optional market_price_per_sqm/price_source; цена =
  рынок если есть, иначе класс-норма. Паркинг/СМР НЕ калибруем (только жильё).
- concepts.py _lookup_market_price(db, lon, lat): центроид → ближайший
  ekb_district (ST_DWithin 5км) → медиана objective_lots.price_per_m2_rub
  (n>=10, фильтр 30k-600k) → fallback ekb_districts.median_price_per_m2 →
  None. try/except → graceful (None, class_norm) при любой ошибке (вне ЕКБ/
  нет гео/SQL) без краха генерации. psycopg3 CAST.
- FinancialModel +price_per_sqm_used/price_is_calibrated/price_source (additive).
- Threading market_price через generate→placement→compute_financial (optional
  kwargs, backward-compat).
- UI/CSV: честный caption источника (рынок Objective / справочник района /
  норматив класса). Старый лживый footnote «не калиброванная модель» → условный.

Prod-verified: калибруется 4 главных ЕКБ-района по name-match (Академический
204k лотов, Ленинский 38k, Кировский, Орджоникидзевский); остальные 5 admin-
районов честно → district_reference. Гео-радиус matching (полное покрытие) —
follow-up.

api-types.ts регенерён авторитетно. mypy strict clean (generative.*), +14
тестов (калибровка/lookup 4 ветки/SQL-ошибки graceful/backward-compat).

Refs #1881
2026-06-23 21:10:25 +05:00

227 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* ConceptExportButtons — export controls for one concept variant.
*
* The Stage-1a backend contract (`backend/app/api/v1/concepts.py`) ships only
* `POST /concepts`; no export endpoint exists yet. So:
* - GeoJSON (buildings) + CSV (ТЭП + финмодель) are generated client-side
* here, re-using `triggerDownload` and the CSV-injection-safe escaping from
* the Site Finder ExportButtons.
* - DXF / PDF are produced server-side (ezdxf / WeasyPrint per the stack).
* We POST the variant to the documented `/api/v1/concepts/export` path and
* download the blob; until that endpoint lands the button reports a clear
* "не готово" message instead of failing silently.
*/
import { useState } from "react";
import { Download, FileCode, FileText, Map as MapIcon } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download";
import {
priceSourceCaption,
STRATEGY_LABELS,
type ConceptVariant,
} from "@/lib/concept-api";
// ── CSV helpers (mirrors site-finder ExportButtons) ───────────────────────────
function escapeCsvCell(cell: string): string {
// CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
// tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
// starters with a leading apostrophe so the spreadsheet treats them as text.
let safe = cell;
if (/^[=+\-@\t\r]/.test(safe)) {
safe = "'" + safe;
}
const needsQuotes = /[",\n\r]/.test(safe);
if (needsQuotes) {
return `"${safe.replace(/"/g, '""')}"`;
}
return safe;
}
function buildCsvRows(variant: ConceptVariant): string[][] {
const t = variant.teap;
const f = variant.financial;
return [
["Показатель", "Значение"],
["Стратегия", STRATEGY_LABELS[variant.strategy]],
["Площадь застройки, м²", String(t.built_area_sqm)],
["Общая площадь, м²", String(t.total_floor_area_sqm)],
["Жилая площадь, м²", String(t.residential_area_sqm)],
["Квартир, шт", String(t.apartments_count)],
["Плотность", String(t.density)],
["Машино-мест", String(t.parking_spaces)],
["Выручка, ₽", String(f.revenue_rub)],
["Затраты, ₽", String(f.cost_rub)],
["Валовая прибыль, ₽", String(f.gross_margin_rub)],
["IRR", String(f.irr)],
["Цена продажи жилья, ₽/м²", String(f.price_per_sqm_used)],
["Источник цены", priceSourceCaption(f)],
];
}
function generateCsvBlob(rows: string[][]): Blob {
const csv = rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n");
// UTF-8 BOM so Excel renders Cyrillic correctly.
return new Blob(["" + csv], { type: "text/csv;charset=utf-8" });
}
// ── Component ─────────────────────────────────────────────────────────────────
const today = () => new Date().toISOString().slice(0, 10);
interface Props {
variant: ConceptVariant;
}
export function ConceptExportButtons({ variant }: Props) {
const [serverLoading, setServerLoading] = useState<"dxf" | "pdf" | null>(
null,
);
const [error, setError] = useState<string | null>(null);
// The parent renders one reused instance per tab (no `key`), so switching
// variants only changes this prop without remounting. Reset stale export
// state during render when the variant changes, otherwise an error message
// or in-flight disabled state would leak onto another variant's controls.
const [prevStrategy, setPrevStrategy] = useState(variant.strategy);
if (prevStrategy !== variant.strategy) {
setPrevStrategy(variant.strategy);
setServerLoading(null);
setError(null);
}
const fileStem = `gendesign_concept_${variant.strategy}_${today()}`;
function handleGeojson() {
setError(null);
const blob = new Blob(
[JSON.stringify(variant.buildings_geojson, null, 2)],
{
type: "application/geo+json",
},
);
triggerDownload(blob, `${fileStem}.geojson`);
}
function handleCsv() {
setError(null);
const blob = generateCsvBlob(buildCsvRows(variant));
triggerDownload(blob, `${fileStem}.csv`);
}
async function handleServerExport(format: "dxf" | "pdf") {
setServerLoading(format);
setError(null);
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/concepts/export?format=${format}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variant),
},
);
if (res.status === 404 || res.status === 405) {
setError(
format === "dxf"
? "Экспорт DXF появится после публикации движка генерации."
: "Экспорт PDF появится после публикации движка генерации.",
);
return;
}
if (!res.ok) {
throw new Error(`Ошибка сервера ${res.status}`);
}
const blob = await res.blob();
triggerDownload(blob, `${fileStem}.${format}`);
} catch (err) {
const msg =
err instanceof Error
? err.message
: `Ошибка экспорта ${format.toUpperCase()}`;
setError(msg);
} finally {
setServerLoading(null);
}
}
const secondaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: "var(--accent-2)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={handleGeojson}
aria-label="Скачать геометрию зданий GeoJSON"
style={secondaryBtn}
>
<MapIcon size={14} strokeWidth={1.5} />
GeoJSON
</button>
<button
type="button"
onClick={handleCsv}
aria-label="Скачать ТЭП и финмодель CSV"
style={secondaryBtn}
>
<Download size={14} strokeWidth={1.5} />
CSV
</button>
<button
type="button"
onClick={() => void handleServerExport("dxf")}
disabled={serverLoading !== null}
aria-label="Скачать DXF"
style={{
...secondaryBtn,
opacity: serverLoading !== null ? 0.6 : 1,
cursor: serverLoading !== null ? "not-allowed" : "pointer",
}}
>
<FileCode size={14} strokeWidth={1.5} />
{serverLoading === "dxf" ? "Экспорт…" : "DXF"}
</button>
<button
type="button"
onClick={() => void handleServerExport("pdf")}
disabled={serverLoading !== null}
aria-label="Скачать PDF"
style={{
...secondaryBtn,
opacity: serverLoading !== null ? 0.6 : 1,
cursor: serverLoading !== null ? "not-allowed" : "pointer",
}}
>
<FileText size={14} strokeWidth={1.5} />
{serverLoading === "pdf" ? "Экспорт…" : "PDF"}
</button>
</div>
{error && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
role="status"
>
{error}
</p>
)}
</div>
);
}