Merge pull request 'feat(concept): picker «выбери дом» + каталог-эндпоинт — Stage 3b, финал #1965' (#2030) from feat/concept-house-picker-1965-stage3b into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m7s
Deploy / build-worker (push) Successful in 3m9s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m26s

This commit is contained in:
bot-backend 2026-06-27 22:08:57 +00:00
commit 2fb34112b5
9 changed files with 790 additions and 18 deletions

View file

@ -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,

View file

@ -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."""

View file

@ -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())

View file

@ -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>

View file

@ -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="Размещение застройки"

View 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 (150) and floors (140,
* 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>
);
}

View file

@ -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>

View file

@ -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;

View file

@ -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;
/** Этажность группы секций, 140. */
floors: number;
/** Сколько секций этого типа разместить, 150. */
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 подставляет; пользователь меняет в 140). */
default_floors: number;
/** Подходящий класс жилья. */
housing_class: HousingClass;
}
interface HouseTypeCatalog {
house_types: HouseTypeCatalogItem[];
}
/**
* GET /api/v1/concepts/house-types каталог типовых домов для пикера Stage 3b.
* Read-only справочник (статичен на бэке) `useQuery` per data-layer rule;
* `staleTime: Infinity`, т.к. каталог не меняется в рамках сессии. Возвращает
* массив пунктов каталога (разворачиваем `{house_types}` в `select`).
*/
export function useHouseTypes() {
return useQuery<HouseTypeCatalogItem[]>({
queryKey: ["concept", "house-types"],
queryFn: async () => {
const data = await apiFetch<HouseTypeCatalog>(
"/api/v1/concepts/house-types",
);
return data.house_types;
},
staleTime: Infinity,
});
}
// ── Live recompute contract (Stage 2b, #1965) ─────────────────────────────── // ── Live recompute contract (Stage 2b, #1965) ───────────────────────────────
/** /**