From 975c400536f0d74c95c17477a290787fdf858527 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Tue, 30 Jun 2026 09:48:11 +0000
Subject: [PATCH] =?UTF-8?q?feat(section7):=20editable=20section=20footprin?=
=?UTF-8?q?t=20+=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81=20plural/program?=
=?UTF-8?q?=20hint=20(#1953)=20(#2102)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/schemas/concept.py | 12 +++
backend/app/services/generative/placement.py | 11 +-
.../services/generative/test_placement.py | 48 +++++++--
.../services/test_quarter_dump_lookup.py | 7 +-
.../components/concept/ConceptProgramForm.tsx | 100 ++++++++++++++++--
.../concept/ConceptVariantsResult.tsx | 43 ++++++--
.../site-finder/analysis/Section7Concept.tsx | 4 +
frontend/src/lib/api-types.ts | 10 ++
frontend/src/lib/concept-api.ts | 8 ++
9 files changed, 214 insertions(+), 29 deletions(-)
diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py
index c0c4cde8..c4778c20 100644
--- a/backend/app/schemas/concept.py
+++ b/backend/app/schemas/concept.py
@@ -20,6 +20,18 @@ class BuildingProgramItem(BaseModel):
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
# Сколько секций этого типа разместить.
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
+ # ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м).
+ # None → габариты берутся из каталога по ``section_type`` (byte-for-byte
+ # backward-compat). Заданы → place_program строит footprint из них вместо
+ # каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе;
+ # частичное задание (только w / только d) трактуется как «не задано» на слое
+ # размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса.
+ footprint_w_m: float | None = Field(
+ None, ge=4, le=120, description="Optional manual section footprint width, m"
+ )
+ footprint_d_m: float | None = Field(
+ None, ge=4, le=120, description="Optional manual section footprint depth, m"
+ )
class HouseTypeCatalogItem(BaseModel):
diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py
index 5c17de4e..0c4d2ce1 100644
--- a/backend/app/services/generative/placement.py
+++ b/backend/app/services/generative/placement.py
@@ -273,13 +273,18 @@ def place_program(
for item in program:
house = catalog.get_house_type(item.section_type)
requested += item.count
+ # Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
+ # его вместо каталожного («вписать пятно»). Частичное задание игнорируем
+ # (нужны и ширина, и глубина), падая обратно на каталог.
+ if item.footprint_w_m is not None and item.footprint_d_m is not None:
+ fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
+ else:
+ fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
placed_for_item = 0
for cell in parcel.grid:
if placed_for_item >= item.count:
break
- footprint = _centered_footprint(
- cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m
- )
+ footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
if placer.try_place(footprint, buildable, half_gap):
placed_sections.append(
_PlacedSection(
diff --git a/backend/tests/services/generative/test_placement.py b/backend/tests/services/generative/test_placement.py
index 558b556c..fb83db94 100644
--- a/backend/tests/services/generative/test_placement.py
+++ b/backend/tests/services/generative/test_placement.py
@@ -9,8 +9,8 @@ from __future__ import annotations
from shapely.geometry import shape
-from app.schemas.concept import ConceptInput
-from app.services.generative import geometry, placement
+from app.schemas.concept import BuildingProgramItem, ConceptInput
+from app.services.generative import catalog, geometry, placement
_PARCEL_COORDS = [
[60.60, 56.830],
@@ -67,10 +67,7 @@ def test_max_area_denser_than_max_insolation() -> None:
parcel = geometry.parse_parcel(payload)
variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
# Максимум площади даёт большее пятно/жилую, чем максимум инсоляции.
- assert (
- variants["max_area"].teap.built_area_sqm
- > variants["max_insolation"].teap.built_area_sqm
- )
+ assert variants["max_area"].teap.built_area_sqm > variants["max_insolation"].teap.built_area_sqm
assert (
variants["max_area"].teap.residential_area_sqm
> variants["max_insolation"].teap.residential_area_sqm
@@ -104,6 +101,45 @@ def test_buildings_geojson_features_are_valid_polygons() -> None:
assert feat["properties"]["floors"] >= 1
+def test_program_footprint_override_changes_section_area() -> None:
+ # Stage 3c: ручное пятно (footprint_w_m/_d_m) кладётся вместо каталожного.
+ parcel = geometry.parse_parcel(_payload())
+ section_type = next(iter(catalog.available_section_types()))
+ house = catalog.get_house_type(section_type)
+
+ override = BuildingProgramItem(
+ section_type=section_type,
+ floors=10,
+ count=2,
+ footprint_w_m=10.0,
+ footprint_d_m=10.0,
+ )
+ placed = placement.place_program(parcel, [override])
+ assert placed.placed_count >= 1
+ # Каждая размещённая секция несёт пятно override (10×10 = 100 м²), не каталог.
+ for sec in placed.sections:
+ assert abs(sec.footprint.area - 100.0) < 1.0
+
+ # Без override — пятно из каталога (контроль: байт-в-байт прежнее поведение).
+ base = BuildingProgramItem(section_type=section_type, floors=10, count=2)
+ placed_base = placement.place_program(parcel, [base])
+ catalog_area = house.footprint_w_m * house.footprint_d_m
+ for sec in placed_base.sections:
+ assert abs(sec.footprint.area - catalog_area) < 1.0
+
+
+def test_program_partial_footprint_falls_back_to_catalog() -> None:
+ # Только ширина задана (без глубины) → трактуем как «не задано», берём каталог.
+ parcel = geometry.parse_parcel(_payload())
+ section_type = next(iter(catalog.available_section_types()))
+ house = catalog.get_house_type(section_type)
+ item = BuildingProgramItem(section_type=section_type, floors=10, count=1, footprint_w_m=10.0)
+ placed = placement.place_program(parcel, [item])
+ catalog_area = house.footprint_w_m * house.footprint_d_m
+ for sec in placed.sections:
+ assert abs(sec.footprint.area - catalog_area) < 1.0
+
+
def test_placement_deterministic() -> None:
payload = _payload()
p1 = geometry.parse_parcel(payload)
diff --git a/backend/tests/services/test_quarter_dump_lookup.py b/backend/tests/services/test_quarter_dump_lookup.py
index 87ef06a1..43ef1aae 100644
--- a/backend/tests/services/test_quarter_dump_lookup.py
+++ b/backend/tests/services/test_quarter_dump_lookup.py
@@ -685,12 +685,15 @@ def test_nspd_path_used_when_zouit_count_nonzero() -> None:
Проверяем source='nspd-quarter-dump' (не cad_zouit).
"""
# Строка дампа: все поля через MagicMock
- from datetime import UTC, datetime
+ from datetime import UTC, datetime, timedelta
dump_row = MagicMock()
dump_row.__getitem__ = lambda self, i: [
"66:41:0603016", # quarter_cad
- datetime(2026, 1, 1, tzinfo=UTC), # fetched_at_utc (свежий)
+ # Свежесть ОТНОСИТЕЛЬНО now: был хардкод datetime(2026, 1, 1) → date-bomb
+ # (с 2026-06-30 перевалил _DUMP_MAX_AGE_DAYS=180 → stale-ветка → _trigger_harvest
+ # → _empty_unavailable → overlaps=[] → assert 0 == 1). now − 1 день всегда свеж.
+ datetime.now(UTC) - timedelta(days=1), # fetched_at_utc (свежий)
100, # total_features
None, # harvest_error
1, # territorial_zones_count
diff --git a/frontend/src/components/concept/ConceptProgramForm.tsx b/frontend/src/components/concept/ConceptProgramForm.tsx
index af41a801..7c8f32f7 100644
--- a/frontend/src/components/concept/ConceptProgramForm.tsx
+++ b/frontend/src/components/concept/ConceptProgramForm.tsx
@@ -48,6 +48,11 @@ const FLOOR_HEIGHT_MIN = 2.4;
const FLOOR_HEIGHT_MAX = 4.5;
const FLOOR_HEIGHT_DEFAULT = 3.0;
+// Ручное пятно секции (м). Границы зеркалят backend BuildingProgramItem
+// (footprint_w_m / footprint_d_m, ge=4 le=120).
+const FOOTPRINT_MIN = 4;
+const FOOTPRINT_MAX = 120;
+
// ── Public payload (lifted to the parent on submit) ───────────────────────────
/**
@@ -66,6 +71,10 @@ export interface ConceptProgramFormState {
floor_height_m: number;
/** Каталожный тип секции → building_program item `section_type`. */
section_type: string;
+ /** Пятно секции (ширина, м) → building_program item `footprint_w_m`. */
+ footprint_w_m: number;
+ /** Пятно секции (глубина, м) → building_program item `footprint_d_m`. */
+ footprint_d_m: number;
housing_class: HousingClass;
development_type: DevelopmentType;
land_cost_rub: number | null;
@@ -165,6 +174,11 @@ export function ConceptProgramForm({
const [floors, setFloors] = useState(FLOORS_FALLBACK);
const [floorHeight, setFloorHeight] = useState(FLOOR_HEIGHT_DEFAULT);
const [sectionType, setSectionType] = useState("");
+ // Ручное пятно: null → используем габариты каталога по типу секции; число →
+ // пользователь «вписал» свой габарит (override). Сбрасывается в null при смене
+ // типа (новый тип несёт свои каталожные габариты).
+ const [footprintW, setFootprintW] = useState(null);
+ const [footprintD, setFootprintD] = useState(null);
const [housingClass, setHousingClass] = useState(
seeded.housing_class,
);
@@ -191,9 +205,16 @@ export function ConceptProgramForm({
[catalog, sectionType],
);
+ // Эффективное пятно секции: ручной override (footprintW/D) или каталог по типу.
+ const effFootprintW = footprintW ?? selectedType?.footprint_w_m ?? null;
+ const effFootprintD = footprintD ?? selectedType?.footprint_d_m ?? null;
+
// ── Derived read-only readouts (closes «видеть характеристики» complaint) ──
const buildingHeightM = floors * floorHeight;
- const footprintSqm = selectedType?.footprint_sqm ?? null;
+ const footprintSqm =
+ effFootprintW != null && effFootprintD != null
+ ? effFootprintW * effFootprintD
+ : null;
const totalFootprintSqm =
footprintSqm != null ? corpuses * footprintSqm : null;
const ksitEstimate =
@@ -207,11 +228,14 @@ export function ConceptProgramForm({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (disabled || sectionType === "") return;
+ if (effFootprintW == null || effFootprintD == null) return;
onSubmit({
corpuses,
floors,
floor_height_m: floorHeight,
section_type: sectionType,
+ footprint_w_m: effFootprintW,
+ footprint_d_m: effFootprintD,
housing_class: housingClass,
development_type: developmentType,
land_cost_rub: landCostRub,
@@ -223,6 +247,9 @@ export function ConceptProgramForm({
setSectionType(nextType);
const ht = catalog.find((c) => c.section_type === nextType);
if (ht && ht.default_floors > 0) setFloors(ht.default_floors);
+ // Смена типа сбрасывает ручной override пятна на каталожные габариты нового типа.
+ setFootprintW(null);
+ setFootprintD(null);
}
return (
@@ -375,7 +402,7 @@ export function ConceptProgramForm({
- {/* 4. Тип секции + пятно секции readout. */}
+ {/* 4. Тип секции. */}
+
+ {/* 4b. Пятно секции — редактируемое (ширина × глубина). */}
+
+
+ Пятно секции, м (ширина × глубина)
+
+
+
+ // Пусто → null (вернуться к каталожным габаритам), иначе clamp.
+ setFootprintW(
+ e.target.value.trim() === ""
+ ? null
+ : clampFloat(
+ Number(e.target.value),
+ FOOTPRINT_MIN,
+ FOOTPRINT_MAX,
+ ),
+ )
+ }
+ style={{ ...controlStyle, flex: 1 }}
+ />
+ ×
+
+ // Пусто → null (вернуться к каталожным габаритам), иначе clamp.
+ setFootprintD(
+ e.target.value.trim() === ""
+ ? null
+ : clampFloat(
+ Number(e.target.value),
+ FOOTPRINT_MIN,
+ FOOTPRINT_MAX,
+ ),
+ )
+ }
+ style={{ ...controlStyle, flex: 1 }}
+ />
+
+ {footprintSqm != null && (
- Пятно секции {nf0.format(selectedType.footprint_w_m)} ×{" "}
- {nf0.format(selectedType.footprint_d_m)} м ≈{" "}
-
- {nf0.format(selectedType.footprint_sqm)}
- {" "}
- м² — задаётся типом секции.
+ ≈ {nf0.format(footprintSqm)} м²
+ на секцию
+ {footprintW == null && footprintD == null ? " (из типа)" : ""}.
)}
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx
index 1fcca110..c8ee27e2 100644
--- a/frontend/src/components/concept/ConceptVariantsResult.tsx
+++ b/frontend/src/components/concept/ConceptVariantsResult.tsx
@@ -91,6 +91,32 @@ function variantLabel(variant: ConceptVariant): string {
: STRATEGY_LABELS[variant.strategy];
}
+/**
+ * Variant subtitle hint: program variants get a program-appropriate sentence
+ * (the 1b STRATEGY_HINTS speak of «приоритет площади/инсоляции/баланс», which
+ * misdescribes a user-defined program reported under the "balanced" slot).
+ */
+function variantHint(variant: ConceptVariant): string {
+ return isProgramVariant(variant)
+ ? "Раскладка заданной программы по геометрии участка."
+ : STRATEGY_HINTS[variant.strategy];
+}
+
+/** Russian plural for «корпус» (1 корпус / 2–4 корпуса / 5+ корпусов). */
+function pluralizeCorpus(n: number): string {
+ const mod100 = n % 100;
+ const mod10 = n % 10;
+ if (mod100 >= 11 && mod100 <= 14) return "корпусов";
+ if (mod10 === 1) return "корпус";
+ if (mod10 >= 2 && mod10 <= 4) return "корпуса";
+ return "корпусов";
+}
+
+/** «3 корпуса» / «1 корпус» / «12 корпусов» — count + agreed plural. */
+function formatCorpuses(n: number): string {
+ return `${formatInt(n)} ${pluralizeCorpus(n)}`;
+}
+
function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`;
}
@@ -149,8 +175,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}}
>
- {variantLabel(variant)}: {formatInt(buildingCount(variant))} корпусов
- · {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
+ {variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "}
+ {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)}
@@ -164,9 +190,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-on-dark-muted)",
}}
>
- {STRATEGY_HINTS[variant.strategy]} NPV{" "}
- {formatMoneyCompact(financial.npv_rub)} · IRR{" "}
- {formatPct(financial.irr)}
+ {variantHint(variant)} NPV {formatMoneyCompact(financial.npv_rub)} ·
+ IRR {formatPct(financial.irr)}
{financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "}
{formatPayback(financial.payback_months)} · плотность (FAR){" "}
{teap.density.toLocaleString("ru-RU", {
@@ -193,8 +218,8 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}}
>
Размещено {formatInt(partialFit.placed)} из{" "}
- {formatInt(partialFit.requested)} корпусов — участок вмещает меньше,
- чем в заданной программе. ТЭП и финмодель рассчитаны по фактически
+ {formatCorpuses(partialFit.requested)} — участок вмещает меньше, чем в
+ заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам.
)}
@@ -202,9 +227,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{/* Placement map */}
0 ? def.default_floors : FLOORS_FALLBACK,
floor_height_m: 3.0,
section_type: def.section_type,
+ footprint_w_m: def.footprint_w_m,
+ footprint_d_m: def.footprint_d_m,
housing_class: seeded.housing_class,
development_type: seeded.development_type,
land_cost_rub: seeded.land_cost_rub,
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts
index 18b99fb6..fa921bce 100644
--- a/frontend/src/lib/api-types.ts
+++ b/frontend/src/lib/api-types.ts
@@ -3046,6 +3046,16 @@ export interface components {
* @description Number of sections to place
*/
count: number;
+ /**
+ * Footprint W M
+ * @description Optional manual section footprint width, m
+ */
+ footprint_w_m?: number | null;
+ /**
+ * Footprint D M
+ * @description Optional manual section footprint depth, m
+ */
+ footprint_d_m?: number | null;
};
/**
* BulkGeoEnqueueRequest
diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts
index 7d3aa24d..c9c2aab6 100644
--- a/frontend/src/lib/concept-api.ts
+++ b/frontend/src/lib/concept-api.ts
@@ -39,6 +39,14 @@ export interface BuildingProgramItem {
floors: number;
/** Сколько секций этого типа разместить, 1–50. */
count: number;
+ /**
+ * Опциональное ручное пятно секции (ширина, м), 4–120. None / не задано →
+ * габариты берёт бэк из каталога по `section_type`. Должно задаваться вместе с
+ * `footprint_d_m` (частичное задание бэк игнорирует). ADDITIVE (Stage 3c).
+ */
+ footprint_w_m?: number | null;
+ /** Опциональное ручное пятно секции (глубина, м), 4–120. См. `footprint_w_m`. */
+ footprint_d_m?: number | null;
}
export interface ConceptInput {