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;
}