diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index 418d298e..17e6f967 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -544,6 +544,9 @@ def _demand_supply_overlay( "obj_class": seg.segment.get("obj_class"), "deficit_index": seg.deficit_index, "balance_units": seg.balance_units, + # #1745: спрос на горизонт (квартир) — для таблицы прогноза по форматам. + # demand_only его не несёт (без геометрии supply/спрос-проекция неизмеримы). + "projected_demand_units": seg.projected_demand_units, "confidence": seg.confidence, } ) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 8abb27cb..b9e4d11c 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -84,6 +84,10 @@ _LEVEL_RU: dict[ReportConfidenceLevel, str] = { # special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]). _STRONG_DEFICIT_THRESHOLD: float = 0.25 +# Нейтральная зона |deficit_index|, внутри которой рынок считаем сбалансированным +# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05. +_SIGNAL_BALANCE_EPS: float = 0.05 + # Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём # 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default). _PRIMARY_HORIZON_MONTHS: int = 12 @@ -514,12 +518,16 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) — продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем - ranked_segments в компактные {bucket, obj_class, deficit_index}. Нет ни того, ни - другого → []. + ranked_segments в компактные {bucket, obj_class, deficit_index, projected_demand_units, + signal} (#1745 — таблица прогноза по форматам). Нет ни того, ни другого → []. + + #1745 ADDITIVE: проброс `projected_demand_units` (прогноз спроса, квартир) и + деривированного `signal` («строить»/«баланс»/«избегать») из deficit_index. Старые + потребители mix читают bucket/obj_class/deficit_index — новые поля их не ломают. """ explicit = product_tz.get("mix") if isinstance(explicit, list): - return [m for m in explicit if isinstance(m, dict)] + return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)] ranked = product_tz.get("ranked_segments") if isinstance(ranked, list): return [ @@ -527,6 +535,8 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: "bucket": seg.get("bucket"), "obj_class": seg.get("obj_class"), "deficit_index": seg.get("deficit_index"), + "projected_demand_units": seg.get("projected_demand_units"), + "signal": _build_signal(seg.get("deficit_index")), } for seg in ranked if isinstance(seg, dict) @@ -534,6 +544,35 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: return [] +def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]: + """Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE. + + Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет + `signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует + вход — assembler-чистота). + """ + enriched = dict(entry) + if enriched.get("signal") is None: + enriched["signal"] = _build_signal(enriched.get("deficit_index")) + return enriched + + +def _build_signal(deficit_index: Any) -> str | None: + """Сигнал «строить»/«баланс»/«избегать» из deficit_index ∈ [−1,1] (#1745). PURE. + + Зеркало deficit-семантики §22 (frontend forecast-helpers.deficitWord): >+0.05 + недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс». + deficit_index None (тонкие данные) → None (фронт рисует «тонкие данные», НЕ 0/сигнал). + """ + if not isinstance(deficit_index, (int, float)) or isinstance(deficit_index, bool): + return None + if deficit_index > _SIGNAL_BALANCE_EPS: + return "строить" + if deficit_index < -_SIGNAL_BALANCE_EPS: + return "избегать" + return "баланс" + + def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]: """Достать список dict'ов по ключу (мусор/None → []). PURE.""" value = source.get(key) diff --git a/backend/app/services/forecasting/what_to_build.py b/backend/app/services/forecasting/what_to_build.py index 761838cb..55fc99d0 100644 --- a/backend/app/services/forecasting/what_to_build.py +++ b/backend/app/services/forecasting/what_to_build.py @@ -99,12 +99,14 @@ class RankedSegment: deficit_index: float # не None в ранкинге (None-ячейки отброшены) balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка) confidence: Confidence # ≤ 'medium' (#980 advisory-cap) + projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир) def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), "deficit_index": _round_or_none(self.deficit_index, 3), "balance_units": _round_or_none(self.balance_units, 1), + "projected_demand_units": _round_or_none(self.projected_demand_units, 1), "confidence": self.confidence, } @@ -359,4 +361,5 @@ def _forecast_cell( deficit_index=forecast.deficit_index, balance_units=forecast.balance_units, confidence=forecast.confidence, + projected_demand_units=forecast.projected_demand_units, ) diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index f80f6349..5bfed1e0 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; import { ChatPanel } from "@/components/site-finder/ChatPanel"; +import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner"; import { HorizonSelector } from "@/components/site-finder/HorizonSelector"; import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo"; import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; @@ -160,22 +161,48 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Main scroll area ──────────────────────────────────────────── */}
- {/* Section 1 — IMPLEMENTED in A5 */} + {/* Вердикт-баннер участка — выше всех секций (#1741). */} + + + {/* ── Группа «Участок» ──────────────────────────────────────── */} + + + {/* 1. Информация — IMPLEMENTED in A5 */} - {/* Section 2 — IMPLEMENTED in A6 */} - - - {/* Section 3 — IMPLEMENTED in A7 */} - - - {/* Section 4 — IMPLEMENTED in A10 */} + {/* 2. Оценка участка — IMPLEMENTED in A10 */} - {/* Section 5 — IMPLEMENTED in A11 */} + {/* 3. Сети и точки подключения — IMPLEMENTED in A6 */} + + + {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} + + + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} + + + {/* 5. Атмосфера — IMPLEMENTED in A11 */} - {/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */} + {/* Статус пересчёта прогноза при смене горизонта (#1744) — + нейтральная серая строка вместо молчаливого disabled. */} + {isFetching && ( +
+ Пересчитываем прогноз на {horizon} мес… +
+ )} + + {/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */} {/* Chat — grounded parcel-chat over the §22 forecast (#958) */} @@ -245,32 +272,85 @@ function Breadcrumb({ ); } -// ── Sidebar nav ──────────────────────────────────────────────────────────────── +// ── Group divider ──────────────────────────────────────────────────────────── +// +// Плашка-разделитель между смысловыми группами секций (#1741): тёмный фон +// --bg-headline + светлый uppercase-label --fg-on-dark, паттерн headline-bar. -const NAV_ITEMS = [ - { id: "section-1", label: "1. Информация" }, - { id: "section-2", label: "2. Сети" }, +function GroupDivider({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + +// ── Sidebar nav ──────────────────────────────────────────────────────────────── +// +// Две группы с групп-хедерами (#1741). Нумерация секций сквозная 1–6 под новый +// порядок: «Участок» → 1-3, «Стройка и рынок» → 4-6. + +interface NavItem { + id: string; + label: string; + children?: { id: string; label: string }[]; +} + +interface NavGroup { + label: string; + items: NavItem[]; +} + +const NAV_GROUPS: NavGroup[] = [ { - id: "section-3", - label: "3. Продажи конкурентов", - children: [ - { id: "section-3-1", label: "3.1 Настройки выборки" }, - { id: "section-3-2", label: "3.2 Планировки" }, - { id: "section-3-3", label: "3.3 Остатки и скорость" }, + label: "Участок", + items: [ + { id: "section-1", label: "1. Информация" }, + { id: "section-4", label: "2. Оценка участка" }, + { id: "section-2", label: "3. Сети и точки подключения" }, ], }, - { id: "section-4", label: "4. Оценка" }, - { id: "section-5", label: "5. Атмосфера" }, { - id: "section-6", - label: "6. Прогноз", - children: [ - { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, - { id: "section-6-2", label: "6.2 Сценарии" }, - { id: "section-6-3", label: "6.3 Уверенность" }, - { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, - { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, - { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, + label: "Стройка и рынок", + items: [ + { + id: "section-3", + label: "4. Рынок и конкуренты", + children: [ + { id: "section-3-1", label: "4.1 Настройки выборки" }, + { id: "section-3-2", label: "4.2 Планировки" }, + { id: "section-3-3", label: "4.3 Остатки и скорость" }, + ], + }, + { id: "section-5", label: "5. Атмосфера" }, + { + id: "section-6", + label: "6. Прогноз и рекомендация", + children: [ + { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, + { id: "section-6-2", label: "6.2 Сценарии" }, + { id: "section-6-3", label: "6.3 Уверенность" }, + { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, + { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, + { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, + ], + }, ], }, ]; @@ -293,49 +373,36 @@ function AnalysisSidebarNav() { flexDirection: "column", }} > - {NAV_ITEMS.map((item) => ( -
- - {"children" in item && - item.children?.map((child) => ( + {group.label} +
+ {group.items.map((item) => ( +
- ))} + {item.children?.map((child) => ( + + ))} +
+ ))}
))} diff --git a/frontend/src/components/site-finder/HorizonSelector.tsx b/frontend/src/components/site-finder/HorizonSelector.tsx index 40608ba7..247be64a 100644 --- a/frontend/src/components/site-finder/HorizonSelector.tsx +++ b/frontend/src/components/site-finder/HorizonSelector.tsx @@ -22,64 +22,88 @@ interface Props { disabled?: boolean; } +// Подсказка на сегментах — поясняет, что даёт ближний / дальний горизонт. +const HORIZON_TOOLTIP = + "6 мес — ближний спрос, точнее; 24 мес — стратегия, шире интервал"; + export function HorizonSelector({ value, onChange, disabled = false }: Props) { return ( -
+
+
+ + Горизонт прогноза + +
+ {HORIZONS.map((h) => { + const selected = h === value; + return ( + + ); + })} +
+
- Горизонт прогноза + Смена горизонта пересчитывает прогноз заново (~10–30 сек) -
- {HORIZONS.map((h) => { - const selected = h === value; - return ( - - ); - })} -
); } diff --git a/frontend/src/components/site-finder/Pipeline24moBlock.tsx b/frontend/src/components/site-finder/Pipeline24moBlock.tsx index e6c466ba..e71a59ee 100644 --- a/frontend/src/components/site-finder/Pipeline24moBlock.tsx +++ b/frontend/src/components/site-finder/Pipeline24moBlock.tsx @@ -80,15 +80,33 @@ export function Pipeline24moBlock({ data }: Props) { > - Конкуренты на 24 мес (5 км) + Будущее предложение конкурентов (24 мес) + + прогноз + {data.severity_label ?? data.severity} ·{" "} - {data.flats_total.toLocaleString("ru-RU")} квартир + {data.flats_total.toLocaleString("ru")} лотов
{data.objects_count === 0 ? ( -
- Нет ЖК-конкурентов с planned_commissioning в окне 24мес в радиусе 5км. +
+ Нет ЖК-конкурентов со сроком сдачи в ближайшие 24 месяца в радиусе 5 + км.
) : ( <> @@ -123,20 +142,10 @@ export function Pipeline24moBlock({ data }: Props) { }} >
-
Объектов
- {data.objects_count} -
-
-
-
- Квартир суммарно + ЖК
- {data.flats_total.toLocaleString("ru-RU")} + {data.objects_count.toLocaleString("ru")} ЖК
-
Горизонт
+
+ Лотов суммарно +
+
+ {data.flats_total.toLocaleString("ru")} лотов +
+
+
+
+ Горизонт +
{fmtClass(cls)}:{" "} - {flats.toLocaleString("ru-RU")} + {flats.toLocaleString("ru")} лотов
))} @@ -200,11 +229,11 @@ export function Pipeline24moBlock({ data }: Props) { style={{ fontSize: 11, fontWeight: 600, - color: "#6b7280", + color: "var(--fg-secondary, #5B6066)", marginBottom: 6, }} > - По кварталам сдачи + По кварталам сдачи (прогноз)
{expanded ? "Скрыть список" - : `Топ-${data.top_objects.length} ЖК pipeline`} + : `Топ-${data.top_objects.length} ЖК в прогнозе`} {expanded && (
- {obj.flat_count?.toLocaleString("ru-RU") ?? "—"} + {obj.flat_count != null + ? `${obj.flat_count.toLocaleString("ru")} лотов` + : "—"} = 0.66) + return { label: "быстро", color: "var(--success, #0A7A3A)" }; + if (score >= 0.33) return { label: "средне", color: "var(--warn, #9A6700)" }; + return { label: "медленно", color: "var(--danger, #B3261E)" }; +} + export function VelocityBlock({ velocity }: VelocityBlockProps) { if (!velocity) { return ( @@ -55,6 +63,16 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { const confColor = CONFIDENCE_COLOR[velocity.confidence]; const scorePct = formatPercent(velocity.velocity_score); const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; + const band = paceBand(velocity.velocity_score); + // Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/ + // медленнее рынка» по соотношению, отдельно от шкалы 0–100%). + const aboveMarket = ratio >= 1; + const verdict = `×${ratio.toFixed(1)} к медиане ЕКБ — ${ + aboveMarket ? "выше рынка" : "ниже рынка" + }`; + const verdictColor = aboveMarket + ? "var(--success, #0A7A3A)" + : "var(--danger, #B3261E)"; return (
@@ -97,23 +115,46 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { {/* Score gauge — показываем только если данные есть */} {dataAvailable && (
+ {/* Крупный вердикт — главное число секции */} +
+ {verdict} +
+
- Velocity-score - + + Темп продаж + + {scorePct}
= 0.66 - ? "#10b981" - : velocity.velocity_score >= 0.33 - ? "#f59e0b" - : "#ef4444", + background: band.color, width: `${velocity.velocity_score * 100}%`, height: "100%", }} />
-
- {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "} - {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "} - {ratio >= 1 - ? `x${ratio.toFixed(1)} выше` - : `${formatPercent(ratio)} от среднего`} +
+ выше — продаётся быстрее + + {band.label} + +
+ + {/* Сами м²/мес — мелкий caption под вердиктом */} +
+ {Math.round(velocity.monthly_velocity_sqm).toLocaleString("ru")}{" "} + м²/мес против{" "} + {Math.round(velocity.ekb_median_sqm).toLocaleString("ru")} м²/мес + (медиана ЕКБ)
)} diff --git a/frontend/src/components/site-finder/analysis/ForecastAssortmentBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastAssortmentBlock.tsx new file mode 100644 index 00000000..5fb4123c --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastAssortmentBlock.tsx @@ -0,0 +1,311 @@ +"use client"; + +/** + * 6.4 Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1). + * + * Статическая таблица под рекомендацией продукта: строки = форматы (студия / 1к / + * 2к / 3к / 4-5к) РЕКОМЕНДОВАННОГО класса, колонки = Формат · Индекс дефицита · + * Прогноз спроса (квартир) · Сигнал. Цель — показать продуктовому директору весь + * срез дефицита по форматам одним взглядом (а не только рекомендованный формат). + * + * Источник: `product_tz.mix` (квартирография — ячейки формат×класс с per-ячейкой + * deficit_index + #1745-проброшенными projected_demand_units/signal). Фильтруем к + * форматам рекомендованного класса, упорядочиваем по канонической комнатной сетке. + * + * СЕМАНТИКА (зеркало §22 deficit): deficit_index >0 — недонасыщенность (--success, + * повод строить), <0 — затоварка (--danger), ≈0 — баланс. Рекомендованный формат + * (сильнейший дефицит класса) — строка --accent-soft. deficit_index=null → «тонкие + * данные» серым (--fg-tertiary), НЕ 0 (честность: отсутствие сигнала ≠ нулевой + * сигнал). Прогноз спроса null → тоже «тонкие данные». + * + * БЕЗ слайдеров и без рублей — это фаза 1 (фаза 2 с экономикой/сценариями придёт + * после бэктеста движка #951). Блок помечен «Советующий расчёт, не основание для + * инвест-решения». + * + * GRACEFUL: returns null когда у рекомендованного класса нет ни одной форматной + * ячейки (нечего показывать). + */ + +import type { ProductMixEntry } from "@/types/forecast"; + +import { Badge } from "@/components/ui/Badge"; +import type { BadgeVariant } from "@/components/ui/Badge"; +import { + DEFICIT_BALANCE_EPS, + deficitSignalWord, + deficitVariant, + fmtNum, +} from "./forecast-helpers"; + +interface Props { + /** Рекомендованный класс (§10.2) — фильтр строк таблицы. */ + objClass: string | null; + /** Квартирография — ячейки формат×класс с deficit_index/прогнозом/сигналом. */ + mix: ProductMixEntry[]; +} + +// Каноническая комнатная сетка для строк (порядок ОБЯЗАТЕЛЕН: от меньшего к +// большему). Ключ — live bucket-id (вокабуляр overlay), значение — короткая +// RU-метка строки. Неизвестные bucket'ы рисуем как есть в конце. +const ROOM_ROW_ORDER: { bucket: string; label: string }[] = [ + { bucket: "1-Студия", label: "Студия" }, + { bucket: "2-1-к", label: "1-к" }, + { bucket: "3-2-к", label: "2-к" }, + { bucket: "4-3-к", label: "3-к" }, + { bucket: "5-80+ м²", label: "4-5-к" }, +]; + +const ROOM_INDEX: Map = new Map( + ROOM_ROW_ORDER.map((r, i) => [r.bucket, i]), +); + +const ROOM_LABEL: Map = new Map( + ROOM_ROW_ORDER.map((r) => [r.bucket, r.label]), +); + +// Сигнал-бейдж: вариант по знаку дефицита (строить success / баланс neutral / +// избегать danger). Зеркало deficitVariant, но neutral вместо нейтрального. +function signalVariant(deficitIndex: number): BadgeVariant { + const v = deficitVariant(deficitIndex); + if (v === "success") return "success"; + if (v === "danger") return "danger"; + return "neutral"; +} + +// Цвет ячейки индекса дефицита (только токены): >0 --success, <0 --danger, +// ≈0 нейтрально (--fg-secondary). Зеркало deficitVariant. +function deficitColor(deficitIndex: number): string { + const v = deficitVariant(deficitIndex); + if (v === "success") return "var(--success)"; + if (v === "danger") return "var(--danger)"; + return "var(--fg-secondary)"; +} + +interface AssortmentRow { + bucket: string; + label: string; + deficit_index: number | null; + projected_demand_units: number | null; + signal: string | null; +} + +function buildRows( + objClass: string | null, + mix: ProductMixEntry[], +): AssortmentRow[] { + // Ячейки рекомендованного класса (или все, если класс не задан — деградация). + const cells = mix.filter( + (m) => m.bucket != null && (objClass == null || m.obj_class === objClass), + ); + + // По одной строке на bucket (если дубликаты — берём первую встреченную). + const byBucket = new Map(); + for (const m of cells) { + if (m.bucket != null && !byBucket.has(m.bucket)) byBucket.set(m.bucket, m); + } + + const rows: AssortmentRow[] = []; + for (const [bucket, m] of byBucket) { + rows.push({ + bucket, + label: ROOM_LABEL.get(bucket) ?? bucket, + deficit_index: m.deficit_index ?? null, + projected_demand_units: m.projected_demand_units ?? null, + // Готовый backend-signal в приоритете; иначе деривируем из индекса (fallback). + signal: + m.signal ?? + (m.deficit_index != null ? deficitSignalWord(m.deficit_index) : null), + }); + } + + // Сортируем по канонической сетке; неизвестные bucket'ы — в конец (стабильно). + rows.sort((a, b) => { + const ai = ROOM_INDEX.get(a.bucket) ?? ROOM_ROW_ORDER.length; + const bi = ROOM_INDEX.get(b.bucket) ?? ROOM_ROW_ORDER.length; + if (ai !== bi) return ai - bi; + return a.bucket.localeCompare(b.bucket); + }); + return rows; +} + +// Рекомендованный формат = сильнейший дефицит класса (как в ранкинге). null — +// нет ни одной ячейки с измеримым индексом → нечего подсвечивать. +function recommendedBucket(rows: AssortmentRow[]): string | null { + let best: AssortmentRow | null = null; + for (const r of rows) { + if (r.deficit_index == null) continue; + if (best == null || r.deficit_index > (best.deficit_index ?? -Infinity)) { + best = r; + } + } + return best?.bucket ?? null; +} + +const TH_STYLE: React.CSSProperties = { + fontSize: 11, + fontWeight: 500, + letterSpacing: "0.04em", + textTransform: "uppercase", + color: "var(--fg-secondary)", + padding: "8px 12px", + whiteSpace: "nowrap", +}; + +const TD_STYLE: React.CSSProperties = { + fontSize: 13, + color: "var(--fg-primary)", + padding: "9px 12px", + fontVariantNumeric: "tabular-nums", +}; + +export function ForecastAssortmentBlock({ objClass, mix }: Props) { + const rows = buildRows(objClass, mix); + if (rows.length === 0) return null; + + const recoBucket = recommendedBucket(rows); + + return ( +
+ {/* Headline-bar: один-предложение caveat (--bg-headline + --fg-on-dark) */} +
+ Советующий расчёт, не основание для инвест-решения. +
+ + {/* Таблица прогноза по всем форматам */} +
+ + + + + + + + + + + {rows.map((r, i) => { + const isReco = recoBucket != null && r.bucket === recoBucket; + const di = r.deficit_index; + const demand = r.projected_demand_units; + return ( + + {/* Формат */} + + + {/* Индекс дефицита */} + + + {/* Прогноз спроса, квартир */} + + + {/* Сигнал */} + + + ); + })} + +
Формат + Индекс дефицита + + Прогноз спроса, квартир + Сигнал
+ + {r.label} + {isReco && ( + + рекомендован + + )} + + + {di != null ? ( + + {di > 0 ? "+" : ""} + {fmtNum(di, 2)} + + ) : ( + + тонкие данные + + )} + + {demand != null ? ( + <> + {fmtNum(demand, 0)}{" "} + кв. + + ) : ( + + тонкие данные + + )} + + {di != null ? ( + + {r.signal ?? + (Math.abs(di) < DEFICIT_BALANCE_EPS + ? "баланс" + : deficitSignalWord(di))} + + ) : ( + + тонкие данные + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx index e6592bfd..0d67bd3d 100644 --- a/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastHorizonsBlock.tsx @@ -77,12 +77,17 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) { Горизонт Индекс дефицита - + Мес. предложения - Прогноз спроса - Прогноз предложения + Прогноз спроса, квартир + + + Прогноз предложения, квартир Ставка @@ -154,6 +159,19 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
+ {/* Caveat — единицы и статус оценки под таблицей (advisory, не решение). */} +

+ Спрос/предложение — прогнозные квартиры за горизонт; оценка advisory, + уверенность ≤ средней. +

+ {/* Rate-sensitivity phrase — shared across horizons in current data; show the distinct phrases once each to avoid noisy repetition. */} diff --git a/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx index e4b3f77b..c7f24156 100644 --- a/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx @@ -20,6 +20,7 @@ import type { ProductCommercial, ProductMixEntry, ProductReason, + ProductReasonRejected, ProductUsp, ReportProductTz, } from "@/types/forecast"; @@ -27,12 +28,14 @@ import type { import { Badge } from "@/components/ui/Badge"; import type { BadgeVariant } from "@/components/ui/Badge"; import { + aggregateClassDeficits, DEFICIT_BALANCE_EPS, deficitBarWidthPct, deficitVariant, deficitWord, fmtNum, } from "./forecast-helpers"; +import type { ClassDeficit } from "./forecast-helpers"; interface Props { /** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */ @@ -74,14 +77,21 @@ export function ForecastProductTzBlock({ productTz }: Props) { // Graceful: nothing meaningful to show → render nothing. if (!isNonEmpty(productTz)) return null; + // #1742 — объяснение выбора класса: per-класс дефицит (агрегат mix по классам). + const classDeficits = aggregateClassDeficits(productTz.mix); + return (
- {/* Recommended class — prominent pill + summary line */} + {/* Recommended class — prominent pill + автоген-фраза про дефицит + 3 класса */} + {/* #1742 — отвергнутые альтернативы подняты из-под ката (выше обоснования) */} + + {/* Квартирография — deficit/share bars */} @@ -91,7 +101,7 @@ export function ForecastProductTzBlock({ productTz }: Props) { {/* USP-ниши */} - {/* §16 обоснование (раскрываемое) */} + {/* §16 обоснование (раскрываемое) — без «отвергнутых» (подняты выше) */}
); @@ -102,21 +112,68 @@ export function ForecastProductTzBlock({ productTz }: Props) { function ClassHeadline({ obj_class, summary, + classDeficits, }: { obj_class: string | null; summary: string | null; + classDeficits: ClassDeficit[]; }) { if (obj_class == null && !summary) return null; + + // #1742 — дефицит рекомендованного класса (для автоген-фразы). Ищем его агрегат + // в per-класс таблице; null → у класса нет измеримого индекса (тонкие данные). + const recoDeficit = + obj_class != null + ? (classDeficits.find((c) => c.obj_class === obj_class) + ?.meanDeficitIndex ?? null) + : null; + return (
{obj_class != null && (
Рекомендованный класс - {obj_class} + {capitalize(obj_class)}
)} + + {/* Автоген-фраза: почему именно этот класс (#1742) */} + {obj_class != null && recoDeficit != null && ( +

+ {capitalize(obj_class)} — + здесь сильнее всего недозакрыт спрос: индекс дефицита{" "} + + {recoDeficit > 0 ? "+" : ""} + {fmtNum(recoDeficit, 2)} + {" "} + + (где +1 — острый дефицит, −1 — затоварка). + +

+ )} + + {/* Мини-таблица 3 классов с их индексом дефицита (контраст) (#1742) */} + + {summary && (

+ {/* header */} +

+ Класс + + Индекс дефицита + +
+ {classDeficits.map((c, i) => { + const isReco = recommended != null && c.obj_class === recommended; + const di = c.meanDeficitIndex; + return ( +
+ + {capitalize(c.obj_class)} + {isReco && ( + + рекомендован + + )} + + {di != null ? ( + + {di > 0 ? "+" : ""} + {fmtNum(di, 2)} + + ) : ( + + тонкие данные + + )} +
+ ); + })} +
+ ); +} + +// ── Отвергнутые альтернативы (#1742 — поднято из-под ката) ───────────────────── + +function RejectedAlternatives({ reasons }: { reasons: ProductReason[] }) { + // Собираем уникальные отвергнутые альтернативы из всех §16-причин (де-дуп по + // alternative — одна и та же альтернатива может встречаться в нескольких reason). + const seen = new Set(); + const rejected: ProductReasonRejected[] = []; + for (const r of reasons) { + for (const rej of r.rejected ?? []) { + if (!rej.alternative || seen.has(rej.alternative)) continue; + seen.add(rej.alternative); + rejected.push(rej); + } + } + if (rejected.length === 0) return null; + + return ( +
+ Отвергнутые альтернативы +
+ {rejected.map((rej, i) => ( + + + {capitalize(rej.alternative)} + + {rej.deficit_index != null && ( + + {rej.deficit_index > 0 ? "+" : ""} + {fmtNum(rej.deficit_index, 2)} + + )} + · {rej.reason} + + ))} +
+
+ ); +} + // ── Квартирография (mix bars) ───────────────────────────────────────────────── function mixLabel(m: ProductMixEntry): string { @@ -446,42 +656,8 @@ function ReasonCard({ reason }: { reason: ProductReason }) {

)} - {reason.rejected && reason.rejected.length > 0 && ( -
- Отвергнутые альтернативы -
- {reason.rejected.map((rej, i) => ( - - - {rej.alternative} - - {rej.deficit_index != null && ( - - {rej.deficit_index > 0 ? "+" : ""} - {fmtNum(rej.deficit_index, 2)} - - )} - - · {rej.reason} - - - ))} -
-
- )} + {/* «Отвергнутые альтернативы» подняты на уровень блока (#1742) — + здесь не дублируем. */} {reason.what_would_change && reason.what_would_change.length > 0 && (
diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx index 84662cd6..9d5193e5 100644 --- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx +++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx @@ -1,7 +1,7 @@ "use client"; /** - * Section2NetworksUtilities — "2. Сети и точки подключения" + * Section2NetworksUtilities — "3. Сети и точки подключения" * * Layout: * HeadlineBar (title «Сети» + subtitle с расстояниями) diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index c774b3e2..fbb09010 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -126,7 +126,7 @@ function Section31Settings({ margin: 0, }} > - 3.1 Настройки выборки + 4.1 Настройки выборки

- 3.2 Планировки + 4.2 Планировки

+ {children} +

+ ); +} + +/** + * Собирает одно предложение-вердикт для headline-bar из уже доступных данных + * velocity / pipeline / market_trend. Возвращает null если данных совсем нет. + * + * - ratio = темп продаж конкурентов / медиана ЕКБ (м²/мес). + * - months_of_inventory — за сколько месяцев при текущем темпе разойдётся + * будущее предложение конкурентов (24 мес). Темп в лотах/мес выводим из + * velocity.by_room_bucket (units за months_observed); без него клаузу + * пропускаем, чтобы не выдумывать цифру. + * - price change — market_trend.delta_6m_pct в процентных пунктах. + */ +function buildVerdict(data: ParcelAnalysis): string | null { + const v = data.velocity; + const pipeline = data.pipeline_24mo; + const trend = data.market_trend; + + const parts: string[] = []; + + if (v && v.ekb_median_sqm > 0 && v.velocity_data_available !== false) { + const ratio = v.monthly_velocity_sqm / v.ekb_median_sqm; + const pace = ratio >= 1 ? "быстрее рынка" : "медленнее рынка"; + parts.push( + `Конкуренты продают ×${ratio.toFixed(1)} к медиане ЕКБ (${pace})`, + ); + + // Темп в лотах/мес из наблюдённых сделок по комнатности. + if ( + pipeline && + pipeline.flats_total > 0 && + v.by_room_bucket && + v.months_observed > 0 + ) { + const unitsSold = Object.values(v.by_room_bucket).reduce( + (sum, b) => sum + b.units, + 0, + ); + const lotsPerMonth = unitsSold / v.months_observed; + if (lotsPerMonth > 0) { + const moi = pipeline.flats_total / lotsPerMonth; + parts.push( + `при текущем темпе остаток разойдётся за ~${Math.round( + moi, + ).toLocaleString("ru")} мес`, + ); + } + } + } + + if (trend) { + const d = trend.delta_6m_pct; + const sign = d > 0 ? "+" : d < 0 ? "−" : ""; + const abs = Math.abs(d).toLocaleString("ru", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + parts.push(`цены за период ${sign}${abs} п.п.`); + } + + if (parts.length === 0) return null; + return `${parts.join("; ")}.`; +} + +function Section33MarketSales({ data }: { data: ParcelAnalysis }) { const hasPipeline = data.pipeline_24mo != null; const hasVelocity = data.velocity != null; const hasTrend = data.market_trend != null; const isEmpty = !hasPipeline && !hasVelocity && !hasTrend; + const verdict = buildVerdict(data); return (
@@ -319,7 +400,7 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) { margin: 0, }} > - 3.3 Остатки и скорость + 4.3 Как продаётся рынок рядом

- Pipeline конкурентов на 24 мес · темп продаж · тренд цен + Остатки конкурентов · темп продаж · динамика цен

@@ -343,31 +424,65 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) { fontSize: 13, }} > - Данные по остаткам и скорости продаж отсутствуют для этого участка + Данные по продажам рынка рядом отсутствуют для этого участка
) : ( -
- {hasPipeline && } - {hasVelocity && ( + <> + {/* Headline-bar — один вердикт-предложение */} + {verdict && (
- + {verdict}
)} - {hasTrend && } -
+ +
+ {hasVelocity && ( +
+ Темп продаж +
+ +
+
+ )} + {hasPipeline && ( +
+ + Будущее предложение конкурентов (24 мес) + + +
+ )} + {hasTrend && ( +
+ Динамика цен + +
+ )} +
+ )}
); @@ -684,7 +799,7 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { )} - +
{/* Filtered competitors count hint */} diff --git a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx index 9ce12237..118dea0a 100644 --- a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx +++ b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx @@ -1,7 +1,7 @@ "use client"; /** - * Section4Estimate — "4. Оценка участка" + * Section4Estimate — "2. Оценка участка" * * Layout: * HeadlineBar (score + verdict label as title) @@ -18,7 +18,6 @@ */ import { HeadlineBar } from "@/components/ui/HeadlineBar"; -import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner"; import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel"; import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar"; import { GeologyBlock } from "@/components/site-finder/GeologyBlock"; @@ -166,12 +165,7 @@ export function Section4Estimate({ cad }: Props) {
- {/* ── Gate verdict banner ──────────────────────────────────────────── */} - {analysis.gate_verdict && ( -
- -
- )} + {/* Gate verdict banner перенесён НАД группой «Участок» (AnalysisPageContent, #1741) */} {/* ── Score breakdown: 2-col on desktop ───────────────────────────── */} {hasBreakdown && ( diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index 5dd66b5e..4dc23c8d 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -33,6 +33,7 @@ import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock"; import { ScenariosBlock } from "./ScenariosBlock"; import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock"; import { ForecastProductTzBlock } from "./ForecastProductTzBlock"; +import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock"; import { ForecastScoringBlock } from "./ForecastScoringBlock"; import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock"; import { ForecastMetaLine } from "./ForecastMetaLine"; @@ -217,6 +218,17 @@ function ForecastReady({ (pt.commercial != null && (pt.commercial.available != null || !!pt.commercial.caveat))); + // 6.4a — прогноз по всем форматам (#1745). Показываем, когда квартирография + // несёт хотя бы одну форматную ячейку (с bucket) рекомендованного класса — + // иначе таблица пуста (объект класс не задан → деградируем к любым ячейкам). + const hasAssortment = + pt != null && + pt.mix.some( + (m) => + m.bucket != null && + (pt.obj_class == null || m.obj_class === pt.obj_class), + ); + // 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial // ForecastReport type; populated when product_scores / special_indices exist. const sc = report.scoring; @@ -240,6 +252,7 @@ function ForecastReady({ hasScenarios || hasConfidence || hasProductTz || + hasAssortment || hasScoring || hasFutureSupplyDetail; @@ -379,6 +392,15 @@ function ForecastReady({ )} + {/* 6.4a Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1). + Под рекомендацией: весь срез дефицита по форматам рекомендованного + класса. Показываем только когда есть форматные ячейки квартирографии. */} + {hasAssortment && pt && ( + + + + )} + {/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */} {hasScoring && sc && ( diff --git a/frontend/src/components/site-finder/analysis/forecast-helpers.ts b/frontend/src/components/site-finder/analysis/forecast-helpers.ts index 111335eb..f865184f 100644 --- a/frontend/src/components/site-finder/analysis/forecast-helpers.ts +++ b/frontend/src/components/site-finder/analysis/forecast-helpers.ts @@ -5,7 +5,11 @@ */ import type { BadgeVariant } from "@/components/ui/Badge"; -import type { ConfidenceLevel, FutureSupply } from "@/types/forecast"; +import type { + ConfidenceLevel, + FutureSupply, + ProductMixEntry, +} from "@/types/forecast"; /** Below this |deficit_index| we treat the market as balanced (neutral). */ export const DEFICIT_BALANCE_EPS = 0.05; @@ -26,6 +30,70 @@ export function deficitWord(deficitIndex: number): string { return "баланс"; } +/** + * Сигнал-вердикт по deficit_index для таблицы форматов (#1745): «строить» + * (недонасыщенность) / «баланс» / «избегать» (затоварка). Зеркало backend + * `report_assembler._build_signal` — фронт-fallback, когда mix-ячейка не несёт + * готового `signal` (старый прогон). Семантика та же, что у `deficitWord`. + */ +export function deficitSignalWord(deficitIndex: number): string { + if (deficitIndex > DEFICIT_BALANCE_EPS) return "строить"; + if (deficitIndex < -DEFICIT_BALANCE_EPS) return "избегать"; + return "баланс"; +} + +// ── Class-level deficit aggregation (#1742 — объяснение выбора класса) ────────── + +/** Агрегат дефицита одного класса: среднее deficit_index по его форматам. */ +export interface ClassDeficit { + obj_class: string; + /** Среднее deficit_index по форматам класса (null — у всех форматов нет индекса). */ + meanDeficitIndex: number | null; + /** Сколько форматов класса несут не-null deficit_index (для honest-агрегата). */ + nWithIndex: number; +} + +/** + * Агрегировать `mix` (квартирография — ячейки формат×класс) в per-класс дефицит: + * среднее deficit_index по форматам каждого класса. Зеркало backend + * `recommendation._recommend_class` (среднее по room-bucket'ам класса). Это даёт + * мини-таблицу 3 классов (#1742) из уже-доступного `mix` без backend-расширения — + * `mix` несёт все 15 ячеек (5 форматов × 3 класса) с per-ячейкой deficit_index. + * + * Сортировка DESC по meanDeficitIndex (сильнейший дефицит сверху, как в ранкинге); + * классы без obj_class игнорируются (нечего агрегировать — НЕ фабрикуем класс). + * Класс, у которого ни у одной ячейки нет индекса → meanDeficitIndex=null (фронт + * рисует «тонкие данные», НЕ 0). + */ +export function aggregateClassDeficits(mix: ProductMixEntry[]): ClassDeficit[] { + const byClass = new Map(); + for (const m of mix) { + if (m.obj_class == null) continue; + const list = byClass.get(m.obj_class) ?? []; + if (m.deficit_index != null) list.push(m.deficit_index); + byClass.set(m.obj_class, list); + } + const out: ClassDeficit[] = []; + for (const [obj_class, values] of byClass) { + const meanDeficitIndex = + values.length > 0 + ? values.reduce((a, b) => a + b, 0) / values.length + : null; + out.push({ obj_class, meanDeficitIndex, nWithIndex: values.length }); + } + // DESC по среднему дефициту; null-индексы — в конец (стабильно). tie-break — класс ASC. + out.sort((a, b) => { + const av = a.meanDeficitIndex; + const bv = b.meanDeficitIndex; + if (av == null && bv == null) return a.obj_class.localeCompare(b.obj_class); + if (av == null) return 1; + if (bv == null) return -1; + if (bv !== av) return bv - av; + return a.obj_class.localeCompare(b.obj_class); + }); + return out; +} + /** * Bar width (%) for a signed deficit_index (∈ −1..1) on a 0..100 scale. Uses the * magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar diff --git a/frontend/src/types/forecast.ts b/frontend/src/types/forecast.ts index 3f454588..c8034dd6 100644 --- a/frontend/src/types/forecast.ts +++ b/frontend/src/types/forecast.ts @@ -217,6 +217,19 @@ export interface ProductMixEntry { deficit_index: number | null; /** Доля формата, % (0..100) — присутствует только в явном mix. */ pct?: number | null; + /** + * #1745 — прогноз спроса по формату (квартир) на целевом горизонте. Проброшен из + * ranked_segments overlay (`projected_demand_units`). null при тонких данных или в + * demand_only-режиме (без геометрии участка спрос-проекция неизмерима) — рендерится + * «тонкие данные», НЕ 0. + */ + projected_demand_units?: number | null; + /** + * #1745 — деривированный сигнал из deficit_index: «строить» (недонасыщенность) / + * «баланс» / «избегать» (затоварка). null при deficit_index=null (тонкие данные). + * Дублирует frontend-семантику deficitWord — фронт может пересчитать сам как fallback. + */ + signal?: string | null; confidence?: ConfidenceLevel | null; }