Merge pull request 'feat(concept): picker «выбери дом» + каталог-эндпоинт — Stage 3b, финал #1965' (#2030) from feat/concept-house-picker-1965-stage3b into main
This commit is contained in:
commit
2fb34112b5
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 (
|
||||
ConceptInput,
|
||||
ConceptOutput,
|
||||
HouseTypeCatalog,
|
||||
HouseTypeCatalogItem,
|
||||
MassingProgram,
|
||||
MassingRecomputeOutput,
|
||||
)
|
||||
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.geometry import ParcelGeometryError, _parse_polygon
|
||||
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"
|
||||
|
||||
|
||||
@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)
|
||||
async def create_concept(
|
||||
payload: ConceptInput,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,39 @@ class BuildingProgramItem(BaseModel):
|
|||
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):
|
||||
"""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 "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,
|
||||
} from "@/components/concept/ConceptParamsForm";
|
||||
import { ConceptVariantsResult } from "@/components/concept/ConceptVariantsResult";
|
||||
import {
|
||||
HouseProgramPicker,
|
||||
type ProgramMode,
|
||||
} from "@/components/concept/HouseProgramPicker";
|
||||
import {
|
||||
polygonAreaSqm,
|
||||
useCadastreGeom,
|
||||
useCreateConcept,
|
||||
type BuildingProgramItem,
|
||||
} from "@/lib/concept-api";
|
||||
|
||||
// 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 [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 concept = useCreateConcept();
|
||||
|
||||
|
|
@ -93,12 +102,19 @@ export default function ConceptPage() {
|
|||
|
||||
function handleGenerate() {
|
||||
if (!polygon) return;
|
||||
// «Выбрать дома» с непустой программой → building_program (бэк кладёт ровно
|
||||
// её). Иначе omit → жадная max-FAR раскладка (поведение по умолчанию).
|
||||
const program =
|
||||
programMode === "pick" && houseProgram.length > 0
|
||||
? houseProgram
|
||||
: undefined;
|
||||
concept.mutate({
|
||||
parcel_geojson: polygon,
|
||||
housing_class: params.housing_class,
|
||||
target_floors: params.target_floors,
|
||||
development_type: params.development_type,
|
||||
land_cost_rub: params.land_cost_rub,
|
||||
building_program: program,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -228,15 +244,24 @@ export default function ConceptPage() {
|
|||
{/* Parameters */}
|
||||
<Section
|
||||
title="Параметры проекта"
|
||||
subtitle="Класс, тип застройки и этажность."
|
||||
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
|
||||
>
|
||||
<ConceptParamsForm
|
||||
params={params}
|
||||
onChange={setParams}
|
||||
onSubmit={handleGenerate}
|
||||
hasPolygon={polygon != null}
|
||||
isPending={concept.isPending}
|
||||
/>
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<HouseProgramPicker
|
||||
mode={programMode}
|
||||
onModeChange={setProgramMode}
|
||||
program={houseProgram}
|
||||
onChange={setHouseProgram}
|
||||
isPending={concept.isPending}
|
||||
/>
|
||||
<ConceptParamsForm
|
||||
params={params}
|
||||
onChange={setParams}
|
||||
onSubmit={handleGenerate}
|
||||
hasPolygon={polygon != null}
|
||||
isPending={concept.isPending}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
? false
|
||||
: 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 (
|
||||
<div>
|
||||
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
|
||||
|
|
@ -140,6 +150,28 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
</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 */}
|
||||
<Section
|
||||
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,
|
||||
type ConceptParams,
|
||||
} from "@/components/concept/ConceptParamsForm";
|
||||
import {
|
||||
HouseProgramPicker,
|
||||
type ProgramMode,
|
||||
} from "@/components/concept/HouseProgramPicker";
|
||||
import {
|
||||
extractPolygon,
|
||||
polygonAreaSqm,
|
||||
polygonCentroidWkt,
|
||||
useCreateConcept,
|
||||
type BuildingProgramItem,
|
||||
type DevelopmentType,
|
||||
type HousingClass,
|
||||
type MassingProgram,
|
||||
|
|
@ -183,6 +188,12 @@ export function Section7Concept({ analysis }: Props) {
|
|||
}, [fin, analysis.egrn?.cadastral_value_rub]);
|
||||
|
||||
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 financeUnavailable = fin == null;
|
||||
|
|
@ -248,12 +259,20 @@ export function Section7Concept({ analysis }: Props) {
|
|||
|
||||
function handleGenerate() {
|
||||
if (!polygon) return;
|
||||
// «Выбрать дома» с непустой программой → передаём building_program (бэк кладёт
|
||||
// ровно её, один вариант). Иначе (авто / пустой список) — omit → жадная
|
||||
// max-FAR раскладка (3 стратегии), поведение по умолчанию не меняется.
|
||||
const program =
|
||||
programMode === "pick" && houseProgram.length > 0
|
||||
? houseProgram
|
||||
: undefined;
|
||||
concept.mutate({
|
||||
parcel_geojson: polygon,
|
||||
housing_class: params.housing_class,
|
||||
target_floors: params.target_floors,
|
||||
development_type: params.development_type,
|
||||
land_cost_rub: params.land_cost_rub,
|
||||
building_program: program,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -396,15 +415,24 @@ export function Section7Concept({ analysis }: Props) {
|
|||
|
||||
<Section
|
||||
title="Параметры проекта"
|
||||
subtitle="Класс, тип застройки и этажность."
|
||||
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
|
||||
>
|
||||
<ConceptParamsForm
|
||||
params={params}
|
||||
onChange={setParams}
|
||||
onSubmit={handleGenerate}
|
||||
hasPolygon={polygon != null}
|
||||
isPending={concept.isPending}
|
||||
/>
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<HouseProgramPicker
|
||||
mode={programMode}
|
||||
onModeChange={setProgramMode}
|
||||
program={houseProgram}
|
||||
onChange={setHouseProgram}
|
||||
isPending={concept.isPending}
|
||||
/>
|
||||
<ConceptParamsForm
|
||||
params={params}
|
||||
onChange={setParams}
|
||||
onSubmit={handleGenerate}
|
||||
hasPolygon={polygon != null}
|
||||
isPending={concept.isPending}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,32 @@
|
|||
*/
|
||||
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3819,6 +3845,62 @@ export interface components {
|
|||
*/
|
||||
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: {
|
||||
/**
|
||||
|
|
@ -5039,6 +5121,26 @@ export interface components {
|
|||
}
|
||||
export type $defs = Record<string, never>;
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* ConceptExportButtons).
|
||||
*/
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
|
||||
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 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 {
|
||||
/** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */
|
||||
parcel_geojson: Polygon;
|
||||
|
|
@ -34,6 +50,12 @@ export interface ConceptInput {
|
|||
development_type: DevelopmentType;
|
||||
/** Стоимость участка (₽) — опционально, для финмодели. */
|
||||
land_cost_rub?: number | null;
|
||||
/**
|
||||
* Stage 3b (#1965): опциональная программа типовых домов. Не задана / пусто →
|
||||
* жадная max-FAR раскладка (3 стратегии, без изменений). Задана → бэк кладёт
|
||||
* РОВНО эту программу (один вариант) и возвращает partial-fit сигнал.
|
||||
*/
|
||||
building_program?: BuildingProgramItem[] | null;
|
||||
}
|
||||
|
||||
// ── Output contract (ConceptOutput) ─────────────────────────────────────────
|
||||
|
|
@ -130,6 +152,14 @@ export interface ConceptVariant {
|
|||
buildings_geojson: FeatureCollection;
|
||||
teap: Teap;
|
||||
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 {
|
||||
|
|
@ -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) ───────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue