feat(tradein): house analytics section (chart + KPI + sold list) (#546)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Successful in 2m34s
Deploy Trade-In / deploy (push) Successful in 39s

This commit is contained in:
lekss361 2026-05-24 18:09:45 +00:00
parent f7a5c5d4f7
commit 80850a591e
11 changed files with 4250 additions and 1 deletions

View file

@ -18,10 +18,14 @@ from app.core.db import get_db
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
HouseInfoForEstimate,
IMVBenchmarkResponse,
PhotoMeta,
PlacementHistoryEntry,
PriceHistoryYearPoint,
RecentSoldEntry,
TradeInEstimateInput,
)
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
@ -532,6 +536,179 @@ def get_estimate_placement_history(
return [PlacementHistoryEntry(**dict(r)) for r in history]
@router.get("/estimate/{estimate_id}/house-analytics", response_model=HouseAnalyticsResponse)
def get_estimate_house_analytics(
estimate_id: UUID,
db: Annotated[Session, Depends(get_db)],
) -> HouseAnalyticsResponse:
"""House-level analytics from house_placement_history backfill.
Resolves target house(s) если в самом доме <8 hist rows расширяем поиск до 300м.
Возвращает: price-history by year (median /м²), recent sold (12mo), KPI.
"""
target = db.execute(
text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(estimate_id)},
).fetchone()
if target is None:
raise HTTPException(status_code=404, detail="estimate not found")
# 1. Resolve target house_ids (same as placement-history endpoint)
house_ids: list[int] = []
if target.address:
rows = db.execute(
text(
"SELECT id FROM houses WHERE short_address = tradein_normalize_short_addr(:addr) "
"OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)"
),
{"addr": target.address},
).all()
house_ids = [r.id for r in rows]
if not house_ids and target.lat is not None and target.lon is not None:
rows = db.execute(
text(
"SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
"geom::geography, ST_MakePoint(:lon, :lat)::geography, 100) LIMIT 3"
),
{"lat": target.lat, "lon": target.lon},
).all()
house_ids = [r.id for r in rows]
# 2. Expand to 300m if too few hist rows
radius_used = 0
n_in_house = 0
if house_ids:
n_in_house = (
db.execute(
text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
{"ids": house_ids},
).scalar()
or 0
)
if n_in_house < 8 and target.lat is not None and target.lon is not None:
rows = db.execute(
text(
"SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
"geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
),
{"lat": target.lat, "lon": target.lon},
).all()
house_ids = sorted(set(house_ids) | {r.id for r in rows})
radius_used = 300
if not house_ids:
return HouseAnalyticsResponse(
house_ids=[],
radius_m=0,
price_history=[],
recent_sold=[],
kpi=HouseAnalyticsKpi(
total_lots=0,
sold_count=0,
sold_rate_pct=0.0,
median_exposure_days=None,
median_bargain_pct=None,
),
)
# 3. Price history by year (median ₽/м²)
price_history_rows = db.execute(
text(
"""
SELECT
EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
COUNT(*) AS n_lots,
percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int
AS median_price_per_m2,
percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price)::int AS median_price_rub
FROM house_placement_history
WHERE house_id = ANY(:ids)
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
"""
),
{"ids": house_ids},
).mappings().all()
# 4. Recent sold (12 months, with removed_date)
recent_sold_rows = db.execute(
text(
"""
SELECT id, source, rooms, area_m2, floor, start_price, last_price,
removed_date, exposure_days,
CASE WHEN start_price > 0 AND last_price IS NOT NULL
THEN ROUND((start_price - last_price)::numeric / start_price * 100, 1)
ELSE NULL END AS discount_pct
FROM house_placement_history
WHERE house_id = ANY(:ids)
AND removed_date IS NOT NULL
AND removed_date > (NOW() - INTERVAL '12 months')::date
ORDER BY removed_date DESC
LIMIT 20
"""
),
{"ids": house_ids},
).mappings().all()
# 5. KPI aggregate
kpi_row = db.execute(
text(
"""
SELECT
COUNT(*) AS total_lots,
COUNT(*) FILTER (WHERE removed_date IS NOT NULL) AS sold_count,
CASE WHEN COUNT(*) > 0
THEN ROUND(
COUNT(*) FILTER (WHERE removed_date IS NOT NULL)::numeric
/ COUNT(*) * 100,
1
)
ELSE 0 END AS sold_rate_pct,
percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)
FILTER (WHERE exposure_days IS NOT NULL) AS median_exposure_days,
ROUND(
percentile_cont(0.5) WITHIN GROUP (
ORDER BY (start_price - last_price)::numeric / NULLIF(start_price, 0) * 100
) FILTER (
WHERE start_price > 0
AND last_price IS NOT NULL
AND last_price != start_price
)::numeric,
1
) AS median_bargain_pct
FROM house_placement_history
WHERE house_id = ANY(:ids)
"""
),
{"ids": house_ids},
).mappings().first()
return HouseAnalyticsResponse(
house_ids=house_ids,
radius_m=radius_used,
price_history=[PriceHistoryYearPoint(**dict(r)) for r in price_history_rows],
recent_sold=[RecentSoldEntry(**dict(r)) for r in recent_sold_rows],
kpi=HouseAnalyticsKpi(
total_lots=kpi_row["total_lots"] or 0 if kpi_row else 0,
sold_count=kpi_row["sold_count"] or 0 if kpi_row else 0,
sold_rate_pct=float(kpi_row["sold_rate_pct"] or 0) if kpi_row else 0.0,
median_exposure_days=(
int(kpi_row["median_exposure_days"])
if kpi_row and kpi_row["median_exposure_days"] is not None
else None
),
median_bargain_pct=(
float(kpi_row["median_bargain_pct"])
if kpi_row and kpi_row["median_bargain_pct"] is not None
else None
),
),
)
@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
def get_estimate_imv_benchmark(
estimate_id: UUID,

View file

@ -199,3 +199,50 @@ class ScheduleConfigUpdate(BaseModel):
window_start_hour: int = Field(default=2, ge=0, le=23)
window_end_hour: int = Field(default=5, ge=0, le=23)
default_params: dict[str, Any] = Field(default_factory=dict)
# ── House analytics (house_placement_history backfill) ───────────────────────
class PriceHistoryYearPoint(BaseModel):
"""Медианная цена ₽/м² и число лотов за год."""
year: int
median_price_per_m2: int
n_lots: int
median_price_rub: int
class RecentSoldEntry(BaseModel):
"""Лот из house_placement_history снятый с продажи за последние 12 мес."""
id: int
source: str # 'avito_imv' | 'yandex_valuation'
rooms: int | None
area_m2: float | None
floor: int | None
start_price: int | None
last_price: int | None
removed_date: date | None
exposure_days: int | None
discount_pct: float | None
class HouseAnalyticsKpi(BaseModel):
"""Агрегированные KPI по дому(ам) из house_placement_history."""
total_lots: int
sold_count: int
sold_rate_pct: float
median_exposure_days: int | None
median_bargain_pct: float | None
class HouseAnalyticsResponse(BaseModel):
"""Ответ GET /estimate/{id}/house-analytics."""
house_ids: list[int]
radius_m: int
price_history: list[PriceHistoryYearPoint]
recent_sold: list[RecentSoldEntry]
kpi: HouseAnalyticsKpi

View file

@ -13,7 +13,8 @@
"@tanstack/react-query": "^5.50.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@types/node": "^22.0.0",

3705
tradein-mvp/frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard";
import { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSection";
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { DealsCard } from "@/components/trade-in/DealsCard";
@ -175,6 +176,9 @@ export default function TradeInPage() {
{currentEstimateId && (
<PlacementHistoryCard estimateId={currentEstimateId} />
)}
{currentEstimateId && (
<HouseAnalyticsSection estimateId={currentEstimateId} />
)}
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
<ListingsCard estimate={resultData.estimate} />
<DealsCard estimate={resultData.estimate} />

View file

@ -0,0 +1,66 @@
"use client";
import type { HouseAnalyticsKpi } from "@/types/trade-in";
type Props = { kpi: HouseAnalyticsKpi };
function KpiCard({
label,
value,
sub,
}: {
label: string;
value: string;
sub?: string;
}) {
return (
<div className="card" style={{ padding: 12, flex: 1, minWidth: 0 }}>
<div
style={{ fontSize: 11, color: "var(--muted, #6b7280)", marginBottom: 4 }}
>
{label}
</div>
<div style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
{sub && (
<div
style={{
fontSize: 11,
color: "var(--muted, #6b7280)",
marginTop: 2,
}}
>
{sub}
</div>
)}
</div>
);
}
export function HouseAnalyticsKpiRow({ kpi }: Props) {
return (
<div style={{ display: "flex", gap: 12, marginTop: 8 }}>
<KpiCard
label="Средняя экспозиция"
value={
kpi.median_exposure_days != null
? `${kpi.median_exposure_days} дн.`
: "—"
}
sub="по архивным объявлениям"
/>
<KpiCard
label="Средний торг"
value={
kpi.median_bargain_pct != null
? `${kpi.median_bargain_pct.toFixed(1)}%`
: "—"
}
sub="от start_price до last_price"
/>
<KpiCard
label="Доля снятых"
value={`${kpi.sold_rate_pct.toFixed(0)}%`}
sub={`${kpi.sold_count} из ${kpi.total_lots}`}
/>
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { useEstimateHouseAnalytics } from "@/lib/trade-in-api";
import { PriceHistoryChart } from "./PriceHistoryChart";
import { HouseAnalyticsKpiRow } from "./HouseAnalyticsKpiRow";
import { RecentSoldList } from "./RecentSoldList";
type Props = { estimateId: string };
export function HouseAnalyticsSection({ estimateId }: Props) {
const { data, isPending, isError } = useEstimateHouseAnalytics(estimateId);
if (isPending || isError || !data) return null;
if (data.kpi.total_lots === 0) return null;
return (
<section style={{ marginTop: 16 }}>
<header
style={{
marginBottom: 12,
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
Аналитика дома
</h3>
<small style={{ color: "var(--muted, #6b7280)" }}>
{data.kpi.total_lots} объявлений
{data.radius_m > 0
? ` · в радиусе ${data.radius_m}м`
: " · в этом доме"}
</small>
</header>
<HouseAnalyticsKpiRow kpi={data.kpi} />
{data.price_history.length >= 2 && (
<PriceHistoryChart points={data.price_history} />
)}
{data.recent_sold.length > 0 && (
<RecentSoldList items={data.recent_sold} />
)}
</section>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import type { PriceHistoryYearPoint } from "@/types/trade-in";
type Props = { points: PriceHistoryYearPoint[] };
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
export function PriceHistoryChart({ points }: Props) {
const totalLots = points.reduce((s, p) => s + p.n_lots, 0);
return (
<article className="card" style={{ marginTop: 12, padding: 16 }}>
<header style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
Динамика цен в доме
</h4>
<small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по году (по {totalLots} лотам)
</small>
</header>
<div style={{ width: "100%", height: 240 }}>
<ResponsiveContainer>
<LineChart
data={points}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid stroke="#eee" strokeDasharray="3 3" />
<XAxis dataKey="year" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number, name: string) =>
name === "median_price_per_m2"
? [`${value.toLocaleString("ru-RU")} ₽/м²`, "Медиана"]
: [value, name]
}
labelFormatter={(year) => `${year} год`}
/>
<Line
type="monotone"
dataKey="median_price_per_m2"
stroke="#2563eb"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</article>
);
}

View file

@ -0,0 +1,91 @@
"use client";
import type { RecentSoldEntry } from "@/types/trade-in";
type Props = { items: RecentSoldEntry[] };
const fmtPrice = (n: number | null): string =>
n == null ? "—" : `${(n / 1_000_000).toFixed(2)} М`;
const fmtDate = (d: string | null): string => {
if (!d) return "—";
try {
return new Date(d).toLocaleDateString("ru-RU");
} catch {
return d;
}
};
export function RecentSoldList({ items }: Props) {
const suffix = items.length === 1 ? "" : "ов";
return (
<article className="card" style={{ marginTop: 12 }}>
<header className="card-head">
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
Недавно снятые (12 мес)
</h4>
<small style={{ color: "var(--muted, #6b7280)" }}>
{items.length} лот{suffix}
</small>
</header>
<div style={{ padding: "8px 16px 16px" }}>
<table
style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}
>
<thead>
<tr
style={{
borderBottom: "1px solid var(--border, #e5e7eb)",
textAlign: "left",
}}
>
<th style={{ padding: "6px 8px" }}>Лот</th>
<th style={{ padding: "6px 8px" }}>Цена</th>
<th style={{ padding: "6px 8px" }}>Торг</th>
<th style={{ padding: "6px 8px" }}>Снято</th>
<th style={{ padding: "6px 8px" }}>Экспозиция</th>
</tr>
</thead>
<tbody>
{items.map((it) => {
const hasDiscount =
it.discount_pct != null && it.discount_pct !== 0;
const discountColor =
it.discount_pct != null && it.discount_pct > 0
? "#16a34a"
: "var(--muted, #6b7280)";
return (
<tr
key={it.id}
style={{
borderBottom: "1px solid var(--border-soft, #f3f4f6)",
}}
>
<td style={{ padding: "6px 8px" }}>
{it.rooms ?? "?"}к, {it.area_m2 ?? "?"} м²
{it.floor != null ? `, эт. ${it.floor}` : ""}
</td>
<td style={{ padding: "6px 8px", whiteSpace: "nowrap" }}>
{fmtPrice(it.last_price)}
</td>
<td style={{ padding: "6px 8px", color: discountColor }}>
{hasDiscount
? `${it.discount_pct! > 0 ? "" : "+"}${Math.abs(it.discount_pct!).toFixed(1)}%`
: "—"}
</td>
<td style={{ padding: "6px 8px", whiteSpace: "nowrap" }}>
{fmtDate(it.removed_date)}
</td>
<td style={{ padding: "6px 8px" }}>
{it.exposure_days ?? "—"} дн.
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</article>
);
}

View file

@ -5,6 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
AggregatedEstimate,
HouseAnalyticsResponse,
HouseInfoForEstimate,
IMVBenchmarkResponse,
PlacementHistoryItem,
@ -84,3 +85,19 @@ export function useEstimateImvBenchmark(estimate_id: string | null) {
staleTime: 10 * 60_000,
});
}
/**
* GET /api/v1/trade-in/estimate/{id}/house-analytics
* Price history, KPI and recent sold lots for the target house.
*/
export function useEstimateHouseAnalytics(estimate_id: string | null) {
return useQuery<HouseAnalyticsResponse>({
queryKey: ["trade-in", "estimate", estimate_id, "house-analytics"],
queryFn: () =>
apiFetch<HouseAnalyticsResponse>(
`${BASE}/estimate/${estimate_id}/house-analytics`,
),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}

View file

@ -141,3 +141,41 @@ export interface IMVBenchmarkResponse {
our_median_price: number | null;
diff_pct: number | null;
}
// ── House Analytics (endpoint: GET /estimate/{id}/house-analytics) ──
export interface PriceHistoryYearPoint {
year: number;
median_price_per_m2: number;
n_lots: number;
median_price_rub: number;
}
export interface RecentSoldEntry {
id: number;
source: string; // 'avito_imv' | 'yandex_valuation'
rooms: number | null;
area_m2: number | null;
floor: number | null;
start_price: number | null;
last_price: number | null;
removed_date: string | null; // ISO date
exposure_days: number | null;
discount_pct: number | null; // (start - last) / start * 100
}
export interface HouseAnalyticsKpi {
total_lots: number;
sold_count: number;
sold_rate_pct: number;
median_exposure_days: number | null;
median_bargain_pct: number | null;
}
export interface HouseAnalyticsResponse {
house_ids: number[];
radius_m: number; // 0 = exact house, 300 = соседи в радиусе
price_history: PriceHistoryYearPoint[];
recent_sold: RecentSoldEntry[];
kpi: HouseAnalyticsKpi;
}