fix(tradein/ui): «только этот дом» только когда выборка не расширялась соседями (#2583 M2)
Some checks failed
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 8m11s
CI Trade-In / changes (pull_request) Successful in 20s
CI / changes (pull_request) Successful in 33s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 3m17s

/house-analytics при <8 архивных лотах добирает до 30 соседних домов в
радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала
«История цен в этом доме · только этот дом», а рядом на том же экране стояло
«в радиусе 300 м».

Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и
соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый
PriceHistoryChart).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 14:39:10 +05:00
parent b9c89641d1
commit aefbd993bb
6 changed files with 100 additions and 9 deletions

View file

@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
<HouseAnalyticsKpiRow kpi={data.kpi} />
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
{data.price_history.length >= 2 && (
<PriceHistoryChart points={data.price_history} />
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
)}
{data.recent_sold.length > 0 && (
<RecentSoldList items={data.recent_sold} />

View file

@ -13,7 +13,9 @@ import {
import type { PriceHistoryYearPoint } from "@/types/trade-in";
import { sourcePublicLabel } from "@/lib/source-registry";
type Props = { points: PriceHistoryYearPoint[] };
// radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние
// в этом радиусе (M2 audit #2583).
type Props = { points: PriceHistoryYearPoint[]; radiusM: number };
const AVITO_LABEL = sourcePublicLabel("avito_imv");
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
@ -28,7 +30,7 @@ interface PivotRow {
n_yandex?: number;
}
export function PriceHistoryChart({ points }: Props) {
export function PriceHistoryChart({ points, radiusM }: Props) {
const pivoted = useMemo<PivotRow[]>(() => {
const byYear: Record<number, PivotRow> = {};
for (const p of points) {
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
<article className="card" style={{ marginTop: 12, padding: 16 }}>
<header style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
История цен в этом доме
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
</h4>
<small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
Медиана /м² по годам ·{" "}
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
</small>
</header>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}

View file

@ -357,7 +357,7 @@ export default function AnalyticsView({
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
{data.priceHistory.title}
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote}
@ -401,9 +401,9 @@ export default function AnalyticsView({
style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
>
<title>История цен в этом доме медиана /м² по годам</title>
<title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
{PH_GRID_Y.map((y) => (
<line key={y} x1={40} y1={y} x2={900} y2={y} />

View file

@ -0,0 +1,75 @@
// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была
// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до
// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом
// стояло «в радиусе 300 м» — две взаимоисключающие подписи.
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PriceHistoryChart } from "../../PriceHistoryChart";
import AnalyticsView from "../AnalyticsView";
import { mapAnalytics } from "../mappers";
import type {
HouseAnalyticsResponse,
PriceHistoryYearPoint,
} from "@/types/trade-in";
const points: PriceHistoryYearPoint[] = [
{ year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 },
{ year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 },
];
function analytics(radius_m: number): HouseAnalyticsResponse {
return {
house_ids: [1, 2, 3],
radius_m,
price_history: points,
recent_sold: [],
kpi: {
total_lots: 7,
sold_count: 0,
sold_rate_pct: 0,
median_exposure_days: null,
median_bargain_pct: null,
},
};
}
describe("v2 история цен: подпись следует radius_m", () => {
it("выборка расширена соседями — не «только этот дом»", () => {
const { priceHistory } = mapAnalytics(analytics(300), null, null);
expect(priceHistory.title).toBe("История цен: дом и соседние");
expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м");
expect(priceHistory.note).not.toContain("только этот дом");
});
it("экран v2 печатает подпись из данных, а не константу", () => {
render(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
expect(screen.getByText("История цен: дом и соседние")).toBeTruthy();
expect(screen.queryByText(/в этом доме/)).toBeNull();
});
it("выборка ровно по дому — прежняя подпись", () => {
const { priceHistory } = mapAnalytics(analytics(0), null, null);
expect(priceHistory.title).toBe("История цен в этом доме");
expect(priceHistory.note).toContain("только этот дом");
});
});
describe("старый экран: PriceHistoryChart", () => {
it("radiusM>0 — заголовок и подпись про соседей", () => {
// recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
render(<PriceHistoryChart points={points} radiusM={300} />);
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
expect(screen.queryByText(/только этот дом/)).toBeNull();
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
});
});

View file

@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number {
}
function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
// M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних
// домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране
// стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому.
const radiusM = a?.radius_m ?? 0;
const title =
radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме";
const scope =
radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом";
const empty: PriceHistory = {
title,
note: "Нет данных по истории цен",
years: [],
avito: "",
@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
.join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
totalLots,
["лот", "лота", "лотов"],
)})`;
@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
});
return {
title,
note,
years: yearsNum.map(String),
avito: series("avito_imv"),

View file

@ -264,6 +264,8 @@ export interface SellTimeTier {
}
export interface PriceHistory {
/** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */
title: string;
note: string;
years: string[];
/** SVG polyline points string (viewBox 0 0 900 220). */