feat(site-finder): §7 «Концепция» — методики + синхронизация подписей (#1953, batch D) #2098

Merged
bot-backend merged 1 commit from fix/concept-explain-batchD-1953 into main 2026-06-29 12:24:22 +00:00
2 changed files with 102 additions and 21 deletions

View file

@ -114,16 +114,19 @@ function KpiGrid({
label="Общая площадь (GFA)" label="Общая площадь (GFA)"
value={formatInt(teap.total_floor_area_sqm)} value={formatInt(teap.total_floor_area_sqm)}
unit="м²" unit="м²"
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
/> />
<KpiCard <KpiCard
label="Продаваемая площадь" label="Продаваемая площадь"
value={formatInt(teap.residential_area_sqm)} value={formatInt(teap.residential_area_sqm)}
unit="м²" unit="м²"
hint="Жилая к продаже = (GFA нежилой 1-й этаж) × коэффициент эффективности класса."
/> />
<KpiCard <KpiCard
label="Квартир" label="Квартир"
value={formatInt(teap.apartments_count)} value={formatInt(teap.apartments_count)}
unit="шт" unit="шт"
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
/> />
<KpiCard <KpiCard
label="КСИТ — факт / цель" label="КСИТ — факт / цель"
@ -134,6 +137,7 @@ function KpiGrid({
: "В пределах регламента", : "В пределах регламента",
positive: ksitOver ? false : true, positive: ksitOver ? false : true,
}} }}
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
/> />
</div> </div>
@ -149,10 +153,12 @@ function KpiGrid({
<KpiCard <KpiCard
label="Выручка (GDV)" label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)} value={formatMoneyCompact(financial.revenue_rub)}
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
/> />
<KpiCard <KpiCard
label="Себестоимость" label="Себестоимость"
value={formatMoneyCompact(financial.cost_rub)} value={formatMoneyCompact(financial.cost_rub)}
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
/> />
<KpiCard <KpiCard
label="Чистая прибыль" label="Чистая прибыль"
@ -166,6 +172,7 @@ function KpiGrid({
: "Нулевая", : "Нулевая",
positive: netPositive, positive: netPositive,
}} }}
hint="Выручка себестоимость НДС на нежилое налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
/> />
<KpiCard <KpiCard
label="ROI на затраты" label="ROI на затраты"
@ -174,6 +181,7 @@ function KpiGrid({
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`, value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null, positive: null,
}} }}
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
/> />
<KpiCard <KpiCard
label="NPV (DCF)" label="NPV (DCF)"
@ -187,6 +195,9 @@ function KpiGrid({
? false ? false
: null, : null,
}} }}
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
financial.discount_rate_used,
)} годовых).`}
/> />
<KpiCard <KpiCard
label="IRR (DCF, годовой)" label="IRR (DCF, годовой)"
@ -201,11 +212,13 @@ function KpiGrid({
? true ? true
: false, : false,
}} }}
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
/> />
<KpiCard <KpiCard
label="Цена продажи жилья" label="Цена продажи жилья"
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`} value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
delta={{ value: priceSourceCaption(financial), positive: null }} delta={{ value: priceSourceCaption(financial), positive: null }}
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
/> />
</div> </div>
</div> </div>
@ -253,9 +266,20 @@ interface Props {
farTarget: number; farTarget: number;
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */ /** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
ksitOver: boolean; 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(); const recompute = useRecomputeMassing();
// Last successful result kept locally so a failed/stale recompute never blanks // 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. // A fresher request is in flight over the last-good values.
const stale = recompute.isPending; 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 ( return (
<Section <Section
title="Экономика по 3D-модели" title="Экономика по 3D-модели"
@ -329,6 +360,33 @@ export function MassingEconomics({ program, farTarget, ksitOver }: Props) {
stale={stale} 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 ? ( {errored ? (
<p <p
role="status" role="status"

View file

@ -1,13 +1,22 @@
"use client"; "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 + * Folds the generative concept tooling into the LIGHT analysis report as a new
* ТЭП + financial model) into the LIGHT analysis report as a new section AFTER * section AFTER «6. Прогноз». Frontend-only reuses the existing concept
* «6. Прогноз». Frontend-only: it reuses the existing concept components * components and the /concepts mutations AS-IS (no duplication, no backend
* (ConceptParamsForm / ConceptVariantsResult) and the /concepts mutation * change). Two distinct surfaces sit in this section:
* (useCreateConcept) AS-IS no duplication, no backend change. *
* 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 * Inputs are seeded from the analysis the report already has (no re-entry of the
* cadastre / polygon): * cadastre / polygon):
@ -17,16 +26,12 @@
* land_cost_rub egrn.cadastral_value_rub * land_cost_rub egrn.cadastral_value_rub
* Falls back to comfort / mid_rise when those are null. * 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 * The section defaults to collapsed (above-the-fold rule).
* auto on mount. The result block (Leaflet map) is lazy-mounted via `dynamic(…,
* { ssr: false })`. The section defaults to collapsed (above-the-fold rule).
* *
* СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a * СЗЗ / financial_estimate === null is NOT a dead end: the massing + /concepts
* polygon + the form inputs (it is independent of the financial_estimate * only need a polygon + form inputs (independent of the financial_estimate
* regulatory gate), so we show an honest banner and STILL allow a manual run. * regulatory gate), so we show an honest banner, mark the live economics as
* * «условный расчёт», 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.
*/ */
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -198,6 +203,12 @@ export function Section7Concept({ analysis }: Props) {
const financeUnavailable = fin == null; 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; const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0;
// КСИТ-цель (max_far) fed to the 3D scene + economics: real ПЗЗ-регламент when // КСИТ-цель (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 }}> <section id="section-7" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar <HeadlineBar
title="7. Концепция" title="7. Концепция"
subtitle="Генеративный дизайн застройки: три стратегии размещения с расчётом ТЭП и финансовой модели. Считается по запросу из геометрии и параметров этого участка." subtitle="Интерактивная 3D-масса застройки: меняете класс, тип, этажность и число секций — ТЭП и финмодель пересчитываются вживую по геометрии этого участка. Отдельной кнопкой ниже движок раскладывает варианты размещения."
/> />
{/* Collapsed by default (above-the-fold rule) form + button inside; {/* Collapsed by default (above-the-fold rule) form + button inside;
@ -358,7 +369,7 @@ export function Section7Concept({ analysis }: Props) {
> >
<Section <Section
title="Интерактивная масса застройки" title="Интерактивная масса застройки"
subtitle={`Меняйте этажность и число секций — экономика справа пересчитывается по финмодели. КСИТ-цель ${farTarget.toFixed( subtitle={`Редактируемая 3D-модель: тяните ползунки этажности и числа секций — ТЭП и финмодель справа пересчитываются вживую. КСИТ-цель ${farTarget.toFixed(
1, 1,
)} (${ )} (${
farIsReal ? "регламент НСПД" : "дефолт, источник НСПД" farIsReal ? "регламент НСПД" : "дефолт, источник НСПД"
@ -375,6 +386,7 @@ export function Section7Concept({ analysis }: Props) {
program={program} program={program}
farTarget={farTarget} farTarget={farTarget}
ksitOver={ksitOver} ksitOver={ksitOver}
gateConstrained={gateConstrained}
/> />
</div> </div>
@ -397,9 +409,20 @@ export function Section7Concept({ analysis }: Props) {
color: "var(--fg-primary)", color: "var(--fg-primary)",
}} }}
> >
Кадастровый номер {analysis.cad_num} · площадь {" "} Кадастровый номер {analysis.cad_num} · площадь пятна по
{ha(parcelAreaSqm)} га. геометрии {ha(parcelAreaSqm)} га.
</div> </div>
<p
style={{
margin: "8px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
Массинг и КСИТ считаются от площади застраиваемого пятна по
геометрии участка (контур НСПД), поэтому она может
отличаться от площади по ЕГРН в карточке участка.
</p>
<p <p
style={{ style={{
margin: "8px 0 0", margin: "8px 0 0",
@ -415,7 +438,7 @@ export function Section7Concept({ analysis }: Props) {
<Section <Section
title="Параметры проекта" title="Параметры проекта"
subtitle="Класс, тип застройки и этажность. Можно задать программу из типовых домов вместо авторазмещения." subtitle="Задайте класс, тип застройки и этажность для расчёта вариантов размещения по кнопке ниже (отдельно от 3D-массы сверху). Можно указать программу из типовых домов вместо авторазмещения."
> >
<div style={{ display: "grid", gap: 16 }}> <div style={{ display: "grid", gap: 16 }}>
<HouseProgramPicker <HouseProgramPicker