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 restoreLoading = restoreActive && restored.isPending;
|
||||||
|
|
||||||
const loading = mutation.isPending || restoreLoading;
|
const loading = mutation.isPending || restoreLoading;
|
||||||
const insufficient =
|
// fix (v2 honest-price display) — `insufficient_data` is the ONE honest
|
||||||
estimate != null &&
|
// "no answer" signal from the backend (@computed_field = median_price_rub
|
||||||
(estimate.insufficient_data || estimate.n_analogs === 0);
|
// <= 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;
|
const apiError = mutation.error?.message ?? null;
|
||||||
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
|
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
|
||||||
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
|
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,27 @@ export function ObjectSummary({
|
||||||
{data.summary.quality.cv}
|
{data.summary.quality.cv}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,13 @@ export default function ResultPanel({
|
||||||
{data.meta.sources}
|
{data.meta.sources}
|
||||||
</b>
|
</b>
|
||||||
</span>
|
</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>
|
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
|
||||||
</span>
|
</span>
|
||||||
<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. */
|
/** Header row + body rows wrapped in role="table"; columns are pre-filtered. */
|
||||||
function DataTable<R>({
|
function DataTable<R>({
|
||||||
label,
|
label,
|
||||||
|
|
@ -558,16 +586,29 @@ export default function SourcesView({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
{data.adRows.length > 0 ? (
|
||||||
label="Объявления — аналогичные квартиры в продаже"
|
<DataTable
|
||||||
cols={adCols}
|
label="Объявления — аналогичные квартиры в продаже"
|
||||||
rows={data.adRows}
|
cols={adCols}
|
||||||
grid={adGrid}
|
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
|
{/* Fix #1/#8 — discloses the top-10 display cap vs. the true n_analogs
|
||||||
total (mirrors the deals-table footer below) + any outlier exclusion. */}
|
total (mirrors the deals-table footer below) + any outlier exclusion.
|
||||||
{data.adsFootnote && (
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "11px 18px",
|
padding: "11px 18px",
|
||||||
|
|
@ -618,24 +659,29 @@ export default function SourcesView({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
{data.dealRows.length > 0 ? (
|
||||||
label="Фактические сделки по аналогичным квартирам"
|
<>
|
||||||
cols={dealCols}
|
<DataTable
|
||||||
rows={data.dealRows}
|
label="Фактические сделки по аналогичным квартирам"
|
||||||
grid={dealGrid}
|
cols={dealCols}
|
||||||
/>
|
rows={data.dealRows}
|
||||||
|
grid={dealGrid}
|
||||||
<div
|
/>
|
||||||
style={{
|
<div
|
||||||
padding: "11px 18px",
|
style={{
|
||||||
fontSize: "10px",
|
padding: "11px 18px",
|
||||||
color: tokens.hint,
|
fontSize: "10px",
|
||||||
background: KPI_BG,
|
color: tokens.hint,
|
||||||
}}
|
background: KPI_BG,
|
||||||
>
|
}}
|
||||||
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
>
|
||||||
фактических сделок
|
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
||||||
</div>
|
фактических сделок
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyTableNote text="Сделок по этому адресу за 12 месяцев не найдено." />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1049,17 +1049,36 @@ export function mapResultPanel(
|
||||||
e.expected_sold_price_rub != null &&
|
e.expected_sold_price_rub != null &&
|
||||||
Number.isFinite(e.expected_sold_price_rub);
|
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[] = [
|
const cards: ResultCard[] = [
|
||||||
{
|
{
|
||||||
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
title: dealsOnlyPrice
|
||||||
|
? ["РЕКОМЕНДОВАННАЯ ЦЕНА", "ПО СДЕЛКАМ РОСРЕЕСТРА"]
|
||||||
|
: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
||||||
value: fmtMln(e.median_price_rub),
|
value: fmtMln(e.median_price_rub),
|
||||||
unit: "млн ₽",
|
unit: "млн ₽",
|
||||||
range: rangeLine(e.range_low_rub, e.range_high_rub),
|
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
|
// 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
|
// (meta.cv below) — otherwise the histogram spans [min,max] INCLUDING the
|
||||||
// 872k выброс this card already reports as "исключён", squashing the real
|
// 872k выброс this card already reports as "исключён", squashing the real
|
||||||
// analogs into the left bins while the number says the outlier is dropped.
|
// 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),
|
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
||||||
nav: 2,
|
nav: 2,
|
||||||
},
|
},
|
||||||
|
|
@ -1139,6 +1158,10 @@ export function mapResultPanel(
|
||||||
const meta: ResultMeta = {
|
const meta: ResultMeta = {
|
||||||
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||||||
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
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),
|
cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
||||||
builtOn,
|
builtOn,
|
||||||
sourcesNote,
|
sourcesNote,
|
||||||
|
|
@ -1250,6 +1273,9 @@ export function mapSummary(
|
||||||
quality: {
|
quality: {
|
||||||
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
|
||||||
confidence: CONFIDENCE_RU[e.confidence],
|
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),
|
cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,15 @@ export interface ResultCard {
|
||||||
export interface ResultMeta {
|
export interface ResultMeta {
|
||||||
sources: string;
|
sources: string;
|
||||||
confidence: 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;
|
cv: string;
|
||||||
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
|
/** Data-driven footer provenance, e.g. "ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ". */
|
||||||
builtOn?: string;
|
builtOn?: string;
|
||||||
|
|
@ -146,6 +155,8 @@ export interface SummaryRow {
|
||||||
export interface SummaryQuality {
|
export interface SummaryQuality {
|
||||||
sources: string;
|
sources: string;
|
||||||
confidence: string;
|
confidence: string;
|
||||||
|
/** Same backend confidence_explanation as ResultMeta — see that field's doc. */
|
||||||
|
confidenceExplanation?: string;
|
||||||
cv: string;
|
cv: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue