From 6a46585e8c1487412953e8bd9af70cc8488f2e35 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 27 May 2026 17:28:38 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20StreetDealsCard=20=D1=81=20pai?= =?UTF-8?q?red=20listings=20(PR=20L=20/=20#564=20Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch StreetDealsCard от /street-deals к /sales-vs-listings. В каждой строке ДКП-сделки теперь показан linked исторический ASK с источником (Avito/Cian/Yandex/N1), ценой listing, сроком до сделки и discount_pct в цвете (зелёный = торг, красный = overshoot). В шапке секции — linkage_rate % сделок имеют match + медианный торг по pairs с listing. Changes: - types/trade-in.ts: SalesListingPair + SalesVsListingsResponse - lib/trade-in-api.ts: useSalesVsListings hook (TanStack, staleTime 10m) - components/StreetDealsCard.tsx: rewrite — switch endpoint, add listing column, compute aggregates client-side (count/median/range), extract subcomponent - components/trade-in.css: token-based styles (.linkage-hint, .discount-pos/neg, .listing-cell, .listing-link, .listing-source-badge) + mobile breakpoint Decisions: - Endpoint replacement (не parallel fetch) — /sales-vs-listings суперсет - Period default 24 мес (от endpoint default) — больше data для pairing - Discount sign: var(--success) при <0 (торг), var(--danger) при >0 - safeUrl() guard для listing URLs (XSS) + target="_blank" rel="noopener" - Mobile <720px — listing column stacks vertically - linkage_rate=0 → listing column скрыт целиком (`hasAnyListing` flag) Tests: tsc --noEmit clean. No new deps. Out of scope: useStreetDeals hook deprecated, можно убрать в cleanup PR. Closes #564 Phase 2. --- .../components/trade-in/StreetDealsCard.tsx | 231 ++++++++++++++---- .../src/components/trade-in/trade-in.css | 79 ++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 29 +++ tradein-mvp/frontend/src/types/trade-in.ts | 42 ++++ 4 files changed, 340 insertions(+), 41 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx index 59bec39c..f627d4db 100644 --- a/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx @@ -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 (
@@ -49,27 +92,49 @@ export function StreetDealsCard({ estimate }: Props) {
Секция · По вашей улице

ДКП-сделки на улице {data.street}

+ {hasAnyListing && ( +
+ {data.linkage_rate_pct.toFixed(0)}% сделок имеют + исторический ASK + {data.median_discount_pct !== null && ( + <> + {" · медианный торг "} + 0 + ? "discount-pos mono" + : "mono" + } + > + {fmtDiscount(data.median_discount_pct)} + + + )} +
+ )}
- Период: 12 мес + Период: {data.period_months} мес
-
источник: Росреестр (открытые данные)
+
источник: Росреестр + объявления
-
Сделок за 12 мес
+
Сделок за {data.period_months} мес
- {data.count} + {data.total_deals} шт
Медиана
- {fmtRub(data.median_price_per_m2)} + {fmtRub(medianPpm2)} ₽/м²
@@ -84,51 +149,135 @@ export function StreetDealsCard({ estimate }: Props) {
- +
- + + {hasAnyListing && } - {data.deals.map((d: AnalogLot, i: number) => ( - - - - - - - - - ))} + {visiblePairs.map((p) => { + const safeListingUrl = safeUrl(p.listing_source_url); + const hasListing = p.listing_id !== null && p.listing_price_rub !== null; + return ( + + + + + + + {hasAnyListing && ( + + )} + + + ); + })}
Адрес ПлощадьЦенаЦена сделки ₽/м² ДатаОбъявление до сделки
- - {d.address} - - {d.area_m2.toFixed(1)} м²{fmtRub(d.price_rub)} ₽{fmtRub(d.price_per_m2)}{fmtDate(d.listing_date)} - -
+ + {p.deal_address} + + {p.deal_area_m2.toFixed(1)} м²{fmtRub(p.deal_price_rub)} ₽{fmtRub(p.deal_price_per_m2)}{fmtDate(p.deal_date)} + {hasListing ? ( + + ) : ( + + )} + + +
Показано{" "} - {Math.min(data.deals.length, 10)}{" "} - из {data.count} сделок + {visiblePairs.length} из{" "} + {data.total_deals} сделок
); } + +// ── 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 = ( + <> + {sourceLabel} + + {priceRub !== null ? `${fmtRub(priceRub)} ₽` : "—"} + + + {fmtDaysToDeal(daysToDeal)} + {discountPct !== null && ( + <> + {" · "} + {fmtDiscount(discountPct)} + + )} + + + ); + + if (url) { + return ( + + {inner} + + ); + } + + return {inner}; +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index f6ce77c8..e54febf1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -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; } +} diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index 818f212a..336d332a 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -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({ + queryKey: ["trade-in", "sales-vs-listings", address, area_m2, rooms], + queryFn: () => + apiFetch(`${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%). diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 97345d55..361e271f 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -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 {