fix(tradein/v2): честность+консистентность — реконсиляция счётчика аналогов, убрать фейк-donut/линейку, outlier-guard, created_at, дисклеймеры (#2481)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m55s
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped

This commit is contained in:
bot-backend 2026-07-12 15:09:10 +00:00
parent a88e755403
commit 338ccbb78f
8 changed files with 278 additions and 185 deletions

View file

@ -406,6 +406,12 @@ export default function HeroBar({
{data.object.city}
</span>
</div>
{/* 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 && (
<>
<div
style={{
height: 1,
@ -423,6 +429,8 @@ export default function HeroBar({
>
{data.object.streetView}
</div>
</>
)}
<div
style={{
height: 1,
@ -520,7 +528,12 @@ export default function HeroBar({
</div>
</div>
{/* compass */}
{/* compass Fix #4 (audit): compass bearing is always "" (TODO BE-3,
mappers.ts mapObject), so the icon+label are hidden rather than
showing a compass that never actually points anywhere. Mirrors the
locationCoef "нет данных" graceful-fallback pattern already used
in this file. */}
{data.object.compass && (
<div
style={{
position: "absolute",
@ -555,79 +568,12 @@ export default function HeroBar({
{data.object.compass}
</span>
</div>
)}
{/* distance scale */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: 16,
width: 170,
fontFamily: tokens.font.mono,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
height: 8,
borderBottom: "1px solid rgba(46,139,255,.55)",
}}
>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "7.5px",
color: tokens.muted,
marginTop: 3,
}}
>
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100м</span>
</div>
</div>
{/* 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 */}
<div

View file

@ -273,6 +273,11 @@ export function ObjectSummary({
</div>
{/* КАЧЕСТВО ДАННЫХ */}
{/* 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. */}
<div
style={{
marginTop: 14,
@ -280,34 +285,9 @@ export function ObjectSummary({
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 6,
padding: 12,
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.track}
strokeWidth="3"
/>
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="126"
strokeDashoffset="72"
transform="rotate(-90 25 25)"
/>
<circle cx="25" cy="25" r="11" stroke="#cdddec" strokeWidth="1" />
<circle cx="25" cy="25" r="1.6" fill={tokens.accent} />
</svg>
<div style={{ flex: 1 }}>
<div>
<div
style={{
fontSize: 9.5,

View file

@ -1078,6 +1078,23 @@ export default function ParamsPanel({
/>
</div>
{/* 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. */}
<div
style={{
fontSize: 9,
color: tokens.hint,
lineHeight: 1.4,
marginTop: 6,
flex: "0 0 auto",
}}
>
Схематично · не географическая карта · радиус не в масштабе
</div>
{/* radius row */}
<div
style={{
@ -1634,7 +1651,12 @@ export default function ParamsPanel({
</div>
)}
{/* cache status */}
{/* cache status Fix #6 (audit): was static, so it showed "fresh" before
any estimate existed. Gated on hasEstimate (same signal that already
swaps the CTA to «СКАЧАТЬ PDF-ОТЧЁТ» above) the 24 Ч TTL itself is a
real cache constant (ParamsPanel/mappers.ts cacheStatus), only the
"always visible" part was dishonest. */}
{hasEstimate && (
<div
style={{
display: "flex",
@ -1669,6 +1691,7 @@ export default function ParamsPanel({
ДЕЙСТВИТЕЛЕН 24 Ч
</span>
</div>
)}
</div>
);
}

View file

@ -789,6 +789,14 @@ export default function ResultPanel({
Подробнее <span></span>
</button>
</div>
{/* 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 && (
<div style={{ fontSize: 9.5, color: hint, marginBottom: 8 }}>
{data.meta.sourcesNote}
</div>
)}
<div
style={{
display: "grid",

View file

@ -194,7 +194,7 @@ function dealPopupHtml(l: AnalogLot, stackCount: number): string {
<div style="color:${tokens.muted}">${esc(dealMeta(l))}</div>
<div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
fmtMonthYear(l.listing_date),
)} (квартал, Росреестр)</div>
)} (месяц, Росреестр)</div>
${stackNote}
</div>`;
}
@ -452,7 +452,13 @@ export function SourcesMap({ estimate }: Props) {
<div style={cardTitle}>Аналоги и сделки на карте</div>
</div>
<div style={headerRight}>
{/* 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. */}
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
{" из "}
{estimate.n_analogs}
{dealPoints.length > 0 && (
<>
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>

View file

@ -223,6 +223,25 @@ const AD_COLS: Col<AdRowData>[] = [
cell: (r) => (
<span role="cell" style={monoMuted}>
{r.ppm}
{r.outlier && (
<span
style={{
display: "inline-block",
marginLeft: 6,
fontSize: "8px",
fontFamily: tokens.font.sans,
color: tokens.warn,
border: `1px solid ${tokens.warn}`,
borderRadius: 3,
padding: "1px 4px",
whiteSpace: "nowrap",
verticalAlign: "middle",
}}
title="₽/м² сильно выше медианы выборки — исключено из расчёта «РАЗБРОС ЦЕН»"
>
возможный выброс
</span>
)}
</span>
),
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 && (
<div
style={{
padding: "11px 18px",
fontSize: "10px",
color: tokens.hint,
background: KPI_BG,
}}
>
{data.adsFootnote}
</div>
)}
</div>
{/* фактические сделки */}

View file

@ -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
// expires24h 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.53× 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 = expires24h. */
/**
* 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
* expires24h 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: "СРЕДНЯЯ ЦЕНА",

View file

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