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
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:
parent
a4057a5b28
commit
679e75b9e8
10 changed files with 140 additions and 21 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
57
tradein-mvp/frontend/src/app/global-error.tsx
Normal file
57
tradein-mvp/frontend/src/app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue