/** * 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, DemandSupplyForecast, FutureSupply, ProductMixEntry, } from "@/types/forecast"; /** Below this |deficit_index| we treat the market as balanced (neutral). */ export const DEFICIT_BALANCE_EPS = 0.05; /** * #1958/#1959 honesty-guard — at/above this |deficit_index| the signal is * SATURATED («упёрся» в край шкалы). The backend clamps deficit_index to [−1, 1] * and saturates the index at balance_ratio=0.5; when supply massively dwarfs * demand (ratio ≪ 0.5 на всех горизонтах) every horizon clamps to exactly −1.00 * (зеркально +1.00 для острого дефицита). Showing the SAME «−1.00» ×4 reads as * broken / falsely precise — so we mark such values «на пределе» and раскрываем * честную плашку. Mirrors `ForecastChart.DEGENERATE_CLAMP_EPS` (0.02 → * |value| ≥ 0.98) but tightened to 0.99 per the §6 brief (а у графика свой порог). */ export const DEFICIT_PEG_THRESHOLD = 0.99; /** Saturated (clamp-floor) signal? `|deficit_index| ≥ DEFICIT_PEG_THRESHOLD`. */ export function isDeficitPegged(deficitIndex: number): boolean { return Math.abs(deficitIndex) >= DEFICIT_PEG_THRESHOLD; } /** * Pegged-signal state across a set of horizon rows (HONESTY-GUARD, #1958/#1959). * The signal is «на пределе шкалы» only when EVERY present deficit_index is * saturated (|value| ≥ 0.99) AND all саме знака — then «−1.00 ×4» не различает * горизонты и плашка честнее точного числа. A single non-pegged / opposite-sign * row means there IS differentiation → not pegged (plain table tells the story). * * Returns `{ pegged, sign }`: * pegged — render the honesty plaque + mark values «на пределе»; * sign — −1 затоварка (supply ≫ demand) / +1 острый дефицит, или null when * не pegged. Drives which RU plaque text to show (zeroкально). * PURE (no JSX) — single source of truth shared by 6.1 (table) and 6.2 (scenarios). */ export interface DeficitPegState { pegged: boolean; sign: -1 | 1 | null; } export function deficitPegState( forecasts: Pick[], ): DeficitPegState { const present = forecasts .map((f) => f.deficit_index) .filter((v): v is number => v != null); if (present.length === 0) return { pegged: false, sign: null }; const allPegged = present.every(isDeficitPegged); if (!allPegged) return { pegged: false, sign: null }; const allSameSign = present.every((v) => v > 0) || present.every((v) => v < 0); if (!allSameSign) return { pegged: false, sign: null }; return { pegged: true, sign: present[0] > 0 ? 1 : -1 }; } /** * Human RU plaque text for a pegged deficit signal (#1958/#1959). Финдиректору * объясняем простым языком, что точное «−1.00 ×4» — артефакт упора в край шкалы * (рынок перенасыщен), а не различие горизонтов. Зеркально для +1.0 — острый * дефицит. Источник правды для 6.1 и 6.2 (не дублировать строку в TSX). */ export function deficitPegPlaque(sign: -1 | 1): string { if (sign < 0) { return ( "Сигнал на пределе шкалы: предложение во много раз превышает спрос на всех " + "горизонтах — сильная затоварка. Точное −1.00 не различает горизонты (рынок " + "перенасыщен), оценка грубая." ); } return ( "Сигнал на пределе шкалы: спрос во много раз превышает предложение на всех " + "горизонтах — острый дефицит. Точное +1.00 не различает горизонты (рынок " + "сильно недонасыщен), оценка грубая." ); } /** * 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 "баланс"; } /** * Сигнал-вердикт по deficit_index для таблицы форматов (#1745): «строить» * (недонасыщенность) / «баланс» / «избегать» (затоварка). Зеркало backend * `report_assembler._build_signal` — фронт-fallback, когда mix-ячейка не несёт * готового `signal` (старый прогон). Семантика та же, что у `deficitWord`. */ export function deficitSignalWord(deficitIndex: number): string { if (deficitIndex > DEFICIT_BALANCE_EPS) return "строить"; if (deficitIndex < -DEFICIT_BALANCE_EPS) return "избегать"; return "баланс"; } // ── Class-level deficit aggregation (#1742 — объяснение выбора класса) ────────── /** Агрегат дефицита одного класса: среднее deficit_index по его форматам. */ export interface ClassDeficit { obj_class: string; /** Среднее deficit_index по форматам класса (null — у всех форматов нет индекса). */ meanDeficitIndex: number | null; /** Сколько форматов класса несут не-null deficit_index (для honest-агрегата). */ nWithIndex: number; } /** * Агрегировать `mix` (квартирография — ячейки формат×класс) в per-класс дефицит: * среднее deficit_index по форматам каждого класса. Зеркало backend * `recommendation._recommend_class` (среднее по room-bucket'ам класса). Это даёт * мини-таблицу 3 классов (#1742) из уже-доступного `mix` без backend-расширения — * `mix` несёт все 15 ячеек (5 форматов × 3 класса) с per-ячейкой deficit_index. * * Сортировка DESC по meanDeficitIndex (сильнейший дефицит сверху, как в ранкинге); * классы без obj_class игнорируются (нечего агрегировать — НЕ фабрикуем класс). * Класс, у которого ни у одной ячейки нет индекса → meanDeficitIndex=null (фронт * рисует «тонкие данные», НЕ 0). */ export function aggregateClassDeficits(mix: ProductMixEntry[]): ClassDeficit[] { const byClass = new Map(); for (const m of mix) { if (m.obj_class == null) continue; const list = byClass.get(m.obj_class) ?? []; if (m.deficit_index != null) list.push(m.deficit_index); byClass.set(m.obj_class, list); } const out: ClassDeficit[] = []; for (const [obj_class, values] of byClass) { const meanDeficitIndex = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : null; out.push({ obj_class, meanDeficitIndex, nWithIndex: values.length }); } // DESC по среднему дефициту; null-индексы — в конец (стабильно). tie-break — класс ASC. out.sort((a, b) => { const av = a.meanDeficitIndex; const bv = b.meanDeficitIndex; if (av == null && bv == null) return a.obj_class.localeCompare(b.obj_class); if (av == null) return 1; if (bv == null) return -1; if (bv !== av) return bv - av; return a.obj_class.localeCompare(b.obj_class); }); return out; } /** * Bar width (%) for a signed deficit_index (∈ −1..1) on a 0..100 scale. Uses the * magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar * (colour carries the sign via `deficitVariant`). Clamped to [0, 100]. */ export function deficitBarWidthPct(deficitIndex: number): number { const pct = Math.abs(deficitIndex) * 100; return Math.max(0, Math.min(100, pct)); } // ── Confidence ──────────────────────────────────────────────────────────────── export const CONFIDENCE_RU: Record = { high: "высокая", medium: "средняя", low: "низкая", }; export function confidenceVariant(level: ConfidenceLevel): BadgeVariant { if (level === "high") return "success"; if (level === "medium") return "warning"; return "danger"; } // ── Scoring (§13.6 — product scores #985 + special indices #986) ─────────────── /** * Product-score semantics (HARD): every §14.2 score is ∈ [0,1] with «выше = * лучше для девелопера» (risk scores supply_risk / future_competition / * mortgage_sensitivity are pre-inverted at the source — so score>0.5 = * favourable). The backend anchors quality on 0.5 = баланс/midpoint * (`product_scoring.py:127` `_MARKET_FIT_MIDPOINT = 0.5`; inverted-risk reasons * flip favourable/unfavourable at risk<0.5 → score>0.5), so the gradient is * anchored on 0.5 too: a narrow ±0.05 neutral band straddles the balance point — * ≥0.55 success / 0.45–0.55 warning / <0.45 danger. This is NOT the deficit * mapping (signed [−1,1]) — do not reuse deficitVariant here. */ export const SCORE_GOOD_THRESHOLD = 0.55; export const SCORE_WEAK_THRESHOLD = 0.45; export function scoreVariant(value: number): BadgeVariant { if (value >= SCORE_GOOD_THRESHOLD) return "success"; if (value >= SCORE_WEAK_THRESHOLD) return "warning"; return "danger"; } /** * Bar width (%) for a 0..1 score on a 0..100 scale (direct, no magnitude trick — * the score is already unsigned 0..1). Clamped to [0, 100]. */ export function scoreBarWidthPct(value: number): number { return Math.max(0, Math.min(100, value * 100)); } /** * RU labels for the 10 §14.2 product-score keys (#985). The MD/PDF exporters * render the raw English key — these are the established in-app RU names, kept * here so the block and any future reuse share one source of truth. Unknown * keys fall back to the raw key (graceful). */ export const PRODUCT_SCORE_RU: Record = { market_fit: "Соответствие рынку", demand: "Спрос", // #1963: «Риск …» сбивал (скор инвертирован, выше=лучше) → «Запас по предложению». supply_risk: "Запас по предложению", future_competition: "Будущая конкуренция", price_feasibility: "Доступность цены", infra_fit: "Инфраструктура", mortgage_sensitivity: "Чувствительность к ставке", differentiation: "Дифференциация", commercial: "Коммерция", confidence: "Надёжность данных", }; /** * RU labels for the 6 §25 special-index keys (#986). The per-entry `label` field * is a value-descriptor («6 мес» / «Комфорт»), not a metric name, so it is shown * as supplementary context — these are the metric names. Unknown keys fall back * to the raw key (graceful). */ export const SPECIAL_INDEX_RU: Record = { launch_window: "Окно запуска", product_void: "Белые пятна продукта", cannibalization: "Каннибализация", competitor_strength: "Сила конкурентов", artificial_demand: "Искусственный спрос", cost_of_error: "Цена ошибки", }; /** * #1963 — однострочное «что это + куда лучше» для 6 спец-индексов. Раньше индексы * показывались только сырым именем + числом без объяснения, что значит «выше» — * а направление у них РАЗНОЕ (часть растёт к лучшему, часть к худшему). Здесь * проговариваем смысл и желаемую сторону. Источник правды до появления поля от * бэкенда — этот map (graceful: незнакомый ключ → пусто, строка просто без подписи). */ export const SPECIAL_INDEX_DESC: Record = { product_void: "Доля незакрытых ниш (форматов с дефицитом) — выше лучше: больше места для нового продукта.", cost_of_error: "Цена ошибки выхода = риск затоварки × средний чек лота — ниже лучше: дешевле ошибиться.", launch_window: "Лучший момент для старта по горизонтам прогноза — раньше окно, тем лучше.", cannibalization: "Насколько проект отъедает спрос у соседних ЖК того же класса — ниже лучше.", artificial_demand: "Доля спроса, держащегося на льготной ипотеке (уязвима к смене ставки) — ниже лучше.", competitor_strength: "Сила топ-конкурентов рядом — ниже лучше: слабее конкуренты, проще выйти.", }; /** * #1963 — вынести §-ссылки («§10.4», «§9.6», «§16») из основного reason в отдельную * деталь/тултип. Возвращает {clean, refs}: `clean` — текст без §-маркеров и пустых * скобок, `refs` — извлечённые ссылки (через запятую) для tooltip. PURE. */ export function stripSectionRefs(reason: string): { clean: string; refs: string | null; } { // Match «§9.6» / «§10.4» / «§13.6a» but NOT the sentence-final dot after it. // Trailing letter only when the ref ends at a separator/EOL (не «съедает» букву // следующего слова, если § приклеена без пробела). const SECTION_RE = /§\s*\d+(?:\.\d+)*(?:[a-zа-я](?=$|[\s.,;)]))?/gi; const refs = reason.match(SECTION_RE) ?? []; let clean = reason.replace(SECTION_RE, ""); // Подчистить осиротевшие скобки/запятые/двойные пробелы после выреза ссылки. clean = clean .replace(/\(\s*[,;]?\s*\)/g, "") .replace(/,\s*\)/g, ")") .replace(/\(\s+/g, "(") .replace(/\s+\)/g, ")") .replace(/\s{2,}/g, " ") .replace(/\s+([.,;)])/g, "$1") .trim(); return { clean, refs: refs.length > 0 ? refs.join(", ") : null }; } // ── 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 ───────────────────────────────────────────────────────── /** * 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("-", "−"); }