From 07cbcd235b54513a40d45f79ac56a307f007f7a4 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 21:14:26 +0300 Subject: [PATCH] feat(tradein): cian price-change badges + split price-history chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New GET /api/v1/trade-in/estimate/{id}/cian-price-changes — per-analog price change stats (n_changes, last_diff_pct, total_change_pct) из offer_price_history (cian playwright_history backfill, 268+ rows). - house-analytics: PriceHistoryYearPoint gains source field; SQL теперь GROUP BY year, source → 2 серии для chart (avito_imv / yandex_valuation). Frontend: - ListingsCard показывает colored badge ↓N ±X.X% рядом с ценой cian-аналога если есть price changes (зелёный = снижение, красный = рост). - PriceHistoryChart: pivot long→wide format client-side, 2 Line components (синий Avito #2563eb, оранжевый Яндекс #ea580c) + Legend, connectNulls. Использует данные: - offer_price_history (live cian price changes) - house_placement_history.source разделение avito_imv vs yandex_valuation --- tradein-mvp/backend/app/api/v1/trade_in.py | 93 ++++++++++++++++++- tradein-mvp/backend/app/schemas/trade_in.py | 19 +++- tradein-mvp/frontend/src/app/page.tsx | 2 +- .../src/components/trade-in/ListingsCard.tsx | 68 ++++++++++++-- .../components/trade-in/PriceHistoryChart.tsx | 54 +++++++++-- tradein-mvp/frontend/src/lib/trade-in-api.ts | 17 ++++ tradein-mvp/frontend/src/types/trade-in.ts | 12 +++ 7 files changed, 248 insertions(+), 17 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index c3e86b71..28494859 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -18,6 +18,7 @@ from app.core.db import get_db from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, + CianPriceChangeStats, HouseAnalyticsKpi, HouseAnalyticsResponse, HouseInfoForEstimate, @@ -611,12 +612,13 @@ def get_estimate_house_analytics( ), ) - # 3. Price history by year (median ₽/м²) + # 3. Price history by year × source (median ₽/м²) price_history_rows = db.execute( text( """ SELECT EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year, + source, COUNT(*) AS n_lots, percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int AS median_price_per_m2, @@ -626,8 +628,8 @@ def get_estimate_house_analytics( AND last_price IS NOT NULL AND last_price > 100000 AND area_m2 IS NOT NULL AND area_m2 > 10 AND COALESCE(last_price_date, start_price_date) IS NOT NULL - GROUP BY year - ORDER BY year ASC + GROUP BY year, source + ORDER BY year ASC, source ASC """ ), {"ids": house_ids}, @@ -709,6 +711,91 @@ def get_estimate_house_analytics( ) +@router.get( + "/estimate/{estimate_id}/cian-price-changes", + response_model=list[CianPriceChangeStats], +) +def get_estimate_cian_price_changes( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], +) -> list[CianPriceChangeStats]: + """История изменений цены для Cian-аналогов из estimate. + + Для каждого cian-аналога в estimate.analogs: + - extract cian_id из URL (/sale/flat//) + - JOIN listings (source='cian', source_id IN cian_ids) + - JOIN offer_price_history per listing_id + - Aggregate: n_changes, last_change_time, last_diff_percent, total_change_pct + + Возвращает только аналоги с хотя бы одним изменением цены. + Пустой список если нет cian-аналогов или нет истории. + """ + rows = db.execute( + text( + """ + WITH cian_analogs AS ( + SELECT DISTINCT + substring(a->>'source_url' from '/sale/flat/(\\d+)/') AS cian_id + FROM trade_in_estimates, jsonb_array_elements(analogs) a + WHERE id = CAST(:eid AS uuid) + AND a->>'source' = 'cian' + AND a->>'source_url' IS NOT NULL + AND substring(a->>'source_url' from '/sale/flat/(\\d+)/') IS NOT NULL + ), + listings_resolved AS ( + SELECT l.id AS listing_id, l.source_id AS cian_id, + l.price_rub::int AS current_price + FROM listings l + JOIN cian_analogs c ON l.source_id = c.cian_id + WHERE l.source = 'cian' + ), + changes_agg AS ( + SELECT + oph.listing_id, + COUNT(*) AS n_changes, + MAX(oph.change_time) AS last_change_time, + (array_agg(oph.diff_percent ORDER BY oph.change_time DESC))[1] + AS last_diff_percent, + ( + SELECT oph2.price_rub::int + FROM offer_price_history oph2 + WHERE oph2.listing_id = oph.listing_id + ORDER BY oph2.change_time ASC + LIMIT 1 + ) AS first_seen_price + FROM offer_price_history oph + WHERE oph.listing_id IN (SELECT listing_id FROM listings_resolved) + GROUP BY oph.listing_id + ) + SELECT + lr.cian_id, + lr.listing_id, + ca.n_changes::int, + ca.last_change_time, + ca.last_diff_percent::float AS last_diff_percent, + ca.first_seen_price, + lr.current_price, + CASE + WHEN ca.first_seen_price IS NOT NULL AND ca.first_seen_price > 0 + THEN ROUND( + (lr.current_price - ca.first_seen_price)::numeric + / ca.first_seen_price * 100, + 1 + )::float + ELSE NULL + END AS total_change_pct + FROM listings_resolved lr + JOIN changes_agg ca ON ca.listing_id = lr.listing_id + WHERE ca.n_changes > 0 + ORDER BY ca.last_change_time DESC + """ + ), + {"eid": str(estimate_id)}, + ).mappings().all() + + return [CianPriceChangeStats(**dict(r)) for r in rows] + + @router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse) def get_estimate_imv_benchmark( estimate_id: UUID, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index aa76bf95..9d772845 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -205,14 +205,31 @@ class ScheduleConfigUpdate(BaseModel): class PriceHistoryYearPoint(BaseModel): - """Медианная цена ₽/м² и число лотов за год.""" + """Медианная цена ₽/м² и число лотов за год, разбитая по источнику.""" year: int + source: str # 'avito_imv' | 'yandex_valuation' median_price_per_m2: int n_lots: int median_price_rub: int +class CianPriceChangeStats(BaseModel): + """Статистика изменений цены для одного Cian-аналога. + + Источник: offer_price_history JOIN listings (source='cian'). + """ + + cian_id: str + listing_id: int + n_changes: int # COUNT(*) из offer_price_history + last_change_time: datetime | None + last_diff_percent: float | None # последняя дельта (-5% если цена снизилась) + total_change_pct: float | None # суммарно (current - first) / first * 100 + first_seen_price: int | None + current_price: int # из listings.price_rub + + class RecentSoldEntry(BaseModel): """Лот из house_placement_history снятый с продажи за последние 12 мес.""" diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 4993c5a1..83407069 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -180,7 +180,7 @@ export default function TradeInPage() { )} - + diff --git a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx index 441b6b0b..c73a0f19 100644 --- a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx @@ -4,11 +4,14 @@ * ListingsCard — Секция 2 «Рынок» из mockup tradein.html. * Counts strip + filters + price bar + table. */ -import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; +import { useMemo } from "react"; +import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/types/trade-in"; import { safeUrl } from "@/lib/safeUrl"; +import { useEstimateCianPriceChanges } from "@/lib/trade-in-api"; interface Props { estimate: AggregatedEstimate; + estimateId?: string; } const SOURCE_DOTS: Record = { @@ -37,7 +40,24 @@ function fmtArea(v: number): string { return v.toFixed(1); } -export function ListingsCard({ estimate }: Props) { +/** 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; + const m = url.match(/\/sale\/flat\/(\d+)/); + return m ? m[1] : null; +} + +export function ListingsCard({ estimate, estimateId }: Props) { + const priceChangesQuery = useEstimateCianPriceChanges(estimateId ?? null); + + const priceChangeMap = useMemo>(() => { + const map = new Map(); + for (const stats of priceChangesQuery.data ?? []) { + map.set(stats.cian_id, stats); + } + return map; + }, [priceChangesQuery.data]); + const lots = estimate.analogs; if (lots.length === 0) { return ( @@ -224,7 +244,7 @@ export function ListingsCard({ estimate }: Props) { {lots.map((lot, i) => ( - + ))} @@ -239,7 +259,13 @@ export function ListingsCard({ estimate }: Props) { ); } -function AnalogRow({ lot }: { lot: AnalogLot }) { +function AnalogRow({ + lot, + priceChangeMap, +}: { + lot: AnalogLot; + priceChangeMap: Map; +}) { const src = lot.source ?? ""; const dot = SOURCE_DOTS[src] ?? "dom"; const label = SOURCE_LABELS[src] ?? src; @@ -251,6 +277,9 @@ function AnalogRow({ lot }: { lot: AnalogLot }) { : `${(lot.distance_m / 1000).toFixed(1)} км`; const externalUrl = safeUrl(lot.source_url); + const cianId = src === "cian" ? extractCianId(lot.source_url) : null; + const priceStats = cianId ? priceChangeMap.get(cianId) : undefined; + return ( @@ -269,8 +298,35 @@ function AnalogRow({ lot }: { lot: AnalogLot }) { {label} - {fmtRub(lot.price_per_m2)} - {fmtRub(lot.price_rub)} + + {fmtRub(lot.price_per_m2)} + + + {fmtRub(lot.price_rub)} + {priceStats && ( + + {priceStats.n_changes > 1 ? `↓${priceStats.n_changes}` : "↓1"} + {priceStats.last_diff_percent != null + ? ` ${priceStats.last_diff_percent > 0 ? "+" : ""}${priceStats.last_diff_percent.toFixed(1)}%` + : ""} + + )} + {distance} {externalUrl && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx b/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx index bec2e77d..1322756e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx @@ -1,10 +1,12 @@ "use client"; +import { useMemo } from "react"; import { LineChart, Line, XAxis, YAxis, Tooltip, + Legend, ResponsiveContainer, CartesianGrid, } from "recharts"; @@ -14,7 +16,30 @@ type Props = { points: PriceHistoryYearPoint[] }; const fmtK = (n: number) => `${Math.round(n / 1000)}k`; +interface PivotRow { + year: number; + avito_imv?: number; + yandex_valuation?: number; + n_avito?: number; + n_yandex?: number; +} + export function PriceHistoryChart({ points }: Props) { + const pivoted = useMemo(() => { + const byYear: Record = {}; + for (const p of points) { + if (!byYear[p.year]) byYear[p.year] = { year: p.year }; + if (p.source === "avito_imv") { + byYear[p.year].avito_imv = p.median_price_per_m2; + byYear[p.year].n_avito = p.n_lots; + } else if (p.source === "yandex_valuation") { + byYear[p.year].yandex_valuation = p.median_price_per_m2; + byYear[p.year].n_yandex = p.n_lots; + } + } + return Object.values(byYear).sort((a, b) => a.year - b.year); + }, [points]); + const totalLots = points.reduce((s, p) => s + p.n_lots, 0); return ( @@ -24,13 +49,13 @@ export function PriceHistoryChart({ points }: Props) { Динамика цен в доме - Медиана ₽/м² по году (по {totalLots} лотам) + Медиана ₽/м² по году · Avito + Яндекс (по {totalLots} лотам)
@@ -38,18 +63,35 @@ export function PriceHistoryChart({ points }: Props) { - name === "median_price_per_m2" - ? [`${value.toLocaleString("ru-RU")} ₽/м²`, "Медиана"] - : [value, name] + [ + `${value.toLocaleString("ru-RU")} ₽/м²`, + name === "avito_imv" + ? "Avito" + : name === "yandex_valuation" + ? "Яндекс" + : name, + ] } labelFormatter={(year) => `${year} год`} /> + + diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index df9c21dd..e1aa234c 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -5,6 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiFetch } from "./api"; import type { AggregatedEstimate, + CianPriceChangeStats, HouseAnalyticsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, @@ -86,6 +87,22 @@ export function useEstimateImvBenchmark(estimate_id: string | null) { }); } +/** + * GET /api/v1/trade-in/estimate/{id}/cian-price-changes + * Price change history for Cian analog listings (only those with >0 changes). + */ +export function useEstimateCianPriceChanges(estimate_id: string | null) { + return useQuery({ + queryKey: ["trade-in", "estimate", estimate_id, "cian-price-changes"], + queryFn: () => + apiFetch( + `${BASE}/estimate/${estimate_id}/cian-price-changes`, + ), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, + }); +} + /** * GET /api/v1/trade-in/estimate/{id}/house-analytics * Price history, KPI and recent sold lots for the target house. diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 6849afa9..55552b0e 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -146,11 +146,23 @@ export interface IMVBenchmarkResponse { export interface PriceHistoryYearPoint { year: number; + source: string; // 'avito_imv' | 'yandex_valuation' median_price_per_m2: number; n_lots: number; median_price_rub: number; } +export interface CianPriceChangeStats { + cian_id: string; + listing_id: number; + n_changes: number; + last_change_time: string | null; + last_diff_percent: number | null; + total_change_pct: number | null; + first_seen_price: number | null; + current_price: number; +} + export interface RecentSoldEntry { id: number; source: string; // 'avito_imv' | 'yandex_valuation' -- 2.45.3