feat(concept): фронт-пикер типовых домов (#1965 Stage 3b)
Финальная часть эпика #1953: пользователь выбирает типовые дома (тип × этажность × число секций) вместо авто max-FAR раскладки, формируя building_program из Stage 3a. Бэкенд: - GET /api/v1/concepts/house-types — read-only каталог HOUSE_TYPES (section_type, label_ru, footprint w×d + sqm, default_floors, housing_class) как single source of truth; фронт ничего не хардкодит. - Схема HouseTypeCatalog / HouseTypeCatalogItem в schemas/concept.py. - Тесты эндпоинта: полнота каталога + совпадение ключей с available_section_types. Кодген: api-types.ts перегенерён (dump OpenAPI → openapi-typescript → project-local prettier 3.9.0); 2-й прогон без диффа. Фронтенд: - useHouseTypes() (TanStack useQuery, staleTime Infinity) в concept-api.ts; building_program в ConceptInput, placed_count/requested_count в ConceptVariant. - HouseProgramPicker: toggle «Авто (max-FAR)» (default, program omit → greedy) vs «Выбрать дома» (список каталожных типов, count 1-50 / floors 1-40, дефолт из каталога; габариты/этажность/класс как подсказка). Смонтирован в Section7Concept и на странице /concept. - Partial-fit заметка в ConceptVariantsResult: при placed<requested честное «Разместилось N из M секций — участок вмещает меньше» (нейтрально, не ошибка).
This commit is contained in:
parent
3b84609670
commit
c55eb4f4d4
9 changed files with 790 additions and 18 deletions
|
|
@ -11,11 +11,13 @@ 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 available_section_types
|
from app.services.generative.catalog import HOUSE_TYPES, 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
|
||||||
|
|
@ -176,6 +178,32 @@ 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,6 +22,39 @@ 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,3 +246,46 @@ 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,10 +26,15 @@ 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.
|
||||||
|
|
@ -70,6 +75,10 @@ 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();
|
||||||
|
|
||||||
|
|
@ -93,12 +102,19 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,15 +244,24 @@ export default function ConceptPage() {
|
||||||
{/* Parameters */}
|
{/* Parameters */}
|
||||||
<Section
|
<Section
|
||||||
title="Параметры проекта"
|
title="Параметры проекта"
|
||||||
subtitle="Класс, тип застройки и этажность."
|
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
|
||||||
>
|
>
|
||||||
<ConceptParamsForm
|
<div style={{ display: "grid", gap: 16 }}>
|
||||||
params={params}
|
<HouseProgramPicker
|
||||||
onChange={setParams}
|
mode={programMode}
|
||||||
onSubmit={handleGenerate}
|
onModeChange={setProgramMode}
|
||||||
hasPolygon={polygon != null}
|
program={houseProgram}
|
||||||
isPending={concept.isPending}
|
onChange={setHouseProgram}
|
||||||
/>
|
isPending={concept.isPending}
|
||||||
|
/>
|
||||||
|
<ConceptParamsForm
|
||||||
|
params={params}
|
||||||
|
onChange={setParams}
|
||||||
|
onSubmit={handleGenerate}
|
||||||
|
hasPolygon={polygon != null}
|
||||||
|
isPending={concept.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,16 @@ 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. */}
|
||||||
|
|
@ -140,6 +150,28 @@ 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="Размещение застройки"
|
||||||
|
|
|
||||||
403
frontend/src/components/concept/HouseProgramPicker.tsx
Normal file
403
frontend/src/components/concept/HouseProgramPicker.tsx
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
"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,11 +41,16 @@ 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,
|
||||||
|
|
@ -183,6 +188,12 @@ 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;
|
||||||
|
|
@ -248,12 +259,20 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,15 +415,24 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Параметры проекта"
|
title="Параметры проекта"
|
||||||
subtitle="Класс, тип застройки и этажность."
|
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
|
||||||
>
|
>
|
||||||
<ConceptParamsForm
|
<div style={{ display: "grid", gap: 16 }}>
|
||||||
params={params}
|
<HouseProgramPicker
|
||||||
onChange={setParams}
|
mode={programMode}
|
||||||
onSubmit={handleGenerate}
|
onModeChange={setProgramMode}
|
||||||
hasPolygon={polygon != null}
|
program={houseProgram}
|
||||||
isPending={concept.isPending}
|
onChange={setHouseProgram}
|
||||||
/>
|
isPending={concept.isPending}
|
||||||
|
/>
|
||||||
|
<ConceptParamsForm
|
||||||
|
params={params}
|
||||||
|
onChange={setParams}
|
||||||
|
onSubmit={handleGenerate}
|
||||||
|
hasPolygon={polygon != null}
|
||||||
|
isPending={concept.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,32 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -3819,6 +3845,62 @@ 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: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -5039,6 +5121,26 @@ 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 } from "@tanstack/react-query";
|
import { useMutation, useQuery } 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,6 +25,22 @@ 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;
|
||||||
|
|
@ -34,6 +50,12 @@ 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) ─────────────────────────────────────────
|
||||||
|
|
@ -130,6 +152,14 @@ 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 {
|
||||||
|
|
@ -196,6 +226,54 @@ 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