Merge pull request 'fix(tradein-v2): пустая выборка сделок больше не рисуется нулями — «0,00 млн · −100%» зелёным исчезает' (#3328) from fix/3320-v2-honest-zeros into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m20s
Deploy Trade-In / deploy (push) Successful in 1m1s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s

This commit is contained in:
bot-backend 2026-09-02 10:00:48 +00:00
commit 5cdf2bd74c
5 changed files with 276 additions and 16 deletions

View file

@ -415,7 +415,13 @@ export default function HistoryView({ data }: HistoryViewProps) {
background: tokens.surfaceTint,
}}
>
Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок
{/* #3320 «из M» брало знаменатель из ДРУГОЙ выборки: строки ниже
пары ДКПобъявление (sales-vs-listings), а dkpKpi.count коридор
street-deals. Расходились и числами («Показано 7 из 3»), и после
гашения пустого коридора («Показано 7 из »). Тотала у той же
выборки в ответе нет знаменатель убран, как в 05 РЫНОК. */}
Показано {data.dkpRows.length}{" "}
{pluralRu(data.dkpRows.length, ["сделка", "сделки", "сделок"])}
</div>
</div>
</div>

View file

@ -4,6 +4,7 @@ import type { CSSProperties, ReactNode } from "react";
import { safeUrl } from "@/lib/safeUrl";
import type { AggregatedEstimate } from "@/types/trade-in";
import { tokens } from "./tokens";
import { pluralRu } from "./mappers";
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
import { SourcesMap } from "./SourcesMap";
@ -637,7 +638,10 @@ export default function SourcesView({
label="МЕДИАНА СДЕЛОК"
value={data.marketDeals.kpi.median}
unit={`млн ₽ · ${data.marketDeals.kpi.delta}`}
unitColor={tokens.success}
// #3320 — цвет по знаку дельты (её считает mapSources): зелёным
// здесь красился и минус, и прочерк. undefined → нейтральный
// дефолт плитки.
unitColor={data.marketDeals.kpi.deltaColor}
/>
<KpiCell
label="ДИАПАЗОН"
@ -664,8 +668,17 @@ export default function SourcesView({
background: KPI_BG,
}}
>
Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "}
фактических сделок
{/* #3320 «из M» здесь брало M из ДРУГОЙ выборки (KPI выше
корридор ДКП по улице, а строки таблицы actual_deals оценки
по дому/району): выходило «Показано 10 из 3». Тотала у той же
выборки в ответе нет, поэтому знаменатель убран, а не починен
разводить подпись на две выборки читается ещё хуже. */}
Показано {data.dealRows.length}{" "}
{pluralRu(data.dealRows.length, [
"фактическая сделка",
"фактические сделки",
"фактических сделок",
])}
</div>
</>
) : (

View file

@ -0,0 +1,184 @@
// #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 HistoryView from "../HistoryView";
import SourcesView from "../SourcesView";
import { mapHistory, mapResultPanel, mapSources } from "../mappers";
import { tokens } from "../tokens";
import { FIXTURE_ESTIMATE, FIXTURE_SALES } 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: "—",
});
// Цвет подписи тоже гаснет: зелёным красилась даже дельта, которой нет.
expect(data.marketDeals.kpi.deltaColor).toBeUndefined();
const { container } = render(<SourcesView data={data} />);
// Ни «100% к цене объявления», ни «0,00 0,00» на экране быть не может.
expect(container.textContent).not.toContain("100");
expect(container.textContent).not.toMatch(/(?<![\d,])0,00/);
});
it("дельта красится по знаку: минус — не зелёным", () => {
// Второй причинный узел жалобы «100% зелёным»: цвет был захардкожен.
// Сделки по фикстуре (8,00 млн) ниже медианы объявлений (9,85 млн).
const below = mapSources(estimateWith([deal({ price_rub: 8_000_000 })]), null);
expect(below.marketDeals.kpi.delta).toContain("");
expect(below.marketDeals.kpi.deltaColor).toBe(tokens.danger);
const above = mapSources(
estimateWith([deal({ price_rub: 12_000_000 })]),
null,
);
expect(above.marketDeals.kpi.delta).toContain("+");
expect(above.marketDeals.kpi.deltaColor).toBe(tokens.success);
});
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)} />);
// («Показано N из M объявлений» над таблицей аналогов — законный футер
// СВОЕЙ выборки, поэтому проверяется именно подпись под сделками.)
const footer = screen.getByText(/Показано \d+ .*сделк/);
expect(footer.textContent).toBe("Показано 3 фактические сделки");
expect(footer.textContent).not.toMatch(/\sиз\s/);
});
it("04 ПРОДАЖИ — футер ДКП-таблицы тоже считает свою выборку", () => {
// Строки тут — пары ДКП↔объявление, а прежний знаменатель брался из
// коридора street-deals: 2 пары против count=7, а при пустом коридоре
// (после гашения нулей) выходило «Показано 2 из —».
const data = mapHistory(
null,
{ ...EMPTY_STREET_DEALS, count: 7, median_price_rub: 12_000_000 },
{ ...FIXTURE_SALES, pairs: FIXTURE_SALES.pairs.slice(0, 2) },
);
render(<HistoryView data={data} />);
expect(screen.getByText(/Показано 2 сделки/)).toBeTruthy();
expect(screen.queryByText(/Показано \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(
"ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ",
);
});
});

View file

@ -613,6 +613,28 @@ function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | 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
* real totals, then (2) the dkp_corridor /м² × area, then (3) the estimate's
@ -1083,9 +1105,11 @@ export function marketPositionPill(
export function mapResultPanel(
e: AggregatedEstimate,
streetDeals?: StreetDealsResponse | null,
streetDealsRaw?: StreetDealsResponse | null,
analytics?: HouseAnalyticsResponse | null,
): ResultPanelData {
// #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули.
const streetDeals = usableStreetDeals(streetDealsRaw);
const dealTier = resolveDealTier(e, streetDeals);
// H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
@ -1240,7 +1264,14 @@ export function mapResultPanel(
};
const adsBar: RangeBar = {
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
// #3320 — полоса строится из median/range самой оценки, а в deals-only
// ветке (n_analogs === 0) эти числа посчитаны по ДКП, не по объявлениям:
// подпись «В ОБЪЯВЛЕНИЯХ» обещала бы выборку, которой нет. Формулировка —
// та же, что #2629 дал карточке 1 над этой полосой («РЕКОМЕНДОВАННАЯ ЦЕНА
// ПО СДЕЛКАМ РОСРЕЕСТРА»), чтобы подпись и число читались одной парой.
label: dealsOnlyPrice
? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА"]
: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
median: `${fmtMln(e.median_price_rub)} млн ₽`,
lo: `${fmtMln(e.range_low_rub)} млн`,
hi: `${fmtMln(e.range_high_rub)} млн`,
@ -1270,8 +1301,10 @@ export function mapResultPanel(
export function mapSummary(
e: AggregatedEstimate,
analytics?: HouseAnalyticsResponse | null,
streetDeals?: StreetDealsResponse | null,
streetDealsRaw?: StreetDealsResponse | null,
): Summary {
// #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули.
const streetDeals = usableStreetDeals(streetDealsRaw);
const kpi = analytics?.kpi;
const houseSold = kpi?.sold_count ?? null;
@ -1600,16 +1633,16 @@ export function mapHistory(
.filter(Boolean)
.join(" ");
// #3320 — числа только из НЕПУСТОЙ выборки (нулевая оболочка → прочерки).
// Название улицы выше берётся из сырого ответа: оно известно и без сделок.
const sd = usableStreetDeals(streetDeals);
const dkpKpi = {
count: streetDeals?.count != null ? String(streetDeals.count) : "—",
count: sd != null ? String(sd.count) : "—",
median:
streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
? numRu(streetDeals.median_price_per_m2)
: "—",
range:
streetDeals != null
? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
sd != null && Number.isFinite(sd.median_price_per_m2)
? numRu(sd.median_price_per_m2)
: "—",
range: sd != null ? mlnRangeSp(sd.range_low_rub, sd.range_high_rub) : "—",
};
const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
@ -2132,9 +2165,12 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
*/
export function mapSources(
estimate: AggregatedEstimate | null,
streetDeals: StreetDealsResponse | null,
streetDealsRaw: StreetDealsResponse | null,
): SourcesData {
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
// fold them into the median/spread math).
@ -2204,6 +2240,15 @@ export function mapSources(
// §M8 — единая формулировка дельт: всё считается «относительно цен
// объявлений» (deltaPct = медиана сделок / медиана объявлений 1).
delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
// #3320 — знак → цвет, тот же признак, что у пар ДКП↔объявление в 04
// (deltaColor у DkpRowData): продали дороже объявления — success,
// дешевле — danger, числа нет — нейтральный цвет плитки.
deltaColor:
deltaPct == null
? undefined
: deltaPct < 0
? tokens.danger
: tokens.success,
range: mlnRangeSp(dealLo, dealHi),
},
};

View file

@ -227,7 +227,19 @@ export interface MarketAds {
}
export interface MarketDeals {
kpi: { count: string; median: string; delta: string; range: string };
kpi: {
count: string;
median: string;
delta: string;
range: string;
/**
* #3320 цвет подписи дельты по её знаку (как deltaColor у DkpRowData):
* сделки выше объявлений success, ниже danger, «» undefined
* (плитка красит нейтральным по умолчанию). Раньше цвет был захардкожен
* зелёным и красил им же и минус, и прочерк.
*/
deltaColor?: string;
};
}
// ---- OVERLAY: АНАЛИТИКА ДОМА ----------------------------------------------