Compare commits
No commits in common. "964b62d27d937f77dad3480c02555dc58e345933" and "55254b43e4cb8a35ec8788b032e825ea6eba0b10" have entirely different histories.
964b62d27d
...
55254b43e4
6 changed files with 32 additions and 152 deletions
|
|
@ -564,19 +564,9 @@ export default function TradeInV2Page() {
|
||||||
const restoreLoading = restoreActive && restored.isPending;
|
const restoreLoading = restoreActive && restored.isPending;
|
||||||
|
|
||||||
const loading = mutation.isPending || restoreLoading;
|
const loading = mutation.isPending || restoreLoading;
|
||||||
// fix (v2 honest-price display) — `insufficient_data` is the ONE honest
|
const insufficient =
|
||||||
// "no answer" signal from the backend (@computed_field = median_price_rub
|
estimate != null &&
|
||||||
// <= 0, see backend/app/schemas/trade_in.py). `n_analogs === 0` alone used
|
(estimate.insufficient_data || estimate.n_analogs === 0);
|
||||||
// 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,27 +342,6 @@ 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,13 +157,7 @@ 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,34 +419,6 @@ 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,
|
||||||
|
|
@ -586,29 +558,16 @@ export default function SourcesView({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.adRows.length > 0 ? (
|
|
||||||
<DataTable
|
<DataTable
|
||||||
label="Объявления — аналогичные квартиры в продаже"
|
label="Объявления — аналогичные квартиры в продаже"
|
||||||
cols={adCols}
|
cols={adCols}
|
||||||
rows={data.adRows}
|
rows={data.adRows}
|
||||||
grid={adGrid}
|
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. */}
|
||||||
Skipped when the table above is already an EmptyTableNote — "Показано
|
{data.adsFootnote && (
|
||||||
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",
|
||||||
|
|
@ -659,14 +618,13 @@ export default function SourcesView({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.dealRows.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<DataTable
|
<DataTable
|
||||||
label="Фактические сделки по аналогичным квартирам"
|
label="Фактические сделки по аналогичным квартирам"
|
||||||
cols={dealCols}
|
cols={dealCols}
|
||||||
rows={data.dealRows}
|
rows={data.dealRows}
|
||||||
grid={dealGrid}
|
grid={dealGrid}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "11px 18px",
|
padding: "11px 18px",
|
||||||
|
|
@ -678,10 +636,6 @@ export default function SourcesView({
|
||||||
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
||||||
фактических сделок
|
фактических сделок
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<EmptyTableNote text="Сделок по этому адресу за 12 месяцев не найдено." />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1049,36 +1049,17 @@ 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: dealsOnlyPrice
|
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
||||||
? ["РЕКОМЕНДОВАННАЯ ЦЕНА", "ПО СДЕЛКАМ РОСРЕЕСТРА"]
|
|
||||||
: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
|
||||||
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: dealsOnlyPrice
|
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
|
||||||
? `${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,
|
||||||
},
|
},
|
||||||
|
|
@ -1158,10 +1139,6 @@ 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,
|
||||||
|
|
@ -1273,9 +1250,6 @@ 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,15 +66,6 @@ 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;
|
||||||
|
|
@ -155,8 +146,6 @@ 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