feat(site-finder): §7 «Концепция» — методики + синхронизация подписей (#1953, batch D) (#2098)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m30s
Deploy / deploy (push) Successful in 1m14s

This commit is contained in:
bot-backend 2026-06-29 12:24:21 +00:00
parent d9915c7834
commit f448b550b5
2 changed files with 102 additions and 21 deletions

View file

@ -114,16 +114,19 @@ function KpiGrid({
label="Общая площадь (GFA)"
value={formatInt(teap.total_floor_area_sqm)}
unit="м²"
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
/>
<KpiCard
label="Продаваемая площадь"
value={formatInt(teap.residential_area_sqm)}
unit="м²"
hint="Жилая к продаже = (GFA нежилой 1-й этаж) × коэффициент эффективности класса."
/>
<KpiCard
label="Квартир"
value={formatInt(teap.apartments_count)}
unit="шт"
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
/>
<KpiCard
label="КСИТ — факт / цель"
@ -134,6 +137,7 @@ function KpiGrid({
: "В пределах регламента",
positive: ksitOver ? false : true,
}}
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
/>
</div>
@ -149,10 +153,12 @@ function KpiGrid({
<KpiCard
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
/>
<KpiCard
label="Себестоимость"
value={formatMoneyCompact(financial.cost_rub)}
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
/>
<KpiCard
label="Чистая прибыль"
@ -166,6 +172,7 @@ function KpiGrid({
: "Нулевая",
positive: netPositive,
}}
hint="Выручка себестоимость НДС на нежилое налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
/>
<KpiCard
label="ROI на затраты"
@ -174,6 +181,7 @@ function KpiGrid({
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null,
}}
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
/>
<KpiCard
label="NPV (DCF)"
@ -187,6 +195,9 @@ function KpiGrid({
? false
: null,
}}
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
financial.discount_rate_used,
)} годовых).`}
/>
<KpiCard
label="IRR (DCF, годовой)"
@ -201,11 +212,13 @@ function KpiGrid({
? true
: false,
}}
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
/>
<KpiCard
label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{ value: priceSourceCaption(financial), positive: null }}
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
/>
</div>
</div>
@ -253,9 +266,20 @@ interface Props {
farTarget: number;
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
ksitOver: boolean;
/**
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
* non-residential / ЗОУИТ-СЗЗ) drives the honest «условный расчёт» caveat
* below the strip alongside the negative-economics case.
*/
gateConstrained?: boolean;
}
export function MassingEconomics({ program, farTarget, ksitOver }: Props) {
export function MassingEconomics({
program,
farTarget,
ksitOver,
gateConstrained = false,
}: Props) {
const recompute = useRecomputeMassing();
// Last successful result kept locally so a failed/stale recompute never blanks
@ -316,6 +340,13 @@ export function MassingEconomics({ program, farTarget, ksitOver }: Props) {
// A fresher request is in flight over the last-good values.
const stale = recompute.isPending;
// Honest «условный расчёт» caveat: when the economics turn negative OR the
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
// we say so plainly rather than presenting the figures as a viable project.
const economicsNegative =
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
const showConditionalNote = economicsNegative || gateConstrained;
return (
<Section
title="Экономика по 3D-модели"
@ -329,6 +360,33 @@ export function MassingEconomics({ program, farTarget, ksitOver }: Props) {
stale={stale}
/>
{showConditionalNote ? (
<p
role="note"
style={{
margin: "12px 0 0",
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: 12,
lineHeight: "16px",
color: "var(--warn)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden="true"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
и/или экономика отрицательна при текущих вводных. Измените
этажность, число секций или класс модель пересчитается.
</span>
</p>
) : null}
{errored ? (
<p
role="status"

View file

@ -1,13 +1,22 @@
"use client";
/**
* Section7Concept «7. Концепция» (epic #1953, #1965 Stage 1).
* Section7Concept «7. Концепция» (epic #1953, #1965 Stage 2b).
*
* Folds the generative concept generator (3 strategy variants: placement map +
* ТЭП + financial model) into the LIGHT analysis report as a new section AFTER
* «6. Прогноз». Frontend-only: it reuses the existing concept components
* (ConceptParamsForm / ConceptVariantsResult) and the /concepts mutation
* (useCreateConcept) AS-IS no duplication, no backend change.
* Folds the generative concept tooling into the LIGHT analysis report as a new
* section AFTER «6. Прогноз». Frontend-only reuses the existing concept
* components and the /concepts mutations AS-IS (no duplication, no backend
* change). Two distinct surfaces sit in this section:
*
* 1. LIVE interactive 3D massing (top): MassingViewer + MassingEconomics.
* Dragging the этажность / секции sliders re-folds the program and the
* economics strip recomputes ТЭП + финмодель vживую via /concepts/recompute
* (debounced). This is the primary, always-on surface.
* 2. ON-DEMAND variant layouts (bottom): ConceptParamsForm + button
* useCreateConcept /concepts returns up to 3 placement strategies
* (max_area / max_insolation / balanced) rendered on a Leaflet map. Heavy,
* so it runs only from the button, never auto on mount, and the result map
* is lazy-mounted via `dynamic(…, { ssr: false })`.
*
* Inputs are seeded from the analysis the report already has (no re-entry of the
* cadastre / polygon):
@ -17,16 +26,12 @@
* land_cost_rub egrn.cadastral_value_rub
* Falls back to comfort / mid_rise when those are null.
*
* On-demand only: the generative call is heavy, so it runs from a button never
* auto on mount. The result block (Leaflet map) is lazy-mounted via `dynamic(…,
* { ssr: false })`. The section defaults to collapsed (above-the-fold rule).
* The section defaults to collapsed (above-the-fold rule).
*
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a
* polygon + the form inputs (it is independent of the financial_estimate
* regulatory gate), so we show an honest banner and STILL allow a manual run.
*
* Stage 2 (separate PR) wires an interactive 3D MassingScene + a /concepts/
* recompute endpoint for live economics left as a clear seam here, not built.
* СЗЗ / financial_estimate === null is NOT a dead end: the massing + /concepts
* only need a polygon + form inputs (independent of the financial_estimate
* regulatory gate), so we show an honest banner, mark the live economics as
* «условный расчёт», and STILL allow a manual run.
*/
import dynamic from "next/dynamic";
@ -198,6 +203,12 @@ export function Section7Concept({ analysis }: Props) {
const financeUnavailable = fin == null;
// Regulatorily constrained for МКД: no регламентная финмодель (ЗОУИТ / СЗЗ /
// нет зоны → financial_estimate == null) OR the gate verdict says МКД нельзя.
// Drives the honest «условный расчёт» caveat in the live economics strip.
const gateConstrained =
financeUnavailable || analysis.gate_verdict?.can_build_mkd === false;
const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
// КСИТ-цель (max_far) fed to the 3D scene + economics: real ПЗЗ-регламент when
@ -280,7 +291,7 @@ export function Section7Concept({ analysis }: Props) {
<section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar
title="7. Концепция"
subtitle="Генеративный дизайн застройки: три стратегии размещения с расчётом ТЭП и финансовой модели. Считается по запросу из геометрии и параметров этого участка."
subtitle="Интерактивная 3D-масса застройки: меняете класс, тип, этажность и число секций — ТЭП и финмодель пересчитываются вживую по геометрии этого участка. Отдельной кнопкой ниже движок раскладывает варианты размещения."
/>
{/* Collapsed by default (above-the-fold rule) form + button inside;
@ -358,7 +369,7 @@ export function Section7Concept({ analysis }: Props) {
>
<Section
title="Интерактивная масса застройки"
subtitle={`Меняйте этажность и число секций — экономика справа пересчитывается по финмодели. КСИТ-цель ${farTarget.toFixed(
subtitle={`Редактируемая 3D-модель: тяните ползунки этажности и числа секций — ТЭП и финмодель справа пересчитываются вживую. КСИТ-цель ${farTarget.toFixed(
1,
)} (${
farIsReal ? "регламент НСПД" : "дефолт, источник НСПД"
@ -375,6 +386,7 @@ export function Section7Concept({ analysis }: Props) {
program={program}
farTarget={farTarget}
ksitOver={ksitOver}
gateConstrained={gateConstrained}
/>
</div>
@ -397,9 +409,20 @@ export function Section7Concept({ analysis }: Props) {
color: "var(--fg-primary)",
}}
>
Кадастровый номер {analysis.cad_num} · площадь {" "}
{ha(parcelAreaSqm)} га.
Кадастровый номер {analysis.cad_num} · площадь пятна по
геометрии {ha(parcelAreaSqm)} га.
</div>
<p
style={{
margin: "8px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
Массинг и КСИТ считаются от площади застраиваемого пятна по
геометрии участка (контур НСПД), поэтому она может
отличаться от площади по ЕГРН в карточке участка.
</p>
<p
style={{
margin: "8px 0 0",
@ -415,7 +438,7 @@ export function Section7Concept({ analysis }: Props) {
<Section
title="Параметры проекта"
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения."
subtitle="Задайте класс, тип застройки и этажность для расчёта вариантов размещения по кнопке ниже (отдельно от 3D-массы сверху). Можно указать программу из типовых домов вместо авторазмещения."
>
<div style={{ display: "grid", gap: 16 }}>
<HouseProgramPicker