Audit #1871 P1.2: estimate 5fcc1e99 показывал confidence='high' при n_analogs=0 (median 38.45M ₽, sources=['yandex_valuation']) при реальной истории листингов дома 4.2-6.5M. По prod-DB это ЕДИНСТВЕННАЯ legacy-строка от 2026-05-30, давно expired (TTL оценок = 1 день, все 66 high/medium-c-n<5 строк expired). Текущий mainline уже честен. Фикс — defensive invariant, чтобы ghost не мог родиться снова из будущих external-valuation-blend / rehydrate / migration путей. Backend (estimator.py): - helper _enforce_zero_analog_low(confidence, n_analogs, explanation, ...) форсит 'low' + caveat когда n_analogs==0 and confidence!='low'. Идемпотентен (low+0 → low без дубля caveat), не трогает high+N>0, реальный median внешнего сервиса сохраняется (fake-коэффициенты не вводятся), logger.warning для observability. - Вызов перед сборкой AggregatedEstimate в estimate_quality (единственный уязвимый return-путь; _empty_estimate хардкодит low — структурно безопасен). - +5 unit-тестов (high+0→low+caveat, medium+0→low, low+0 идемпотент, high+N unchanged, None-explanation). Frontend (HeroSummary, HeroTransparency, history): - effectiveConfidence = n_analogs===0 ? 'low' : confidence — defensive depth на случай legacy-rehydrate / контракт-drift. Caveat-текст из backend confidence_explanation (не хардкод). Type-safe (ConfidenceLevel). Refs #1871
90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
"use client";
|
||
|
||
/* История оценок (#399). */
|
||
import { useEffect, useState } from "react";
|
||
|
||
import "@/components/trade-in/trade-in.css";
|
||
import { API_BASE_URL } from "@/lib/api";
|
||
|
||
interface HistoryRow {
|
||
id: string;
|
||
address: string;
|
||
rooms: number;
|
||
area_m2: number;
|
||
median_price: number;
|
||
confidence: string;
|
||
n_analogs: number;
|
||
created_at: string;
|
||
}
|
||
|
||
export default function HistoryPage() {
|
||
const [rows, setRows] = useState<HistoryRow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
// #770 finding #25: не маскировать ошибку пустым списком — иначе «нет оценок»
|
||
// показывается и при реальном сбое загрузки.
|
||
const [error, setError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
fetch(`${API_BASE_URL}/api/v1/trade-in/history?limit=100`)
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
return r.json();
|
||
})
|
||
.then((d) => setRows(d as HistoryRow[]))
|
||
.catch(() => setError(true))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
return (
|
||
<main className="page" style={{ padding: 24, maxWidth: 1000, margin: "0 auto" }}>
|
||
<p style={{ marginBottom: 12 }}>
|
||
<a href={`${API_BASE_URL}/`}>← К оценке</a>
|
||
</p>
|
||
<h1 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>История оценок</h1>
|
||
|
||
{loading ? (
|
||
<p style={{ color: "var(--muted)" }}>Загрузка…</p>
|
||
) : error ? (
|
||
<p style={{ color: "var(--danger, #b3261e)" }}>
|
||
Не удалось загрузить историю оценок. Обновите страницу или попробуйте позже.
|
||
</p>
|
||
) : rows.length === 0 ? (
|
||
<p style={{ color: "var(--muted)" }}>Оценок пока нет.</p>
|
||
) : (
|
||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||
<thead>
|
||
<tr style={{ textAlign: "left", borderBottom: "2px solid var(--border, #ddd)" }}>
|
||
<th style={{ padding: 8 }}>Дата</th>
|
||
<th style={{ padding: 8 }}>Адрес</th>
|
||
<th style={{ padding: 8 }}>Комн.</th>
|
||
<th style={{ padding: 8 }}>Площадь</th>
|
||
<th style={{ padding: 8, textAlign: "right" }}>Медиана, ₽</th>
|
||
<th style={{ padding: 8 }}>Точность</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} style={{ borderBottom: "1px solid var(--border, #eee)" }}>
|
||
<td style={{ padding: 8, whiteSpace: "nowrap" }}>
|
||
{new Date(r.created_at).toLocaleDateString("ru-RU")}
|
||
</td>
|
||
<td style={{ padding: 8 }}>
|
||
<a href={`${API_BASE_URL}/?id=${r.id}`}>{r.address}</a>
|
||
</td>
|
||
<td style={{ padding: 8 }}>{r.rooms}</td>
|
||
<td style={{ padding: 8 }}>{r.area_m2} м²</td>
|
||
<td style={{ padding: 8, textAlign: "right" }}>
|
||
{Number(r.median_price).toLocaleString("ru-RU")}
|
||
</td>
|
||
<td style={{ padding: 8 }}>
|
||
{/* #1871 P1.2 — ghost-anchor defensive depth: n_analogs=0 → low */}
|
||
{r.n_analogs === 0 ? "low" : r.confidence}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|