fix(tradein): resilience-хвост аудита #770 (findings #25-33) (#793)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 35s

Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
This commit is contained in:
bot-frontend 2026-05-30 17:13:31 +00:00 committed by bot-reviewer
parent a4057a5b28
commit 679e75b9e8
10 changed files with 140 additions and 21 deletions

4
.gitignore vendored
View file

@ -29,9 +29,11 @@ frontend/.sentryclirc
.mcp.json
# креды-заметки — НЕ коммитить (см. issue #391)
sshkey.txt
# Cian session cookies (browser-export) для local-sweep — НЕ коммитить
# Session cookies (browser-export) для local-sweep — НЕ коммитить (#770)
tradein-mvp/scripts/.cian-cookies.json
tradein-mvp/scripts/*.cookies.json
tradein-mvp/scripts/*cookies*.json
tradein-mvp/scripts/.*cookies*.json
# IDE
.vscode/

View file

@ -0,0 +1,57 @@
"use client";
/**
* Глобальный error-boundary (#770 finding #25). Ловит непойманные ошибки рендера
* корневого layout/страниц без него Next.js показывает голый дефолтный экран.
* Должен сам рендерить <html>/<body> (заменяет root layout при краше).
*/
export default function GlobalError({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="ru">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily:
'Inter, -apple-system, "Segoe UI", system-ui, sans-serif',
background: "#f6f7f9",
color: "#111111",
}}
>
<div style={{ textAlign: "center", maxWidth: 420, padding: 24 }}>
<h1 style={{ fontSize: 22, fontWeight: 600, marginBottom: 8 }}>
Что-то пошло не так
</h1>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#5b6066", marginBottom: 20 }}>
Произошла непредвиденная ошибка. Попробуйте обновить страницу если
повторится, вернитесь чуть позже.
</p>
<button
type="button"
onClick={() => reset()}
style={{
border: "none",
borderRadius: 8,
padding: "10px 18px",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
background: "#1d4ed8",
color: "#ffffff",
}}
>
Обновить
</button>
</div>
</body>
</html>
);
}

View file

@ -20,12 +20,18 @@ interface HistoryRow {
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) => (r.ok ? r.json() : []))
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((d) => setRows(d as HistoryRow[]))
.catch(() => setRows([]))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
@ -38,6 +44,10 @@ export default function HistoryPage() {
{loading ? (
<p style={{ color: "var(--muted)" }}>Загрузка</p>
) : error ? (
<p style={{ color: "var(--danger, #b3261e)" }}>
Не удалось загрузить историю оценок. Обновите страницу или попробуйте позже.
</p>
) : rows.length === 0 ? (
<p style={{ color: "var(--muted)" }}>Оценок пока нет.</p>
) : (

View file

@ -11,6 +11,16 @@ export function Providers({ children }: { children: React.ReactNode }) {
queries: {
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
// #770 finding #26: не ретраить 4xx (включая 429) — иначе на rate-limit
// дефолтные 3 ретрая превращаются в шторм. Ретраим только 5xx/сеть, ≤2 раза.
retry: (failureCount, error) => {
const status = (error as { status?: number } | null)?.status;
if (typeof status === "number" && status >= 400 && status < 500) return false;
return failureCount < 2;
},
},
mutations: {
retry: false,
},
},
}),

View file

@ -101,7 +101,7 @@ export function DealsCard({ estimate }: Props) {
</span>
<span className="unit">млн</span>
</div>
<div className="sub">7 месяцев · разница к рынку</div>
<div className="sub">{estimate.period_months} мес · разница к рынку</div>
</div>
</div>
@ -133,7 +133,7 @@ export function DealsCard({ estimate }: Props) {
</thead>
<tbody>
{deals.map((d, i) => (
<DealRow key={i} deal={d} />
<DealRow key={`${d.source ?? ""}|${d.address}|${d.price_rub}|${i}`} deal={d} />
))}
</tbody>
</table>

View file

@ -2,11 +2,8 @@
import { useState } from "react";
import type {
HouseType,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
import type { TradeInEstimateInput } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { AddressInput } from "@/components/trade-in/AddressInput";
import { MapPicker } from "@/components/trade-in/MapPicker";
@ -86,8 +83,10 @@ function toPayload(f: FormState): TradeInEstimateInput {
has_balcony: f.has_balcony,
};
if (f.year_built !== "") out.year_built = Number(f.year_built);
if (f.house_type) out.house_type = f.house_type as HouseType;
if (f.repair_state) out.repair_state = f.repair_state as RepairState;
const ht = asHouseType(f.house_type);
if (ht) out.house_type = ht;
const rs = asRepairState(f.repair_state);
if (rs) out.repair_state = rs;
if (f.ownership_type) out.ownership_type = f.ownership_type;
if (f.has_mortgage) out.has_mortgage = true;
if (f.client_name.trim()) out.client_name = f.client_name.trim();

View file

@ -8,6 +8,7 @@
*/
import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { HeroTransparency } from "./HeroTransparency";
@ -159,8 +160,10 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
function handleEnrichSubmit(e: React.FormEvent) {
e.preventDefault();
const patch: { house_type?: HouseType; repair_state?: RepairState } = {};
if (enrichHouseType) patch.house_type = enrichHouseType as HouseType;
if (enrichRepairState) patch.repair_state = enrichRepairState as RepairState;
const ht = asHouseType(enrichHouseType);
if (ht) patch.house_type = ht;
const rs = asRepairState(enrichRepairState);
if (rs) patch.repair_state = rs;
onResubmit(patch);
}

View file

@ -41,6 +41,14 @@ function fmtArea(v: number): string {
return v.toFixed(1);
}
/** Гуманизация свежести данных (#770 finding #31): мин → ч → дн. */
function humanizeAgo(min: number | null): string {
if (min === null || min < 0) return "—";
if (min < 60) return `${min} мин назад`;
if (min < 1440) return `${Math.round(min / 60)} ч назад`;
return `${Math.round(min / 1440)} дн назад`;
}
/** Extract Cian numeric ID from a source_url like https://ekb.cian.ru/sale/flat/123456789/ */
function extractCianId(url: string | null): string | null {
if (!url) return null;
@ -112,11 +120,7 @@ export function ListingsCard({ estimate, estimateId }: Props) {
<div className="card-meta">
<div>
Обновлено:{" "}
<b className="mono">
{estimate.data_freshness_minutes !== null
? `${estimate.data_freshness_minutes} мин назад`
: "—"}
</b>
<b className="mono">{humanizeAgo(estimate.data_freshness_minutes)}</b>
</div>
<div style={{ marginTop: 4 }}>
Источников: <b>{estimate.sources_used.length} / 7</b>
@ -259,7 +263,11 @@ export function ListingsCard({ estimate, estimateId }: Props) {
</thead>
<tbody>
{lots.map((lot, i) => (
<AnalogRow key={i} lot={lot} priceChangeMap={priceChangeMap} />
<AnalogRow
key={`${lot.source ?? ""}|${lot.address}|${lot.price_rub}|${i}`}
lot={lot}
priceChangeMap={priceChangeMap}
/>
))}
</tbody>
</table>

View file

@ -117,6 +117,9 @@ export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
// Ключ последнего ОТПРАВЛЕННОГО драфта — чтобы не дёргать бэкенд на тех же значениях.
const lastSentKeyRef = useRef<string>(baseKey);
// #770 finding #27: монотонный счётчик запросов — фильтрует out-of-order ответы
// (медленный старый пересчёт мог перетереть свежий результат).
const reqSeqRef = useRef(0);
useEffect(() => {
lastSentKeyRef.current = baseKey;
}, [baseKey]);
@ -126,8 +129,12 @@ export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
const key = draftKey(d);
if (key === lastSentKeyRef.current) return; // ничего не изменилось
lastSentKeyRef.current = key;
const seq = ++reqSeqRef.current;
mutation.mutate(applyDraft(baseInput, d), {
onSuccess: (est) => setWhatIfEstimate(est),
onSuccess: (est) => {
// Применяем только результат последнего отправленного пересчёта.
if (seq === reqSeqRef.current) setWhatIfEstimate(est);
},
});
},
[baseInput, mutation],
@ -316,6 +323,7 @@ export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
<>
<span
className={`whatif-delta${delta > 0 ? " up" : delta < 0 ? " down" : ""}`}
title="Пересчёт ходит к источникам заново — дельта включает естественный разброс свежих объявлений, а не только эффект изменённого параметра."
>
{formatDeltaMln(delta)}
{deltaPct !== null && Math.abs(deltaPct) >= 0.1

View file

@ -18,6 +18,28 @@ export type HouseType =
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
// Runtime-валидаторы enum-значений (#770 finding #30): защищают `as`-cast от
// произвольной строки (битый ?id=-restore / подменённый payload).
export const HOUSE_TYPES: readonly HouseType[] = [
"panel",
"brick",
"monolith",
"monolith_brick",
"other",
];
export const REPAIR_STATES: readonly RepairState[] = [
"needs_repair",
"standard",
"good",
"excellent",
];
export function asHouseType(v: string | null | undefined): HouseType | undefined {
return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined;
}
export function asRepairState(v: string | null | undefined): RepairState | undefined {
return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined;
}
export type ConfidenceLevel = "low" | "medium" | "high";
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.