fix(tradein-v2): пустая выборка сделок больше не рисуется нулями
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m0s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m0s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Бэкенд по контракту не умеет отдать null в street-deals: «сделок на улице нет» приезжает как count=0 с нулевыми ценами. Витрина сохраняла этот ноль через ?? и показывала его как данные — «0,00 млн ₽ · −100% к цене объявления» зелёным и диапазон «0,00 – 0,00». - usableStreetDeals() схлопывает пустую оболочку в null на границе мапперов (mapSources / mapSummary / mapHistory / mapResultPanel) — дальше null уже везде означает «нет данных», отдельного состояния заводить не нужно; - футер таблицы сделок брал N и M из РАЗНЫХ выборок (строки — actual_deals оценки, знаменатель — коридор по улице), выходило «Показано 10 из 3»; тотала у той же выборки в ответе нет, поэтому знаменатель убран; - в deals-only ветке (n_analogs=0) полоса диапазона строилась из ДКП-цен, но была подписана «В ОБЪЯВЛЕНИЯХ» — подпись согласована с карточкой 1. Closes #3320
This commit is contained in:
parent
1c533c8a98
commit
08cabe8d98
3 changed files with 202 additions and 13 deletions
|
|
@ -664,8 +664,12 @@ export default function SourcesView({
|
||||||
background: KPI_BG,
|
background: KPI_BG,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
|
{/* #3320 — «из M» здесь брало M из ДРУГОЙ выборки (KPI выше —
|
||||||
фактических сделок
|
корридор ДКП по улице, а строки таблицы — actual_deals оценки
|
||||||
|
по дому/району): выходило «Показано 10 из 3». Тотала у той же
|
||||||
|
выборки в ответе нет, поэтому знаменатель убран, а не починен
|
||||||
|
— разводить подпись на две выборки читается ещё хуже. */}
|
||||||
|
Показано {data.dealRows.length} фактических сделок
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
// #3320 — числа посчитаны честно, показаны нечестно.
|
||||||
|
//
|
||||||
|
// Бэкенд по схеме НЕ УМЕЕТ отдать null в street-deals: «сделок на улице нет»
|
||||||
|
// приезжает как count=0 и нулевые цены (trade_in.py:2258, менять там нечего —
|
||||||
|
// это контракт). Витрина такой ноль сохраняла через `??` и рисовала его как
|
||||||
|
// данные: «05 РЫНОК» показывал медиану сделок «0,00 млн ₽ · −100% к цене
|
||||||
|
// объявления» зелёным и диапазон «0,00 – 0,00».
|
||||||
|
//
|
||||||
|
// Тут держатся три места, где посчитанное честно показывалось нечестно:
|
||||||
|
// пустая выборка по улице, футер таблицы сделок (N и M брались из РАЗНЫХ
|
||||||
|
// выборок) и полоса диапазона в deals-only ветке (подписана «В ОБЪЯВЛЕНИЯХ»
|
||||||
|
// при нуле объявлений).
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import SourcesView from "../SourcesView";
|
||||||
|
import { mapResultPanel, mapSources } from "../mappers";
|
||||||
|
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type {
|
||||||
|
AggregatedEstimate,
|
||||||
|
AnalogLot,
|
||||||
|
StreetDealsResponse,
|
||||||
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
|
/** Ответ street-deals «сделок нет» ровно в том виде, в каком его шлёт бэкенд. */
|
||||||
|
const EMPTY_STREET_DEALS: StreetDealsResponse = {
|
||||||
|
street: "Космонавтов",
|
||||||
|
period_from: "2025-09-02",
|
||||||
|
period_to: "2026-09-02",
|
||||||
|
count: 0,
|
||||||
|
median_price_rub: 0,
|
||||||
|
median_price_per_m2: 0,
|
||||||
|
range_low_rub: 0,
|
||||||
|
range_high_rub: 0,
|
||||||
|
deals: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function deal(over: Partial<AnalogLot>): AnalogLot {
|
||||||
|
return {
|
||||||
|
address: "ул. Космонавтов, 12",
|
||||||
|
area_m2: 55,
|
||||||
|
rooms: 2,
|
||||||
|
floor: 5,
|
||||||
|
total_floors: 9,
|
||||||
|
price_rub: 8_000_000,
|
||||||
|
price_per_m2: 145_454,
|
||||||
|
listing_date: "2026-02-14",
|
||||||
|
days_on_market: null,
|
||||||
|
photo_url: null,
|
||||||
|
source: "rosreestr",
|
||||||
|
source_url: null,
|
||||||
|
distance_m: null,
|
||||||
|
tier: "T1_per_street",
|
||||||
|
date_precision: "quarter",
|
||||||
|
lat: null,
|
||||||
|
lon: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateWith(deals: AnalogLot[]): AggregatedEstimate {
|
||||||
|
return { ...FIXTURE_ESTIMATE, actual_deals: deals };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("05 РЫНОК — пустая выборка сделок не рисуется данными", () => {
|
||||||
|
it("count=0 с нулевыми ценами → KPI сделок в состоянии «нет данных»", () => {
|
||||||
|
const data = mapSources(estimateWith([]), EMPTY_STREET_DEALS);
|
||||||
|
|
||||||
|
expect(data.marketDeals.kpi).toEqual({
|
||||||
|
count: "—",
|
||||||
|
median: "—",
|
||||||
|
delta: "—",
|
||||||
|
range: "—",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<SourcesView data={data} />);
|
||||||
|
|
||||||
|
// Тот же пустой блок, что и при отсутствующем ответе street-deals.
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Сделок по этому адресу за 12 месяцев не найдено/),
|
||||||
|
).toBeTruthy();
|
||||||
|
// Ни «−100% к цене объявления», ни «0,00 – 0,00» на экране быть не может.
|
||||||
|
expect(container.textContent).not.toContain("−100");
|
||||||
|
expect(container.textContent).not.toMatch(/(?<![\d,])0,00/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("count=0, но у оценки есть свои сделки → числа из НИХ, а не прочерки", () => {
|
||||||
|
// Схлопывание пустой оболочки не должно гасить реальную выборку: маппер
|
||||||
|
// обязан упасть на actual_deals, чьи строки видно в таблице ниже KPI.
|
||||||
|
const data = mapSources(
|
||||||
|
estimateWith([deal({ price_rub: 8_000_000 })]),
|
||||||
|
EMPTY_STREET_DEALS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(data.marketDeals.kpi.count).toBe("1");
|
||||||
|
expect(data.marketDeals.kpi.median).toBe("8,00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("05 РЫНОК — футер таблицы сделок считает одну выборку", () => {
|
||||||
|
it("N и M не смешивают street-deals и actual_deals", () => {
|
||||||
|
// Расходящиеся числа: в таблице 3 сделки оценки, коридор по улице знает 1 —
|
||||||
|
// прежний футер писал «Показано 3 из 1 фактических сделок».
|
||||||
|
const streetDeals: StreetDealsResponse = {
|
||||||
|
...EMPTY_STREET_DEALS,
|
||||||
|
count: 1,
|
||||||
|
median_price_rub: 12_000_000,
|
||||||
|
median_price_per_m2: 200_000,
|
||||||
|
range_low_rub: 12_000_000,
|
||||||
|
range_high_rub: 12_000_000,
|
||||||
|
deals: [deal({ price_rub: 12_000_000 })],
|
||||||
|
};
|
||||||
|
const estimate = estimateWith([
|
||||||
|
deal({ address: "ул. Космонавтов, 12" }),
|
||||||
|
deal({ address: "ул. Космонавтов, 14" }),
|
||||||
|
deal({ address: "ул. Космонавтов, 16" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SourcesView data={mapSources(estimate, streetDeals)} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Показано 3 фактических сделок")).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/Показано \d+ из \d+ фактических/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("02 РЕЗУЛЬТАТ — полоса диапазона называет свою выборку", () => {
|
||||||
|
it("deals-only (n_analogs=0) → полоса подписана сделками, как и карточка 1", () => {
|
||||||
|
const dealsOnly: AggregatedEstimate = {
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
analogs: [],
|
||||||
|
n_analogs: 0,
|
||||||
|
insufficient_data: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { cards, ranges } = mapResultPanel(dealsOnly);
|
||||||
|
|
||||||
|
expect(ranges.ads.label.join(" ")).not.toContain("ОБЪЯВЛЕНИЯХ");
|
||||||
|
expect(ranges.ads.label.join(" ")).toContain("ПО СДЕЛКАМ РОСРЕЕСТРА");
|
||||||
|
// Формулировка та же, что #2629 дал карточке над этой полосой.
|
||||||
|
expect(cards[0].title.join(" ")).toContain("ПО СДЕЛКАМ РОСРЕЕСТРА");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("объявления есть → полоса остаётся про объявления", () => {
|
||||||
|
expect(mapResultPanel(FIXTURE_ESTIMATE).ranges.ads.label.join(" ")).toContain(
|
||||||
|
"ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -613,6 +613,28 @@ function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null]
|
||||||
return [best?.listing_date ?? null, best?.date_precision ?? null];
|
return [best?.listing_date ?? null, best?.date_precision ?? null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #3320 — «нет сделок на улице» приезжает с бэкенда НУЛЯМИ, а не null: схема
|
||||||
|
* StreetDealsResponse не допускает null в count/median/range (trade_in.py:2258
|
||||||
|
* отдаёт count=0 и нулевые цены). Дальше по витрине `??` такой ноль СОХРАНЯЕТ —
|
||||||
|
* и пустая выборка рисуется как данные: «0,00 млн ₽ · −100 % к цене
|
||||||
|
* объявления» зелёным и «0,00 – 0,00» в диапазоне.
|
||||||
|
*
|
||||||
|
* Схлопываем пустую оболочку в null на границе мапперов — ниже null уже везде
|
||||||
|
* означает «нет данных» (прочерк / EmptyTableNote), отдельного состояния
|
||||||
|
* заводить не нужно.
|
||||||
|
*/
|
||||||
|
function usableStreetDeals(
|
||||||
|
sd: StreetDealsResponse | null | undefined,
|
||||||
|
): StreetDealsResponse | null {
|
||||||
|
if (sd == null || sd.count <= 0) return null;
|
||||||
|
// count > 0 при нулевой медиане — тоже оболочка без цен (медиана считается по
|
||||||
|
// тем же строкам, что и count: ноль тут значит «денег в выборке нет»).
|
||||||
|
return Number.isFinite(sd.median_price_rub) && sd.median_price_rub > 0
|
||||||
|
? sd
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
|
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
|
||||||
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
|
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
|
||||||
|
|
@ -1083,9 +1105,11 @@ export function marketPositionPill(
|
||||||
|
|
||||||
export function mapResultPanel(
|
export function mapResultPanel(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
streetDeals?: StreetDealsResponse | null,
|
streetDealsRaw?: StreetDealsResponse | null,
|
||||||
analytics?: HouseAnalyticsResponse | null,
|
analytics?: HouseAnalyticsResponse | null,
|
||||||
): ResultPanelData {
|
): ResultPanelData {
|
||||||
|
// #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули.
|
||||||
|
const streetDeals = usableStreetDeals(streetDealsRaw);
|
||||||
const dealTier = resolveDealTier(e, streetDeals);
|
const dealTier = resolveDealTier(e, streetDeals);
|
||||||
|
|
||||||
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
|
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
|
||||||
|
|
@ -1240,7 +1264,14 @@ export function mapResultPanel(
|
||||||
};
|
};
|
||||||
|
|
||||||
const adsBar: RangeBar = {
|
const adsBar: RangeBar = {
|
||||||
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
|
// #3320 — полоса строится из median/range самой оценки, а в deals-only
|
||||||
|
// ветке (n_analogs === 0) эти числа посчитаны по ДКП, не по объявлениям:
|
||||||
|
// подпись «В ОБЪЯВЛЕНИЯХ» обещала бы выборку, которой нет. Формулировка —
|
||||||
|
// та же, что #2629 дал карточке 1 над этой полосой («РЕКОМЕНДОВАННАЯ ЦЕНА
|
||||||
|
// ПО СДЕЛКАМ РОСРЕЕСТРА»), чтобы подпись и число читались одной парой.
|
||||||
|
label: dealsOnlyPrice
|
||||||
|
? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА (ДКП)"]
|
||||||
|
: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
|
||||||
median: `${fmtMln(e.median_price_rub)} млн ₽`,
|
median: `${fmtMln(e.median_price_rub)} млн ₽`,
|
||||||
lo: `${fmtMln(e.range_low_rub)} млн`,
|
lo: `${fmtMln(e.range_low_rub)} млн`,
|
||||||
hi: `${fmtMln(e.range_high_rub)} млн`,
|
hi: `${fmtMln(e.range_high_rub)} млн`,
|
||||||
|
|
@ -1270,8 +1301,10 @@ export function mapResultPanel(
|
||||||
export function mapSummary(
|
export function mapSummary(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
analytics?: HouseAnalyticsResponse | null,
|
analytics?: HouseAnalyticsResponse | null,
|
||||||
streetDeals?: StreetDealsResponse | null,
|
streetDealsRaw?: StreetDealsResponse | null,
|
||||||
): Summary {
|
): Summary {
|
||||||
|
// #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули.
|
||||||
|
const streetDeals = usableStreetDeals(streetDealsRaw);
|
||||||
const kpi = analytics?.kpi;
|
const kpi = analytics?.kpi;
|
||||||
|
|
||||||
const houseSold = kpi?.sold_count ?? null;
|
const houseSold = kpi?.sold_count ?? null;
|
||||||
|
|
@ -1600,16 +1633,16 @@ export function mapHistory(
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
// #3320 — числа только из НЕПУСТОЙ выборки (нулевая оболочка → прочерки).
|
||||||
|
// Название улицы выше берётся из сырого ответа: оно известно и без сделок.
|
||||||
|
const sd = usableStreetDeals(streetDeals);
|
||||||
const dkpKpi = {
|
const dkpKpi = {
|
||||||
count: streetDeals?.count != null ? String(streetDeals.count) : "—",
|
count: sd != null ? String(sd.count) : "—",
|
||||||
median:
|
median:
|
||||||
streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
|
sd != null && Number.isFinite(sd.median_price_per_m2)
|
||||||
? numRu(streetDeals.median_price_per_m2)
|
? numRu(sd.median_price_per_m2)
|
||||||
: "—",
|
|
||||||
range:
|
|
||||||
streetDeals != null
|
|
||||||
? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
|
|
||||||
: "—",
|
: "—",
|
||||||
|
range: sd != null ? mlnRangeSp(sd.range_low_rub, sd.range_high_rub) : "—",
|
||||||
};
|
};
|
||||||
|
|
||||||
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
|
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
|
||||||
|
|
@ -2132,9 +2165,12 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
|
||||||
*/
|
*/
|
||||||
export function mapSources(
|
export function mapSources(
|
||||||
estimate: AggregatedEstimate | null,
|
estimate: AggregatedEstimate | null,
|
||||||
streetDeals: StreetDealsResponse | null,
|
streetDealsRaw: StreetDealsResponse | null,
|
||||||
): SourcesData {
|
): SourcesData {
|
||||||
const e = estimate;
|
const e = estimate;
|
||||||
|
// #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули: иначе
|
||||||
|
// KPI сделок читается как «медиана 0,00 млн ₽ · −100 % к цене объявления».
|
||||||
|
const streetDeals = usableStreetDeals(streetDealsRaw);
|
||||||
|
|
||||||
// Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently
|
// Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently
|
||||||
// fold them into the median/spread math).
|
// fold them into the median/spread math).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue