gendesign/frontend/src/components/site-finder/VelocityBlock.tsx
lekss361 e1a8ad9395 fix(sf-17): rosreestr velocity fallback via cad_quarter (~100% EKB coverage)
When Objective mapping coverage falls below 50% of competitors in the radius,
fall back to rosreestr_deals JOIN on the parcel's cadastral quarter. Audit shows
237/237 EKB quarters (100%) have rosreestr data for the last 12 months, compared
to <20% Objective coverage before bulk mapping.

- velocity.py: add _compute_rosreestr_fallback(), _OBJECTIVE_COVERAGE_MIN_RATIO
  constant, velocity_source field on VelocityResult (objective/rosreestr_fallback/none)
- parcels.py: extract cad_quarter from cad_num, pass to compute_velocity
- site-finder.ts: add velocity_source field to Velocity interface
- VelocityBlock.tsx: badge "Источник: квартальные сделки" when rosreestr_fallback

Epic #271 item #17
2026-05-17 16:50:19 +03:00

362 lines
11 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 type { Velocity } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
const BUCKET_ORDER = ["студия", "1", "2", "3", "4+"] as const;
type KnownBucket = (typeof BUCKET_ORDER)[number];
const BUCKET_LABEL: Record<KnownBucket, string> = {
студия: "Студия",
"1": "1-к",
"2": "2-к",
"3": "3-к",
"4+": "4+",
};
interface VelocityBlockProps {
velocity: Velocity | null | undefined;
}
const CONFIDENCE_COLOR: Record<
Velocity["confidence"],
{ bg: string; fg: string }
> = {
high: { bg: "#dcfce7", fg: "#166534" },
medium: { bg: "#fef3c7", fg: "#854d0e" },
low: { bg: "#fee2e2", fg: "#991b1b" },
};
const CONFIDENCE_LABEL: Record<Velocity["confidence"], string> = {
high: "Высокая",
medium: "Средняя",
low: "Низкая",
};
function formatPercent(score: number): string {
return `${Math.round(score * 100)}%`;
}
export function VelocityBlock({ velocity }: VelocityBlockProps) {
if (!velocity) {
return (
<div>
<SectionLabel style={{ marginBottom: 8 }}>
Темп продаж конкурентов
</SectionLabel>
<EmptyState message="Данные о продажах не найдены (нет конкурентов в радиусе 3км или нет sale_graph данных)" />
</div>
);
}
const dataAvailable = velocity.velocity_data_available !== false;
const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<SectionLabel>Темп продаж конкурентов</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{!dataAvailable && (
<span className="bg-slate-100 text-slate-500 text-xs px-2 py-0.5 rounded">
нет данных velocity
</span>
)}
{isRosreestrFallback && (
<span className="bg-amber-50 text-amber-800 text-xs px-2 py-0.5 rounded">
Источник: квартальные сделки
</span>
)}
<span
style={{
padding: "2px 8px",
background: confColor.bg,
color: confColor.fg,
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
}}
title={`Уверенность: ${CONFIDENCE_LABEL[velocity.confidence]}`}
>
{CONFIDENCE_LABEL[velocity.confidence]}
</span>
</div>
</div>
{/* Score gauge — показываем только если данные есть */}
{dataAvailable && (
<div style={{ marginBottom: 12 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#6b7280",
marginBottom: 4,
}}
>
<span>Velocity-score</span>
<span style={{ fontWeight: 600, color: "#111827" }}>
{scorePct}
</span>
</div>
<div
style={{
background: "#e5e7eb",
borderRadius: 4,
height: 8,
overflow: "hidden",
}}
>
<div
style={{
background:
velocity.velocity_score >= 0.66
? "#10b981"
: velocity.velocity_score >= 0.33
? "#f59e0b"
: "#ef4444",
width: `${velocity.velocity_score * 100}%`,
height: "100%",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) &middot;{" "}
{ratio >= 1
? `x${ratio.toFixed(1)} выше`
: `${formatPercent(ratio)} от среднего`}
</div>
</div>
)}
{/* Period + competitors meta */}
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
В радиусе 3 км: <b>{velocity.competitors_count}</b> ЖК
{dataAvailable && (
<>
{" "}
&middot; период{" "}
<b>
{velocity.period.start} &rarr; {velocity.period.end}
</b>{" "}
({velocity.months_observed} мес)
</>
)}
</div>
{/* By room bucket aggregate */}
{velocity.by_room_bucket && (
<div style={{ marginBottom: 12 }}>
<SectionLabel>По комнатности</SectionLabel>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{ color: "#6b7280", borderBottom: "1px solid #e5e7eb" }}
>
<th
style={{
textAlign: "left",
padding: "4px 8px",
fontWeight: 500,
}}
>
Комната
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Сделок
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
м²
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
ЖК
</th>
</tr>
</thead>
<tbody>
{BUCKET_ORDER.map((b) => {
const stat = velocity.by_room_bucket?.[b];
if (!stat) return null;
return (
<tr key={b} style={{ borderBottom: "1px solid #f3f4f6" }}>
<td style={{ padding: "4px 8px" }}>{BUCKET_LABEL[b]}</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
fontWeight: 500,
}}
>
{stat.units.toLocaleString("ru")}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{Math.round(stat.sqm).toLocaleString("ru")}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{stat.complexes_count}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Top competitors */}
{velocity.sample_competitors.length > 0 && (
<div>
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{
color: "#6b7280",
borderBottom: "1px solid #e5e7eb",
}}
>
<th
style={{
textAlign: "left",
padding: "4px 8px",
fontWeight: 500,
}}
>
Название
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Расст.
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Продано м²
</th>
</tr>
</thead>
<tbody>
{velocity.sample_competitors.map((c) => (
<tr
key={c.obj_id}
style={{ borderBottom: "1px solid #f3f4f6" }}
>
<td style={{ padding: "4px 8px" }}>
{c.name ?? `obj#${c.obj_id}`}
{c.obj_class && (
<span
style={{
marginLeft: 6,
fontSize: 10,
color: "#9ca3af",
}}
>
{c.obj_class}
</span>
)}
{c.by_room_bucket && (
<div
style={{ fontSize: 10, color: "#9ca3af", marginTop: 2 }}
>
{BUCKET_ORDER.filter(
(b) => (c.by_room_bucket?.[b] ?? 0) > 0,
)
.map(
(b) => `${BUCKET_LABEL[b]} ${c.by_room_bucket![b]}`,
)
.join(" · ")}
</div>
)}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{Math.round(c.distance_m)}м
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
fontWeight: 500,
}}
>
{Math.round(c.total_sqm_period).toLocaleString("ru")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}