diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 83d76bae..66d63588 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -520,3 +520,47 @@ def get_photo( media_type=row.content_type, headers={"Cache-Control": "private, max-age=3600"}, ) + + +# ── История и кэш (#399) ───────────────────────────────────────────────────── +@router.get("/history") +def estimate_history( + db: Annotated[Session, Depends(get_db)], + limit: int = 50, +) -> list[dict[str, object]]: + """История оценок (#399) — последние N записей trade_in_estimates.""" + rows = db.execute( + text( + """ + SELECT id, address, rooms, area_m2, median_price, + confidence, n_analogs, created_at + FROM trade_in_estimates + ORDER BY created_at DESC + LIMIT :limit + """ + ), + {"limit": min(max(limit, 1), 200)}, + ).mappings().all() + return [dict(r) for r in rows] + + +@router.get("/cache-stats") +def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]: + """Состояние данных и кэшей (#399) — для страницы «Кэш».""" + row = db.execute( + text( + """ + SELECT + (SELECT count(*) FROM geocode_cache) AS geocode_cache, + (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW()) + AS geocode_cache_fresh, + (SELECT count(*) FROM listings WHERE is_active) AS listings_active, + (SELECT max(scraped_at) FROM listings) AS listings_last_scraped, + (SELECT count(*) FROM deals) AS deals, + (SELECT count(*) FROM cad_buildings) AS cad_buildings, + (SELECT count(*) FROM house_metadata) AS house_metadata, + (SELECT count(*) FROM trade_in_estimates) AS estimates_total + """ + ) + ).mappings().fetchone() + return dict(row) if row else {} diff --git a/tradein-mvp/frontend/src/app/cache/page.tsx b/tradein-mvp/frontend/src/app/cache/page.tsx new file mode 100644 index 00000000..5d431c19 --- /dev/null +++ b/tradein-mvp/frontend/src/app/cache/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +/* Состояние данных и кэшей (#399). */ +import { useEffect, useState } from "react"; + +import "@/components/trade-in/trade-in.css"; +import { API_BASE_URL } from "@/lib/api"; + +interface CacheStats { + geocode_cache: number; + geocode_cache_fresh: number; + listings_active: number; + listings_last_scraped: string | null; + deals: number; + cad_buildings: number; + house_metadata: number; + estimates_total: number; +} + +export default function CachePage() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(`${API_BASE_URL}/api/v1/trade-in/cache-stats`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setStats(d as CacheStats | null)) + .catch(() => setStats(null)) + .finally(() => setLoading(false)); + }, []); + + const items: Array<[string, string]> = stats + ? [ + ["Оценок всего", String(stats.estimates_total)], + ["Объявлений активных", String(stats.listings_active)], + ["Сделок", String(stats.deals)], + ["Кадастр зданий", String(stats.cad_buildings)], + ["Кэш домов (house_metadata)", String(stats.house_metadata)], + [ + "Кэш геокодера (всего / свежих)", + `${stats.geocode_cache} / ${stats.geocode_cache_fresh}`, + ], + [ + "Последний парсинг объявлений", + stats.listings_last_scraped + ? new Date(stats.listings_last_scraped).toLocaleString("ru-RU") + : "—", + ], + ] + : []; + + return ( +
+

+ ← К оценке +

+

+ Состояние данных и кэшей +

+ + {loading ? ( +

Загрузка…

+ ) : !stats ? ( +

Не удалось загрузить статистику.

+ ) : ( +
+ {items.map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/history/page.tsx b/tradein-mvp/frontend/src/app/history/page.tsx new file mode 100644 index 00000000..0b1c5237 --- /dev/null +++ b/tradein-mvp/frontend/src/app/history/page.tsx @@ -0,0 +1,77 @@ +"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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(`${API_BASE_URL}/api/v1/trade-in/history?limit=100`) + .then((r) => (r.ok ? r.json() : [])) + .then((d) => setRows(d as HistoryRow[])) + .catch(() => setRows([])) + .finally(() => setLoading(false)); + }, []); + + return ( +
+

+ ← К оценке +

+

История оценок

+ + {loading ? ( +

Загрузка…

+ ) : rows.length === 0 ? ( +

Оценок пока нет.

+ ) : ( + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
ДатаАдресКомн.ПлощадьМедиана, ₽Точность
+ {new Date(r.created_at).toLocaleDateString("ru-RU")} + + {r.address} + {r.rooms}{r.area_m2} м² + {Number(r.median_price).toLocaleString("ru-RU")} + {r.confidence}
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 8f12f03b..cf2473f8 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -11,6 +11,7 @@ import { useRouter } from "next/navigation"; import "@/components/trade-in/trade-in.css"; import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in"; import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api"; +import { API_BASE_URL } from "@/lib/api"; import { EstimateForm } from "@/components/trade-in/EstimateForm"; import { SourcesProgress } from "@/components/trade-in/SourcesProgress"; import { HeroSummary } from "@/components/trade-in/HeroSummary"; @@ -80,9 +81,9 @@ export default function TradeInPage() { Trade-In