feat(site-finder): forecast Section 6 (прогноз/сценарии/confidence) на экране анализа (#998) #1071
9 changed files with 3536 additions and 1 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
237
frontend/src/components/site-finder/analysis/ScenariosBlock.tsx
Normal file
237
frontend/src/components/site-finder/analysis/ScenariosBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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("-", "−");
|
||||
}
|
||||
2270
frontend/src/lib/mocks/parcel-forecast.json
Normal file
2270
frontend/src/lib/mocks/parcel-forecast.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
181
frontend/src/types/forecast.ts
Normal file
181
frontend/src/types/forecast.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue