From c672e0322da0a8e6e5391cb33d5b8e20e3c9f31c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 22:49:30 +0300 Subject: [PATCH] feat(tradein/v2): wire AnalyticsView + SourcesView overlays to real API data (#2040, #2041) Audit of /trade-in/v2 found the FE-3/FE-4 overlay wiring (mapAnalytics/mapSources feeding AnalyticsView/SourcesView from useEstimateHouseAnalytics, useEstimateSellTimeSensitivity, analogs[]/actual_deals[]) already shipped in 267585e3. The remaining gap was BE-1 (#2043, merged 2026-07-02) landing real cv/source_counts/created_at on AggregatedEstimate AFTER that wiring pass, so v2/mappers.ts was still FE-approximating cv and duplicating a hardcoded source label map instead of the single source-registry (#2211). - mapSources' marketAds.kpi.cv (#2041) now prefers the real estimate.cv (0..1 ratio from BE-1) and only falls back to the FE coefficient-of-variation of analog prices for pre-#2043 cached estimates where cv is absent. - adRows/dealRows source badges (#2041) now resolve via the shared src/lib/source-registry sourceLabel() instead of a local SOURCE_LABEL map, picking up etagi/canonical labels and staying in sync with the #2211 roster. - AnalyticsView (#2040) KPIs/sell-time/price-history/scatterDetail were already wired to real data with no gaps found against the issue spec. --- .../src/components/trade-in/v2/mappers.ts | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 854bb054..b9724fdc 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -8,10 +8,13 @@ // renders. ALL money/format/geometry lives here — never copy fixture literals. // // Reconciliations / known FE-approximations (look for "TODO BE-N"): -// BE-1 created_at, cv, per-source lot counts are NOT in the API response. -// Report date is derived as expires_at − 24h; cv is the FE coefficient -// of variation of analog ₽/м²; source counts are an FE groupBy of -// analogs + actual_deals by .source. +// BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real +// cv/source_counts/created_at. mapSources' marketAds.kpi.cv (#2041) +// prefers estimate.cv and only falls back to the FE coefficient of +// variation (analog ₽/м²) for pre-#2043 cached estimates where cv is +// absent. mapReport/mapResultPanel/mapSummary/mapCache still use the +// pre-BE-1 approximations (created_at shift, FE source groupBy) — out +// of scope for #2040/#2041, left as-is. // BE-2 target_address is a single string; street/city are split heuristically // (parseAddress). Backend should return structured address components. // BE-3 location coefficient / street-view caption / compass bearing are not in @@ -67,6 +70,7 @@ import type { SummaryRow, } from "./types"; import { tokens } from "./tokens"; +import { sourceLabel as registrySourceLabel } from "@/lib/source-registry"; // Total number of source slots — meta "N / M". Single source of truth is // SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES). @@ -268,6 +272,26 @@ function cvStr(values: number[]): string { : "—"; } +/** Format a 0..1 coefficient-of-variation ratio the same way as cvStr's %. */ +function cvRatioStr(cv: number): string { + return `${(cv * 100).toLocaleString("ru-RU", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + })}%`; +} + +/** + * Analog ₽/м² spread, used by #2041's marketAds.kpi.cv. Prefers the real + * backend estimate.cv (#2043/BE-1, 0..1 ratio) and only falls back to the FE + * coefficient of variation for pre-#2043 cached estimates where cv is absent + * (older persisted rows don't carry it — see the file-header TODO BE-1 note). + */ +function cvLabel(e: AggregatedEstimate | null): string { + if (e == null) return "—"; + if (e.cv != null && Number.isFinite(e.cv)) return cvRatioStr(e.cv); + return cvStr(e.analogs.map((a) => a.price_per_m2)); +} + /** * Bin `values` into 8 buckets across [min,max]; return per-bucket height as * count/maxCount*100 (the mini-histogram bar heights). Empty input -> []. @@ -1107,22 +1131,13 @@ function fmtDist(m: number | null | undefined): string { })}${NBSP}км`; } -// Source key -> brand/RU display label (badges + link source). -const SOURCE_LABEL: Record = { - avito: "Avito", - avito_imv: "Avito", - cian: "Циан", - yandex: "Яндекс", - rosreestr: "Росреестр", - domklik: "Домклик", - domclick: "Домклик", - restate: "Restate", - // n1 полностью выключен (#2204) → истор. строки source='n1' идут через fallback `?? s`. -}; - +// Source key -> brand/RU display label (badges + link source). Delegates to the +// single source-registry roster (#2211) instead of a local hardcoded map — an +// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim +// via the registry's own fallback rather than inventing a label. function sourceLabel(s: string | null | undefined): string { if (!s) return "—"; - return SOURCE_LABEL[s.toLowerCase()] ?? s; + return registrySourceLabel(s.toLowerCase()); } /** Rosreestr deal tier -> RU caption next to the badge. */ @@ -1762,7 +1777,7 @@ export function mapSources( ? numRu(e.median_price_per_m2) : "—", range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—", - cv: e != null ? cvStr(e.analogs.map((a) => a.price_per_m2)) : "—", + cv: cvLabel(e), }, filters: buildAdFilters(e), };