feat(forecast): in-app product_tz recommendation (subsection 6.4) (#958) #1117

Merged
bot-backend merged 1 commit from feat/forecast-product-tz-block into main 2026-06-07 09:55:55 +00:00
5 changed files with 642 additions and 11 deletions

View file

@ -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 Рекомендация по продукту" },
],
},
];

View file

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

View file

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

View file

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

View file

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