feat(tradein): house analytics section на trade-in странице
Backend: GET /api/v1/trade-in/estimate/{id}/house-analytics — resolves target
house_ids через нормализованный адрес или geo-fallback 100m, expand до 300m
если в самом доме <8 hist rows. Возвращает:
- price_history: median ₽/м² по году (из house_placement_history)
- recent_sold: последние 12 мес снятые с фактической ценой + торгом
- kpi: median exposure_days, median bargain %, sold_rate %
Frontend: HouseAnalyticsSection (под PlacementHistoryCard) c 3 блоками:
- PriceHistoryChart (Recharts line, median ₽/м²)
- HouseAnalyticsKpiRow (3 KPI cards)
- RecentSoldList (таблица снятых лотов)
Использует уже собранные данные house_placement_history (15k+ rows от
backfill scripts: avito_imv + yandex_valuation). Новых таблиц нет.
This commit is contained in:
parent
f7a5c5d4f7
commit
7eeffe353a
11 changed files with 4250 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
3705
tradein-mvp/frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue