feat(forecast): in-app product_tz recommendation (subsection 6.4) (#958) #1117
5 changed files with 642 additions and 11 deletions
|
|
@ -261,6 +261,7 @@ const NAV_ITEMS = [
|
|||
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,521 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 6.4 Рекомендация по продукту — §13.4 product_tz (#953/#983).
|
||||
*
|
||||
* The literal answer to «что строить на участке»: рекомендованный класс (pill) +
|
||||
* квартирография (горизонтальные bar'ы по дефициту форматов) + коммерческий
|
||||
* сигнал + USP-ниши + §16-обоснование (раскрываемое).
|
||||
*
|
||||
* Quartirografiya is rendered as pure-Tailwind/token width-% bars (mobile-first,
|
||||
* NOT a pie): bar length = |deficit_index| (or `pct` when an explicit mix carries
|
||||
* shares), colour = deficit semantics (>0 недонасыщенность success, <0 затоварка
|
||||
* danger, ≈0 баланс neutral) — same mapping as 6.1.
|
||||
*
|
||||
* GRACEFUL: returns null when product_tz is absent/empty; each sub-field is
|
||||
* guarded individually so a thin/partial section never crashes.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProductCommercial,
|
||||
ProductMixEntry,
|
||||
ProductReason,
|
||||
ProductUsp,
|
||||
ReportProductTz,
|
||||
} from "@/types/forecast";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||
import {
|
||||
DEFICIT_BALANCE_EPS,
|
||||
deficitBarWidthPct,
|
||||
deficitVariant,
|
||||
deficitWord,
|
||||
fmtNum,
|
||||
} from "./forecast-helpers";
|
||||
|
||||
interface Props {
|
||||
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
|
||||
productTz: ReportProductTz;
|
||||
}
|
||||
|
||||
// Deficit BadgeVariant → bar-fill token. Semantic colours encode deficit sign
|
||||
// (success/danger/neutral) — this is the documented viz exception (bar = signal).
|
||||
const BAR_FILL: Record<BadgeVariant, string> = {
|
||||
success: "var(--success)",
|
||||
danger: "var(--danger)",
|
||||
warning: "var(--warn)",
|
||||
info: "var(--accent)",
|
||||
neutral: "var(--border-strong)",
|
||||
};
|
||||
|
||||
const SUBHEAD_STYLE: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--fg-secondary)",
|
||||
};
|
||||
|
||||
function isNonEmpty(productTz: ReportProductTz): boolean {
|
||||
return (
|
||||
productTz.obj_class != null ||
|
||||
productTz.mix.length > 0 ||
|
||||
productTz.usp.length > 0 ||
|
||||
productTz.reasons.length > 0 ||
|
||||
(productTz.commercial != null &&
|
||||
(productTz.commercial.available != null ||
|
||||
!!productTz.commercial.caveat)) ||
|
||||
!!productTz.summary
|
||||
);
|
||||
}
|
||||
|
||||
export function ForecastProductTzBlock({ productTz }: Props) {
|
||||
// Graceful: nothing meaningful to show → render nothing.
|
||||
if (!isNonEmpty(productTz)) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* Recommended class — prominent pill + summary line */}
|
||||
<ClassHeadline
|
||||
obj_class={productTz.obj_class}
|
||||
summary={productTz.summary}
|
||||
/>
|
||||
|
||||
{/* Квартирография — deficit/share bars */}
|
||||
<MixBars mix={productTz.mix} />
|
||||
|
||||
{/* Коммерция */}
|
||||
<CommercialSignal commercial={productTz.commercial} />
|
||||
|
||||
{/* USP-ниши */}
|
||||
<UspList usp={productTz.usp} />
|
||||
|
||||
{/* §16 обоснование (раскрываемое) */}
|
||||
<ReasonsDisclosure reasons={productTz.reasons} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recommended class ─────────────────────────────────────────────────────────
|
||||
|
||||
function ClassHeadline({
|
||||
obj_class,
|
||||
summary,
|
||||
}: {
|
||||
obj_class: string | null;
|
||||
summary: string | null;
|
||||
}) {
|
||||
if (obj_class == null && !summary) return null;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{obj_class != null && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
|
||||
<Badge variant="info" size="md">
|
||||
{obj_class}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Квартирография (mix bars) ─────────────────────────────────────────────────
|
||||
|
||||
function mixLabel(m: ProductMixEntry): string {
|
||||
const bucket = m.bucket ?? "формат";
|
||||
return m.obj_class ? `${bucket} · ${m.obj_class}` : bucket;
|
||||
}
|
||||
|
||||
function MixBars({ mix }: { mix: ProductMixEntry[] }) {
|
||||
// Keep only entries with something to plot (a share or a deficit signal).
|
||||
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
// Prefer explicit shares (доли) when the mix carries them; else fall back to
|
||||
// the deficit signal as the bar driver (current overlay shape).
|
||||
const hasShares = rows.some((m) => m.pct != null);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={SUBHEAD_STYLE}>
|
||||
Квартирография{hasShares ? "" : " · по дефициту форматов"}
|
||||
</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{rows.map((m, i) => (
|
||||
<MixBar
|
||||
key={`${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`}
|
||||
entry={m}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!hasShares && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-tertiary)",
|
||||
}}
|
||||
>
|
||||
Длина полосы — сила сигнала по индексу дефицита: положительный
|
||||
(зелёный) — недонасыщенность, отрицательный (красный) — затоварка.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MixBar({ entry }: { entry: ProductMixEntry }) {
|
||||
const di = entry.deficit_index;
|
||||
// Width: explicit share if present, else deficit magnitude.
|
||||
const widthPct =
|
||||
entry.pct != null
|
||||
? Math.max(0, Math.min(100, entry.pct))
|
||||
: di != null
|
||||
? deficitBarWidthPct(di)
|
||||
: 0;
|
||||
const variant: BadgeVariant = di != null ? deficitVariant(di) : "neutral";
|
||||
const balanced = di != null && Math.abs(di) < DEFICIT_BALANCE_EPS;
|
||||
|
||||
// Right-hand value: share % takes priority, else the signed deficit index.
|
||||
const valueStr =
|
||||
entry.pct != null
|
||||
? `${fmtNum(entry.pct, entry.pct % 1 === 0 ? 0 : 1)}%`
|
||||
: di != null
|
||||
? `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`
|
||||
: "—";
|
||||
|
||||
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)",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{mixLabel(entry)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--fg-secondary)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{valueStr}
|
||||
{di != null && entry.pct == null && (
|
||||
<span style={{ marginLeft: 6, color: "var(--fg-tertiary)" }}>
|
||||
{balanced ? "баланс" : deficitWord(di)}
|
||||
</span>
|
||||
)}
|
||||
</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={`${mixLabel(entry)}: ${valueStr}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
insetBlock: 0,
|
||||
insetInlineStart: 0,
|
||||
width: `${widthPct}%`,
|
||||
background: BAR_FILL[variant],
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Коммерция ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CommercialSignal({
|
||||
commercial,
|
||||
}: {
|
||||
commercial: ProductCommercial | null;
|
||||
}) {
|
||||
if (
|
||||
commercial == null ||
|
||||
(commercial.available == null && !commercial.caveat)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const available = commercial.available === true;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={SUBHEAD_STYLE}>Коммерция</span>
|
||||
{commercial.available != null && (
|
||||
<Badge variant={available ? "success" : "neutral"} size="sm">
|
||||
{available ? "есть сигнал" : "нет данных"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{commercial.caveat && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
{capitalize(commercial.caveat)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── USP-ниши ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function UspList({ usp }: { usp: ProductUsp[] }) {
|
||||
const items = usp.filter((u) => !!u.usp_text || !!u.segment);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={SUBHEAD_STYLE}>УТП · ниши по дефициту ({items.length})</span>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{items.map((u, i) => (
|
||||
<li
|
||||
key={`${u.segment ?? "?"}-${u.obj_class ?? "?"}-${i}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-soft)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
marginTop: 7,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "var(--accent)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
{u.usp_text ?? uspFallbackText(u)}
|
||||
</div>
|
||||
{u.deficit_index != null && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--fg-tertiary)",
|
||||
marginTop: 2,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
Индекс дефицита {u.deficit_index > 0 ? "+" : ""}
|
||||
{fmtNum(u.deficit_index, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uspFallbackText(u: ProductUsp): string {
|
||||
const seg = u.segment ?? "формат";
|
||||
return u.obj_class ? `Ниша: ${seg} (${u.obj_class}).` : `Ниша: ${seg}.`;
|
||||
}
|
||||
|
||||
// ── §16 обоснование (раскрываемое) ────────────────────────────────────────────
|
||||
|
||||
function ReasonsDisclosure({ reasons }: { reasons: ProductReason[] }) {
|
||||
const items = reasons.filter(
|
||||
(r) =>
|
||||
!!r.why ||
|
||||
(r.rejected?.length ?? 0) > 0 ||
|
||||
(r.what_would_change?.length ?? 0) > 0,
|
||||
);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--fg-secondary)",
|
||||
listStyle: "none",
|
||||
}}
|
||||
>
|
||||
Обоснование рекомендации
|
||||
</summary>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{items.map((r, i) => (
|
||||
<ReasonCard key={i} reason={r} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonCard({ reason }: { reason: ProductReason }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{reason.why && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
{reason.why}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reason.rejected && reason.rejected.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{reason.rejected.map((rej, i) => (
|
||||
<span
|
||||
key={`${rej.alternative}-${i}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: "var(--fg-secondary)",
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-soft)",
|
||||
borderRadius: 6,
|
||||
padding: "3px 8px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--fg-primary)" }}>
|
||||
{rej.alternative}
|
||||
</span>
|
||||
{rej.deficit_index != null && (
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{rej.deficit_index > 0 ? "+" : ""}
|
||||
{fmtNum(rej.deficit_index, 2)}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||
· {rej.reason}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reason.what_would_change && reason.what_would_change.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={SUBHEAD_STYLE}>Что изменит вывод</span>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingInlineStart: 18,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{reason.what_would_change.map((w, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-secondary)",
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function capitalize(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
|
@ -11,9 +11,10 @@
|
|||
*
|
||||
* Layout mirrors Section1-5: dark HeadlineBar + section root id="section-6".
|
||||
* exec_summary banner (headline + verdict + KPI row)
|
||||
* 6.1 Прогноз по горизонтам → ForecastHorizonsBlock
|
||||
* 6.2 Сценарии → ScenariosBlock
|
||||
* 6.3 Уверенность → ForecastConfidenceBlock
|
||||
* 6.1 Прогноз по горизонтам → ForecastHorizonsBlock
|
||||
* 6.2 Сценарии → ScenariosBlock
|
||||
* 6.3 Уверенность → ForecastConfidenceBlock
|
||||
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
|
||||
* advisory disclaimer (footer)
|
||||
*/
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ import { ForecastChart } from "./ForecastChart";
|
|||
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||||
import { ScenariosBlock } from "./ScenariosBlock";
|
||||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
||||
|
||||
|
|
@ -187,17 +189,27 @@ function ForecastReady({
|
|||
report.confidence.level != null ||
|
||||
Object.keys(report.confidence.factors).length > 0;
|
||||
|
||||
const hasAny = hasHorizons || hasScenarios || hasConfidence;
|
||||
// 6.4 — рекомендация продукта (§13.4). product_tz is optional on the partial
|
||||
// ForecastReport type; treat absent/thin section as "nothing to show".
|
||||
const pt = report.product_tz;
|
||||
const hasProductTz =
|
||||
pt != null &&
|
||||
(pt.obj_class != null ||
|
||||
pt.mix.length > 0 ||
|
||||
pt.usp.length > 0 ||
|
||||
pt.reasons.length > 0 ||
|
||||
!!pt.summary ||
|
||||
(pt.commercial != null &&
|
||||
(pt.commercial.available != null || !!pt.commercial.caveat)));
|
||||
|
||||
const hasAny = hasHorizons || hasScenarios || hasConfidence || hasProductTz;
|
||||
|
||||
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
||||
const subtitle = fm.summary ?? undefined;
|
||||
|
||||
return (
|
||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||
<HeadlineBar
|
||||
title={exec.headline ?? "6. Прогноз"}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -255,9 +267,7 @@ function ForecastReady({
|
|||
/>
|
||||
<KpiCard
|
||||
label="Итоговый скор"
|
||||
value={
|
||||
kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"
|
||||
}
|
||||
value={kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"}
|
||||
color="blue"
|
||||
/>
|
||||
<KpiCard
|
||||
|
|
@ -323,6 +333,13 @@ function ForecastReady({
|
|||
</SubBlock>
|
||||
)}
|
||||
|
||||
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
|
||||
{hasProductTz && pt && (
|
||||
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
|
||||
<ForecastProductTzBlock productTz={pt} />
|
||||
</SubBlock>
|
||||
)}
|
||||
|
||||
{/* Advisory disclaimer */}
|
||||
<p
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ export function deficitWord(deficitIndex: number): string {
|
|||
return "баланс";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ConfidenceLevel, string> = {
|
||||
|
|
|
|||
|
|
@ -146,6 +146,83 @@ export interface ReportConfidence {
|
|||
factors: Record<string, ConfidenceFactor>;
|
||||
}
|
||||
|
||||
// ── Product TZ (§13.4 — рекомендация продукта, #953/#983) ────────────────────
|
||||
|
||||
/**
|
||||
* §16 structured reason — shared by `reasons[]` and the `reason` nested on each
|
||||
* USP / mix entry. All fields optional: the assembler lifts class_reco.reason or
|
||||
* per-segment reasons, and a thin overlay may carry only `why`.
|
||||
*/
|
||||
export interface ProductReasonDriver {
|
||||
factor: string;
|
||||
value: number | null;
|
||||
/** "+" сигнал вверх / "−" давление вниз (типографский минус). */
|
||||
direction: string;
|
||||
}
|
||||
|
||||
export interface ProductReasonRejected {
|
||||
alternative: string;
|
||||
deficit_index: number | null;
|
||||
/** «затоварка» | «слабее сигнал». */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ProductReason {
|
||||
why?: string | null;
|
||||
drivers?: ProductReasonDriver[];
|
||||
rejected?: ProductReasonRejected[];
|
||||
what_would_change?: string[];
|
||||
confidence?: ConfidenceLevel | null;
|
||||
advisory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Квартирография entry. The assembler emits {bucket, obj_class, deficit_index}
|
||||
* from ranked_segments; `pct` (доля формата) is consumed by the export renderers
|
||||
* but absent from current runs — kept optional so a future explicit `mix` with
|
||||
* shares renders too. `deficit_index` semantics mirror the rest of §22:
|
||||
* >0 недонасыщенность, <0 затоварка.
|
||||
*/
|
||||
export interface ProductMixEntry {
|
||||
bucket: string | null;
|
||||
obj_class: string | null;
|
||||
deficit_index: number | null;
|
||||
/** Доля формата, % (0..100) — присутствует только в явном mix. */
|
||||
pct?: number | null;
|
||||
confidence?: ConfidenceLevel | null;
|
||||
}
|
||||
|
||||
/** §10.4 коммерческий сигнал — обычно «нет данных» (available=false + caveat). */
|
||||
export interface ProductCommercial {
|
||||
available?: boolean;
|
||||
caveat?: string | null;
|
||||
advisory?: boolean;
|
||||
}
|
||||
|
||||
/** §10.5 USP-из-дефицитов — top-K недообеспеченных форматов. */
|
||||
export interface ProductUsp {
|
||||
segment: string | null;
|
||||
obj_class: string | null;
|
||||
deficit_index: number | null;
|
||||
usp_text: string | null;
|
||||
reason?: ProductReason;
|
||||
}
|
||||
|
||||
export interface ReportProductTz {
|
||||
/** Рекомендованный класс (§10.2). */
|
||||
obj_class: string | null;
|
||||
/** Квартирография — доли/дефицит форматов. */
|
||||
mix: ProductMixEntry[];
|
||||
/** Коммерческий сигнал (§10.4). */
|
||||
commercial: ProductCommercial | null;
|
||||
/** USP-ниши из дефицитов (§10.5). */
|
||||
usp: ProductUsp[];
|
||||
/** §16-причины (отвергнутые альтернативы / what-would-change). */
|
||||
reasons: ProductReason[];
|
||||
/** Короткий RU-текст про рекомендованный продукт. */
|
||||
summary: string | null;
|
||||
}
|
||||
|
||||
// ── Meta ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportMeta {
|
||||
|
|
@ -168,6 +245,11 @@ export interface ForecastReport {
|
|||
future_market: ReportFutureMarket;
|
||||
scenarios: ReportScenarios;
|
||||
confidence: ReportConfidence;
|
||||
/**
|
||||
* §13.4 рекомендация продукта (#953). Optional: the report may be 202-pending
|
||||
* or a thin assembly may omit the section entirely — guarded at render time.
|
||||
*/
|
||||
product_tz?: ReportProductTz;
|
||||
schema_version?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue