From f5aa7ed19139e3744bbe7fe552304a3b7c5ee7c8 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 7 Jun 2026 15:55:25 +0500 Subject: [PATCH] =?UTF-8?q?feat(sf):=206.6=20in-app=20future-supply=20pres?= =?UTF-8?q?sure=20+=20pipeline=20competitors=20(=C2=A713.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../analysis/[cad]/AnalysisPageContent.tsx | 1 + .../analysis/ForecastFutureSupplyBlock.tsx | 449 ++++++++++++++++++ .../site-finder/analysis/Section6Forecast.tsx | 38 +- .../site-finder/analysis/forecast-helpers.ts | 31 +- frontend/src/types/forecast.ts | 35 +- 5 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/ForecastFutureSupplyBlock.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index c2ba1b1c..c0bcc8f1 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -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 Будущее предложение и конкуренты" }, ], }, ]; diff --git a/frontend/src/components/site-finder/analysis/ForecastFutureSupplyBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastFutureSupplyBlock.tsx new file mode 100644 index 00000000..68a35e68 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastFutureSupplyBlock.tsx @@ -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 ( +
+ {showSupply && futureSupply && ( + + )} + {showCompetitors && } +
+ ); +} + +// ── Supply-pressure panel (§9.3) ───────────────────────────────────────────────── + +function SupplyPressurePanel({ supply }: { supply: FutureSupply }) { + const b = supply.breakdown; + return ( +
+ {/* Header: subhead + context (район · помещение · горизонт) + confidence. */} +
+ Давление будущего предложения + + +
+ + {/* Pressure index — token bar (∈ 0…1, higher = more queued supply). */} + + + {/* Breakdown — labeled stat cells (the §16 evidence). */} + + +

+ Месяцев давления — сколько месяцев конкурирующего предложения (скрытый + запас плюс будущий слой) стоит в очереди при текущем темпе поглощения. + Индекс давления ∈ 0…1: выше — больше грядущего предложения относительно + скорости рынка. +

+
+ ); +} + +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 ( + + {parts.join(" · ")} + + ); +} + +function ConfidencePill({ level }: { level: ConfidenceLevel }) { + return ( + + уверенность {CONFIDENCE_RU[level]} + + ); +} + +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 ( +
+
+ + Индекс давления + + + {valueStr} + +
+
+
+
+
+ ); +} + +// ── 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 ( +
+ {cells.map((c) => ( + + ))} +
+ ); +} + +function StatCellView({ cell }: { cell: StatCell }) { + const { value } = cell; + const hasValue = value != null; + const valueStr = hasValue ? fmtNum(value, cell.digits) : NO_DATA; + return ( +
+ {cell.label} + + {valueStr} + +
+ ); +} + +// ── 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 ( +
+ Будущие конкуренты ({rows.length}) +
+ + + + + + + + + + + + + {rows.map((c) => ( + + ))} + +
НазваниеКлассРасстояниеКвартирСкорость/месРелевантность
+
+

+ Релевантность ∈ 0…1 — взвешенный сигнал близости, класса, цены и стадии + к горизонту (§9.7). Список — top-N будущих конкурентов по релевантности. +

+
+ ); +} + +function CompetitorRow({ competitor }: { competitor: FutureCompetitor }) { + const name = competitor.comm_name?.trim() || "Без названия"; + const rw = competitor.relevance_weight; + return ( + + + + {name} + + + + {competitor.obj_class ? ( + + {competitor.obj_class} + + ) : ( + {NO_DATA} + )} + + + {competitor.distance_m != null + ? `${fmtNum(competitor.distance_m, 0)} м` + : NO_DATA} + + + {competitor.flats_total != null + ? competitor.flats_total.toLocaleString("ru") + : NO_DATA} + + + {competitor.velocity_per_month != null + ? fmtNum(competitor.velocity_per_month, 1) + : NO_DATA} + + + + + + ); +} + +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 ( +
+
+
+
+ + {valueStr} + +
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index eec05ee5..7f182491 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -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({ )} + {/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */} + {hasFutureSupplyDetail && ( + + + + )} + {/* Advisory disclaimer */}

= { 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 ───────────────────────────────────────────────────────── /** diff --git a/frontend/src/types/forecast.ts b/frontend/src/types/forecast.ts index 7b31e00c..367cb142 100644 --- a/frontend/src/types/forecast.ts +++ b/frontend/src/types/forecast.ts @@ -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;