+ {/* 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 && (
+ <>
+
+
+ {/* 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 Ч
-
-
+ {/* 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),
- )} (квартал, Росреестр)
+ {/* 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 {