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 ( +
+ Месяцев давления — сколько месяцев конкурирующего предложения (скрытый + запас плюс будущий слой) стоит в очереди при текущем темпе поглощения. + Индекс давления ∈ 0…1: выше — больше грядущего предложения относительно + скорости рынка. +
+| Название | +Класс | +Расстояние | +Квартир | +Скорость/мес | +Релевантность | +
|---|
+ Релевантность ∈ 0…1 — взвешенный сигнал близости, класса, цены и стадии + к горизонту (§9.7). Список — top-N будущих конкурентов по релевантности. +
+= { 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;