From 338ccbb78fcde881e1bc476aac5d88f77221e723 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 15:09:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/v2):=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C+=D0=BA=D0=BE=D0=BD=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BD=D1=81=D0=B8=D0=BB?= =?UTF-8?q?=D1=8F=D1=86=D0=B8=D1=8F=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?,=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA-donut/=D0=BB=D0=B8=D0=BD=D0=B5=D0=B9=D0=BA=D1=83,=20outl?= =?UTF-8?q?ier-guard,=20created=5Fat,=20=D0=B4=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=D0=BC=D0=B5=D1=80=D1=8B=20(#2481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/trade-in/v2/HeroBar.tsx | 188 +++++++----------- .../components/trade-in/v2/ObjectSummary.tsx | 32 +-- .../components/trade-in/v2/ParamsPanel.tsx | 77 ++++--- .../components/trade-in/v2/ResultPanel.tsx | 8 + .../src/components/trade-in/v2/SourcesMap.tsx | 8 +- .../components/trade-in/v2/SourcesView.tsx | 34 ++++ .../src/components/trade-in/v2/mappers.ts | 107 +++++++++- .../src/components/trade-in/v2/types.ts | 9 + 8 files changed, 278 insertions(+), 185 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index afea7a59..0a89cd51 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -406,23 +406,31 @@ export default function HeroBar({ {data.object.city} -
-
- {data.object.streetView} -
+ {/* streetView — Fix #4 (audit): always "" (TODO BE-3, mappers.ts + mapObject), so this slot + its divider are hidden together + rather than showing an empty caption row. Leaves the single + divider below to separate the address block from the coef row. */} + {data.object.streetView && ( + <> +
+
+ {data.object.streetView} +
+ + )}
- {/* compass */} -
- - -
+ + + {data.object.compass} + +
+ )} - {/* distance scale */} -
-
- - - - - -
-
- 0 - 25 - 50 - 75 - 100м -
-
+ {/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a + hardcoded tick scale over a generic stock photo with no real + measurement behind it (not tied to any actual distance/scale + value). Removed rather than fabricating a scale. */} {/* corner brackets */}
{/* КАЧЕСТВО ДАННЫХ */} + {/* Fix #2 (audit) — the donut ring here used to be a hardcoded + strokeDasharray/strokeDashoffset (~43%, never changed): no composite + data-quality SCORE exists anywhere in the mapper to back a ring + fraction, so it always lied. Removed rather than inventing a metric; + the three real rows below (Источников/Достоверность/Разброс) stay. */}
- -
+
+ {/* Fix #7 (audit) — the SVG above is decorative (grid + fake streets + + radius rings), not a real map, and the rings themselves aren't drawn + to scale (outerRingR is a clamped best-effort, not a true geometric + projection of РАДИУС). Same honesty-caption tone as SourcesMap's + footer disclosure. */} +
+ Схематично · не географическая карта · радиус не в масштабе +
+ {/* radius row */}
)} - {/* cache status */} -
- ДАННЫЕ АКТУАЛЬНЫ - + ДАННЫЕ АКТУАЛЬНЫ - ДЕЙСТВИТЕЛЕН 24 Ч - -
+ > + + ДЕЙСТВИТЕЛЕН 24 Ч + +
+ )}
); } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index b627d0b6..bbe9b9f1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -789,6 +789,14 @@ export default function ResultPanel({ Подробнее
+ {/* Fix #1 (audit) — the tile counts below are summed from the top-10 + persisted sample, which can be less than builtOn's full n_analogs + total (footer, below) for large result sets. */} + {data.meta.sourcesNote && ( +
+ {data.meta.sourcesNote} +
+ )}
${esc(dealMeta(l))}
Период сделки: ${esc( fmtMonthYear(l.listing_date), - )} (квартал, Росреестр)
+ )} (месяц, Росреестр)
${stackNote}
`; } @@ -452,7 +452,13 @@ export function SourcesMap({ estimate }: Props) {
Аналоги и сделки на карте
+ {/* Fix #1 — analogPoints is the top-10 display sample (only what the + backend returns coords for); estimate.n_analogs is the true total + used in the calc. "N из M" mirrors the deals-table "Показано N из + M" pattern so this never contradicts the market KPI band above. */} Объявлений: {analogPoints.length} + {" из "} + {estimate.n_analogs} {dealPoints.length > 0 && ( <> {" · "}сделок: {dealPoints.length} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 25193150..7e697102 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -223,6 +223,25 @@ const AD_COLS: Col[] = [ cell: (r) => ( {r.ppm} + {r.outlier && ( + + возможный выброс + + )} ), empty: (r) => isDash(r.ppm), @@ -545,6 +564,21 @@ export default function SourcesView({ rows={data.adRows} grid={adGrid} /> + + {/* Fix #1/#8 — discloses the top-10 display cap vs. the true n_analogs + total (mirrors the deals-table footer below) + any outlier exclusion. */} + {data.adsFootnote && ( +
+ {data.adsFootnote} +
+ )}
{/* фактические сделки */} 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 c8710f14..ab87da1f 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -12,9 +12,10 @@ // 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. +// absent. mapReport now prefers real created_at too (falls back to the +// expires−24h shift only for pre-BE-1 rows without it). mapResultPanel/ +// mapSummary/mapCache still use the other pre-BE-1 approximations (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 shipped 2026-07-03 (#2045) and is wired here @@ -265,6 +266,40 @@ function coeffVar(values: number[]): number | null { return (Math.sqrt(variance) / mean) * 100; } +// Robust ₽/м² outlier guard (Fix #8 / audit): a single mis-scraped or +// mislabeled lot (e.g. 872 093 ₽/м² next to a 177k pool median) silently +// blows up «РАЗБРОС ЦЕН» (CV) with no visible cause. Any price_per_m2 more +// than OUTLIER_MULT× the pool's OWN median is flagged as a "возможный +// выброс" (row badge, see mapSources) AND excluded from the spread calc below +// — never silently: the exclusion count is surfaced next to the number it +// affects (see mapSources' adsFootnote). +const OUTLIER_MULT = 2.75; // midpoint of the audited 2.5–3× band + +/** + * Splits `ppm2` (parallel to the source row array) into outlier-free values + * for spread/median math and a same-length flag array for row badges. + */ +function guardPriceOutliers(ppm2: (number | null | undefined)[]): { + clean: number[]; + isOutlier: boolean[]; + excludedCount: number; +} { + const finite = ppm2.filter( + (v): v is number => v != null && Number.isFinite(v) && v > 0, + ); + const pool = median(finite); + const isOutlier = ppm2.map((v) => + v != null && Number.isFinite(v) && v > 0 && pool != null && pool > 0 + ? v > pool * OUTLIER_MULT + : false, + ); + const clean = ppm2.filter( + (v, i): v is number => + v != null && Number.isFinite(v) && v > 0 && !isOutlier[i], + ); + return { clean, isOutlier, excludedCount: isOutlier.filter(Boolean).length }; +} + // «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of // variation) is not surfaced to users anymore, and the витрина rounds it to a // whole percent ("42%", not "42,2%") — the extra decimal implied a false @@ -295,7 +330,7 @@ function cvRatioStr(cv: number): string { 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)); + return cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean); } /** @@ -727,11 +762,17 @@ function buildScatterMini( // ── Public mappers ────────────────────────────────────────────────────────── -/** Report header (HeroBar / Footer). id = first 8 of UUID; date = expires−24h. */ +/** + * Report header (HeroBar / Footer). id = first 8 of UUID; date = real + * created_at (#2043/BE-1) when the estimate has one, falling back to the old + * expires−24h approximation only for pre-BE-1 rows where created_at is absent. + */ export function mapReport(e: AggregatedEstimate): Report { return { id: e.estimate_id.slice(0, 8), - date: fmtDateShift(e.expires_at, -24), // TODO BE-1: real created_at + date: e.created_at + ? fmtDate(e.created_at) + : fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at validUntil: fmtDate(e.expires_at), }; } @@ -1042,11 +1083,21 @@ export function mapResultPanel( "СДЕЛКАМ", ])}`; + // Fix #1 — the ИСТОЧНИКИ ДАННЫХ tiles below sum lots from e.analogs (top-10 + // persisted sample, backend #2087 M1), not the full e.n_analogs population + // that builtOn (above) reports. Disclose the gap so the two numbers on this + // one card never read as a single contradictory total. + const sourcesNote = + e.analogs.length < e.n_analogs + ? `Разбивка — по показанным ${e.analogs.length} из ${e.n_analogs} аналогам` + : undefined; + const meta: ResultMeta = { sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence].toUpperCase(), - cv: cvStr(e.analogs.map((a) => a.price_per_m2)), + cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), builtOn, + sourcesNote, }; const adsBar: RangeBar = { @@ -1155,7 +1206,7 @@ export function mapSummary( quality: { sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`, confidence: CONFIDENCE_RU[e.confidence], - cv: cvStr(e.analogs.map((a) => a.price_per_m2)), + cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean), }, }; } @@ -1835,6 +1886,9 @@ export interface AdRowData extends AdRow { dist?: string; /** Original listing URL (was a hardcoded "Росреестр ↗" link). */ url?: string | null; + /** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV, + * flagged with a "возможный выброс" badge instead of silently distorting it. */ + outlier?: boolean; } export interface DealRowData extends DealRow { /** Source brand label for the badge (was hardcoded "Росреестр"). */ @@ -1852,6 +1906,13 @@ export interface SourcesData { marketDeals: MarketDeals; /** Canonical active-source counter for the overlay header, e.g. "4 / 7". */ sourceCount?: string; + /** + * Fix #1/#8 — disclosure caption under the ads table: "Показано N из M" + * (adRows.length is the top-10 display sample; marketAds.kpi.count/n_analogs + * is the true total used in calc — mirrors the deals-table footer below) plus + * an outlier-exclusion note when guardPriceOutliers flagged any row. + */ + adsFootnote?: string; } // Filter chips: distance corridor + rooms + analog area span (design order). @@ -1917,7 +1978,12 @@ export function mapSources( ): SourcesData { const e = estimate; - const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({ + // Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently + // fold them into the median/spread math). + const { isOutlier: adOutliers, excludedCount: adOutlierCount } = + guardPriceOutliers((e?.analogs ?? []).map((l) => l.price_per_m2)); + + const adRows: AdRowData[] = (e?.analogs ?? []).map((l, i) => ({ addr: deglueAddr(l.address || "—"), meta: adMeta(l), ppm: numRu(l.price_per_m2), @@ -1925,6 +1991,7 @@ export function mapSources( source: sourceLabel(l.source), dist: fmtDist(l.distance_m), url: l.source_url ?? null, + outlier: adOutliers[i] ?? false, })); const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({ @@ -1978,6 +2045,23 @@ export function mapSources( }, }; + // Fix #1 — adRows is the top-10 display sample; marketAds.kpi.count/n_analogs + // is the true total used in the calc (backend: "Консьюмер должен брать счёт + // отсюда, а не из len()"). Disclose the gap the same way the deals table + // already does ("Показано N из M"), plus the outlier exclusion from Fix #8. + const outlierNote = + adOutlierCount > 0 + ? ` · ${adOutlierCount} ${pluralRu(adOutlierCount, [ + "возможный выброс исключён", + "возможных выброса исключено", + "возможных выбросов исключено", + ])} из расчёта разброса` + : ""; + const adsFootnote = + e != null + ? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}` + : undefined; + return { adRows, dealRows, @@ -1986,6 +2070,7 @@ export function mapSources( sourceCount: estimate ? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}` : "—", + adsFootnote, }; } @@ -2123,7 +2208,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData { { label: "ВСЕГО ОЦЕНОК", value: String(total), - sub: "по вашим оценкам", + // #2479-audit: useEstimateHistory() caps at 50 (trade-in-api.ts:247) — this + // is a page count, not a true lifetime total, so it must not read as one. + sub: "последние 50", }, { label: "СРЕДНЯЯ ЦЕНА", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 3f3da24d..b8ce9886 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -58,6 +58,15 @@ export interface ResultMeta { cv: string; /** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */ builtOn?: string; + /** + * Fix #1 (audit) — the ИСТОЧНИКИ ДАННЫХ tile counts are summed from the + * persisted top-10 analogs/deals sample (backend #2087 M1: source_counts is + * deliberately computed from the SAME capped list as `analogs`, to stay + * consistent across POST/GET), so they can undercount vs. builtOn's real + * n_analogs total for large result sets. e.g. "Разбивка — по показанным 10 + * из 37 аналогам" so the two numbers on this card never read as one total. + */ + sourcesNote?: string; } export interface RangeMarker {