feat(tradein): StreetDealsCard с paired listings (PR L, #564 Phase 2) #599
4 changed files with 340 additions and 41 deletions
|
|
@ -1,13 +1,20 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса.
|
||||
* Питается от GET /api/v1/trade-in/street-deals (PR #555).
|
||||
* Не рендерится если street не извлёкся или count = 0.
|
||||
* StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса
|
||||
* с привязкой к историческим listings (Avito / Cian / Yandex / N1).
|
||||
*
|
||||
* Питается от GET /api/v1/trade-in/sales-vs-listings (PR L / #564 Phase 2).
|
||||
* Endpoint = superset предыдущего /street-deals: возвращает все pairs (LEFT JOIN
|
||||
* deal → listing) + linkage_rate. Aggregates (count / median / range) считаются
|
||||
* client-side по pairs[].deal_price_rub.
|
||||
*
|
||||
* Не рендерится если street не извлёкся или pairs.length = 0.
|
||||
*/
|
||||
import { useStreetDeals } from "@/lib/trade-in-api";
|
||||
import { useSalesVsListings } from "@/lib/trade-in-api";
|
||||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
import type { AggregatedEstimate, SalesListingPair } from "@/types/trade-in";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
|
|
@ -30,18 +37,54 @@ function fmtDate(iso: string | null): string {
|
|||
}
|
||||
}
|
||||
|
||||
/** Median по числовому массиву (linear interpolation between centre items). */
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length / 2;
|
||||
if (Number.isInteger(mid)) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
return sorted[Math.floor(mid)];
|
||||
}
|
||||
|
||||
/** Срок «за N дней до» / «через N дней после» сделки. */
|
||||
function fmtDaysToDeal(days: number | null): string {
|
||||
if (days === null) return "—";
|
||||
const abs = Math.abs(days);
|
||||
// Положительное = listing раньше сделки (типичный случай: «за 45 дней до»).
|
||||
// Отрицательное = listing появился позже сделки (отложенный парсинг, rare).
|
||||
return days >= 0 ? `за ${abs} дн до` : `через ${abs} дн после`;
|
||||
}
|
||||
|
||||
/** Формат discount_pct со знаком и одной десятичной. */
|
||||
function fmtDiscount(pct: number): string {
|
||||
const sign = pct > 0 ? "+" : pct < 0 ? "−" : "";
|
||||
return `${sign}${Math.abs(pct).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function StreetDealsCard({ estimate }: Props) {
|
||||
const { data, isLoading, isError } = useStreetDeals(
|
||||
const { data, isLoading, isError } = useSalesVsListings(
|
||||
estimate.target_address ?? null,
|
||||
estimate.area_m2 ?? null,
|
||||
estimate.rooms ?? null,
|
||||
);
|
||||
|
||||
if (isLoading || isError) return null;
|
||||
if (!data || !data.street || data.count === 0) return null;
|
||||
if (!data || !data.street || data.pairs.length === 0) return null;
|
||||
|
||||
const rangeLowM = (data.range_low_rub / 1_000_000).toFixed(1);
|
||||
const rangeHighM = (data.range_high_rub / 1_000_000).toFixed(1);
|
||||
// Client-side aggregates по deal_price_rub / deal_price_per_m2 — endpoint
|
||||
// /sales-vs-listings не возвращает их (только linkage-связанные).
|
||||
const prices = data.pairs.map((p) => p.deal_price_rub);
|
||||
const ppm2Values = data.pairs.map((p) => p.deal_price_per_m2);
|
||||
const medianPpm2 = Math.round(median(ppm2Values));
|
||||
const rangeLow = Math.min(...prices);
|
||||
const rangeHigh = Math.max(...prices);
|
||||
const rangeLowM = (rangeLow / 1_000_000).toFixed(1);
|
||||
const rangeHighM = (rangeHigh / 1_000_000).toFixed(1);
|
||||
|
||||
const hasAnyListing = data.deals_with_listings > 0;
|
||||
const visiblePairs: SalesListingPair[] = data.pairs.slice(0, 10);
|
||||
|
||||
return (
|
||||
<article className="card">
|
||||
|
|
@ -49,27 +92,49 @@ export function StreetDealsCard({ estimate }: Props) {
|
|||
<div>
|
||||
<div className="section-kicker">Секция · По вашей улице</div>
|
||||
<h2>ДКП-сделки на улице {data.street}</h2>
|
||||
{hasAnyListing && (
|
||||
<div className="linkage-hint">
|
||||
<b className="mono">{data.linkage_rate_pct.toFixed(0)}%</b> сделок имеют
|
||||
исторический ASK
|
||||
{data.median_discount_pct !== null && (
|
||||
<>
|
||||
{" · медианный торг "}
|
||||
<b
|
||||
className={
|
||||
data.median_discount_pct < 0
|
||||
? "discount-neg mono"
|
||||
: data.median_discount_pct > 0
|
||||
? "discount-pos mono"
|
||||
: "mono"
|
||||
}
|
||||
>
|
||||
{fmtDiscount(data.median_discount_pct)}
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<div>
|
||||
Период: <b className="mono">12 мес</b>
|
||||
Период: <b className="mono">{data.period_months} мес</b>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>источник: Росреестр (открытые данные)</div>
|
||||
<div style={{ marginTop: 4 }}>источник: Росреестр + объявления</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="count-strip">
|
||||
<div className="count-cell">
|
||||
<div className="label">Сделок за 12 мес</div>
|
||||
<div className="label">Сделок за {data.period_months} мес</div>
|
||||
<div className="value">
|
||||
<span data-tnum>{data.count}</span>
|
||||
<span data-tnum>{data.total_deals}</span>
|
||||
<span className="unit">шт</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="count-cell">
|
||||
<div className="label">Медиана</div>
|
||||
<div className="value">
|
||||
<span data-tnum>{fmtRub(data.median_price_per_m2)}</span>
|
||||
<span data-tnum>{fmtRub(medianPpm2)}</span>
|
||||
<span className="unit">₽/м²</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -84,51 +149,135 @@ export function StreetDealsCard({ estimate }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<table className="dt" aria-label="Сделки по улице">
|
||||
<table className="dt dt-with-asks" aria-label="Сделки по улице с историческим ASK">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Адрес</th>
|
||||
<th className="num">Площадь</th>
|
||||
<th className="num">Цена</th>
|
||||
<th className="num">Цена сделки</th>
|
||||
<th className="num">₽/м²</th>
|
||||
<th className="num">Дата</th>
|
||||
{hasAnyListing && <th>Объявление до сделки</th>}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.deals.map((d: AnalogLot, i: number) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<span className="addr">
|
||||
<span className="a-main">{d.address}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{d.area_m2.toFixed(1)} м²</td>
|
||||
<td className="num">{fmtRub(d.price_rub)} ₽</td>
|
||||
<td className="num">{fmtRub(d.price_per_m2)}</td>
|
||||
<td className="num">{fmtDate(d.listing_date)}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="rosreestr-btn"
|
||||
onClick={() => void openRosreestrWithAddress(d.address)}
|
||||
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||||
>
|
||||
Росреестр ↗
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{visiblePairs.map((p) => {
|
||||
const safeListingUrl = safeUrl(p.listing_source_url);
|
||||
const hasListing = p.listing_id !== null && p.listing_price_rub !== null;
|
||||
return (
|
||||
<tr key={p.deal_id}>
|
||||
<td>
|
||||
<span className="addr">
|
||||
<span className="a-main">{p.deal_address}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{p.deal_area_m2.toFixed(1)} м²</td>
|
||||
<td className="num">{fmtRub(p.deal_price_rub)} ₽</td>
|
||||
<td className="num">{fmtRub(p.deal_price_per_m2)}</td>
|
||||
<td className="num">{fmtDate(p.deal_date)}</td>
|
||||
{hasAnyListing && (
|
||||
<td className="listing-cell">
|
||||
{hasListing ? (
|
||||
<ListingBadge
|
||||
source={p.listing_source}
|
||||
url={safeListingUrl}
|
||||
priceRub={p.listing_price_rub}
|
||||
daysToDeal={p.days_listing_to_deal}
|
||||
discountPct={p.discount_pct}
|
||||
/>
|
||||
) : (
|
||||
<span className="listing-empty">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="rosreestr-btn"
|
||||
onClick={() => void openRosreestrWithAddress(p.deal_address)}
|
||||
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||||
>
|
||||
Росреестр ↗
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="table-foot">
|
||||
<span>
|
||||
Показано{" "}
|
||||
<b style={{ color: "var(--fg)" }}>{Math.min(data.deals.length, 10)}</b>{" "}
|
||||
из <b style={{ color: "var(--fg)" }}>{data.count}</b> сделок
|
||||
<b style={{ color: "var(--fg)" }}>{visiblePairs.length}</b> из{" "}
|
||||
<b style={{ color: "var(--fg)" }}>{data.total_deals}</b> сделок
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Listing badge cell ───────────────────────────────────────────────────────
|
||||
|
||||
interface ListingBadgeProps {
|
||||
source: string | null;
|
||||
url: string | null;
|
||||
priceRub: number | null;
|
||||
daysToDeal: number | null;
|
||||
discountPct: number | null;
|
||||
}
|
||||
|
||||
function ListingBadge({
|
||||
source,
|
||||
url,
|
||||
priceRub,
|
||||
daysToDeal,
|
||||
discountPct,
|
||||
}: ListingBadgeProps) {
|
||||
const sourceLabel = source ? source.toUpperCase() : "—";
|
||||
// discount < 0 → продано дешевле выставленного (типичный торг, success).
|
||||
// discount > 0 → продано дороже выставленного (редко, обычно ошибка data, danger).
|
||||
const discountClass =
|
||||
discountPct === null
|
||||
? ""
|
||||
: discountPct < 0
|
||||
? "discount-neg"
|
||||
: discountPct > 0
|
||||
? "discount-pos"
|
||||
: "";
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<span className="listing-source-badge">{sourceLabel}</span>
|
||||
<span className="listing-price mono">
|
||||
{priceRub !== null ? `${fmtRub(priceRub)} ₽` : "—"}
|
||||
</span>
|
||||
<span className="listing-meta">
|
||||
{fmtDaysToDeal(daysToDeal)}
|
||||
{discountPct !== null && (
|
||||
<>
|
||||
{" · "}
|
||||
<b className={`${discountClass} mono`}>{fmtDiscount(discountPct)}</b>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<a
|
||||
className="listing-link"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Открыть объявление на ${sourceLabel.toLowerCase()}`}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="listing-link is-static">{inner}</span>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1612,3 +1612,82 @@
|
|||
color: var(--fg);
|
||||
border-color: var(--fg);
|
||||
}
|
||||
|
||||
/* ── PR L — StreetDealsCard with paired listings (sales-vs-listings) ── */
|
||||
|
||||
/* Подсказка о покрытии (linkage_rate_pct) в card-head */
|
||||
.linkage-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.linkage-hint b {
|
||||
color: var(--fg-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Знак discount_pct — отрицательный = торг (success), положительный = редко (danger). */
|
||||
.discount-neg { color: var(--success); }
|
||||
.discount-pos { color: var(--danger); }
|
||||
|
||||
/* Ячейка listing-колонки */
|
||||
.listing-cell {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.listing-empty {
|
||||
color: var(--muted-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Container link / static — inline-flex с baseline alignment */
|
||||
.listing-link {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.listing-link:hover:not(.is-static) .listing-price { color: var(--accent); }
|
||||
.listing-link.is-static { cursor: default; }
|
||||
|
||||
/* Source badge (AVITO / CIAN / YANDEX / N1 / DOMKLIK) — per ui-tokens accent-soft + accent text uppercase 11px */
|
||||
.listing-source-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-ink);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.listing-price {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.listing-meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile (≤720px) — listing column stacks; tables уже становятся block per existing rules */
|
||||
@media (max-width: 720px) {
|
||||
.listing-cell { min-width: 0; max-width: none; }
|
||||
.listing-link { flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
HouseInfoForEstimate,
|
||||
IMVBenchmarkResponse,
|
||||
PlacementHistoryItem,
|
||||
SalesVsListingsResponse,
|
||||
SellTimeSensitivityResponse,
|
||||
StreetDealsResponse,
|
||||
TradeInEstimateInput,
|
||||
|
|
@ -144,6 +145,34 @@ export function useStreetDeals(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/trade-in/sales-vs-listings
|
||||
* ДКП-сделки + paired listings по улице (PR L / #564 Phase 2). Возвращает все
|
||||
* пары LEFT JOIN — сделки без listing match сохраняются (listing_* = null),
|
||||
* чтобы вычислить linkage_rate. Используется в StreetDealsCard для показа
|
||||
* исторических ASK рядом с фактической ценой сделки.
|
||||
*
|
||||
* staleTime: 10 минут — данные обновляются раз в сутки (rosreestr import +
|
||||
* scrapers), нет смысла часто рефетчить.
|
||||
*/
|
||||
export function useSalesVsListings(
|
||||
address: string | null,
|
||||
area_m2: number | null,
|
||||
rooms: number | null,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (address) params.set("address", address);
|
||||
if (area_m2 !== null) params.set("area_m2", String(area_m2));
|
||||
if (rooms !== null) params.set("rooms", String(rooms));
|
||||
return useQuery<SalesVsListingsResponse>({
|
||||
queryKey: ["trade-in", "sales-vs-listings", address, area_m2, rooms],
|
||||
queryFn: () =>
|
||||
apiFetch<SalesVsListingsResponse>(`${BASE}/sales-vs-listings?${params}`),
|
||||
enabled: !!address && area_m2 !== null && rooms !== null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
|
||||
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
|
||||
|
|
|
|||
|
|
@ -206,6 +206,48 @@ export interface StreetDealsResponse {
|
|||
deals: AnalogLot[]; // top-10 по deal_date DESC
|
||||
}
|
||||
|
||||
// ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ──
|
||||
// Phase 2 PR L (#564): для каждой ДКП-сделки возвращает paired listing (если есть).
|
||||
// LEFT JOIN — все deals сохраняются; listing_* = null при отсутствии match.
|
||||
|
||||
export interface SalesListingPair {
|
||||
deal_id: number;
|
||||
deal_date: string; // ISO date
|
||||
deal_price_rub: number;
|
||||
deal_price_per_m2: number;
|
||||
deal_area_m2: number;
|
||||
deal_rooms: number;
|
||||
deal_floor: number | null;
|
||||
deal_address: string;
|
||||
|
||||
listing_id: number | null;
|
||||
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik'
|
||||
listing_source_url: string | null;
|
||||
listing_date: string | null; // ISO date
|
||||
listing_price_rub: number | null;
|
||||
listing_price_per_m2: number | null;
|
||||
listing_area_m2: number | null;
|
||||
|
||||
// Положительный = listing date раньше сделки (типичный кейс).
|
||||
// Отрицательный = listing появился позже сделки (отложенный парсинг).
|
||||
days_listing_to_deal: number | null;
|
||||
// discount_pct = (deal_price - listing_price) / listing_price * 100.
|
||||
// Отрицательный = продали дешевле выставленного (нормальный торг).
|
||||
discount_pct: number | null;
|
||||
}
|
||||
|
||||
export interface SalesVsListingsResponse {
|
||||
street: string | null;
|
||||
period_months: number; // 24 default
|
||||
window_days: number; // 180 default
|
||||
area_tolerance: number; // 0.15 default
|
||||
total_deals: number;
|
||||
deals_with_listings: number;
|
||||
linkage_rate_pct: number;
|
||||
median_discount_pct: number | null;
|
||||
pairs: SalesListingPair[]; // все sorted by deal_date DESC
|
||||
}
|
||||
|
||||
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
||||
|
||||
export interface SellTimeBucket {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue