feat(tradein/v2): wire AnalyticsView + SourcesView overlays to real API data (#2040, #2041)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s

Audit of /trade-in/v2 found the FE-3/FE-4 overlay wiring (mapAnalytics/mapSources
feeding AnalyticsView/SourcesView from useEstimateHouseAnalytics,
useEstimateSellTimeSensitivity, analogs[]/actual_deals[]) already shipped in
267585e3. The remaining gap was BE-1 (#2043, merged 2026-07-02) landing real
cv/source_counts/created_at on AggregatedEstimate AFTER that wiring pass, so
v2/mappers.ts was still FE-approximating cv and duplicating a hardcoded source
label map instead of the single source-registry (#2211).

- mapSources' marketAds.kpi.cv (#2041) now prefers the real estimate.cv
  (0..1 ratio from BE-1) and only falls back to the FE coefficient-of-variation
  of analog prices for pre-#2043 cached estimates where cv is absent.
- adRows/dealRows source badges (#2041) now resolve via the shared
  src/lib/source-registry sourceLabel() instead of a local SOURCE_LABEL map,
  picking up etagi/canonical labels and staying in sync with the #2211 roster.
- AnalyticsView (#2040) KPIs/sell-time/price-history/scatterDetail were already
  wired to real data with no gaps found against the issue spec.
This commit is contained in:
bot-backend 2026-07-03 22:49:30 +03:00
parent e9e3cc7dcf
commit c672e0322d

View file

@ -8,10 +8,13 @@
// renders. ALL money/format/geometry lives here — never copy fixture literals.
//
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
// BE-1 created_at, cv, per-source lot counts are NOT in the API response.
// Report date is derived as expires_at 24h; cv is the FE coefficient
// of variation of analog ₽/м²; source counts are an FE groupBy of
// analogs + actual_deals by .source.
// BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real
// 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.
// BE-2 target_address is a single string; street/city are split heuristically
// (parseAddress). Backend should return structured address components.
// BE-3 location coefficient / street-view caption / compass bearing are not in
@ -67,6 +70,7 @@ import type {
SummaryRow,
} from "./types";
import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
// Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
@ -268,6 +272,26 @@ function cvStr(values: number[]): string {
: "—";
}
/** Format a 0..1 coefficient-of-variation ratio the same way as cvStr's %. */
function cvRatioStr(cv: number): string {
return `${(cv * 100).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}%`;
}
/**
* Analog /м² spread, used by #2041's marketAds.kpi.cv. Prefers the real
* backend estimate.cv (#2043/BE-1, 0..1 ratio) and only falls back to the FE
* coefficient of variation for pre-#2043 cached estimates where cv is absent
* (older persisted rows don't carry it see the file-header TODO BE-1 note).
*/
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));
}
/**
* Bin `values` into 8 buckets across [min,max]; return per-bucket height as
* count/maxCount*100 (the mini-histogram bar heights). Empty input -> [].
@ -1107,22 +1131,13 @@ function fmtDist(m: number | null | undefined): string {
})}${NBSP}км`;
}
// Source key -> brand/RU display label (badges + link source).
const SOURCE_LABEL: Record<string, string> = {
avito: "Avito",
avito_imv: "Avito",
cian: "Циан",
yandex: "Яндекс",
rosreestr: "Росреестр",
domklik: "Домклик",
domclick: "Домклик",
restate: "Restate",
// n1 полностью выключен (#2204) → истор. строки source='n1' идут через fallback `?? s`.
};
// Source key -> brand/RU display label (badges + link source). Delegates to the
// single source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
// via the registry's own fallback rather than inventing a label.
function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return SOURCE_LABEL[s.toLowerCase()] ?? s;
return registrySourceLabel(s.toLowerCase());
}
/** Rosreestr deal tier -> RU caption next to the badge. */
@ -1762,7 +1777,7 @@ export function mapSources(
? numRu(e.median_price_per_m2)
: "—",
range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—",
cv: e != null ? cvStr(e.analogs.map((a) => a.price_per_m2)) : "—",
cv: cvLabel(e),
},
filters: buildAdFilters(e),
};