feat(sf): 6.6 in-app future-supply pressure + pipeline competitors (§13.3)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m21s
Deploy / deploy (push) Successful in 1m32s

Surface the §16-traceability evidence behind the supply forecast that
previously lived only in exports: future_market.future_supply (§9.3
supply-pressure breakdown — open/hidden stock, absorption, months of
pressure, future units, pressure index) and future_market.future_competitors
(§9.7 relevance-weighted pipeline, sorted by relevance desc). New
ForecastFutureSupplyBlock as subsection 6.6 (+ section-6-6 nav),
complementing 6.1's per-horizon aggregates.

- Fix FutureSupply/FutureSupplyBreakdown/FutureCompetitor optionality in
  forecast.ts to match the backend (FutureSupplyPressure/Competitor): null
  metrics typed + rendered «нет данных», never a fabricated 0.
- Supply panel gates on computed metrics (index/pressure), NOT open/hidden
  unit stocks — so the empty-supply_layers state (pre Monday-worker, every
  parcel today) hides the panel instead of falsely showing "0 ед."; the
  competitor table still renders. hasSupplySignal shared in forecast-helpers.
- RU labels reuse report_pdf/excel export wording. Graceful on partial/202.

Part of #958; surfaces #950 future-supply data.
This commit is contained in:
Light1YT 2026-06-07 15:55:25 +05:00 committed by bot-backend
parent 5b07c6641b
commit f5aa7ed191
5 changed files with 544 additions and 10 deletions

View file

@ -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 Будущее предложение и конкуренты" },
],
},
];

View file

@ -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 ( 01) 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 01 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}>
Месяцев давления сколько месяцев конкурирующего предложения (скрытый
запас плюс будущий слой) стоит в очереди при текущем темпе поглощения.
Индекс давления 01: выше больше грядущего предложения относительно
скорости рынка.
</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}>
Релевантность 01 взвешенный сигнал близости, класса, цены и стадии
к горизонту (§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>
);
}

View file

@ -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={{

View file

@ -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 ─────────────────────────────────────────────────────────
/**

View file

@ -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;