Compare commits
No commits in common. "80db30c7f7b32509889053f48d32f821a6c7de64" and "2d4f669d4b6ee95baba42e94d50095f61e3824f4" have entirely different histories.
80db30c7f7
...
2d4f669d4b
6 changed files with 3 additions and 150 deletions
|
|
@ -16,7 +16,7 @@ import { HTTPError } from "@/lib/api";
|
||||||
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||||||
import { Topbar } from "@/components/trade-in/Topbar";
|
import { Topbar } from "@/components/trade-in/Topbar";
|
||||||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||||
import { HeroSummary, thinMarketNote } from "@/components/trade-in/HeroSummary";
|
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||||||
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
||||||
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
|
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
|
||||||
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
|
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
|
||||||
|
|
@ -276,7 +276,6 @@ export default function TradeInPage() {
|
||||||
<IMVBenchmark
|
<IMVBenchmark
|
||||||
benchmark={imvBenchmark.data}
|
benchmark={imvBenchmark.data}
|
||||||
isLoading={imvBenchmark.isPending}
|
isLoading={imvBenchmark.isPending}
|
||||||
note={thinMarketNote(resultData.estimate.avito_imv)}
|
|
||||||
/>
|
/>
|
||||||
<CianValuationCard data={resultData.estimate.cian_valuation} />
|
<CianValuationCard data={resultData.estimate.cian_valuation} />
|
||||||
<HouseInfoCard
|
<HouseInfoCard
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`.
|
* 14.08.2026 (блок 4.1): SHORT_ESTIMATE_DISCLAIMER — см. `lib/legal-copy.ts`.
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AggregatedEstimate, AvitoImvSummary, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
|
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
|
||||||
import { asHouseType, asRepairState } from "@/types/trade-in";
|
import { asHouseType, asRepairState } from "@/types/trade-in";
|
||||||
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||||
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
|
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
|
||||||
|
|
@ -18,22 +18,6 @@ import { dealsAsOfLabel } from "@/lib/rosreestr";
|
||||||
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
|
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
|
||||||
import { HeroTransparency } from "./HeroTransparency";
|
import { HeroTransparency } from "./HeroTransparency";
|
||||||
|
|
||||||
/**
|
|
||||||
* #3339 — подпись «тонкого рынка» под числом оценочной модели площадки.
|
|
||||||
*
|
|
||||||
* Бэкенд (thin_market, #audit-5b) при market_count ниже порога ИСКЛЮЧАЕТ IMV из
|
|
||||||
* headline — число остаётся справочным. Показывать его рядом с итогом молча —
|
|
||||||
* то же самое, что выдать не участвующую в расчёте величину за расчётную.
|
|
||||||
* null (нет флага / старая оценка без поля) → подписи нет.
|
|
||||||
*/
|
|
||||||
export function thinMarketNote(imv: AvitoImvSummary | null | undefined): string | null {
|
|
||||||
if (imv?.thin_market !== true) return null;
|
|
||||||
const n = imv.market_count;
|
|
||||||
if (n == null) return "тонкий рынок — в расчёт не входит";
|
|
||||||
// После «из» счётная форма родительная у всех чисел, кроме единицы.
|
|
||||||
return `рынок из ${n} ${n === 1 ? "объявления" : "объявлений"} — в расчёт не входит`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
input: TradeInEstimateInput;
|
input: TradeInEstimateInput;
|
||||||
|
|
@ -191,8 +175,6 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0
|
imv && typeof imv.recommended_price === "number" && imv.recommended_price > 0
|
||||||
? imv.recommended_price
|
? imv.recommended_price
|
||||||
: null;
|
: null;
|
||||||
// #3339: число IMV показываем, но при тонком рынке честно называем его вне расчёта.
|
|
||||||
const imvThin = thinMarketNote(imv);
|
|
||||||
// ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ──
|
// ── #652: коридор реальных ДКП-сделок (advisory). ₽/м² → млн через площадь. ──
|
||||||
const dkp = estimate.dkp_corridor;
|
const dkp = estimate.dkp_corridor;
|
||||||
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
|
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
|
||||||
|
|
@ -555,7 +537,6 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
<div className="bench-chip">
|
<div className="bench-chip">
|
||||||
<span className="bench-chip__src" title="Оценочная модель площадки — независимая оценка рыночной стоимости">{sourcePublicLabel("avito_imv")}</span>
|
<span className="bench-chip__src" title="Оценочная модель площадки — независимая оценка рыночной стоимости">{sourcePublicLabel("avito_imv")}</span>
|
||||||
<span className="bench-chip__val mono">{formatMln(imvPrice)} ₽</span>
|
<span className="bench-chip__val mono">{formatMln(imvPrice)} ₽</span>
|
||||||
{imvThin && <span className="bench-chip__sub">{imvThin}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{estimate.cian_valuation?.sale_price_rub != null && (
|
{estimate.cian_valuation?.sale_price_rub != null && (
|
||||||
|
|
@ -618,7 +599,6 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
|
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
|
||||||
>
|
>
|
||||||
{sourcePublicLabel("avito_imv")}, оценочная модель: {formatMln(imvPrice)} ₽
|
{sourcePublicLabel("avito_imv")}, оценочная модель: {formatMln(imvPrice)} ₽
|
||||||
{imvThin ? ` · ${imvThin}` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,6 @@ import { sourcePublicLabel } from "@/lib/source-registry";
|
||||||
interface Props {
|
interface Props {
|
||||||
benchmark: IMVBenchmarkResponse | undefined;
|
benchmark: IMVBenchmarkResponse | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
/**
|
|
||||||
* #3339 — подпись «тонкого рынка» от thinMarketNote(). Это же число стоит в
|
|
||||||
* hero, и там оно уже подписано; здесь оно печатается второй раз, поэтому
|
|
||||||
* подпись обязана доехать и сюда — иначе рядом с итогом остаётся неподписанная
|
|
||||||
* копия величины, исключённой из расчёта.
|
|
||||||
*/
|
|
||||||
note?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SRC_LABEL = sourcePublicLabel("avito_imv");
|
const SRC_LABEL = sourcePublicLabel("avito_imv");
|
||||||
|
|
@ -23,7 +16,7 @@ function formatRub(n: number | null | undefined): string {
|
||||||
return new Intl.NumberFormat("ru-RU").format(n) + " ₽";
|
return new Intl.NumberFormat("ru-RU").format(n) + " ₽";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IMVBenchmark({ benchmark, isLoading, note }: Props) {
|
export function IMVBenchmark({ benchmark, isLoading }: Props) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="imv-benchmark imv-benchmark--loading">
|
<div className="imv-benchmark imv-benchmark--loading">
|
||||||
|
|
@ -66,7 +59,6 @@ export function IMVBenchmark({ benchmark, isLoading, note }: Props) {
|
||||||
<div className="imv-benchmark__metric-value">
|
<div className="imv-benchmark__metric-value">
|
||||||
{formatRub(recommended_price)}
|
{formatRub(recommended_price)}
|
||||||
</div>
|
</div>
|
||||||
{note && <div className="bench-chip__sub">{note}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="imv-benchmark__metric">
|
<div className="imv-benchmark__metric">
|
||||||
<div className="imv-benchmark__metric-label">Диапазон</div>
|
<div className="imv-benchmark__metric-label">Диапазон</div>
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
/**
|
|
||||||
* #3339 — число, исключённое из расчёта, обязано быть подписано.
|
|
||||||
*
|
|
||||||
* Бэкенд (#3336, thin_market) при market_count ниже порога не пускает IMV в
|
|
||||||
* headline: величина остаётся справочной. На витрине она всё равно стоит рядом
|
|
||||||
* с итоговой ценой — без пометки читается как участвующая в расчёте.
|
|
||||||
*
|
|
||||||
* Проверяется ЗНАЧЕНИЕ подписи (реальный N из market_count) и оба безопасных
|
|
||||||
* состояния: флаг false и полное отсутствие поля (оценка со старого бэкенда).
|
|
||||||
*/
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { HeroSummary, thinMarketNote } from "../HeroSummary";
|
|
||||||
import { IMVBenchmark } from "../IMVBenchmark";
|
|
||||||
import {
|
|
||||||
FIXTURE_ESTIMATE,
|
|
||||||
FIXTURE_IMV,
|
|
||||||
FIXTURE_INPUT,
|
|
||||||
} from "@/app/ui-preview/estimate/fixture";
|
|
||||||
import type { AggregatedEstimate, AvitoImvSummary } from "@/types/trade-in";
|
|
||||||
|
|
||||||
// Бренд и блок прозрачности ходят в сеть (TanStack Query) — к подписи отношения
|
|
||||||
// не имеют, гасим, чтобы рендер hero был оффлайновым.
|
|
||||||
vi.mock("@/lib/useBrand", () => ({
|
|
||||||
useActiveBrandSlug: () => null,
|
|
||||||
useBrand: () => ({ data: null }),
|
|
||||||
}));
|
|
||||||
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
|
|
||||||
|
|
||||||
const IMV: AvitoImvSummary = {
|
|
||||||
recommended_price: 10_200_000,
|
|
||||||
lower_price: 9_400_000,
|
|
||||||
higher_price: 11_000_000,
|
|
||||||
market_count: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
function estimateWithImv(imv: AvitoImvSummary): AggregatedEstimate {
|
|
||||||
return { ...FIXTURE_ESTIMATE, avito_imv: imv };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHero(imv: AvitoImvSummary) {
|
|
||||||
return render(
|
|
||||||
<HeroSummary
|
|
||||||
estimate={estimateWithImv(imv)}
|
|
||||||
input={FIXTURE_INPUT}
|
|
||||||
onResubmit={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("thin_market — подпись у числа оценочной модели площадки", () => {
|
|
||||||
it("thin_market=true, market_count=3 → подпись называет реальные 3 объявления", () => {
|
|
||||||
expect(thinMarketNote({ ...IMV, thin_market: true })).toBe(
|
|
||||||
"рынок из 3 объявлений — в расчёт не входит",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { container } = renderHero({ ...IMV, thin_market: true });
|
|
||||||
|
|
||||||
// Число IMV осталось на экране — гасить его нечестно, оно посчитано.
|
|
||||||
expect(container.textContent).toContain("10,20 млн");
|
|
||||||
// Но рядом с ним стоит N из market_count, а не общая формулировка.
|
|
||||||
expect(screen.getAllByText(/рынок из 3 объявлений — в расчёт не входит/).length)
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("thin_market=false → подписи нет (IMV участвует в расчёте)", () => {
|
|
||||||
expect(thinMarketNote({ ...IMV, thin_market: false })).toBeNull();
|
|
||||||
|
|
||||||
const { container } = renderHero({ ...IMV, thin_market: false });
|
|
||||||
expect(container.textContent).toContain("10,20 млн");
|
|
||||||
expect(container.textContent).not.toContain("в расчёт не входит");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("поля нет вовсе (оценка со старого бэкенда) → подписи нет", () => {
|
|
||||||
expect(thinMarketNote(IMV)).toBeNull();
|
|
||||||
expect(thinMarketNote(null)).toBeNull();
|
|
||||||
|
|
||||||
const { container } = renderHero(IMV);
|
|
||||||
expect(container.textContent).not.toContain("в расчёт не входит");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("единственное объявление — счётная форма не ломается", () => {
|
|
||||||
expect(thinMarketNote({ ...IMV, market_count: 1, thin_market: true })).toBe(
|
|
||||||
"рынок из 1 объявления — в расчёт не входит",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* То же число печатается второй раз в отдельной карточке под hero. Подпись в
|
|
||||||
* hero её не покрывает: у карточки свой источник данных (imv-benchmark endpoint),
|
|
||||||
* и без проброса note рядом с итогом остаётся неподписанная копия величины.
|
|
||||||
*/
|
|
||||||
describe("IMVBenchmark — подпись доезжает до второго места с тем же числом", () => {
|
|
||||||
const NOTE = "рынок из 3 объявлений — в расчёт не входит";
|
|
||||||
|
|
||||||
it("note передан → подпись стоит под числом", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} note={NOTE} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.textContent).toContain(NOTE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("note не передан (рынок не тонкий) → подписи нет", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.textContent).not.toContain("в расчёт не входит");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -846,7 +846,6 @@
|
||||||
.bench-chip__sub {
|
.bench-chip__sub {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
max-width: 240px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── empty-state headline (без данных) ── */
|
/* ── empty-state headline (без данных) ── */
|
||||||
|
|
|
||||||
|
|
@ -147,10 +147,6 @@ export interface AvitoImvSummary {
|
||||||
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
||||||
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
||||||
market_count: number | null; // объём рынка, на котором построена оценка
|
market_count: number | null; // объём рынка, на котором построена оценка
|
||||||
// #3339 / backend #audit-5b: market_count < avito_imv_thin_market_threshold —
|
|
||||||
// IMV построен на малой выборке и в headline НЕ участвует (display-only).
|
|
||||||
// optional: оценки, посчитанные до деплоя гейта, поля не содержат.
|
|
||||||
thin_market?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
|
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue