feat(tradein): cian price-change badges + split chart Avito/Yandex (#547)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Successful in 42s
Deploy Trade-In / build-frontend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 45s

This commit is contained in:
lekss361 2026-05-24 18:24:00 +00:00
parent 80850a591e
commit 967e7185ca
7 changed files with 248 additions and 17 deletions

View file

@ -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/<id>/)
- 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,

View file

@ -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 мес."""

View file

@ -180,7 +180,7 @@ export default function TradeInPage() {
<HouseAnalyticsSection estimateId={currentEstimateId} />
)}
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
<ListingsCard estimate={resultData.estimate} />
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
<DealsCard estimate={resultData.estimate} />
<OfferCard estimate={resultData.estimate} />
</>

View file

@ -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<string, string> = {
@ -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<Map<string, CianPriceChangeStats>>(() => {
const map = new Map<string, CianPriceChangeStats>();
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) {
</thead>
<tbody>
{lots.map((lot, i) => (
<AnalogRow key={i} lot={lot} />
<AnalogRow key={i} lot={lot} priceChangeMap={priceChangeMap} />
))}
</tbody>
</table>
@ -239,7 +259,13 @@ export function ListingsCard({ estimate }: Props) {
);
}
function AnalogRow({ lot }: { lot: AnalogLot }) {
function AnalogRow({
lot,
priceChangeMap,
}: {
lot: AnalogLot;
priceChangeMap: Map<string, CianPriceChangeStats>;
}) {
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 (
<tr>
<td>
@ -269,8 +298,35 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
<span className={`src-dot ${dot}`} /> {label}
</span>
</td>
<td className="num">{fmtRub(lot.price_per_m2)}</td>
<td className="num">{fmtRub(lot.price_rub)}</td>
<td className="num">
{fmtRub(lot.price_per_m2)}
</td>
<td className="num">
{fmtRub(lot.price_rub)}
{priceStats && (
<span
style={{
fontSize: 11,
padding: "2px 6px",
borderRadius: 4,
background:
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
? "#dcfce7"
: "#fee2e2",
color:
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
? "#166534"
: "#991b1b",
marginLeft: 6,
}}
>
{priceStats.n_changes > 1 ? `${priceStats.n_changes}` : "↓1"}
{priceStats.last_diff_percent != null
? ` ${priceStats.last_diff_percent > 0 ? "+" : ""}${priceStats.last_diff_percent.toFixed(1)}%`
: ""}
</span>
)}
</td>
<td className="num">{distance}</td>
<td>
{externalUrl && (

View file

@ -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<PivotRow[]>(() => {
const byYear: Record<number, PivotRow> = {};
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) {
Динамика цен в доме
</h4>
<small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по году (по {totalLots} лотам)
Медиана /м² по году · Avito + Яндекс (по {totalLots} лотам)
</small>
</header>
<div style={{ width: "100%", height: 240 }}>
<ResponsiveContainer>
<LineChart
data={points}
data={pivoted}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid stroke="#eee" strokeDasharray="3 3" />
@ -38,18 +63,35 @@ export function PriceHistoryChart({ points }: Props) {
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number, name: string) =>
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} год`}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line
type="monotone"
dataKey="median_price_per_m2"
dataKey="avito_imv"
stroke="#2563eb"
strokeWidth={2}
dot={{ r: 3 }}
name="Avito"
connectNulls
/>
<Line
type="monotone"
dataKey="yandex_valuation"
stroke="#ea580c"
strokeWidth={2}
dot={{ r: 3 }}
name="Яндекс"
connectNulls
/>
</LineChart>
</ResponsiveContainer>

View file

@ -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<CianPriceChangeStats[]>({
queryKey: ["trade-in", "estimate", estimate_id, "cian-price-changes"],
queryFn: () =>
apiFetch<CianPriceChangeStats[]>(
`${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.

View file

@ -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'