gendesign/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
Light1YT adf3ec7c00
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 1m5s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 10m9s
fix(site-finder): velocity fallback 4500→750, coverage/proxy disclosure, market_trend staleness honesty (audit #1871 P2)
Три honesty/correctness-фикса из audit-issue #1871 (🟠 P2). Все факты
подтверждены prod-DB + разведкой. ETL не трогаем (отдельная задача).

velocity.py:
- _EKB_MEDIAN_FALLBACK 4500→750 (audit-verified медиана ЕКБ 593-766;
  4500 завышало знаменатель нормализации 7.6× → занижало velocity_score
  при DB-error fallback, latent). Консервативно 750 (верх диапазона).
- VelocityResult + as_dict() раскрывают: objective_coverage_pct,
  proxy_used, proxy_sqm_per_deal — теперь видно что объём rosreestr-пути
  фабрикуется count×45 м²/сделка при низком покрытии Objective (<50%).
  Проброшено во все 4 конструктора, additive.

parcels.py (market_trend producer):
- Различать «источник устарел» от «нет сделок». rosreestr_deals
  MAX(period_start_date)=2026-01-01 (stale 5.5 мес) → recent-окно пусто →
  раньше голый null. Теперь status ∈ {ok, source_stale, no_deals} +
  as_of_date + label + recent_deals_count. MAX(deal_date) добавлен в CTE.
  Consumer scoring читает recent_deals_count защищённо (без регрессии).

frontend (types + MarketTrendBlock + VelocityBlock + ptica market-drawer
+ Section3 verdict + legacy guard):
- staleness/no_deals → честный caveat вместо фейкового тренда.
- velocity → caption «объём оценочный (прокси N м²/сделка, покрытие P%)».
- formatAsOfMonth парсит YYYY-MM напрямую (TZ-safe).
- Типы MarketTrend/Velocity расширены (optional, backward-compat;
  отсутствие status = "ok").

Тесты: +3 velocity unit + extended as_dict contract. 17 velocity passed,
138 frontend vitest passed.

Refs #1871
2026-06-23 12:04:08 +05:00

836 lines
26 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 { useState, type ReactNode } from "react";
import { X, Building2, MapPin, Users, Ruler } from "lucide-react";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { BestLayoutsBlock } from "@/components/site-finder/BestLayoutsBlock";
import { Pipeline24moBlock } from "@/components/site-finder/Pipeline24moBlock";
import { VelocityBlock } from "@/components/site-finder/VelocityBlock";
import { MarketTrendBlock } from "@/components/site-finder/MarketTrendBlock";
import { CompetitorTable } from "@/components/site-finder/CompetitorTable";
import { Drawer } from "@/components/ui/Drawer";
import { Badge } from "@/components/ui/Badge";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
} from "@/types/site-finder";
// ── Types ─────────────────────────────────────────────────────────────────────
interface Props {
/** Cadastral number — passed for context/future backend pass-through. */
cad: string;
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
data: ParcelAnalysis;
}
interface FilterState {
/** Radius in km, 15. */
radiusKm: number;
/** Show only buildings under construction. */
onlyUnderConstruction: boolean;
/** Completion deadline ≥ 3 months from now. */
minDeadline3mo: boolean;
/** Project started ≤ 6 months ago. */
maxAge6mo: boolean;
/** Apartments only (excludes commercial / parking-only). */
onlyApartments: boolean;
}
// ── Chip component ─────────────────────────────────────────────────────────────
interface ChipProps {
label: string;
selected: boolean;
onToggle: () => void;
}
function FilterChip({ label, selected, onToggle }: ChipProps) {
return (
<button
type="button"
onClick={onToggle}
style={{
padding: "5px 12px",
fontSize: 12,
fontWeight: 500,
borderRadius: 999,
border: selected
? "1px solid var(--accent, #1D4ED8)"
: "1px solid var(--border-card, #E6E8EC)",
background: selected
? "var(--accent-soft, #DBEAFE)"
: "var(--bg-card, #FFFFFF)",
color: selected
? "var(--accent, #1D4ED8)"
: "var(--fg-secondary, #5B6066)",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
aria-pressed={selected}
>
{label}
</button>
);
}
// ── Section 3.1 «Настройки выборки» ───────────────────────────────────────────
function Section31Settings({
filters,
onFiltersChange,
}: {
filters: FilterState;
onFiltersChange: (f: FilterState) => void;
}) {
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
() => ({ ...POI_DEFAULT_WEIGHTS }),
);
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
onFiltersChange({ ...filters, [key]: !filters[key] });
}
function handleWeightsChange(
newWeights: Record<PoiCategoryKey, number>,
_profileId: number | null,
) {
setWeights(newWeights);
}
const chips: Array<{
key: keyof Omit<FilterState, "radiusKm">;
label: string;
}> = [
{ key: "onlyUnderConstruction", label: "Только строящиеся" },
{ key: "minDeadline3mo", label: "+3 мес срок" },
{ key: "maxAge6mo", label: "6 мес от сегодня" },
{ key: "onlyApartments", label: "Только квартиры" },
];
return (
<div id="section-3-1" style={{ scrollMarginTop: 72 }}>
{/* Sub-section title */}
<div style={{ marginBottom: 16 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: 0,
}}
>
4.1 Настройки выборки
</h3>
<p
style={{
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
margin: "4px 0 0",
}}
>
Фильтры применяются к конкурентам локально без повторного запроса к
бэкенду
</p>
</div>
<div
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "16px 18px",
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{/* Radius slider */}
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
}}
>
<label
htmlFor="radius-slider"
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Радиус выборки
</label>
<span
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
fontVariantNumeric: "tabular-nums",
}}
>
{filters.radiusKm} км
</span>
</div>
<input
id="radius-slider"
type="range"
min={1}
max={5}
step={0.5}
value={filters.radiusKm}
onChange={(e) =>
onFiltersChange({
...filters,
radiusKm: parseFloat(e.target.value),
})
}
style={{
width: "100%",
accentColor: "var(--accent, #1D4ED8)",
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
marginTop: 4,
fontVariantNumeric: "tabular-nums",
}}
>
<span>1 км</span>
<span>5 км</span>
</div>
</div>
{/* Filter chips */}
<div>
<div
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
Фильтры объектов
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{chips.map(({ key, label }) => (
<FilterChip
key={key}
label={label}
selected={filters[key]}
onToggle={() => toggleChip(key)}
/>
))}
</div>
</div>
{/* Weight profile — reuse existing panel */}
<div>
<div
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
Профиль весов POI
</div>
<WeightProfilePanel
currentWeights={weights}
onWeightsChange={handleWeightsChange}
/>
</div>
</div>
</div>
);
}
// ── Section 3.2 «Планировки» ───────────────────────────────────────────────────
function Section32Layouts({ cad }: { cad: string }) {
return (
<div id="section-3-2" style={{ scrollMarginTop: 72 }}>
<div style={{ marginBottom: 16 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: 0,
}}
>
4.2 Планировки
</h3>
<p
style={{
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
margin: "4px 0 0",
}}
>
Data-driven ТЗ на планировочный микс на основе сделок конкурентов в
радиусе
</p>
</div>
<BestLayoutsBlock cadNum={cad} />
</div>
);
}
// ── Section 3.3 «Как продаётся рынок рядом» ───────────────────────────────────
/** Small uppercase label-заголовок над каждым подблоком 3.3. */
function SubBlockLabel({ children }: { children: ReactNode }) {
return (
<div
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
{children}
</div>
);
}
/**
* Собирает одно предложение-вердикт для headline-bar из уже доступных данных
* velocity / pipeline / market_trend. Возвращает null если данных совсем нет.
*
* - ratio = темп продаж конкурентов / медиана ЕКБ (м²/мес).
* - months_of_inventory — за сколько месяцев при текущем темпе разойдётся
* будущее предложение конкурентов (24 мес). Темп в лотах/мес выводим из
* velocity.by_room_bucket (units за months_observed); без него клаузу
* пропускаем, чтобы не выдумывать цифру.
* - price change — market_trend.delta_6m_pct в процентных пунктах.
*/
function buildVerdict(data: ParcelAnalysis): string | null {
const v = data.velocity;
const pipeline = data.pipeline_24mo;
const trend = data.market_trend;
const parts: string[] = [];
if (v && v.ekb_median_sqm > 0 && v.velocity_data_available !== false) {
const ratio = v.monthly_velocity_sqm / v.ekb_median_sqm;
const pace = ratio >= 1 ? "быстрее рынка" : "медленнее рынка";
parts.push(
`Конкуренты продают ×${ratio.toFixed(1)} к медиане ЕКБ (${pace})`,
);
// Темп в лотах/мес из наблюдённых сделок по комнатности.
if (
pipeline &&
pipeline.flats_total > 0 &&
v.by_room_bucket &&
v.months_observed > 0
) {
const unitsSold = Object.values(v.by_room_bucket).reduce(
(sum, b) => sum + b.units,
0,
);
const lotsPerMonth = unitsSold / v.months_observed;
if (lotsPerMonth > 0) {
const moi = pipeline.flats_total / lotsPerMonth;
parts.push(
`при текущем темпе остаток разойдётся за ~${Math.round(
moi,
).toLocaleString("ru")} мес`,
);
}
}
}
// P2 (#1871): дельту цен добавляем только когда тренд посчитан (status "ok"
// или старый ответ без status); при source_stale/no_deals — пропускаем.
if (
trend &&
(trend.status == null || trend.status === "ok") &&
trend.delta_6m_pct != null
) {
const d = trend.delta_6m_pct;
const sign = d > 0 ? "+" : d < 0 ? "" : "";
const abs = Math.abs(d).toLocaleString("ru", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
parts.push(`цены за период ${sign}${abs} п.п.`);
}
if (parts.length === 0) return null;
return `${parts.join("; ")}.`;
}
function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
const hasPipeline = data.pipeline_24mo != null;
const hasVelocity = data.velocity != null;
const hasTrend = data.market_trend != null;
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
const verdict = buildVerdict(data);
return (
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
<div style={{ marginBottom: 16 }}>
<h3
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
margin: 0,
}}
>
4.3 Как продаётся рынок рядом
</h3>
<p
style={{
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
margin: "4px 0 0",
}}
>
Остатки конкурентов · темп продаж · динамика цен
</p>
</div>
{isEmpty ? (
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "24px",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
}}
>
Данные по продажам рынка рядом отсутствуют для этого участка
</div>
) : (
<>
{/* Headline-bar — один вердикт-предложение */}
{verdict && (
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 16,
fontSize: 14,
lineHeight: 1.4,
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
{verdict}
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 16,
}}
>
{hasVelocity && (
<div>
<SubBlockLabel>Темп продаж</SubBlockLabel>
<div
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 10,
padding: "14px 18px",
}}
>
<VelocityBlock velocity={data.velocity} />
</div>
</div>
)}
{hasPipeline && (
<div>
<SubBlockLabel>
Будущее предложение конкурентов (24 мес)
</SubBlockLabel>
<Pipeline24moBlock data={data.pipeline_24mo!} />
</div>
)}
{hasTrend && (
<div>
<SubBlockLabel>Динамика цен</SubBlockLabel>
<MarketTrendBlock trend={data.market_trend} />
</div>
)}
</div>
</>
)}
</div>
);
}
// ── Competitor detail drawer ───────────────────────────────────────────────────
const STATUS_BADGE_MAP: Record<
string,
{ variant: "success" | "neutral" | "warning"; label: string }
> = {
Строящиеся: { variant: "warning", label: "Строящийся" },
Сданные: { variant: "success", label: "Сдан" },
};
function CompetitorDetailDrawer({
competitor,
onClose,
}: {
competitor: ParcelAnalysisCompetitor | null;
onClose: () => void;
}) {
const isOpen = competitor !== null;
return (
<Drawer open={isOpen} onClose={onClose} side="right" width="420px">
{competitor && (
<>
{/* Header */}
<div
style={{
padding: "16px 20px 12px",
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<Building2
size={16}
strokeWidth={1.5}
style={{
color: "var(--fg-secondary, #5B6066)",
marginTop: 3,
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 15,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{competitor.comm_name ?? `ЖК #${competitor.obj_id}`}
</div>
{competitor.dev_name && (
<div
style={{
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
marginTop: 2,
}}
>
{competitor.dev_name}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
color: "var(--fg-tertiary, #73767E)",
flexShrink: 0,
}}
>
<X size={18} strokeWidth={1.5} />
</button>
</div>
{/* Status + badges row */}
<div
style={{
padding: "12px 20px",
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
}}
>
{competitor.site_status && (
<Badge
variant={
STATUS_BADGE_MAP[competitor.site_status]?.variant ?? "neutral"
}
>
{STATUS_BADGE_MAP[competitor.site_status]?.label ??
competitor.site_status}
</Badge>
)}
{competitor.obj_class && (
<Badge variant="info">{competitor.obj_class}</Badge>
)}
</div>
{/* KPI grid */}
<div
style={{
padding: "16px 20px",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
}}
>
<KpiCell
icon={<MapPin size={14} strokeWidth={1.5} />}
label="Расстояние"
value={`${Math.round(competitor.distance_m).toLocaleString("ru-RU")} м`}
/>
<KpiCell
icon={<Users size={14} strokeWidth={1.5} />}
label="Квартир"
value={
competitor.flat_count != null
? competitor.flat_count.toLocaleString("ru-RU")
: "—"
}
/>
{competitor.district_name && (
<KpiCell
icon={<MapPin size={14} strokeWidth={1.5} />}
label="Район"
value={competitor.district_name}
/>
)}
{competitor.ready_dt && (
<KpiCell
icon={<Ruler size={14} strokeWidth={1.5} />}
label="Срок сдачи"
value={new Date(competitor.ready_dt).toLocaleDateString(
"ru-RU",
{
year: "numeric",
month: "2-digit",
},
)}
/>
)}
</div>
{/* Caveat */}
<div
style={{
margin: "0 20px 20px",
padding: "10px 14px",
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-soft, #EEF0F3)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-tertiary, #73767E)",
}}
>
Детальные данные (sold%, цены, план. продажи) появятся после
расширения B5
</div>
</>
)}
</Drawer>
);
}
function KpiCell({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 8,
padding: "10px 12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "var(--fg-secondary, #5B6066)",
marginBottom: 4,
}}
>
<span style={{ display: "flex", color: "var(--fg-tertiary, #73767E)" }}>
{icon}
</span>
{label}
</div>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary, #111111)",
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</div>
</div>
);
}
// ── Derived: filter competitors client-side ───────────────────────────────────
function applyFilters(
data: ParcelAnalysis,
filters: FilterState,
): ParcelAnalysis["competitors"] {
// Guard: a 202 on-demand-fetch stub (#1001) or older backend may omit the
// array entirely — fall back to empty so we render an empty state, not throw.
let result = [...(data.competitors ?? [])];
// Radius filter — distance_m is in metres from the parcel centroid
result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000);
// Note: the competitor type doesn't carry status/date fields yet (B6 extension).
// The chips are wired to state but can't filter until backend enriches data.
// They're kept in state so backend pass-through (Wave 4) can use them.
return result;
}
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
const [filters, setFilters] = useState<FilterState>({
radiusKm: 2,
onlyUnderConstruction: false,
minDeadline3mo: false,
maxAge6mo: false,
onlyApartments: false,
});
const [selectedCompetitor, setSelectedCompetitor] =
useState<ParcelAnalysisCompetitor | null>(null);
const filteredCompetitors = applyFilters(data, filters);
// Normalise once — a 202 stub / older backend may omit `competitors` (#1001).
const competitorCount = (data.competitors ?? []).length;
const districtName = data.district?.district_name ?? null;
return (
<section id="section-3" style={{ scrollMarginTop: 72 }}>
{/* Section headline bar */}
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 20,
}}
>
<div
style={{
fontSize: 15,
fontWeight: 600,
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
3. Продажи конкурентов
</div>
<div
style={{
marginTop: 6,
fontSize: 12,
color: "var(--fg-on-dark-muted, #94A3B8)",
}}
>
{competitorCount > 0
? `${filteredCompetitors.length} из ${competitorCount} объектов в радиусе ${filters.radiusKm} км`
: "Конкуренты не найдены в радиусе анализа"}
</div>
</div>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (
<CompetitorTable
competitors={filteredCompetitors}
districtName={districtName}
onRowClick={setSelectedCompetitor}
/>
)}
<Section32Layouts cad={cad} />
<Section33MarketSales data={data} />
</div>
{/* Filtered competitors count hint */}
{competitorCount > 0 &&
filteredCompetitors.length !== competitorCount && (
<div
style={{
marginTop: 16,
padding: "10px 14px",
background: "var(--warn-soft, #FEF3C7)",
borderRadius: 8,
fontSize: 12,
color: "var(--warn, #9A6700)",
}}
>
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
{competitorCount} конкурентов
</div>
)}
{/* Competitor detail drawer */}
<CompetitorDetailDrawer
competitor={selectedCompetitor}
onClose={() => setSelectedCompetitor(null)}
/>
</section>
);
}