gendesign/frontend/src/components/analytics/RecommendVelocityPanel.tsx
lekss361 8fa1d005fd
feat(velocity): migrate D2 sales source → objective_corpus_room_month (#158)
* feat(velocity): migrate D2 sales source from stale sale_graph to objective_corpus_room_month

Closes #156

* fix(velocity): cross-stack contract + NULL safety per pre-push review

3 pre-push code-reviewer findings fixed:

1. TS union extended ('objective'|'sale_graph'|'rosreestr_fallback')
   + UI conditions handle both objective и sale_graph как valid sources.
2. COALESCE(deals_total_vol_m2, deals_total_count * 45.0) — NULL safety
   for DKP-only rows (vol_m2 nullable, count > 0).
3. room_bucket parking filter — verified false positive (все 5 buckets
   apartments: студия/1/2/3/4+).

Refs: PR #157 pre-push code-reviewer

* fix(types): add 'objective' to velocity_source union (cherry-pick miss)

Cherry-pick от закрытого PR #157 потерял этот файл. Frontend type-check fails
с 'no overlap' error. Restoring.

Refs: PR #158 frontend CI failure

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 09:34:00 +03:00

564 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo } from "react";
import type { RecommendBucket, RecommendMixOutput } from "@/types/analytics";
interface DerivedBucket extends RecommendBucket {
effective_share_pct: number;
effective_units: number | null;
effective_revenue_rub: number | null;
}
interface Props {
scope: RecommendMixOutput["scope"];
derivedRows: DerivedBucket[];
priceFactor: number;
onPriceFactorChange: (next: number) => void;
targetMonths: number | null;
/** Set when user wants the system to suggest required price_factor. */
onTargetMonthsApply?: () => void;
hasAllocation: boolean;
}
const fmtMln = (rub: number | null) =>
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)}`;
/**
* Live "цена↔темп↔срок↔ликвидность" calculator.
*
* Все расчёты — клиентские по базовым коэффициентам из API:
* velocity_at_pf = velocity_base × price_factor^elasticity
* months_to_sellout = units / velocity_at_pf
* liquidity = avg(min(1, 24/months) × units) / total_units × 100
*
* Никаких round-trip к backend на каждое движение слайдера.
*/
export function RecommendVelocityPanel({
scope,
derivedRows,
priceFactor,
onPriceFactorChange,
targetMonths,
hasAllocation,
}: Props) {
const elasticity = scope.elasticity;
const pfPow = useMemo(
() => (priceFactor > 0 ? priceFactor ** elasticity : 1),
[priceFactor, elasticity],
);
// Aggregate live recompute. Темп и сроки считаем per-bucket с СВОЕЙ
// эластичностью (Tier 3) — потому что Студии и 80+ м² реагируют на цену
// по-разному. pfPow выше — fallback для bucket'ов без своей эластичности.
const totals = useMemo(() => {
let units = 0;
let revenue = 0;
let baseVelocity = 0; // sum of bucket velocity at price_factor=1
let adjustedVelocity = 0; // sum of bucket velocity × bucket-specific pf^e
let weightedSold24 = 0;
for (const r of derivedRows) {
const u = r.effective_units ?? 0;
units += u;
const baseRev = r.effective_revenue_rub ?? 0;
revenue += baseRev * priceFactor;
const v = r.velocity_per_month ?? 0;
const shareRatio = r.effective_share_pct / Math.max(r.share_pct, 0.01);
baseVelocity += v * shareRatio;
const be = r.elasticity ?? elasticity;
const bucketPfPow = priceFactor > 0 ? priceFactor ** be : 1;
const adjustedV = v * bucketPfPow;
adjustedVelocity += adjustedV * shareRatio;
if (u > 0 && adjustedV > 0) {
const months = u / adjustedV;
const fracIn24 = Math.min(1, 24 / months);
weightedSold24 += fracIn24 * u;
}
}
const tempo =
adjustedVelocity > 0 ? adjustedVelocity : baseVelocity * pfPow;
const monthsToSellout = tempo > 0 && units > 0 ? units / tempo : null;
const liquidity = units > 0 ? (weightedSold24 / units) * 100 : null;
const avgTicket = units > 0 && revenue > 0 ? revenue / units : null;
return {
units,
revenue,
tempo,
monthsToSellout,
liquidity,
avgTicket,
};
}, [derivedRows, priceFactor, pfPow, elasticity]);
const liquidityColor =
totals.liquidity == null
? "#9ca3af"
: totals.liquidity >= 70
? "#0a7a3a"
: totals.liquidity >= 40
? "#9a6700"
: "#b3261e";
const monthsLabel =
totals.monthsToSellout == null
? "—"
: totals.monthsToSellout > 60
? "60+"
: totals.monthsToSellout.toFixed(1);
// Preview required price_factor if target_months active
const required = scope.required_price_factor;
const requiredPct =
required != null ? Math.round((required - 1) * 100) : null;
return (
<div>
{/* Big KPI strip */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
marginBottom: 16,
}}
>
<BigKpi
label="Средний чек"
value={fmtMln(totals.avgTicket)}
unit="М ₽"
/>
<BigKpi
label="Срок реализации"
value={monthsLabel}
unit="мес"
hint={hasAllocation ? undefined : "укажи площадь"}
/>
<BigKpi
label="Темп продаж"
value={totals.tempo > 0 ? totals.tempo.toFixed(1) : "—"}
unit="кв/мес"
/>
<BigKpi
label="Ликвидность 24 мес"
value={
totals.liquidity != null ? `${totals.liquidity.toFixed(0)}` : "—"
}
unit="/ 100"
color={liquidityColor}
/>
</div>
{/* #22 Noise penalty badge */}
{scope.noise_penalty != null && scope.noise_penalty < 0.98 ? (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
marginBottom: 12,
}}
>
<span
style={{
background: "#fef2f2",
color: "#b91c1c",
padding: "2px 8px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
cursor: "default",
}}
title={(() => {
const nb = scope.noise_breakdown;
if (!nb || !("magistral_n" in nb)) return "нет данных";
return (
`Магистрали: ${nb.magistral_n} · ` +
`ЖД: ${nb.railway_n} · ` +
`Промзоны: ${nb.industrial_n} · ` +
`Итого источников: ${nb.total_sources}`
);
})()}
>
{"🔊"} &minus;
{scope.noise_breakdown && "penalty_pct" in scope.noise_breakdown
? scope.noise_breakdown.penalty_pct
: Math.round((1 - scope.noise_penalty) * 100)}
% (магистрали/жд/промзоны в районе)
</span>
</div>
) : null}
{/* Warning badge — fallback на rosreestr (нет sale_graph для контекста) */}
{scope.velocity_source === "rosreestr_fallback" ? (
<div
style={{
padding: 10,
marginTop: 4,
marginBottom: 12,
background: "#fef3c7",
border: "1px solid #fcd34d",
borderRadius: 8,
fontSize: 13,
color: "#92400e",
lineHeight: 1.5,
}}
>
<strong>Темп грубый</strong> нет sale_graph для ({scope.district}
{scope.target_class ? ` · ${scope.target_class}` : ""}). Показано:
city-wide rosreestr ({scope.total_deals} сделок /{" "}
{scope.effective_window_months} мес) ÷ {scope.competitors_count}{" "}
активных строящихся ЖК. Запусти 442-sweep по {scope.district} для
точных данных.
</div>
) : null}
{/* Price factor slider — 0.01..3.0 (от -99% до +200%).
min=0.01 (а не 0) чтобы избежать pf^elasticity = ∞ при свободной
цене. */}
<div
style={{
display: "grid",
gridTemplateColumns: "180px 1fr 110px",
gap: 12,
alignItems: "center",
marginBottom: 8,
}}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>Цена ± к рынку</span>
<input
type="range"
min={0.01}
max={3.0}
step={0.01}
value={priceFactor}
onChange={(e) => onPriceFactorChange(Number(e.target.value))}
/>
<div style={{ textAlign: "right", fontSize: 14 }}>
<span style={{ fontWeight: 700 }}>
{priceFactor === 1
? "0%"
: `${(priceFactor - 1) * 100 > 0 ? "+" : ""}${((priceFactor - 1) * 100).toFixed(0)}%`}
</span>
<div style={{ fontSize: 11, color: "#73767e" }}>
×{priceFactor.toFixed(2)}
</div>
</div>
</div>
{/* Inverse mode hint */}
{targetMonths && required != null ? (
<div
style={{
padding: 10,
background:
requiredPct != null && requiredPct < -15 ? "#fef2f2" : "#f0fdf4",
border: `1px solid ${requiredPct != null && requiredPct < -15 ? "#fecaca" : "#bbf7d0"}`,
borderRadius: 8,
fontSize: 13,
marginTop: 8,
marginBottom: 8,
}}
>
<strong>Целевой срок {targetMonths} мес</strong> требует price-factor{" "}
<code
style={{
background: "rgba(0,0,0,0.05)",
padding: "1px 5px",
borderRadius: 3,
}}
>
×{required.toFixed(2)}
</code>{" "}
({requiredPct !== null && requiredPct >= 0 ? "+" : ""}
{requiredPct}% к рынку).{" "}
<button
onClick={() =>
onPriceFactorChange(Math.max(0.01, Math.min(3.0, required)))
}
style={{
padding: "2px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#fff",
cursor: "pointer",
marginLeft: 8,
}}
>
Применить
</button>
</div>
) : null}
{/* Cadastre vs market cross-check (NSPD ↔ rosreestr) */}
{scope.cadastre_median_per_m2 != null &&
scope.cadastre_vs_market_pct != null
? (() => {
const pct = scope.cadastre_vs_market_pct;
const isAnomaly = pct > 50 || pct < -30;
const bg = isAnomaly ? "#fef2f2" : "#f0fdf4";
const border = isAnomaly ? "#fecaca" : "#bbf7d0";
const fg = isAnomaly ? "#b3261e" : "#0a7a3a";
return (
<div
style={{
marginTop: 12,
padding: 8,
background: bg,
border: `1px solid ${border}`,
borderRadius: 6,
fontSize: 12,
lineHeight: 1.4,
}}
title={`Спред NSPD-кадастра и rosreestr-сделок. Норма: 0..+50% (рынок справедливо дороже кадастра, тк кадастр обычно отстаёт). Аномалии: >+50% (переоценка рынка) или <-30% (рынок дешевле кадастра, странно).`}
>
<strong>Кадастр vs Рынок: </strong>
кадастровая медиана{" "}
<strong>
{(scope.cadastre_median_per_m2 / 1000).toFixed(0)} тыс /м²
</strong>{" "}
(по {scope.cadastre_buildings_n} зданиям NSPD), рынок{" "}
<strong>
{scope.district_median_price_per_m2 != null
? `${(scope.district_median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
: "—"}
</strong>
{" → "}
<strong style={{ color: fg }}>
{pct > 0 ? "+" : ""}
{pct.toFixed(0)}%
</strong>
{isAnomaly ? (
<span
style={{
marginLeft: 6,
padding: "1px 6px",
background: "#fff",
border: `1px solid ${border}`,
borderRadius: 3,
color: fg,
fontWeight: 600,
}}
>
Аномалия
</span>
) : null}
</div>
);
})()
: null}
{/* Per-bucket elasticity breakdown — Tier 3 */}
{scope.elasticity_per_bucket &&
Object.keys(scope.elasticity_per_bucket).length > 0 ? (
<div
style={{
marginTop: 12,
padding: 8,
background: "#f8fafc",
border: "1px solid #e6e8ec",
borderRadius: 6,
fontSize: 11,
}}
>
<div
style={{
fontWeight: 600,
color: "#374151",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
Эластичность по сегментам
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))",
gap: 6,
}}
>
{Object.entries(scope.elasticity_per_bucket).map(([b, info]) => {
const isRegr = info.source === "regression";
return (
<div
key={b}
style={{
background: "#fff",
padding: "4px 6px",
borderRadius: 4,
border: `1px solid ${isRegr ? "#bbf7d0" : "#e6e8ec"}`,
}}
title={
isRegr
? `regression: R²=${info.r2.toFixed(2)}, n=${info.n}`
: `fallback (общая эластичность ${elasticity}); n=${info.n} мало для регрессии`
}
>
<span style={{ color: "#5b6066" }}>{b}: </span>
<strong style={{ color: isRegr ? "#0a7a3a" : "#9a6700" }}>
{info.elasticity.toFixed(2)}
</strong>
{isRegr ? (
<span style={{ color: "#9ca3af", marginLeft: 4 }}>
(n={info.n})
</span>
) : (
<span style={{ color: "#9ca3af", marginLeft: 4 }}>
(fb)
</span>
)}
</div>
);
})}
</div>
</div>
) : null}
{/* #25 Success ranking banner */}
{scope.success_ranking != null &&
scope.success_ranking.length > 0 &&
scope.success_ranking[0].success_score > 0 ? (
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "#fefce8",
border: "1px solid #fde68a",
borderRadius: 8,
fontSize: 13,
color: "#78350f",
lineHeight: 1.5,
}}
>
💎 Рекомендация смещена в пользу{" "}
<strong>{scope.success_ranking[0].bucket}</strong> лучшая динамика в
районе ({scope.success_ranking[0].n_deals} сделок, успех{" "}
{scope.success_ranking[0].success_score.toFixed(2)})
</div>
) : null}
{/* Methodology note */}
<div
style={{
fontSize: 11,
color: "#73767e",
marginTop: 12,
paddingTop: 8,
borderTop: "1px dashed #e6e8ec",
}}
>
Эластичность ценатемп <strong>{elasticity}</strong> (
{scope.elasticity_source === "regression"
? `регрессия sale_graph за ${scope.elasticity_window_months} мес: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}`
: `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`}
)
{scope.elasticity_weighted != null &&
Math.abs(scope.elasticity_weighted - elasticity) > 0.05
? ` · взвешенная по бакетам: ${scope.elasticity_weighted.toFixed(2)}`
: ""}
. Базовый темп{" "}
<strong>{scope.market_velocity_per_month?.toFixed(1) ?? "—"}</strong>{" "}
кв/мес (
{scope.velocity_source === "objective" ||
scope.velocity_source === "sale_graph"
? `${scope.velocity_source}: ${scope.velocity_objects} ЖК / ${scope.velocity_observations} точек`
: "fallback на rosreestr-сделки"}
),{" "}
{scope.competitors_radius_n != null &&
scope.competitors_district_only_n != null ? (
<span
title="Дальние конкуренты в районе тоже создают спрос, но меньше близких. Учитываются с весом 0.6 при нормировке velocity."
style={{ cursor: "help" }}
>
нормирован: в радиусе 3км{" "}
<strong>{scope.competitors_radius_n} ЖК</strong> · дальше по району{" "}
<strong>{scope.competitors_district_only_n} ЖК</strong> (вес 0.6)
</span>
) : (
<>
нормирован на <strong>{scope.competitors_count}</strong> активных
конкурентов в районе
</>
)}
. Применены macro-факторы:{" "}
<strong>sat ×{scope.sat_factor.toFixed(2)}</strong>
{scope.saturation_median != null
? ` (sold% ${scope.saturation_median.toFixed(0)})`
: ""}{" "}
· <strong>trend ×{scope.trend_factor.toFixed(2)}</strong>
{scope.velocity_trend_ratio != null
? ` (raw ×${scope.velocity_trend_ratio.toFixed(2)} clamp 0.7..2.0)`
: ""}{" "}
· <strong>POI ×{scope.poi_factor.toFixed(2)}</strong> (на цену) ). При
price ×{priceFactor.toFixed(2)} темп = базовый ×{" "}
{priceFactor.toFixed(2)}^{elasticity} = ×{pfPow.toFixed(3)}.
</div>
</div>
);
}
function BigKpi({
label,
value,
unit,
hint,
color,
}: {
label: string;
value: string;
unit?: string;
hint?: string;
color?: string;
}) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 10,
padding: "12px 14px",
}}
>
<div
style={{
fontSize: 11,
color: "#5b6066",
textTransform: "uppercase",
letterSpacing: 0.4,
marginBottom: 4,
}}
>
{label}
</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: color ?? "#0f172a",
lineHeight: 1.1,
}}
>
{value}
{unit ? (
<span
style={{
fontSize: 13,
fontWeight: 500,
color: "#5b6066",
marginLeft: 4,
}}
>
{unit}
</span>
) : null}
</div>
{hint ? (
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
{hint}
</div>
) : null}
</div>
);
}