feat(tradein): StreetDealsCard с paired listings (PR L, #564 Phase 2) #599

Merged
Light1YT merged 1 commit from feat/tradein-street-deals-with-asks into main 2026-05-27 12:29:06 +00:00
4 changed files with 340 additions and 41 deletions

View file

@ -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>;
}

View file

@ -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; }
}

View file

@ -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%).

View file

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