fix(tradein/ui): показывать цену по сделкам Росреестра вместо «недостаточно данных» #2630

Merged
lekss361 merged 1 commit from fix/tradein-v2-deals-fallback-visible into main 2026-08-02 13:21:53 +00:00
6 changed files with 152 additions and 32 deletions

View file

@ -564,9 +564,19 @@ export default function TradeInV2Page() {
const restoreLoading = restoreActive && restored.isPending;
const loading = mutation.isPending || restoreLoading;
const insufficient =
estimate != null &&
(estimate.insufficient_data || estimate.n_analogs === 0);
// fix (v2 honest-price display) — `insufficient_data` is the ONE honest
// "no answer" signal from the backend (@computed_field = median_price_rub
// <= 0, see backend/app/schemas/trade_in.py). `n_analogs === 0` alone used
// to be folded into this same flag, but the backend can (and, since the
// deals fallback expanded in PR #2629, routinely does) return a real
// median_price_rub computed from Rosreestr deals (ДКП) when there are zero
// LISTING analogs — v1 and the PDF export already render that as a normal
// result. Treating n_analogs === 0 as "insufficient" here hid a real,
// honest price behind "недостаточно данных" for every such estimate (23
// existing Серов estimates were already stuck in this state). See
// mapResultPanel's `dealsOnlyPrice` for how the result panel discloses the
// deals-only source instead of mislabelling it as "по объявлениям".
const insufficient = estimate != null && estimate.insufficient_data;
const apiError = mutation.error?.message ?? null;
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.

View file

@ -342,6 +342,27 @@ export function ObjectSummary({
{data.summary.quality.cv}
</span>
</div>
{/* M1 audit #2583 backend's own honest explanation for the
confidence tier above (e.g. "расширили радиус до 2 км из-за
нехватки данных"). Was computed by the backend but never shown
anywhere in v2 (v1 showed it). Rendered visibly, not just as a
hover tooltip (mirrored in ResultPanel's ДОСТОВЕРНОСТЬ) this
rail has room, and this is the more likely place a user actually
reads "почему такая достоверность". undefined nothing renders. */}
{data.summary.quality.confidenceExplanation && (
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: `1px solid ${tokens.lineSoft2}`,
fontSize: 9.5,
lineHeight: 1.5,
color: tokens.muted2,
}}
>
{data.summary.quality.confidenceExplanation}
</div>
)}
</div>
</div>
</div>

View file

@ -157,7 +157,13 @@ export default function ResultPanel({
{data.meta.sources}
</b>
</span>
<span>
<span
// M1 audit #2583 — native hover tooltip carrying the backend's own
// confidence_explanation (e.g. "расширили радиус до 2 км из-за
// нехватки данных"), same zero-layout-cost title= pattern as
// HeroBar's location-index disclaimer. undefined → no tooltip.
title={data.meta.confidenceExplanation}
>
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
</span>
<span>

View file

@ -419,6 +419,34 @@ function KpiCell({
);
}
// fix (v2 honest-price display, item 3) — an empty `rows` array used to still
// render the header row via DataTable (visibleCols keeps every column when
// rows.length === 0, see above), so a genuinely empty analog/deal set painted
// a table with column headers and zero body rows — reading as a broken or
// still-loading table, not an honest "there is nothing here". Shown instead
// of DataTable when rows are empty; `hint` explains WHY when the caller has
// one (e.g. n_analogs === 0 → price came from Rosreestr deals instead).
function EmptyTableNote({ text, hint }: { text: string; hint?: string }) {
return (
<div
style={{
padding: "22px 18px",
fontSize: 11,
lineHeight: 1.5,
color: tokens.muted,
textAlign: "center",
}}
>
{text}
{hint && (
<div style={{ marginTop: 4, fontSize: 10, color: tokens.muted2 }}>
{hint}
</div>
)}
</div>
);
}
/** Header row + body rows wrapped in role="table"; columns are pre-filtered. */
function DataTable<R>({
label,
@ -558,16 +586,29 @@ export default function SourcesView({
))}
</div>
<DataTable
label="Объявления — аналогичные квартиры в продаже"
cols={adCols}
rows={data.adRows}
grid={adGrid}
/>
{data.adRows.length > 0 ? (
<DataTable
label="Объявления — аналогичные квартиры в продаже"
cols={adCols}
rows={data.adRows}
grid={adGrid}
/>
) : (
<EmptyTableNote
text="Похожих объявлений в продаже не найдено."
hint={
estimate != null && !estimate.insufficient_data
? "Цена по этому адресу рассчитана по сделкам Росреестра — см. блок ниже."
: undefined
}
/>
)}
{/* 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 && (
total (mirrors the deals-table footer below) + any outlier exclusion.
Skipped when the table above is already an EmptyTableNote "Показано
0 из 0 объявлений" adds nothing once the empty note already said so. */}
{data.adRows.length > 0 && data.adsFootnote && (
<div
style={{
padding: "11px 18px",
@ -618,24 +659,29 @@ export default function SourcesView({
/>
</div>
<DataTable
label="Фактические сделки по аналогичным квартирам"
cols={dealCols}
rows={data.dealRows}
grid={dealGrid}
/>
<div
style={{
padding: "11px 18px",
fontSize: "10px",
color: tokens.hint,
background: KPI_BG,
}}
>
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
фактических сделок
</div>
{data.dealRows.length > 0 ? (
<>
<DataTable
label="Фактические сделки по аналогичным квартирам"
cols={dealCols}
rows={data.dealRows}
grid={dealGrid}
/>
<div
style={{
padding: "11px 18px",
fontSize: "10px",
color: tokens.hint,
background: KPI_BG,
}}
>
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
фактических сделок
</div>
</>
) : (
<EmptyTableNote text="Сделок по этому адресу за 12 месяцев не найдено." />
)}
</div>
</div>
);

View file

@ -1049,17 +1049,36 @@ export function mapResultPanel(
e.expected_sold_price_rub != null &&
Number.isFinite(e.expected_sold_price_rub);
// fix (v2 honest-price display) — zero LISTING analogs no longer means
// "недостаточно данных" (see page.tsx's `insufficient`, now =
// estimate.insufficient_data only):
// when the backend still returned a real median_price_rub with n_analogs
// === 0, it computed that price from Rosreestr deals (ДКП), not asking
// listings (the deals fallback expanded in PR #2629). The headline card
// must say so — labelling it "В ОБЪЯВЛЕНИИ / по объявлениям" here would be
// dishonest in the other direction (claiming a listings-based price that
// doesn't exist).
const dealsOnlyPrice = e.n_analogs === 0 && !e.insufficient_data;
const cards: ResultCard[] = [
{
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
title: dealsOnlyPrice
? ["РЕКОМЕНДОВАННАЯ ЦЕНА", "ПО СДЕЛКАМ РОСРЕЕСТРА"]
: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
value: fmtMln(e.median_price_rub),
unit: "млн ₽",
range: rangeLine(e.range_low_rub, e.range_high_rub),
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
ppm: dealsOnlyPrice
? `${fmtPpm(e.median_price_per_m2)} · по сделкам Росреестра`
: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
note: dealsOnlyPrice
? "Похожих объявлений в продаже не нашлось — цена рассчитана по сделкам Росреестра (ДКП) в этом районе"
: undefined,
// Fix #1/#8: bin over the SAME outlier-guarded pool the spread/CV uses
// (meta.cv below) — otherwise the histogram spans [min,max] INCLUDING the
// 872k выброс this card already reports as "исключён", squashing the real
// analogs into the left bins while the number says the outlier is dropped.
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
nav: 2,
},
@ -1139,6 +1158,10 @@ export function mapResultPanel(
const meta: ResultMeta = {
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
// M1 audit #2583 — real backend explanation, was computed but never read
// by v2 (v1 showed it). trim(): backend sometimes sends "" rather than
// null for "no explanation" — an empty tooltip string is worse than none.
confidenceExplanation: e.confidence_explanation?.trim() || undefined,
cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
builtOn,
sourcesNote,
@ -1250,6 +1273,9 @@ export function mapSummary(
quality: {
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
confidence: CONFIDENCE_RU[e.confidence],
// M1 audit #2583 — see ResultMeta.confidenceExplanation doc (mapResultPanel
// above) for the full rationale; same source field, same trim() guard.
confidenceExplanation: e.confidence_explanation?.trim() || undefined,
cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
},
};

View file

@ -66,6 +66,15 @@ export interface ResultCard {
export interface ResultMeta {
sources: string;
confidence: string;
/**
* M1 audit #2583 backend's own honest explanation of the
* confidence tier (e.g. "расширили радиус до 2 км из-за нехватки данных").
* Was computed by the backend but never surfaced anywhere in v2 (the old
* v1 interface showed it). Rendered as a hover tooltip on ДОСТОВЕРНОСТЬ
* (mirrors HeroBar's location-index disclaimer pattern) undefined/empty
* hides the tooltip, never a fabricated caveat.
*/
confidenceExplanation?: string;
cv: string;
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
builtOn?: string;
@ -146,6 +155,8 @@ export interface SummaryRow {
export interface SummaryQuality {
sources: string;
confidence: string;
/** Same backend confidence_explanation as ResultMeta — see that field's doc. */
confidenceExplanation?: string;
cv: string;
}