gendesign/frontend/src/components/analytics/RecommendVelocityPanel.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

572 lines
20 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;
// Liquidity-24 KPI должен совпадать с точкой кривой
// RecommendLiquidityChart на 24 мес (подпись секции обещает равенство).
// График считает sellout по ЕДИНОЙ scope-эластичности (v × pfPow),
// поэтому здесь тоже используем pfPow, а не per-bucket bucketPfPow.
const liquidityV = v * pfPow;
if (u > 0 && liquidityV > 0) {
const months = u / liquidityV;
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)}. Показанный «Темп продаж» суммирует бакеты с их
собственной эластичностью (Tier 3) и текущим распределением долей,
поэтому может отличаться от базовый ×{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>
);
}