diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 61400634..813ed0c0 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -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. diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx index e9c9872b..bcdffa01 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -342,6 +342,27 @@ export function ObjectSummary({ {data.summary.quality.cv} + {/* 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 && ( +
+ {data.summary.quality.confidenceExplanation} +
+ )} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index bbe9b9f1..088fc12a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -157,7 +157,13 @@ export default function ResultPanel({ {data.meta.sources} - + ДОСТОВЕРНОСТЬ: {data.meta.confidence} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 7e697102..7d1dc26d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -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 ( +
+ {text} + {hint && ( +
+ {hint} +
+ )} +
+ ); +} + /** Header row + body rows wrapped in role="table"; columns are pre-filtered. */ function DataTable({ label, @@ -558,16 +586,29 @@ export default function SourcesView({ ))} - + {data.adRows.length > 0 ? ( + + ) : ( + + )} {/* 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 && (
- - -
- Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "} - фактических сделок -
+ {data.dealRows.length > 0 ? ( + <> + +
+ Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "} + фактических сделок +
+ + ) : ( + + )} ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index f65f89d7..3c965409 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -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), }, }; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 3151b60c..32021e07 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -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; }