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
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:
parent
a88e755403
commit
338ccbb78f
8 changed files with 278 additions and 185 deletions
|
|
@ -406,6 +406,12 @@ export default function HeroBar({
|
||||||
{data.object.city}
|
{data.object.city}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|
@ -423,6 +429,8 @@ export default function HeroBar({
|
||||||
>
|
>
|
||||||
{data.object.streetView}
|
{data.object.streetView}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|
@ -520,7 +528,12 @@ export default function HeroBar({
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -555,79 +568,12 @@ export default function HeroBar({
|
||||||
{data.object.compass}
|
{data.object.compass}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* distance scale */}
|
{/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a
|
||||||
<div
|
hardcoded tick scale over a generic stock photo with no real
|
||||||
style={{
|
measurement behind it (not tied to any actual distance/scale
|
||||||
position: "absolute",
|
value). Removed rather than fabricating a scale. */}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* corner brackets */}
|
{/* corner brackets */}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,11 @@ export function ObjectSummary({
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
|
|
@ -280,34 +285,9 @@ export function ObjectSummary({
|
||||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
display: "flex",
|
|
||||||
gap: 12,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
|
<div>
|
||||||
<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={{
|
style={{
|
||||||
fontSize: 9.5,
|
fontSize: 9.5,
|
||||||
|
|
|
||||||
|
|
@ -1078,6 +1078,23 @@ export default function ParamsPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* radius row */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1634,7 +1651,12 @@ export default function ParamsPanel({
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -1669,6 +1691,7 @@ export default function ParamsPanel({
|
||||||
ДЕЙСТВИТЕЛЕН 24 Ч
|
ДЕЙСТВИТЕЛЕН 24 Ч
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -789,6 +789,14 @@ export default function ResultPanel({
|
||||||
Подробнее <span>→</span>
|
Подробнее <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
|
|
||||||
|
|
@ -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}">${esc(dealMeta(l))}</div>
|
||||||
<div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
|
<div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
|
||||||
fmtMonthYear(l.listing_date),
|
fmtMonthYear(l.listing_date),
|
||||||
)} (квартал, Росреестр)</div>
|
)} (месяц, Росреестр)</div>
|
||||||
${stackNote}
|
${stackNote}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -452,7 +452,13 @@ export function SourcesMap({ estimate }: Props) {
|
||||||
<div style={cardTitle}>Аналоги и сделки на карте</div>
|
<div style={cardTitle}>Аналоги и сделки на карте</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={headerRight}>
|
<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>
|
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
|
||||||
|
{" из "}
|
||||||
|
{estimate.n_analogs}
|
||||||
{dealPoints.length > 0 && (
|
{dealPoints.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
|
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,25 @@ const AD_COLS: Col<AdRowData>[] = [
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<span role="cell" style={monoMuted}>
|
<span role="cell" style={monoMuted}>
|
||||||
{r.ppm}
|
{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>
|
</span>
|
||||||
),
|
),
|
||||||
empty: (r) => isDash(r.ppm),
|
empty: (r) => isDash(r.ppm),
|
||||||
|
|
@ -545,6 +564,21 @@ export default function SourcesView({
|
||||||
rows={data.adRows}
|
rows={data.adRows}
|
||||||
grid={adGrid}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* фактические сделки */}
|
{/* фактические сделки */}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@
|
||||||
// cv/source_counts/created_at. mapSources' marketAds.kpi.cv (#2041)
|
// cv/source_counts/created_at. mapSources' marketAds.kpi.cv (#2041)
|
||||||
// prefers estimate.cv and only falls back to the FE coefficient of
|
// prefers estimate.cv and only falls back to the FE coefficient of
|
||||||
// variation (analog ₽/м²) for pre-#2043 cached estimates where cv is
|
// variation (analog ₽/м²) for pre-#2043 cached estimates where cv is
|
||||||
// absent. mapReport/mapResultPanel/mapSummary/mapCache still use the
|
// absent. mapReport now prefers real created_at too (falls back to the
|
||||||
// pre-BE-1 approximations (created_at shift, FE source groupBy) — out
|
// expires−24h shift only for pre-BE-1 rows without it). mapResultPanel/
|
||||||
// of scope for #2040/#2041, left as-is.
|
// 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
|
// BE-2 target_address is a single string; street/city are split heuristically
|
||||||
// (parseAddress). Backend should return structured address components.
|
// (parseAddress). Backend should return structured address components.
|
||||||
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
// 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;
|
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
|
// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of
|
||||||
// variation) is not surfaced to users anymore, and the витрина rounds it to a
|
// 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
|
// 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 {
|
function cvLabel(e: AggregatedEstimate | null): string {
|
||||||
if (e == null) return "—";
|
if (e == null) return "—";
|
||||||
if (e.cv != null && Number.isFinite(e.cv)) return cvRatioStr(e.cv);
|
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 ──────────────────────────────────────────────────────────
|
// ── 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 {
|
export function mapReport(e: AggregatedEstimate): Report {
|
||||||
return {
|
return {
|
||||||
id: e.estimate_id.slice(0, 8),
|
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),
|
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 = {
|
const meta: ResultMeta = {
|
||||||
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||||||
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
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,
|
builtOn,
|
||||||
|
sourcesNote,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adsBar: RangeBar = {
|
const adsBar: RangeBar = {
|
||||||
|
|
@ -1155,7 +1206,7 @@ export function mapSummary(
|
||||||
quality: {
|
quality: {
|
||||||
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||||||
confidence: CONFIDENCE_RU[e.confidence],
|
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;
|
dist?: string;
|
||||||
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
|
/** Original listing URL (was a hardcoded "Росреестр ↗" link). */
|
||||||
url?: string | null;
|
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 {
|
export interface DealRowData extends DealRow {
|
||||||
/** Source brand label for the badge (was hardcoded "Росреестр"). */
|
/** Source brand label for the badge (was hardcoded "Росреестр"). */
|
||||||
|
|
@ -1852,6 +1906,13 @@ export interface SourcesData {
|
||||||
marketDeals: MarketDeals;
|
marketDeals: MarketDeals;
|
||||||
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
|
/** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
|
||||||
sourceCount?: string;
|
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).
|
// Filter chips: distance corridor + rooms + analog area span (design order).
|
||||||
|
|
@ -1917,7 +1978,12 @@ export function mapSources(
|
||||||
): SourcesData {
|
): SourcesData {
|
||||||
const e = estimate;
|
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 || "—"),
|
addr: deglueAddr(l.address || "—"),
|
||||||
meta: adMeta(l),
|
meta: adMeta(l),
|
||||||
ppm: numRu(l.price_per_m2),
|
ppm: numRu(l.price_per_m2),
|
||||||
|
|
@ -1925,6 +1991,7 @@ export function mapSources(
|
||||||
source: sourceLabel(l.source),
|
source: sourceLabel(l.source),
|
||||||
dist: fmtDist(l.distance_m),
|
dist: fmtDist(l.distance_m),
|
||||||
url: l.source_url ?? null,
|
url: l.source_url ?? null,
|
||||||
|
outlier: adOutliers[i] ?? false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
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 {
|
return {
|
||||||
adRows,
|
adRows,
|
||||||
dealRows,
|
dealRows,
|
||||||
|
|
@ -1986,6 +2070,7 @@ export function mapSources(
|
||||||
sourceCount: estimate
|
sourceCount: estimate
|
||||||
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
|
? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
|
||||||
: "—",
|
: "—",
|
||||||
|
adsFootnote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2123,7 +2208,9 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
|
||||||
{
|
{
|
||||||
label: "ВСЕГО ОЦЕНОК",
|
label: "ВСЕГО ОЦЕНОК",
|
||||||
value: String(total),
|
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: "СРЕДНЯЯ ЦЕНА",
|
label: "СРЕДНЯЯ ЦЕНА",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@ export interface ResultMeta {
|
||||||
cv: string;
|
cv: string;
|
||||||
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
|
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
|
||||||
builtOn?: string;
|
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 {
|
export interface RangeMarker {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue