feat(tradein): cian price-change badges + split chart Avito/Yandex (#547)
This commit is contained in:
parent
80850a591e
commit
967e7185ca
7 changed files with 248 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 мес."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue