fix(tradein/ui): показывать цену по сделкам Росреестра вместо «недостаточно данных» #2630
6 changed files with 152 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue