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 {