Compare commits
No commits in common. "2fb34112b597c9b1e36048681366b1f95d3b6dd0" and "3b84609670b79e44a44e55b2b6c2b1e820a4d69a" have entirely different histories.
2fb34112b5
...
3b84609670
9 changed files with 18 additions and 790 deletions
|
|
@ -11,13 +11,11 @@ from app.core.db import get_db
|
||||||
from app.schemas.concept import (
|
from app.schemas.concept import (
|
||||||
ConceptInput,
|
ConceptInput,
|
||||||
ConceptOutput,
|
ConceptOutput,
|
||||||
HouseTypeCatalog,
|
|
||||||
HouseTypeCatalogItem,
|
|
||||||
MassingProgram,
|
MassingProgram,
|
||||||
MassingRecomputeOutput,
|
MassingRecomputeOutput,
|
||||||
)
|
)
|
||||||
from app.services.generative import geometry
|
from app.services.generative import geometry
|
||||||
from app.services.generative.catalog import HOUSE_TYPES, available_section_types
|
from app.services.generative.catalog import available_section_types
|
||||||
from app.services.generative.financial import compute_financial
|
from app.services.generative.financial import compute_financial
|
||||||
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
||||||
from app.services.generative.teap import synthesize_teap_from_program
|
from app.services.generative.teap import synthesize_teap_from_program
|
||||||
|
|
@ -178,32 +176,6 @@ def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str
|
||||||
return None, "class_norm"
|
return None, "class_norm"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/house-types", response_model=HouseTypeCatalog)
|
|
||||||
async def list_house_types() -> HouseTypeCatalog:
|
|
||||||
"""Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.
|
|
||||||
|
|
||||||
Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
|
|
||||||
секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
|
|
||||||
выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
|
|
||||||
side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так
|
|
||||||
фронт НЕ хардкодит габариты/этажности — они приходят отсюда.
|
|
||||||
"""
|
|
||||||
return HouseTypeCatalog(
|
|
||||||
house_types=[
|
|
||||||
HouseTypeCatalogItem(
|
|
||||||
section_type=ht.section_type,
|
|
||||||
label_ru=ht.label_ru,
|
|
||||||
footprint_w_m=ht.footprint_w_m,
|
|
||||||
footprint_d_m=ht.footprint_d_m,
|
|
||||||
footprint_sqm=ht.footprint_sqm,
|
|
||||||
default_floors=ht.default_floors,
|
|
||||||
housing_class=ht.housing_class,
|
|
||||||
)
|
|
||||||
for ht in HOUSE_TYPES
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ConceptOutput)
|
@router.post("", response_model=ConceptOutput)
|
||||||
async def create_concept(
|
async def create_concept(
|
||||||
payload: ConceptInput,
|
payload: ConceptInput,
|
||||||
|
|
|
||||||
|
|
@ -22,39 +22,6 @@ class BuildingProgramItem(BaseModel):
|
||||||
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
|
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
|
||||||
|
|
||||||
|
|
||||||
class HouseTypeCatalogItem(BaseModel):
|
|
||||||
"""Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту.
|
|
||||||
|
|
||||||
Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера
|
|
||||||
Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность
|
|
||||||
+ класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это
|
|
||||||
делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля
|
|
||||||
зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Стабильный машинный ключ типа — значение для BuildingProgramItem.section_type.
|
|
||||||
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
|
|
||||||
# Человекочитаемый русский лейбл для UI.
|
|
||||||
label_ru: str = Field(..., description="Human-readable Russian label")
|
|
||||||
# Габариты пятна секции, метры (ширина × глубина).
|
|
||||||
footprint_w_m: float = Field(..., description="Section footprint width, m")
|
|
||||||
footprint_d_m: float = Field(..., description="Section footprint depth, m")
|
|
||||||
# Площадь пятна секции, кв.м (helper для UI: ширина × глубина).
|
|
||||||
footprint_sqm: float = Field(..., description="Section footprint area, m² (w × d)")
|
|
||||||
# Дефолтная этажность типа — UI подставляет, пользователь меняет в [1, 40].
|
|
||||||
default_floors: int = Field(..., description="Default floors for this type")
|
|
||||||
# Подходящий класс жилья (econom / comfort / business).
|
|
||||||
housing_class: Literal["econom", "comfort", "business"] = Field(
|
|
||||||
..., description="Suitable housing class"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HouseTypeCatalog(BaseModel):
|
|
||||||
"""Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``."""
|
|
||||||
|
|
||||||
house_types: list[HouseTypeCatalogItem]
|
|
||||||
|
|
||||||
|
|
||||||
class ConceptInput(BaseModel):
|
class ConceptInput(BaseModel):
|
||||||
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
|
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,46 +246,3 @@ def test_concepts_program_unknown_type_returns_422() -> None:
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert "unknown house type" in response.json()["detail"].lower()
|
assert "unknown house type" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
# ── Stage 3b (#1965): GET /api/v1/concepts/house-types catalog ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_house_types_catalog_returns_full_catalog() -> None:
|
|
||||||
# Каталог отдаётся read-only (GET, без БД) и зеркалит catalog.HOUSE_TYPES —
|
|
||||||
# single source of truth для пикера Stage 3b (фронт ничего не хардкодит).
|
|
||||||
from app.services.generative.catalog import HOUSE_TYPES
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/api/v1/concepts/house-types")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
items = resp.json()["house_types"]
|
|
||||||
# Полный каталог: один пункт на каждый HouseType, ключи совпадают с каталогом.
|
|
||||||
assert len(items) == len(HOUSE_TYPES)
|
|
||||||
assert {it["section_type"] for it in items} == {ht.section_type for ht in HOUSE_TYPES}
|
|
||||||
|
|
||||||
# Каждый пункт несёт всё, что нужно пикеру: лейбл, габариты, дефолт-этажность, класс.
|
|
||||||
by_key = {it["section_type"]: it for it in items}
|
|
||||||
for ht in HOUSE_TYPES:
|
|
||||||
item = by_key[ht.section_type]
|
|
||||||
assert item["label_ru"] == ht.label_ru
|
|
||||||
assert item["footprint_w_m"] == ht.footprint_w_m
|
|
||||||
assert item["footprint_d_m"] == ht.footprint_d_m
|
|
||||||
# footprint_sqm — производный helper для UI (ширина × глубина).
|
|
||||||
assert item["footprint_sqm"] == ht.footprint_w_m * ht.footprint_d_m
|
|
||||||
assert item["default_floors"] == ht.default_floors
|
|
||||||
assert item["housing_class"] == ht.housing_class
|
|
||||||
# default_floors в пределах контракта BuildingProgramItem [1, 40].
|
|
||||||
assert 1 <= item["default_floors"] <= 40
|
|
||||||
|
|
||||||
|
|
||||||
def test_house_types_catalog_keys_match_available_section_types() -> None:
|
|
||||||
# Ключи каталога == множество, валидируемое в create_concept (program-режим).
|
|
||||||
# Гарантирует: всё, что фронт может выбрать, бэк примет (нет рассинхрона 422).
|
|
||||||
from app.services.generative.catalog import available_section_types
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/api/v1/concepts/house-types")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
catalog_keys = {it["section_type"] for it in resp.json()["house_types"]}
|
|
||||||
assert catalog_keys == set(available_section_types())
|
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,10 @@ import {
|
||||||
type ConceptParams,
|
type ConceptParams,
|
||||||
} from "@/components/concept/ConceptParamsForm";
|
} from "@/components/concept/ConceptParamsForm";
|
||||||
import { ConceptVariantsResult } from "@/components/concept/ConceptVariantsResult";
|
import { ConceptVariantsResult } from "@/components/concept/ConceptVariantsResult";
|
||||||
import {
|
|
||||||
HouseProgramPicker,
|
|
||||||
type ProgramMode,
|
|
||||||
} from "@/components/concept/HouseProgramPicker";
|
|
||||||
import {
|
import {
|
||||||
polygonAreaSqm,
|
polygonAreaSqm,
|
||||||
useCadastreGeom,
|
useCadastreGeom,
|
||||||
useCreateConcept,
|
useCreateConcept,
|
||||||
type BuildingProgramItem,
|
|
||||||
} from "@/lib/concept-api";
|
} from "@/lib/concept-api";
|
||||||
|
|
||||||
// ConceptDrawMap uses Leaflet + leaflet-draw — must load without SSR.
|
// ConceptDrawMap uses Leaflet + leaflet-draw — must load without SSR.
|
||||||
|
|
@ -75,10 +70,6 @@ export default function ConceptPage() {
|
||||||
const [polygon, setPolygon] = useState<Polygon | null>(null);
|
const [polygon, setPolygon] = useState<Polygon | null>(null);
|
||||||
const [params, setParams] = useState<ConceptParams>(DEFAULT_PARAMS);
|
const [params, setParams] = useState<ConceptParams>(DEFAULT_PARAMS);
|
||||||
|
|
||||||
// Stage 3b (#1965): program mode — «Авто (max-FAR)» vs «Выбрать дома».
|
|
||||||
const [programMode, setProgramMode] = useState<ProgramMode>("auto");
|
|
||||||
const [houseProgram, setHouseProgram] = useState<BuildingProgramItem[]>([]);
|
|
||||||
|
|
||||||
const cadastreGeom = useCadastreGeom();
|
const cadastreGeom = useCadastreGeom();
|
||||||
const concept = useCreateConcept();
|
const concept = useCreateConcept();
|
||||||
|
|
||||||
|
|
@ -102,19 +93,12 @@ export default function ConceptPage() {
|
||||||
|
|
||||||
function handleGenerate() {
|
function handleGenerate() {
|
||||||
if (!polygon) return;
|
if (!polygon) return;
|
||||||
// «Выбрать дома» с непустой программой → building_program (бэк кладёт ровно
|
|
||||||
// её). Иначе omit → жадная max-FAR раскладка (поведение по умолчанию).
|
|
||||||
const program =
|
|
||||||
programMode === "pick" && houseProgram.length > 0
|
|
||||||
? houseProgram
|
|
||||||
: undefined;
|
|
||||||
concept.mutate({
|
concept.mutate({
|
||||||
parcel_geojson: polygon,
|
parcel_geojson: polygon,
|
||||||
housing_class: params.housing_class,
|
housing_class: params.housing_class,
|
||||||
target_floors: params.target_floors,
|
target_floors: params.target_floors,
|
||||||
development_type: params.development_type,
|
development_type: params.development_type,
|
||||||
land_cost_rub: params.land_cost_rub,
|
land_cost_rub: params.land_cost_rub,
|
||||||
building_program: program,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,16 +228,8 @@ export default function ConceptPage() {
|
||||||
{/* Parameters */}
|
{/* Parameters */}
|
||||||
<Section
|
<Section
|
||||||
title="Параметры проекта"
|
title="Параметры проекта"
|
||||||
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
|
subtitle="Класс, тип застройки и этажность."
|
||||||
>
|
>
|
||||||
<div style={{ display: "grid", gap: 16 }}>
|
|
||||||
<HouseProgramPicker
|
|
||||||
mode={programMode}
|
|
||||||
onModeChange={setProgramMode}
|
|
||||||
program={houseProgram}
|
|
||||||
onChange={setHouseProgram}
|
|
||||||
isPending={concept.isPending}
|
|
||||||
/>
|
|
||||||
<ConceptParamsForm
|
<ConceptParamsForm
|
||||||
params={params}
|
params={params}
|
||||||
onChange={setParams}
|
onChange={setParams}
|
||||||
|
|
@ -261,7 +237,6 @@ export default function ConceptPage() {
|
||||||
hasPolygon={polygon != null}
|
hasPolygon={polygon != null}
|
||||||
isPending={concept.isPending}
|
isPending={concept.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,16 +100,6 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
? false
|
? false
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Stage 3b (#1965) — partial-fit honest note: program mode (requested_count
|
|
||||||
// != null) where fewer sections fit than were requested. Neutral, not an
|
|
||||||
// error: the user asked for M, the parcel takes N < M. Greedy / full-fit → null.
|
|
||||||
const partialFit =
|
|
||||||
variant.requested_count != null &&
|
|
||||||
variant.placed_count != null &&
|
|
||||||
variant.placed_count < variant.requested_count
|
|
||||||
? { placed: variant.placed_count, requested: variant.requested_count }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
|
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
|
||||||
|
|
@ -150,28 +140,6 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stage 3b — partial-fit note: program asked for more sections than fit. */}
|
|
||||||
{partialFit && (
|
|
||||||
<div
|
|
||||||
role="note"
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: "12px 16px",
|
|
||||||
background: "var(--bg-card-alt)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: "20px",
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Разместилось {formatInt(partialFit.placed)} из{" "}
|
|
||||||
{formatInt(partialFit.requested)} секций — участок вмещает меньше, чем
|
|
||||||
в заданной программе. ТЭП и финмодель рассчитаны по фактически
|
|
||||||
размещённым секциям.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placement map */}
|
{/* Placement map */}
|
||||||
<Section
|
<Section
|
||||||
title="Размещение застройки"
|
title="Размещение застройки"
|
||||||
|
|
|
||||||
|
|
@ -1,403 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HouseProgramPicker — Stage 3b (#1965, эпик #1953).
|
|
||||||
*
|
|
||||||
* Lets the user pick WHAT to build instead of the auto max-FAR buildout:
|
|
||||||
* • «Авто (max-FAR)» (default) — building_program omitted → backend keeps its
|
|
||||||
* greedy 3-strategy placement (unchanged behaviour).
|
|
||||||
* • «Выбрать дома» — the user adds typical house types from the catalog
|
|
||||||
* (GET /concepts/house-types), each with a count (1–50) and floors (1–40,
|
|
||||||
* default from the catalog). The component builds the
|
|
||||||
* `building_program: [{section_type, floors, count}, …]` and lifts it to the
|
|
||||||
* parent via `onChange`; auto mode lifts `null`.
|
|
||||||
*
|
|
||||||
* The catalog is the single source of truth — footprint / default floors / class
|
|
||||||
* are shown as helper text straight from the backend (no frontend hardcode).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
HOUSING_CLASS_LABELS,
|
|
||||||
useHouseTypes,
|
|
||||||
type BuildingProgramItem,
|
|
||||||
type HouseTypeCatalogItem,
|
|
||||||
} from "@/lib/concept-api";
|
|
||||||
|
|
||||||
// Contract bounds (mirror BuildingProgramItem in the backend schema).
|
|
||||||
const FLOORS_MIN = 1;
|
|
||||||
const FLOORS_MAX = 40;
|
|
||||||
const COUNT_MIN = 1;
|
|
||||||
const COUNT_MAX = 50;
|
|
||||||
|
|
||||||
export type ProgramMode = "auto" | "pick";
|
|
||||||
|
|
||||||
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.04em",
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
marginBottom: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
const controlStyle: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
fontSize: 14,
|
|
||||||
border: "1px solid var(--border-strong)",
|
|
||||||
borderRadius: 8,
|
|
||||||
outline: "none",
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
color: "var(--fg-primary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode: ProgramMode;
|
|
||||||
onModeChange: (mode: ProgramMode) => void;
|
|
||||||
/** Selected program rows (pick mode). Owned by the parent; lifted via onChange. */
|
|
||||||
program: BuildingProgramItem[];
|
|
||||||
onChange: (program: BuildingProgramItem[]) => void;
|
|
||||||
/** Disables controls while a calculation is in flight. */
|
|
||||||
isPending: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampInt(
|
|
||||||
raw: number,
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
fallback: number,
|
|
||||||
): number {
|
|
||||||
if (!Number.isFinite(raw)) return fallback;
|
|
||||||
return Math.min(max, Math.max(min, Math.round(raw)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HouseProgramPicker({
|
|
||||||
mode,
|
|
||||||
onModeChange,
|
|
||||||
program,
|
|
||||||
onChange,
|
|
||||||
isPending,
|
|
||||||
}: Props) {
|
|
||||||
const houseTypes = useHouseTypes();
|
|
||||||
const catalog = houseTypes.data ?? [];
|
|
||||||
|
|
||||||
function byKey(sectionType: string): HouseTypeCatalogItem | undefined {
|
|
||||||
return catalog.find((ht) => ht.section_type === sectionType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow() {
|
|
||||||
const first = catalog[0];
|
|
||||||
if (!first) return;
|
|
||||||
onChange([
|
|
||||||
...program,
|
|
||||||
{
|
|
||||||
section_type: first.section_type,
|
|
||||||
floors: first.default_floors,
|
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRow(index: number, patch: Partial<BuildingProgramItem>) {
|
|
||||||
onChange(
|
|
||||||
program.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRow(index: number) {
|
|
||||||
onChange(program.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTypeChange(index: number, sectionType: string) {
|
|
||||||
// Switching the type resets floors to that type's catalog default (the
|
|
||||||
// previous floors were tuned for a different house) but keeps the count.
|
|
||||||
const ht = byKey(sectionType);
|
|
||||||
updateRow(index, {
|
|
||||||
section_type: sectionType,
|
|
||||||
floors: ht ? ht.default_floors : program[index].floors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "grid", gap: 16 }}>
|
|
||||||
{/* Mode toggle */}
|
|
||||||
<div>
|
|
||||||
<span style={labelStyle}>Что строить</span>
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Режим программы застройки"
|
|
||||||
style={{ display: "flex", gap: 4 }}
|
|
||||||
>
|
|
||||||
<ModeTab
|
|
||||||
active={mode === "auto"}
|
|
||||||
onClick={() => onModeChange("auto")}
|
|
||||||
label="Авто (max-FAR)"
|
|
||||||
/>
|
|
||||||
<ModeTab
|
|
||||||
active={mode === "pick"}
|
|
||||||
onClick={() => onModeChange("pick")}
|
|
||||||
label="Выбрать дома"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "8px 0 0",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === "auto"
|
|
||||||
? "Движок сам подберёт максимально плотную застройку в пределах нормативов (три стратегии)."
|
|
||||||
: "Соберите программу из типовых домов — движок разместит ровно её и покажет, сколько секций поместилось."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pick mode: catalog program builder */}
|
|
||||||
{mode === "pick" && (
|
|
||||||
<div style={{ display: "grid", gap: 12 }}>
|
|
||||||
{houseTypes.isPending && (
|
|
||||||
<p
|
|
||||||
style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}
|
|
||||||
>
|
|
||||||
Загрузка каталога типовых домов…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{houseTypes.isError && (
|
|
||||||
<p
|
|
||||||
style={{ margin: 0, fontSize: 13, color: "var(--danger)" }}
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
Не удалось загрузить каталог типовых домов:{" "}
|
|
||||||
{houseTypes.error.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{houseTypes.isSuccess && (
|
|
||||||
<>
|
|
||||||
{program.length === 0 && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Добавьте хотя бы один тип дома, чтобы задать программу. Пока
|
|
||||||
список пуст — будет использован авторежим.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{program.map((row, i) => {
|
|
||||||
const ht = byKey(row.section_type);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
background: "var(--bg-card-alt)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr) 88px 88px 36px",
|
|
||||||
gap: 8,
|
|
||||||
alignItems: "end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* House type */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor={`house-type-${i}`} style={labelStyle}>
|
|
||||||
Тип дома
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={`house-type-${i}`}
|
|
||||||
value={row.section_type}
|
|
||||||
disabled={isPending}
|
|
||||||
onChange={(e) => handleTypeChange(i, e.target.value)}
|
|
||||||
style={controlStyle}
|
|
||||||
>
|
|
||||||
{catalog.map((opt) => (
|
|
||||||
<option
|
|
||||||
key={opt.section_type}
|
|
||||||
value={opt.section_type}
|
|
||||||
>
|
|
||||||
{opt.label_ru}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floors */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor={`house-floors-${i}`} style={labelStyle}>
|
|
||||||
Этажей
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`house-floors-${i}`}
|
|
||||||
type="number"
|
|
||||||
min={FLOORS_MIN}
|
|
||||||
max={FLOORS_MAX}
|
|
||||||
value={row.floors}
|
|
||||||
disabled={isPending}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRow(i, {
|
|
||||||
floors: clampInt(
|
|
||||||
Number(e.target.value),
|
|
||||||
FLOORS_MIN,
|
|
||||||
FLOORS_MAX,
|
|
||||||
ht?.default_floors ?? FLOORS_MIN,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={controlStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Count */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor={`house-count-${i}`} style={labelStyle}>
|
|
||||||
Секций
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`house-count-${i}`}
|
|
||||||
type="number"
|
|
||||||
min={COUNT_MIN}
|
|
||||||
max={COUNT_MAX}
|
|
||||||
value={row.count}
|
|
||||||
disabled={isPending}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRow(i, {
|
|
||||||
count: clampInt(
|
|
||||||
Number(e.target.value),
|
|
||||||
COUNT_MIN,
|
|
||||||
COUNT_MAX,
|
|
||||||
COUNT_MIN,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={controlStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Remove */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeRow(i)}
|
|
||||||
disabled={isPending}
|
|
||||||
aria-label="Удалить тип дома из программы"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
height: 36,
|
|
||||||
width: 36,
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 8,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
cursor: isPending ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} strokeWidth={1.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Catalog helper text — footprint / default floors / class */}
|
|
||||||
{ht && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "8px 0 0",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Пятно секции {nf.format(ht.footprint_w_m)} ×{" "}
|
|
||||||
{nf.format(ht.footprint_d_m)} м ≈{" "}
|
|
||||||
{nf.format(ht.footprint_sqm)} м² · типовая этажность{" "}
|
|
||||||
{ht.default_floors} · класс{" "}
|
|
||||||
{HOUSING_CLASS_LABELS[ht.housing_class]}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addRow}
|
|
||||||
disabled={isPending || catalog.length === 0}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "8px 14px",
|
|
||||||
height: 40,
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px dashed var(--border-strong)",
|
|
||||||
borderRadius: 8,
|
|
||||||
color: "var(--accent)",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor:
|
|
||||||
isPending || catalog.length === 0
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={16} strokeWidth={1.5} />
|
|
||||||
Добавить тип дома
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mode tab ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ModeTabProps {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModeTab({ active, onClick, label }: ModeTabProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={active}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
padding: "6px 12px",
|
|
||||||
height: 32,
|
|
||||||
background: active ? "var(--accent-soft)" : "transparent",
|
|
||||||
color: active ? "var(--accent)" : "var(--fg-secondary)",
|
|
||||||
border: active
|
|
||||||
? "1px solid var(--accent-soft)"
|
|
||||||
: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -41,16 +41,11 @@ import {
|
||||||
DEFAULT_PARAMS,
|
DEFAULT_PARAMS,
|
||||||
type ConceptParams,
|
type ConceptParams,
|
||||||
} from "@/components/concept/ConceptParamsForm";
|
} from "@/components/concept/ConceptParamsForm";
|
||||||
import {
|
|
||||||
HouseProgramPicker,
|
|
||||||
type ProgramMode,
|
|
||||||
} from "@/components/concept/HouseProgramPicker";
|
|
||||||
import {
|
import {
|
||||||
extractPolygon,
|
extractPolygon,
|
||||||
polygonAreaSqm,
|
polygonAreaSqm,
|
||||||
polygonCentroidWkt,
|
polygonCentroidWkt,
|
||||||
useCreateConcept,
|
useCreateConcept,
|
||||||
type BuildingProgramItem,
|
|
||||||
type DevelopmentType,
|
type DevelopmentType,
|
||||||
type HousingClass,
|
type HousingClass,
|
||||||
type MassingProgram,
|
type MassingProgram,
|
||||||
|
|
@ -188,12 +183,6 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
}, [fin, analysis.egrn?.cadastral_value_rub]);
|
}, [fin, analysis.egrn?.cadastral_value_rub]);
|
||||||
|
|
||||||
const [params, setParams] = useState<ConceptParams>(seededParams);
|
const [params, setParams] = useState<ConceptParams>(seededParams);
|
||||||
|
|
||||||
// Stage 3b (#1965): program mode — «Авто (max-FAR)» (default, building_program
|
|
||||||
// omitted → greedy unchanged) vs «Выбрать дома» (typed house program).
|
|
||||||
const [programMode, setProgramMode] = useState<ProgramMode>("auto");
|
|
||||||
const [houseProgram, setHouseProgram] = useState<BuildingProgramItem[]>([]);
|
|
||||||
|
|
||||||
const concept = useCreateConcept();
|
const concept = useCreateConcept();
|
||||||
|
|
||||||
const financeUnavailable = fin == null;
|
const financeUnavailable = fin == null;
|
||||||
|
|
@ -259,20 +248,12 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
|
|
||||||
function handleGenerate() {
|
function handleGenerate() {
|
||||||
if (!polygon) return;
|
if (!polygon) return;
|
||||||
// «Выбрать дома» с непустой программой → передаём building_program (бэк кладёт
|
|
||||||
// ровно её, один вариант). Иначе (авто / пустой список) — omit → жадная
|
|
||||||
// max-FAR раскладка (3 стратегии), поведение по умолчанию не меняется.
|
|
||||||
const program =
|
|
||||||
programMode === "pick" && houseProgram.length > 0
|
|
||||||
? houseProgram
|
|
||||||
: undefined;
|
|
||||||
concept.mutate({
|
concept.mutate({
|
||||||
parcel_geojson: polygon,
|
parcel_geojson: polygon,
|
||||||
housing_class: params.housing_class,
|
housing_class: params.housing_class,
|
||||||
target_floors: params.target_floors,
|
target_floors: params.target_floors,
|
||||||
development_type: params.development_type,
|
development_type: params.development_type,
|
||||||
land_cost_rub: params.land_cost_rub,
|
land_cost_rub: params.land_cost_rub,
|
||||||
building_program: program,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,16 +396,8 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Параметры проекта"
|
title="Параметры проекта"
|
||||||
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
|
subtitle="Класс, тип застройки и этажность."
|
||||||
>
|
>
|
||||||
<div style={{ display: "grid", gap: 16 }}>
|
|
||||||
<HouseProgramPicker
|
|
||||||
mode={programMode}
|
|
||||||
onModeChange={setProgramMode}
|
|
||||||
program={houseProgram}
|
|
||||||
onChange={setHouseProgram}
|
|
||||||
isPending={concept.isPending}
|
|
||||||
/>
|
|
||||||
<ConceptParamsForm
|
<ConceptParamsForm
|
||||||
params={params}
|
params={params}
|
||||||
onChange={setParams}
|
onChange={setParams}
|
||||||
|
|
@ -432,7 +405,6 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
hasPolygon={polygon != null}
|
hasPolygon={polygon != null}
|
||||||
isPending={concept.isPending}
|
isPending={concept.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,32 +4,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface paths {
|
export interface paths {
|
||||||
"/api/v1/concepts/house-types": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* List House Types
|
|
||||||
* @description Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.
|
|
||||||
*
|
|
||||||
* Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
|
|
||||||
* секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
|
|
||||||
* выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
|
|
||||||
* side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так
|
|
||||||
* фронт НЕ хардкодит габариты/этажности — они приходят отсюда.
|
|
||||||
*/
|
|
||||||
get: operations["list_house_types_api_v1_concepts_house_types_get"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/v1/concepts": {
|
"/api/v1/concepts": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3845,62 +3819,6 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
include_risks: boolean;
|
include_risks: boolean;
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* HouseTypeCatalog
|
|
||||||
* @description Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``.
|
|
||||||
*/
|
|
||||||
HouseTypeCatalog: {
|
|
||||||
/** House Types */
|
|
||||||
house_types: components["schemas"]["HouseTypeCatalogItem"][];
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* HouseTypeCatalogItem
|
|
||||||
* @description Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту.
|
|
||||||
*
|
|
||||||
* Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера
|
|
||||||
* Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность
|
|
||||||
* + класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это
|
|
||||||
* делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля
|
|
||||||
* зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``).
|
|
||||||
*/
|
|
||||||
HouseTypeCatalogItem: {
|
|
||||||
/**
|
|
||||||
* Section Type
|
|
||||||
* @description Catalog house-type key (HOUSE_TYPES)
|
|
||||||
*/
|
|
||||||
section_type: string;
|
|
||||||
/**
|
|
||||||
* Label Ru
|
|
||||||
* @description Human-readable Russian label
|
|
||||||
*/
|
|
||||||
label_ru: string;
|
|
||||||
/**
|
|
||||||
* Footprint W M
|
|
||||||
* @description Section footprint width, m
|
|
||||||
*/
|
|
||||||
footprint_w_m: number;
|
|
||||||
/**
|
|
||||||
* Footprint D M
|
|
||||||
* @description Section footprint depth, m
|
|
||||||
*/
|
|
||||||
footprint_d_m: number;
|
|
||||||
/**
|
|
||||||
* Footprint Sqm
|
|
||||||
* @description Section footprint area, m² (w × d)
|
|
||||||
*/
|
|
||||||
footprint_sqm: number;
|
|
||||||
/**
|
|
||||||
* Default Floors
|
|
||||||
* @description Default floors for this type
|
|
||||||
*/
|
|
||||||
default_floors: number;
|
|
||||||
/**
|
|
||||||
* Housing Class
|
|
||||||
* @description Suitable housing class
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
housing_class: "econom" | "comfort" | "business";
|
|
||||||
};
|
|
||||||
/** InsightCreate */
|
/** InsightCreate */
|
||||||
InsightCreate: {
|
InsightCreate: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -5121,26 +5039,6 @@ export interface components {
|
||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
export interface operations {
|
||||||
list_house_types_api_v1_concepts_house_types_get: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["HouseTypeCatalog"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
create_concept_api_v1_concepts_post: {
|
create_concept_api_v1_concepts_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* ConceptExportButtons).
|
* ConceptExportButtons).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||||
|
|
||||||
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||||
|
|
@ -25,22 +25,6 @@ export type HousingClass = "econom" | "comfort" | "business";
|
||||||
export type DevelopmentType = "spot" | "mid_rise" | "high_rise";
|
export type DevelopmentType = "spot" | "mid_rise" | "high_rise";
|
||||||
export type ConceptStrategy = "max_area" | "max_insolation" | "balanced";
|
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;
|
|
||||||
/** Этажность группы секций, 1–40. */
|
|
||||||
floors: number;
|
|
||||||
/** Сколько секций этого типа разместить, 1–50. */
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConceptInput {
|
export interface ConceptInput {
|
||||||
/** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */
|
/** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */
|
||||||
parcel_geojson: Polygon;
|
parcel_geojson: Polygon;
|
||||||
|
|
@ -50,12 +34,6 @@ export interface ConceptInput {
|
||||||
development_type: DevelopmentType;
|
development_type: DevelopmentType;
|
||||||
/** Стоимость участка (₽) — опционально, для финмодели. */
|
/** Стоимость участка (₽) — опционально, для финмодели. */
|
||||||
land_cost_rub?: number | null;
|
land_cost_rub?: number | null;
|
||||||
/**
|
|
||||||
* Stage 3b (#1965): опциональная программа типовых домов. Не задана / пусто →
|
|
||||||
* жадная max-FAR раскладка (3 стратегии, без изменений). Задана → бэк кладёт
|
|
||||||
* РОВНО эту программу (один вариант) и возвращает partial-fit сигнал.
|
|
||||||
*/
|
|
||||||
building_program?: BuildingProgramItem[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Output contract (ConceptOutput) ─────────────────────────────────────────
|
// ── Output contract (ConceptOutput) ─────────────────────────────────────────
|
||||||
|
|
@ -152,14 +130,6 @@ export interface ConceptVariant {
|
||||||
buildings_geojson: FeatureCollection;
|
buildings_geojson: FeatureCollection;
|
||||||
teap: Teap;
|
teap: Teap;
|
||||||
financial: FinancialModel;
|
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 {
|
export interface ConceptOutput {
|
||||||
|
|
@ -226,54 +196,6 @@ export function useCreateConcept() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 подставляет; пользователь меняет в 1–40). */
|
|
||||||
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) ───────────────────────────────
|
// ── Live recompute contract (Stage 2b, #1965) ───────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue