feat(sf): in-app future-supply pressure + pipeline competitors (6.6) (#958) #1125
5 changed files with 544 additions and 10 deletions
|
|
@ -263,6 +263,7 @@ const NAV_ITEMS = [
|
|||
{ 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 Будущее предложение и конкуренты" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,449 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 6.6 Будущее предложение и конкуренты — §13.3 future_market evidence (#950/#949).
|
||||
*
|
||||
* The §16-traceability evidence BEHIND the supply forecast. 6.1's per-horizon
|
||||
* table shows the aggregate deficit; this block surfaces the two detail layers
|
||||
* that, until now, lived only in the exports:
|
||||
*
|
||||
* • Supply-pressure panel (from `future_market.future_supply`, §9.3) — the
|
||||
* FutureSupplyPressure breakdown as labeled stat cells: открытый сток /
|
||||
* скрытый запас / поглощение / месяцев давления / будущий слой в горизонте,
|
||||
* with district + premise_kind + horizon context + a confidence badge. The
|
||||
* pressure index (∈ 0…1) gets a token bar — higher = more queued supply.
|
||||
* • Future-competitors table (from `future_market.future_competitors`, §9.7) —
|
||||
* the relevance-weighted pipeline competition, sorted by relevance_weight
|
||||
* desc, with a 0…1 token bar for relevance.
|
||||
*
|
||||
* RU labels reuse the established export wording (report_pdf / excel
|
||||
* `_future_supply_pairs`): «Открытый сток», «Скрытый запас», «Будущий слой в
|
||||
* горизонте», «Поглощение», «Месяцев давления», «Индекс давления».
|
||||
*
|
||||
* GRACEFUL: returns null when BOTH future_supply and future_competitors are
|
||||
* absent/empty; each part is guarded independently; a null metric renders «нет
|
||||
* данных» (never a 0-bar implying a real zero); an empty competitor list skips
|
||||
* the table entirely. No crash on a partial / 202-pending report.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConfidenceLevel,
|
||||
FutureCompetitor,
|
||||
FutureSupply,
|
||||
FutureSupplyBreakdown,
|
||||
} from "@/types/forecast";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import {
|
||||
CONFIDENCE_RU,
|
||||
confidenceVariant,
|
||||
fmtNum,
|
||||
hasSupplySignal,
|
||||
} from "./forecast-helpers";
|
||||
|
||||
interface Props {
|
||||
/** §9.3 future-supply pressure (evidence behind the supply forecast). */
|
||||
futureSupply: FutureSupply | null;
|
||||
/** §9.7 relevance-weighted pipeline competitors. */
|
||||
futureCompetitors: FutureCompetitor[];
|
||||
}
|
||||
|
||||
const NO_DATA = "нет данных";
|
||||
|
||||
const SUBHEAD_STYLE: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--fg-secondary)",
|
||||
};
|
||||
|
||||
const HINT_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-tertiary)",
|
||||
};
|
||||
|
||||
// ── ForecastFutureSupplyBlock ────────────────────────────────────────────────────
|
||||
|
||||
export function ForecastFutureSupplyBlock({
|
||||
futureSupply,
|
||||
futureCompetitors,
|
||||
}: Props) {
|
||||
const showSupply = hasSupplySignal(futureSupply);
|
||||
const competitors = futureCompetitors.filter(
|
||||
(c): c is FutureCompetitor => c != null && typeof c.obj_id === "number",
|
||||
);
|
||||
const showCompetitors = competitors.length > 0;
|
||||
|
||||
// Graceful: nothing meaningful in either part → render nothing.
|
||||
if (!showSupply && !showCompetitors) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
{showSupply && futureSupply && (
|
||||
<SupplyPressurePanel supply={futureSupply} />
|
||||
)}
|
||||
{showCompetitors && <CompetitorsTable competitors={competitors} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Supply-pressure panel (§9.3) ─────────────────────────────────────────────────
|
||||
|
||||
function SupplyPressurePanel({ supply }: { supply: FutureSupply }) {
|
||||
const b = supply.breakdown;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Header: subhead + context (район · помещение · горизонт) + confidence. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={SUBHEAD_STYLE}>Давление будущего предложения</span>
|
||||
<SupplyContext supply={supply} />
|
||||
<ConfidencePill level={supply.confidence} />
|
||||
</div>
|
||||
|
||||
{/* Pressure index — token bar (∈ 0…1, higher = more queued supply). */}
|
||||
<PressureIndexBar index={supply.index} />
|
||||
|
||||
{/* Breakdown — labeled stat cells (the §16 evidence). */}
|
||||
<StatGrid breakdown={b} />
|
||||
|
||||
<p style={HINT_STYLE}>
|
||||
Месяцев давления — сколько месяцев конкурирующего предложения (скрытый
|
||||
запас плюс будущий слой) стоит в очереди при текущем темпе поглощения.
|
||||
Индекс давления ∈ 0…1: выше — больше грядущего предложения относительно
|
||||
скорости рынка.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupplyContext({ supply }: { supply: FutureSupply }) {
|
||||
const parts: string[] = [];
|
||||
if (supply.district) parts.push(supply.district);
|
||||
if (supply.premise_kind) parts.push(supply.premise_kind);
|
||||
parts.push(`горизонт ${supply.horizon_months} мес.`);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfidencePill({ level }: { level: ConfidenceLevel }) {
|
||||
return (
|
||||
<Badge variant={confidenceVariant(level)} size="sm">
|
||||
уверенность {CONFIDENCE_RU[level]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function PressureIndexBar({ index }: { index: number | null }) {
|
||||
const hasValue = index != null;
|
||||
const widthPct = hasValue ? Math.max(0, Math.min(100, index * 100)) : 0;
|
||||
const valueStr = hasValue ? fmtNum(index, 2) : NO_DATA;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
Индекс давления
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: hasValue ? "var(--fg-secondary)" : "var(--fg-tertiary)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{valueStr}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 8,
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-soft)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
role="img"
|
||||
aria-label={`Индекс давления: ${valueStr}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
insetBlock: 0,
|
||||
insetInlineStart: 0,
|
||||
width: `${widthPct}%`,
|
||||
// Pressure is a risk signal — accent fill (neutral valence, not
|
||||
// success/danger): the index value carries the meaning, not colour.
|
||||
background: "var(--accent)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Breakdown stat cells ─────────────────────────────────────────────────────────
|
||||
|
||||
interface StatCell {
|
||||
label: string;
|
||||
value: number | null;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
function StatGrid({ breakdown }: { breakdown: FutureSupplyBreakdown }) {
|
||||
// RU labels reuse the export wording (report_pdf / excel _future_supply_pairs).
|
||||
const cells: StatCell[] = [
|
||||
{ label: "Открытый сток, ед.", value: breakdown.open_units, digits: 0 },
|
||||
{ label: "Скрытый запас, ед.", value: breakdown.hidden_units, digits: 0 },
|
||||
{
|
||||
label: "Будущий слой в горизонте, ед.",
|
||||
value: breakdown.future_units_by_horizon,
|
||||
digits: 1,
|
||||
},
|
||||
{
|
||||
label: "Поглощение, ед./мес",
|
||||
value: breakdown.monthly_absorption_units,
|
||||
digits: 1,
|
||||
},
|
||||
{
|
||||
label: "Месяцев давления",
|
||||
value: breakdown.months_of_pressure,
|
||||
digits: 1,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{cells.map((c) => (
|
||||
<StatCellView key={c.label} cell={c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCellView({ cell }: { cell: StatCell }) {
|
||||
const { value } = cell;
|
||||
const hasValue = value != null;
|
||||
const valueStr = hasValue ? fmtNum(value, cell.digits) : NO_DATA;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-soft)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span style={SUBHEAD_STYLE}>{cell.label}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: hasValue ? "var(--fg-primary)" : "var(--fg-tertiary)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{valueStr}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Future-competitors table (§9.7) ──────────────────────────────────────────────
|
||||
|
||||
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: "middle",
|
||||
borderBottom: "1px solid var(--border-soft)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
};
|
||||
|
||||
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
|
||||
const NUM_TH: React.CSSProperties = { ...TH_STYLE, textAlign: "right" };
|
||||
|
||||
function CompetitorsTable({
|
||||
competitors,
|
||||
}: {
|
||||
competitors: FutureCompetitor[];
|
||||
}) {
|
||||
// Sort by relevance_weight desc; nulls sink to the bottom (no fabricated 0).
|
||||
const rows = [...competitors].sort(
|
||||
(a, b) => (b.relevance_weight ?? -1) - (a.relevance_weight ?? -1),
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={SUBHEAD_STYLE}>Будущие конкуренты ({rows.length})</span>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
minWidth: 640,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Название</th>
|
||||
<th style={TH_STYLE}>Класс</th>
|
||||
<th style={NUM_TH}>Расстояние</th>
|
||||
<th style={NUM_TH}>Квартир</th>
|
||||
<th style={NUM_TH}>Скорость/мес</th>
|
||||
<th style={{ ...TH_STYLE, minWidth: 140 }}>Релевантность</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((c) => (
|
||||
<CompetitorRow key={c.obj_id} competitor={c} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style={HINT_STYLE}>
|
||||
Релевантность ∈ 0…1 — взвешенный сигнал близости, класса, цены и стадии
|
||||
к горизонту (§9.7). Список — top-N будущих конкурентов по релевантности.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompetitorRow({ competitor }: { competitor: FutureCompetitor }) {
|
||||
const name = competitor.comm_name?.trim() || "Без названия";
|
||||
const rw = competitor.relevance_weight;
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ ...TD_STYLE, maxWidth: 280 }}>
|
||||
<span
|
||||
title={name}
|
||||
style={{
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{competitor.obj_class ? (
|
||||
<Badge variant="neutral" size="sm">
|
||||
{competitor.obj_class}
|
||||
</Badge>
|
||||
) : (
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>{NO_DATA}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={NUM_TD}>
|
||||
{competitor.distance_m != null
|
||||
? `${fmtNum(competitor.distance_m, 0)} м`
|
||||
: NO_DATA}
|
||||
</td>
|
||||
<td style={NUM_TD}>
|
||||
{competitor.flats_total != null
|
||||
? competitor.flats_total.toLocaleString("ru")
|
||||
: NO_DATA}
|
||||
</td>
|
||||
<td style={NUM_TD}>
|
||||
{competitor.velocity_per_month != null
|
||||
? fmtNum(competitor.velocity_per_month, 1)
|
||||
: NO_DATA}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
<RelevanceBar value={rw} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function RelevanceBar({ value }: { value: number | null }) {
|
||||
const hasValue = value != null;
|
||||
const widthPct = hasValue ? Math.max(0, Math.min(100, value * 100)) : 0;
|
||||
const valueStr = hasValue ? fmtNum(value, 2) : NO_DATA;
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minWidth: 64,
|
||||
height: 8,
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-soft)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
role="img"
|
||||
aria-label={`Релевантность: ${valueStr}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
insetBlock: 0,
|
||||
insetInlineStart: 0,
|
||||
width: `${widthPct}%`,
|
||||
background: "var(--accent)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: hasValue ? "var(--fg-secondary)" : "var(--fg-tertiary)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flexShrink: 0,
|
||||
minWidth: 36,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{valueStr}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@
|
|||
* 6.3 Уверенность → ForecastConfidenceBlock
|
||||
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
|
||||
* 6.5 Прозрачность скоринга → ForecastScoringBlock (почему скор — §13.6)
|
||||
* 6.6 Будущее предложение и конкуренты → ForecastFutureSupplyBlock
|
||||
* (§16-evidence за прогнозом предложения — §9.3 давление + §9.7 конкуренты)
|
||||
* advisory disclaimer (footer)
|
||||
*/
|
||||
|
||||
|
|
@ -32,8 +34,14 @@ import { ScenariosBlock } from "./ScenariosBlock";
|
|||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||||
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
||||
import {
|
||||
CONFIDENCE_RU,
|
||||
deficitVariant,
|
||||
fmtNum,
|
||||
hasSupplySignal,
|
||||
} from "./forecast-helpers";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
|
|
@ -214,8 +222,21 @@ function ForecastReady({
|
|||
(sc.special_indices != null &&
|
||||
Object.keys(sc.special_indices.indices).length > 0));
|
||||
|
||||
// 6.6 — будущее предложение и конкуренты (§13.3 evidence). future_supply is
|
||||
// shown only when it carries a genuinely-computed pressure signal (see
|
||||
// hasSupplySignal — gates on computed metrics, NOT open/hidden stock which are
|
||||
// 0-not-null when supply_layers is unloaded); future_competitors when the list
|
||||
// is non-empty. The per-horizon 6.1 table stays the aggregate; this is the detail.
|
||||
const hasFutureSupplyDetail =
|
||||
hasSupplySignal(fm.future_supply) || fm.future_competitors.length > 0;
|
||||
|
||||
const hasAny =
|
||||
hasHorizons || hasScenarios || hasConfidence || hasProductTz || hasScoring;
|
||||
hasHorizons ||
|
||||
hasScenarios ||
|
||||
hasConfidence ||
|
||||
hasProductTz ||
|
||||
hasScoring ||
|
||||
hasFutureSupplyDetail;
|
||||
|
||||
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
||||
const subtitle = fm.summary ?? undefined;
|
||||
|
|
@ -360,6 +381,19 @@ function ForecastReady({
|
|||
</SubBlock>
|
||||
)}
|
||||
|
||||
{/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */}
|
||||
{hasFutureSupplyDetail && (
|
||||
<SubBlock
|
||||
id="section-6-6"
|
||||
title="6.6 Будущее предложение и конкуренты"
|
||||
>
|
||||
<ForecastFutureSupplyBlock
|
||||
futureSupply={fm.future_supply}
|
||||
futureCompetitors={fm.future_competitors}
|
||||
/>
|
||||
</SubBlock>
|
||||
)}
|
||||
|
||||
{/* Advisory disclaimer */}
|
||||
<p
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||
import type { ConfidenceLevel } from "@/types/forecast";
|
||||
import type { ConfidenceLevel, FutureSupply } from "@/types/forecast";
|
||||
|
||||
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
||||
export const DEFICIT_BALANCE_EPS = 0.05;
|
||||
|
|
@ -125,6 +125,35 @@ export const SPECIAL_INDEX_RU: Record<string, string> = {
|
|||
cost_of_error: "Цена ошибки",
|
||||
};
|
||||
|
||||
// ── Future supply (§9.3 — 6.6 evidence panel) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Does the §9.3 future-supply payload carry a GENUINELY-COMPUTED pressure signal
|
||||
* worth rendering the supply panel for? Single source of truth — gates both the
|
||||
* 6.6 block's panel and Section6Forecast's 6.6 sub-block (so they can't drift).
|
||||
*
|
||||
* Gates on the `_round_or_none` metrics ONLY (index / months_of_pressure /
|
||||
* future_units_by_horizon / monthly_absorption_units) — DELIBERATELY excludes
|
||||
* `open_units` / `hidden_units`. Those are integer stocks that are 0-NOT-NULL at
|
||||
* the source: when `supply_layers` is empty (e.g. before the Monday 06:00 worker
|
||||
* loads it) the SQL returns 0 rows → open/hidden = 0 while every computed metric
|
||||
* is null. Triggering on open/hidden would render «Открытый сток: 0 ед. / Скрытый
|
||||
* запас: 0 ед.», falsely reading as "zero future supply = safe" when the truth is
|
||||
* "supply data not loaded yet" (null ≠ 0). Gating on the computed metrics keeps
|
||||
* the panel OFF until a real pressure index exists; open/hidden are still DISPLAYED
|
||||
* once it does (a genuine 0 then is honest).
|
||||
*/
|
||||
export function hasSupplySignal(fs: FutureSupply | null): boolean {
|
||||
if (fs == null) return false;
|
||||
const b = fs.breakdown;
|
||||
return (
|
||||
fs.index != null ||
|
||||
b.months_of_pressure != null ||
|
||||
b.future_units_by_horizon != null ||
|
||||
b.monthly_absorption_units != null
|
||||
);
|
||||
}
|
||||
|
||||
// ── Number formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,13 +28,21 @@ export interface ForecastSegment {
|
|||
|
||||
// ── Future competitor (forward-looking, distinct from market_now competitors) ─
|
||||
|
||||
/**
|
||||
* §9.7 forward-looking competitor (top-N by `relevance_weight` at the horizon).
|
||||
* Shape mirrors the backend `Competitor` schema (`schemas/parcel.py`) as flattened
|
||||
* by `_competitor_to_dict` (demand_supply_forecast.py): `distance_m` /
|
||||
* `velocity_per_month` are required floats there, while `comm_name` / `obj_class` /
|
||||
* `flats_total` / `relevance_weight` are nullable — null is rendered as «нет данных»,
|
||||
* never a fabricated 0.
|
||||
*/
|
||||
export interface FutureCompetitor {
|
||||
obj_id: number;
|
||||
comm_name: string | null;
|
||||
obj_class: string | null;
|
||||
distance_m: number;
|
||||
flats_total: number;
|
||||
relevance_weight: number;
|
||||
flats_total: number | null;
|
||||
relevance_weight: number | null;
|
||||
velocity_per_month: number;
|
||||
}
|
||||
|
||||
|
|
@ -81,17 +89,30 @@ export interface ReportExecSummary {
|
|||
|
||||
// ── Future market ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* §9.3 future-supply-pressure breakdown (FutureSupplyPressure.breakdown()).
|
||||
* `open_units` / `hidden_units` are integer unit stocks (never null at the
|
||||
* source); `future_units_by_horizon` / `monthly_absorption_units` /
|
||||
* `months_of_pressure` / `index` go through `_round_or_none` and are null when the
|
||||
* market signal is missing (no absorption → давление неизмеримо) — rendered as
|
||||
* «нет данных», never a fabricated 0.
|
||||
*/
|
||||
export interface FutureSupplyBreakdown {
|
||||
index: number;
|
||||
index: number | null;
|
||||
open_units: number;
|
||||
hidden_units: number;
|
||||
months_of_pressure: number;
|
||||
future_units_by_horizon: number;
|
||||
monthly_absorption_units: number;
|
||||
months_of_pressure: number | null;
|
||||
future_units_by_horizon: number | null;
|
||||
monthly_absorption_units: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.3 future supply pressure (FutureSupplyPressure.as_dict()). `confidence` and
|
||||
* `horizon_months` are always present at the source; `index` is null on thin data;
|
||||
* `district` / `premise_kind` may be null (EKB-wide / unspecified).
|
||||
*/
|
||||
export interface FutureSupply {
|
||||
index: number;
|
||||
index: number | null;
|
||||
district: string | null;
|
||||
breakdown: FutureSupplyBreakdown;
|
||||
confidence: ConfidenceLevel;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue