feat(site-finder): forecast Section 6 (прогноз/сценарии/confidence) на экране анализа (#998) #1071

Merged
bot-backend merged 1 commit from feat/forecast-section-998 into main 2026-06-05 09:12:01 +00:00
9 changed files with 3536 additions and 1 deletions

View file

@ -7,6 +7,7 @@ import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Sec
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate";
import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere";
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
@ -134,6 +135,9 @@ export function AnalysisPageContent({ cad }: Props) {
{/* Section 5 — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} />
</div>
</div>
</main>
@ -215,6 +219,15 @@ const NAV_ITEMS = [
},
{ 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 Уверенность" },
],
},
];
function AnalysisSidebarNav() {

View file

@ -0,0 +1,186 @@
"use client";
/**
* 6.3 Уровень уверенности level pill + EXPLICIT drivers list from
* confidence.factors (DoD: «явный список drivers»).
*
* The factors map is mostly { note, level, value } entries, but the prod
* envelope also carries a stray boolean `advisory_capped` narrowed out via a
* runtime type guard so we never render a non-factor.
*/
import { Badge } from "@/components/ui/Badge";
import type { ConfidenceFactor, ReportConfidence } from "@/types/forecast";
import { CONFIDENCE_RU, confidenceVariant } from "./forecast-helpers";
interface Props {
confidence: ReportConfidence;
}
// Human-readable labels for known factor keys; unknown keys fall back to the key.
const FACTOR_RU: Record<string, string> = {
deal_count: "Объём сделок",
analog_count: "Аналоги (ЖК)",
domrf_coverage: "Покрытие ДОМ.РФ",
history_months: "Глубина истории",
component: "Компонент",
};
function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.note === "string" &&
(o.level === "high" || o.level === "medium" || o.level === "low")
);
}
function factorLabel(key: string): string {
if (FACTOR_RU[key]) return FACTOR_RU[key];
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …»)
const m = key.match(/^component_(\d+)$/);
if (m) return `Компонент ${m[1]}`;
return key;
}
function formatFactorValue(value: number | null): string | null {
if (value == null) return null;
// Fractions in 0..1 read as percent (e.g. coverage); integers/large stay raw.
if (value > 0 && value < 1) return `${Math.round(value * 100)}%`;
return Math.round(value).toLocaleString("ru");
}
export function ForecastConfidenceBlock({ confidence }: Props) {
const level = confidence.level;
const drivers = Object.entries(confidence.factors)
.filter((entry): entry is [string, ConfidenceFactor] =>
isConfidenceFactor(entry[1]),
)
// Sort high → medium → low so the strongest evidence reads first.
.sort((a, b) => LEVEL_RANK[a[1].level] - LEVEL_RANK[b[1].level]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* Level pill + rationale */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
}}
>
Уровень уверенности
</span>
{level ? (
<Badge variant={confidenceVariant(level)} size="md">
{CONFIDENCE_RU[level]}
</Badge>
) : (
<span style={{ fontSize: 13, color: "var(--fg-tertiary)" }}>
не определена
</span>
)}
</div>
{confidence.rationale && (
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{confidence.rationale}
</p>
)}
</div>
{/* Explicit drivers list */}
{drivers.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
}}
>
Факторы достоверности ({drivers.length})
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{drivers.map(([key, factor]) => {
const valueStr = formatFactorValue(factor.value);
return (
<div
key={key}
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
padding: "8px 12px",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
borderRadius: 8,
}}
>
<Badge variant={confidenceVariant(factor.level)} size="sm">
{CONFIDENCE_RU[factor.level]}
</Badge>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
}}
>
{factorLabel(key)}
{valueStr != null && (
<span
style={{
marginLeft: 6,
fontWeight: 400,
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
}}
>
· {valueStr}
</span>
)}
</div>
<div
style={{
fontSize: 12,
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{factor.note}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
Факторы достоверности недоступны.
</p>
)}
</div>
);
}
const LEVEL_RANK: Record<ConfidenceFactor["level"], number> = {
high: 0,
medium: 1,
low: 2,
};

View file

@ -0,0 +1,177 @@
"use client";
/**
* 6.1 Прогноз по горизонтам compact table over future_market.forecasts_by_horizon.
*
* Columns: горизонт (мес) · индекс дефицита (color-coded) · месяцы предложения ·
* прогноз спроса/предложения · ставка (% годовых) · фраза чувствительности к ставке.
*
* Deficit semantics (HARD, see frontend rules):
* deficit_index > 0 недонасыщенность (повод строить) success/green
* deficit_index < 0 затоварка (предложения > спроса) warn/danger
* deficit_index 0 баланс neutral
*/
import { Badge } from "@/components/ui/Badge";
import type { DemandSupplyForecast } from "@/types/forecast";
import {
DEFICIT_BALANCE_EPS,
deficitVariant,
deficitWord,
fmtNum,
} from "./forecast-helpers";
interface Props {
forecasts: DemandSupplyForecast[];
}
const TH_STYLE: React.CSSProperties = {
textAlign: "left",
padding: "8px 12px",
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
whiteSpace: "nowrap",
borderBottom: "1px solid var(--border-card)",
};
const TD_STYLE: React.CSSProperties = {
padding: "10px 12px",
fontSize: 13,
color: "var(--fg-primary)",
verticalAlign: "top",
borderBottom: "1px solid var(--border-soft)",
fontVariantNumeric: "tabular-nums",
};
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
export function ForecastHorizonsBlock({ forecasts }: Props) {
if (forecasts.length === 0) {
return (
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
Прогноз по горизонтам недоступен.
</p>
);
}
const rows = [...forecasts].sort(
(a, b) => a.horizon_months - b.horizon_months,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
minWidth: 720,
}}
>
<thead>
<tr>
<th style={TH_STYLE}>Горизонт</th>
<th style={TH_STYLE}>Индекс дефицита</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>
Мес. предложения
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>Прогноз спроса</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>
Прогноз предложения
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>Ставка</th>
</tr>
</thead>
<tbody>
{rows.map((f) => {
const di = f.deficit_index;
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
return (
<tr key={f.horizon_months}>
<td style={TD_STYLE}>{f.horizon_months} мес.</td>
<td style={TD_STYLE}>
<Badge variant={deficitVariant(di)} size="sm">
{di > 0 ? "+" : ""}
{fmtNum(di, 2)}
</Badge>
<span
style={{
marginLeft: 8,
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
{deficitWord(di)}
</span>
</td>
<td style={NUM_TD}>
{balanced ? "—" : fmtNum(f.months_of_inventory, 1)}
</td>
<td style={NUM_TD}>
{Math.round(f.projected_demand_units).toLocaleString("ru")}
</td>
<td style={NUM_TD}>
{Math.round(f.projected_supply_units).toLocaleString("ru")}
</td>
<td style={NUM_TD}>{fmtNum(f.rate_future, 2)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Rate-sensitivity phrase shared across horizons in current data; show
the distinct phrases once each to avoid noisy repetition. */}
<RateSensitivityNotes forecasts={rows} />
</div>
);
}
function RateSensitivityNotes({
forecasts,
}: {
forecasts: DemandSupplyForecast[];
}) {
const phrases = Array.from(
new Set(
forecasts
.map((f) => f.rate_sensitivity_phrase?.trim())
.filter((p): p is string => !!p),
),
);
if (phrases.length === 0) return null;
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
<span
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
}}
>
Чувствительность к ключевой ставке
</span>
{phrases.map((p, i) => (
<span key={i}>
{p.charAt(0).toUpperCase() + p.slice(1)}
{/\.$/.test(p) ? "" : "."}
</span>
))}
</div>
);
}

View file

@ -0,0 +1,237 @@
"use client";
/**
* 6.2 Сценарии conservative / base / aggressive side by side.
*
* For each scenario shows the deficit_index at the target horizon (preferring
* the scenario's own forecast row, falling back to scenarios_summary) and the
* rate_path across all horizons (ключевая ставка, % годовых).
*/
import { Badge } from "@/components/ui/Badge";
import type {
DemandSupplyForecast,
RatePath,
ReportScenarios,
ScenarioKey,
ScenariosSummary,
} from "@/types/forecast";
import {
DEFICIT_BALANCE_EPS,
deficitVariant,
deficitWord,
fmtNum,
} from "./forecast-helpers";
interface Props {
scenarios: ReportScenarios;
scenariosSummary: ScenariosSummary | null;
targetHorizon: number;
}
// Display order: conservative → base → aggressive (worst-to-best rate path).
const SCENARIO_ORDER: ScenarioKey[] = ["conservative", "base", "aggressive"];
const SCENARIO_RU: Record<ScenarioKey, string> = {
conservative: "Консервативный",
base: "Базовый",
aggressive: "Агрессивный",
};
const SCENARIO_HINT: Record<ScenarioKey, string> = {
conservative: "ставка выше",
base: "ставка без изменений",
aggressive: "ставка ниже",
};
function pickHorizonForecast(
forecasts: DemandSupplyForecast[],
targetHorizon: number,
): DemandSupplyForecast | null {
if (forecasts.length === 0) return null;
const exact = forecasts.find((f) => f.horizon_months === targetHorizon);
if (exact) return exact;
// Fall back to the longest available horizon.
return [...forecasts].sort((a, b) => b.horizon_months - a.horizon_months)[0];
}
function deficitForScenario(
key: ScenarioKey,
scenarios: ReportScenarios,
scenariosSummary: ScenariosSummary | null,
targetHorizon: number,
): number | null {
const sc = scenarios.by_scenario[key];
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
if (fc) return fc.deficit_index;
if (scenariosSummary) return scenariosSummary[key];
return null;
}
export function ScenariosBlock({
scenarios,
scenariosSummary,
targetHorizon,
}: Props) {
const present = SCENARIO_ORDER.filter(
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
);
if (present.length === 0) {
return (
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
Сценарный прогноз недоступен.
</p>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
{present.map((key) => {
const sc = scenarios.by_scenario[key];
const di = deficitForScenario(
key,
scenarios,
scenariosSummary,
targetHorizon,
);
return (
<ScenarioCard
key={key}
name={SCENARIO_RU[key]}
hint={SCENARIO_HINT[key]}
deficitIndex={di}
ratePath={sc?.rate_path}
targetHorizon={targetHorizon}
/>
);
})}
</div>
<p style={{ fontSize: 12, color: "var(--fg-tertiary)", margin: 0 }}>
Индекс дефицита приведён на целевом горизонте {targetHorizon} мес.
Конверт ставки путь ключевой ставки (% годовых) по горизонтам.
</p>
</div>
);
}
function ScenarioCard({
name,
hint,
deficitIndex,
ratePath,
targetHorizon,
}: {
name: string;
hint: string;
deficitIndex: number | null;
ratePath: RatePath | undefined;
targetHorizon: number;
}) {
const horizons = ratePath
? Object.keys(ratePath)
.map((h) => Number(h))
.sort((a, b) => a - b)
: [];
return (
<div
style={{
borderRadius: 12,
border: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: 16,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
{name}
</div>
<div style={{ fontSize: 12, color: "var(--fg-tertiary)", marginTop: 2 }}>
{hint}
</div>
</div>
{/* Deficit at target horizon */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
}}
>
Индекс дефицита · {targetHorizon} мес.
</span>
{deficitIndex != null ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Badge variant={deficitVariant(deficitIndex)} size="md">
{deficitIndex > 0 ? "+" : ""}
{fmtNum(deficitIndex, 2)}
</Badge>
<span style={{ fontSize: 12, color: "var(--fg-secondary)" }}>
{Math.abs(deficitIndex) < DEFICIT_BALANCE_EPS
? "баланс"
: deficitWord(deficitIndex)}
</span>
</div>
) : (
<span style={{ fontSize: 13, color: "var(--fg-tertiary)" }}></span>
)}
</div>
{/* Rate path */}
{horizons.length > 0 && ratePath && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
}}
>
Конверт ставки
</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{horizons.map((h) => (
<span
key={h}
style={{
fontSize: 12,
color: "var(--fg-primary)",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
borderRadius: 6,
padding: "2px 6px",
fontVariantNumeric: "tabular-nums",
}}
>
{h} мес.: {fmtNum(ratePath[String(h)], 2)}%
</span>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,362 @@
"use client";
/**
* Section6Forecast "6. Прогноз" (958-B3 / §22).
*
* Renders the async §22 demand/supply forecast, scenarios and confidence on the
* Site Finder analysis screen. Self-fetches via useParcelForecastQuery, which
* POLLs GET /api/v1/parcels/{cad}/forecast every 4s until status === "ready"
* (the forecast is enqueued by useParcelAnalyzeQuery on page mount render-only
* here, no trigger).
*
* Layout mirrors Section1-5: dark HeadlineBar + section root id="section-6".
* exec_summary banner (headline + verdict + KPI row)
* 6.1 Прогноз по горизонтам ForecastHorizonsBlock
* 6.2 Сценарии ScenariosBlock
* 6.3 Уверенность ForecastConfidenceBlock
* advisory disclaimer (footer)
*/
import { LineChart } from "lucide-react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { useParcelForecastQuery } from "@/lib/site-finder-api";
import type { ForecastReport } from "@/types/forecast";
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
interface Props {
cad: string;
}
const ADVISORY_DISCLAIMER =
"Оценка advisory — не основание для инвест-решения.";
const SCROLL_MARGIN = 72;
// KpiCard color enum (site-finder variant) mapped from Badge variant words.
function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
const v = deficitVariant(deficitIndex);
if (v === "success") return "green";
if (v === "danger") return "red";
return "neutral";
}
// ── Section6Forecast ─────────────────────────────────────────────────────────
export function Section6Forecast({ cad }: Props) {
const { data, isLoading, error } = useParcelForecastQuery(cad);
const isPending =
isLoading || (data != null && data.status !== "ready") || data == null;
// ── Pending: forecast still computing (poll in flight) ─────────────────────
if (isPending && !error) {
return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar
title="6. Прогноз"
subtitle="Прогноз спроса, предложения и сценарии (§22)."
/>
<div
style={{
marginTop: 12,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
borderRadius: 12,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
padding: "24px 20px",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Прогноз рассчитывается
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
borderRadius: 12,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
height: 88,
}}
aria-hidden
/>
))}
</div>
</div>
</section>
);
}
// ── Error ──────────────────────────────────────────────────────────────────
if (error || !data || data.status !== "ready" || !data.report) {
return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar title="6. Прогноз" />
<div
style={{
marginTop: 12,
borderRadius: 12,
padding: "32px 24px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
}}
>
<LineChart size={20} aria-hidden />
Данные прогноза недоступны
</div>
</section>
);
}
return <ForecastReady report={data.report} />;
}
// ── ForecastReady (report present) ───────────────────────────────────────────
function ForecastReady({ report }: { report: ForecastReport }) {
const exec = report.exec_summary;
const fm = report.future_market;
// Target horizon: prefer future_supply.horizon_months, else median of meta
// horizons, else 12.
const targetHorizon =
fm.future_supply?.horizon_months ??
medianHorizon(report.meta.horizons) ??
12;
const kn = exec.key_numbers;
const hasHorizons = fm.forecasts_by_horizon.length > 0;
const hasScenarios =
Object.keys(report.scenarios.by_scenario).length > 0 ||
fm.scenarios_summary != null;
const hasConfidence =
report.confidence.level != null ||
Object.keys(report.confidence.factors).length > 0;
const hasAny = hasHorizons || hasScenarios || hasConfidence;
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
const subtitle = fm.summary ?? undefined;
return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar
title={exec.headline ?? "6. Прогноз"}
subtitle={subtitle}
/>
<div
style={{
marginTop: 12,
display: "flex",
flexDirection: "column",
gap: 24,
}}
>
{/* exec_summary verdict + KPI row */}
{exec.verdict && (
<p
style={{
margin: 0,
fontSize: 14,
lineHeight: 1.5,
color: "var(--fg-primary)",
}}
>
{exec.verdict}
</p>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Индекс дефицита"
value={
kn.deficit_index != null
? `${kn.deficit_index > 0 ? "+" : ""}${fmtNum(kn.deficit_index, 2)}`
: "—"
}
color={
kn.deficit_index != null
? kpiColorForDeficit(kn.deficit_index)
: "neutral"
}
/>
<KpiCard
label="Мес. предложения"
value={
kn.months_of_inventory != null
? fmtNum(kn.months_of_inventory, 1)
: "—"
}
color="neutral"
/>
<KpiCard
label="Итоговый скор"
value={
kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"
}
color="blue"
/>
<KpiCard
label="Уверенность"
value={
(exec.overall_confidence ?? kn.confidence) != null
? CONFIDENCE_RU[
(exec.overall_confidence ?? kn.confidence) as
| "high"
| "medium"
| "low"
]
: "—"
}
color={confidenceKpiColor(exec.overall_confidence ?? kn.confidence)}
/>
</div>
{!hasAny && (
<div
style={{
borderRadius: 12,
padding: "24px 20px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Данные прогноза недоступны
</div>
)}
{/* 6.1 Прогноз по горизонтам */}
{hasHorizons && (
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
<ForecastHorizonsBlock forecasts={fm.forecasts_by_horizon} />
</SubBlock>
)}
{/* 6.2 Сценарии */}
{hasScenarios && (
<SubBlock id="section-6-2" title="6.2 Сценарии">
<ScenariosBlock
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
/>
</SubBlock>
)}
{/* 6.3 Уверенность */}
{hasConfidence && (
<SubBlock id="section-6-3" title="6.3 Уверенность">
<ForecastConfidenceBlock confidence={report.confidence} />
</SubBlock>
)}
{/* Advisory disclaimer */}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
paddingTop: 12,
borderTop: "1px solid var(--border-soft)",
}}
>
{ADVISORY_DISCLAIMER}
</p>
</div>
</section>
);
}
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
function SubBlock({
id,
title,
children,
}: {
id: string;
title: string;
children: React.ReactNode;
}) {
return (
<div id={id} style={{ scrollMarginTop: SCROLL_MARGIN }}>
<h3
style={{
margin: "0 0 12px",
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
{title}
</h3>
<div
style={{
borderRadius: 12,
border: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: 20,
}}
>
{children}
</div>
</div>
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function medianHorizon(horizons: number[]): number | null {
if (horizons.length === 0) return null;
const sorted = [...horizons].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function confidenceKpiColor(
level: "high" | "medium" | "low" | null,
): "green" | "amber" | "red" | "neutral" {
if (level === "high") return "green";
if (level === "medium") return "amber";
if (level === "low") return "red";
return "neutral";
}

View file

@ -0,0 +1,69 @@
/**
* Shared helpers for Section 6 (forecast) blocks deficit semantics, confidence
* mapping, RU number formatting. Kept framework-agnostic (no JSX) so the three
* forecast blocks share one source of truth.
*/
import type { BadgeVariant } from "@/components/ui/Badge";
import type { ConfidenceLevel } from "@/types/forecast";
/** Below this |deficit_index| we treat the market as balanced (neutral). */
export const DEFICIT_BALANCE_EPS = 0.05;
/**
* Deficit semantics (HARD): >0 недонасыщенность (повод строить, success);
* <0 затоварка (warn/danger); 0 баланс (neutral).
*/
export function deficitVariant(deficitIndex: number): BadgeVariant {
if (deficitIndex > DEFICIT_BALANCE_EPS) return "success";
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "danger";
return "neutral";
}
export function deficitWord(deficitIndex: number): string {
if (deficitIndex > DEFICIT_BALANCE_EPS) return "недонасыщенность";
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "затоварка";
return "баланс";
}
// ── Confidence ────────────────────────────────────────────────────────────────
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
high: "высокая",
medium: "средняя",
low: "низкая",
};
export function confidenceVariant(level: ConfidenceLevel): BadgeVariant {
if (level === "high") return "success";
if (level === "medium") return "warning";
return "danger";
}
/**
* ConfidenceBadge expects a numeric 0..1 value but the report carries a level
* only. Derive a representative midpoint so the numeric badge reads sensibly
* if/when reused.
*/
export function confidenceLevelToValue(level: ConfidenceLevel): number {
if (level === "high") return 0.85;
if (level === "medium") return 0.55;
return 0.25;
}
// ── Number formatting ─────────────────────────────────────────────────────────
/**
* Fixed-decimal RU number (тонкая неразрывная группировка тысяч). Нормализует
* ведущий знак минуса в Unicode «» (U+2212) per ui-microcopy ( ± Unicode).
* `.replace` бьёт только первое вхождение = ведущий знак (RU-группировка NBSP,
* не дефис), так что числовая часть не затрагивается.
*/
export function fmtNum(value: number, digits = 1): string {
return value
.toLocaleString("ru", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
.replace("-", "");
}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
import {
MOCK_PARCELS_BBOX,
MOCK_RECENT_PARCELS,
@ -19,6 +19,8 @@ import {
import fixtureParcels from "@/lib/mocks/parcels-bbox.json";
import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json";
import fixturePoiScore from "@/lib/mocks/poi-score.json";
import fixtureForecast from "@/lib/mocks/parcel-forecast.json";
import type { ForecastEnvelope } from "@/types/forecast";
// ── Types ─────────────────────────────────────────────────────────────────────
@ -391,3 +393,41 @@ export function useParcelPoiScoreQuery(cad: string) {
retry: 1,
});
}
// ── Hook: useParcelForecastQuery (958-B3 / §22) ──────────────────────────────
/**
* Polls GET /api/v1/parcels/{cad}/forecast until the async §22 forecast is
* ready. The forecast is enqueued fire-and-forget by useParcelAnalyzeQuery on
* page mount (POST /analyze) this hook does NOT trigger it, only reads.
*
* Endpoint contract:
* 200 { status: "ready", run_id, created_at, report }
* 202 { status: "pending" } (also returned gracefully on any error never 404/500)
*
* Polls every 4s while pending; stops once status === "ready". Mock mode
* (MOCK_ANALYZE) returns the committed prod fixture immediately.
*/
export function useParcelForecastQuery(cad: string) {
return useQuery({
queryKey: ["parcel-forecast", cad],
queryFn: async (): Promise<ForecastEnvelope> => {
if (MOCK_ANALYZE) {
// Fixture is raw JSON (loose shape: factors carries a stray
// `advisory_capped` boolean) — cast via unknown, runtime guards in the
// confidence block narrow factor entries.
return fixtureForecast as unknown as ForecastEnvelope;
}
// apiFetchWithStatus tolerates the 202 path (Accepted) without throwing.
const { body } = await apiFetchWithStatus<ForecastEnvelope>(
`/api/v1/parcels/${encodeURIComponent(cad)}/forecast`,
);
return body;
},
enabled: !!cad,
// Stop polling once ready; otherwise re-poll every 4s while pending.
refetchInterval: (query) =>
query.state.data?.status === "ready" ? false : 4000,
retry: 1,
});
}

View file

@ -0,0 +1,181 @@
/**
* §22 forecast / scenarios / confidence TypeScript contract.
*
* Ground truth: the committed prod report at
* `src/lib/mocks/parcel-forecast.json` (run #68, parcel 66:41:0702048:27).
* Types describe ONLY the sub-shapes Section 6 renders; the §13 report is
* larger (scoring, market_now, product_tz, ) fields not consumed here are
* intentionally left as optional/loose to avoid over-coupling to the backend.
*
* The endpoint envelope (GET /api/v1/parcels/{cad}/forecast):
* 200 { status: "ready", run_id, created_at, report }
* 202 { status: "pending" }
*/
export type ConfidenceLevel = "high" | "medium" | "low";
/** Scenario keys produced by the §22 envelope generator. */
export type ScenarioKey = "base" | "aggressive" | "conservative";
// ── Segment ─────────────────────────────────────────────────────────────────
export interface ForecastSegment {
district: string | null;
obj_class: string | null;
room_bucket: string | null;
price_bucket: string | null;
}
// ── Future competitor (forward-looking, distinct from market_now competitors) ─
export interface FutureCompetitor {
obj_id: number;
comm_name: string | null;
obj_class: string | null;
distance_m: number;
flats_total: number;
relevance_weight: number;
velocity_per_month: number;
}
// ── Demand / supply forecast (one per horizon) ───────────────────────────────
export interface DemandSupplyForecast {
segment: ForecastSegment;
advisory: boolean;
confidence: ConfidenceLevel;
open_units: number;
rate_future: number;
balance_ratio: number;
balance_units: number;
/** 1..1 — see deficit semantics: >0 недонасыщенность, <0 затоварка. */
deficit_index: number;
horizon_months: number;
macro_coefficient: number;
future_competitors: FutureCompetitor[];
future_online_units: number;
months_of_inventory: number;
hidden_release_units: number;
base_pace_units_per_mo: number;
projected_demand_units: number;
projected_supply_units: number;
demand_norm_coefficient: number;
rate_sensitivity_phrase: string;
}
// ── Exec summary ─────────────────────────────────────────────────────────────
export interface ReportExecSummaryKeyNumbers {
confidence: ConfidenceLevel | null;
deficit_index: number | null;
overall_score: number | null;
months_of_inventory: number | null;
}
export interface ReportExecSummary {
headline: string | null;
verdict: string | null;
key_numbers: ReportExecSummaryKeyNumbers;
overall_confidence: ConfidenceLevel | null;
}
// ── Future market ────────────────────────────────────────────────────────────
export interface FutureSupplyBreakdown {
index: number;
open_units: number;
hidden_units: number;
months_of_pressure: number;
future_units_by_horizon: number;
monthly_absorption_units: number;
}
export interface FutureSupply {
index: number;
district: string | null;
breakdown: FutureSupplyBreakdown;
confidence: ConfidenceLevel;
premise_kind: string | null;
horizon_months: number;
}
/** deficit_index per scenario at the target horizon. */
export interface ScenariosSummary {
base: number;
aggressive: number;
conservative: number;
}
export interface ReportFutureMarket {
summary: string | null;
forecasts_by_horizon: DemandSupplyForecast[];
future_supply: FutureSupply | null;
scenarios_summary: ScenariosSummary | null;
future_competitors: FutureCompetitor[];
}
// ── Scenarios ────────────────────────────────────────────────────────────────
/** Key path = horizon months (as string), value = key rate (% годовых). */
export type RatePath = Record<string, number>;
export interface ScenarioForecast {
advisory: boolean;
scenario: string;
forecasts: DemandSupplyForecast[];
rate_path: RatePath;
}
export interface ReportScenarios {
summary: string | null;
by_scenario: Partial<Record<ScenarioKey, ScenarioForecast>>;
}
// ── Confidence ───────────────────────────────────────────────────────────────
export interface ConfidenceFactor {
note: string;
level: ConfidenceLevel;
value: number | null;
}
export interface ReportConfidence {
level: ConfidenceLevel | null;
rationale: string | null;
/** The explicit drivers map (DoD: «явный список drivers»). */
factors: Record<string, ConfidenceFactor>;
}
// ── Meta ─────────────────────────────────────────────────────────────────────
export interface ReportMeta {
cad_num: string;
segment: ForecastSegment;
district: string | null;
horizons: number[];
generated_at: string | null;
schema_version: string;
generated_advisory: boolean;
}
// ── Report (only the sections Section 6 renders are typed precisely) ──────────
export interface ForecastReport {
meta: ReportMeta;
/** Always true for §22 — drives the advisory disclaimer. */
advisory: boolean;
exec_summary: ReportExecSummary;
future_market: ReportFutureMarket;
scenarios: ReportScenarios;
confidence: ReportConfidence;
schema_version?: string;
}
// ── Envelope ─────────────────────────────────────────────────────────────────
export interface ForecastEnvelope {
status: "ready" | "pending";
run_id?: number;
created_at?: string;
report?: ForecastReport;
}