fix(tradein/v2): честность+консистентность UI-блоков
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 58s
CI Trade-In / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 58s
CI Trade-In / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Has been skipped
Полировка Mera v2 после live-аудита — тот же класс "фейк-данные под видом реальных", что уже чинили раньше. Честная деградация: убрать фейк / завести реальные данные / явно разметить, никогда не выдумывать. 1. Реконсиляция счётчика аналогов. Бэкенд (schemas/trade_in.py:163-165,180-181, estimator.py:3286-3310, #2087 M1) чётко документирует: n_analogs — полный счёт, использованный в расчёте; analogs/source_counts — персистентный top-10 сэмпл (намеренно капнутый, чтобы POST/GET не расходились). Это НЕ "найдено vs использовано", а "полный итог vs капнутая выборка для показа". 02/03/05 уже использовали n_analogs в headline-цифрах — оставлено. Добавлена дисклозура там, где рядом всплывала капнутая выборка и создавала мнимое противоречие: 02 (тайлы источников vs "ПОСТРОЕНО ПО N АНАЛОГАМ"), 05 (шапка карты и подвал таблицы объявлений) — по образцу уже существующего паттерна "Показано N из M" (deals-таблица). 2. Убран фейковый donut "КАЧЕСТВО ДАННЫХ" (ObjectSummary) — хардкод strokeDasharray/offset, композитного score нет нигде в мапперах. 3. Убрана фейковая линейка расстояний (HeroBar) — хардкод тиков 0/25/50/75/100м поверх стокового фото. 4. Скрыты компас и street-view слот (HeroBar) — оба поля всегда "" (TODO BE-3), по образцу уже используемого в файле паттерна locationCoef "нет данных". 5. mapReport берёт реальный created_at (#2043/BE-1), фоллбек на expires−24h только для pre-BE-1 записей без created_at. 6. Бейдж "ДАННЫЕ АКТУАЛЬНЫ/ДЕЙСТВИТЕЛЕН 24 Ч" (ParamsPanel) загейчен на hasEstimate — раньше показывался как свежий ДО первой оценки. 7. Дисклеймер у декоративной SVG-карты (ParamsPanel): "Схематично · не географическая карта · радиус не в масштабе". 8. Outlier-guard на ₽/м² (mappers.ts guardPriceOutliers, порог 2.5–3× медианы пула, взята середина — 2.75×): исключение из CV/разброса с дисклозурой count + бейдж "возможный выброс" на строке в таблице объявлений (05). 9. SourcesMap: попап сделки "(квартал, Росреестр)" → "(месяц, Росреестр)" — fmtMonthYear реально рендерит месяц. 10. mapCache "ВСЕГО ОЦЕНОК" → саб-лейбл "последние 50" (useEstimateHistory капает на 50, trade-in-api.ts:247) — было похоже на лайфтайм-тотал. tsc --noEmit + next lint зелёные; next build (задет shared types.ts) зелёный.
This commit is contained in:
parent
4f205bd7da
commit
06a88f683d
8 changed files with 278 additions and 185 deletions
|
|
@ -406,23 +406,31 @@ export default function HeroBar({
|
|||
{data.object.city}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: tokens.lineSoft,
|
||||
margin: "8px 0",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 9,
|
||||
color: tokens.muted,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{data.object.streetView}
|
||||
</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,
|
||||
background: tokens.lineSoft,
|
||||
margin: "8px 0",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 9,
|
||||
color: tokens.muted,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{data.object.streetView}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
|
|
@ -520,114 +528,52 @@ export default function HeroBar({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* compass */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||||
</svg>
|
||||
<span
|
||||
{/* 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={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 8,
|
||||
letterSpacing: ".5px",
|
||||
color: tokens.muted,
|
||||
whiteSpace: "nowrap",
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
{data.object.compass}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 8,
|
||||
letterSpacing: ".5px",
|
||||
color: tokens.muted,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,41 +1651,47 @@ export default function ParamsPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* cache status */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 11,
|
||||
flex: "0 0 auto",
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
|
||||
<span
|
||||
{/* 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",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.success,
|
||||
justifyContent: "space-between",
|
||||
marginTop: 11,
|
||||
flex: "0 0 auto",
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
|
||||
<span
|
||||
className="pp-dot"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: tokens.success,
|
||||
boxShadow: `0 0 6px ${tokens.success}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.success,
|
||||
}}
|
||||
/>
|
||||
ДЕЙСТВИТЕЛЕН 24 Ч
|
||||
</span>
|
||||
</div>
|
||||
>
|
||||
<span
|
||||
className="pp-dot"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: tokens.success,
|
||||
boxShadow: `0 0 6px ${tokens.success}`,
|
||||
}}
|
||||
/>
|
||||
ДЕЙСТВИТЕЛЕН 24 Ч
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* фактические сделки */}
|
||||
|
|
|
|||
|
|
@ -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: "СРЕДНЯЯ ЦЕНА",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue