feat(tradein/v2): #2899 — сами правки типа/маппера/ResultPanel (в первый коммит попал только тест: git checkout HEAD откатил рабочее дерево до коммита)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 / frontend-checks (pull_request) Successful in 56s

This commit is contained in:
bot-backend 2026-08-21 17:00:05 +05:00
parent 07655a2642
commit 03af7386e9
3 changed files with 418 additions and 346 deletions

View file

@ -37,6 +37,41 @@ const {
font, font,
} = tokens; } = tokens;
// #2899 — одна и та же спокойная плашка для «18% · к цене объявления» (карточка
// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX
// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы
// молча.
function DeltaPill({
delta,
deltaLabel,
}: {
delta: string;
deltaLabel?: string;
}) {
return (
<span
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 5,
background: infoSoftBg,
border: `1px solid ${lineSoft}`,
borderRadius: 20,
padding: "3px 10px",
color: accentDeep,
fontFamily: font.sans,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
<span style={{ fontFamily: font.mono, fontSize: 12, fontWeight: 600 }}>
{delta}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>{deltaLabel}</span>
</span>
);
}
interface ResultPanelProps { interface ResultPanelProps {
// Required on the app path (v2/page.tsx always supplies mapResultPanel // Required on the app path (v2/page.tsx always supplies mapResultPanel
// output) — an omitted prop must be a TS error, not a silent fallback to // output) — an omitted prop must be a TS error, not a silent fallback to
@ -255,7 +290,11 @@ export default function ResultPanel({
// even the AA "large text" floor (3:1). accentDeep is the same // even the AA "large text" floor (3:1). accentDeep is the same
// brand hue, darkened (existing token, not a one-off hex) — it // brand hue, darkened (existing token, not a one-off hex) — it
// clears ~4.2:1 here, so the label reads reliably too. // clears ~4.2:1 here, so the label reads reliably too.
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2, color: isHeadline
? accentDeep
: isRegistry
? muted2
: body2,
fontWeight: isHeadline ? 600 : undefined, fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6, lineHeight: 1.6,
}} }}
@ -419,6 +458,7 @@ export default function ResultPanel({
Подробнее <span></span> Подробнее <span></span>
</button> </button>
) : card.bars ? ( ) : card.bars ? (
<>
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -472,6 +512,17 @@ export default function ResultPanel({
Подробнее <span></span> Подробнее <span></span>
</button> </button>
</div> </div>
{/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */}
{card.delta ? (
<div style={{ marginTop: 8 }}>
<DeltaPill
delta={card.delta}
deltaLabel={card.deltaLabel}
/>
</div>
) : null}
</>
) : ( ) : (
// M2 — calm delta pill (was a 51px circular gauge that read like a // M2 — calm delta pill (was a 51px circular gauge that read like a
// tech "занижение" indicator). Full text «18% к цене объявления», // tech "занижение" indicator). Full text «18% к цене объявления»,
@ -487,34 +538,10 @@ export default function ResultPanel({
}} }}
> >
{card.delta ? ( {card.delta ? (
<span <DeltaPill
style={{ delta={card.delta}
display: "inline-flex", deltaLabel={card.deltaLabel}
alignItems: "baseline", />
gap: 5,
background: infoSoftBg,
border: `1px solid ${lineSoft}`,
borderRadius: 20,
padding: "3px 10px",
color: accentDeep,
fontFamily: font.sans,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 12,
fontWeight: 600,
}}
>
{card.delta}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
{card.deltaLabel}
</span>
</span>
) : ( ) : (
<span /> <span />
)} )}
@ -583,8 +610,8 @@ export default function ResultPanel({
marginTop: -6, marginTop: -6,
}} }}
> >
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, {SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
а не погрешность оценки. не погрешность оценки.
</div> </div>
{/* ranges + radar */} {/* ranges + radar */}

View file

@ -605,10 +605,16 @@ interface DealTier {
} }
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */ /** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] { function newestLot(
lots: AnalogLot[],
): [string | null, "day" | "quarter" | null] {
let best: AnalogLot | null = null; let best: AnalogLot | null = null;
for (const l of lots) { for (const l of lots) {
if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l; if (
l.listing_date &&
(!best?.listing_date || l.listing_date > best.listing_date)
)
best = l;
} }
return [best?.listing_date ?? null, best?.date_precision ?? null]; return [best?.listing_date ?? null, best?.date_precision ?? null];
} }
@ -636,7 +642,9 @@ function resolveDealTier(
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the // Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
// same way the ads card does — otherwise a single mis-scraped lot bins // same way the ads card does — otherwise a single mis-scraped lot bins
// over [min,max] and crushes the real deals into the left buckets. // over [min,max] and crushes the real deals into the left buckets.
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean), bars: bins8(
guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean,
),
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам, // sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
// так что максимум по ним = максимум по всей выборке, не по показанным. // так что максимум по ним = максимум по всей выборке, не по показанным.
asOf: dealsAsOfLabel(...newestLot(sd.deals)), asOf: dealsAsOfLabel(...newestLot(sd.deals)),
@ -825,11 +833,13 @@ export function mapReport(e: AggregatedEstimate): Report {
* - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg) * - "out_of_coverage" "вне ЕКБ" (the index only covers Yekaterinburg)
* - "insufficient_data" "мало данных" (too few comparable listings) * - "insufficient_data" "мало данных" (too few comparable listings)
*/ */
function locationIndexBadge( function locationIndexBadge(li: LocationIndexResponse | null | undefined): {
li: LocationIndexResponse | null | undefined, label: string;
): { label: string; ok: boolean } { ok: boolean;
} {
if (li == null) return { label: "нет данных", ok: false }; if (li == null) return { label: "нет данных", ok: false };
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true }; if (li.status === "ok")
return { label: fmtPct(li.location_index_pct), ok: true };
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false }; if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
return { label: "мало данных", ok: false }; // status === "insufficient_data" return { label: "мало данных", ok: false }; // status === "insufficient_data"
} }
@ -1063,6 +1073,23 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
} }
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */ /** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
// #2899 — позиция квартиры внутри когорты аналогов: плашка на карточке
// «РЕКОМЕНДОВАННАЯ ЦЕНА». Перцентиль считает бэкенд (1..99, «какая доля аналогов
// дешевле»; null при когорте < 15 — тогда плашки нет, а не «В рынке» по умолчанию).
// Лейбл — по терцилям: ≤33 «Низ рынка», ≥67 «Верх рынка», между — «В рынке»;
// число рядом, чтобы лейбл не читался точнее, чем он есть. Это НЕ
// location_index_pct (район против города) — тот живёт в HeroBar.
export function marketPositionPill(
e: Pick<AggregatedEstimate, "market_percentile" | "n_analogs">,
): { delta: string; deltaLabel: string } | undefined {
const pct = e.market_percentile;
if (pct == null || !Number.isFinite(pct)) return undefined;
const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке";
const n = e.n_analogs;
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`;
return { delta, deltaLabel };
}
export function mapResultPanel( export function mapResultPanel(
e: AggregatedEstimate, e: AggregatedEstimate,
streetDeals?: StreetDealsResponse | null, streetDeals?: StreetDealsResponse | null,
@ -1108,7 +1135,12 @@ export function mapResultPanel(
// 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). // 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,
),
// #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная
// плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null).
...(marketPositionPill(e) ?? {}),
nav: 2, nav: 2,
}, },
{ {
@ -1121,7 +1153,9 @@ export function mapResultPanel(
e.expected_sold_range_high_rub, e.expected_sold_range_high_rub,
) )
: "", : "",
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "", ppm: hasExpected
? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`
: "",
delta: delta:
hasExpected && e.asking_to_sold_ratio != null hasExpected && e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100) ? fmtPct((e.asking_to_sold_ratio - 1) * 100)
@ -2148,7 +2182,10 @@ export function mapSources(
// sample still shown in the table right below this KPI tile) — a bare // sample still shown in the table right below this KPI tile) — a bare
// "0" here would directly contradict visible rows. Fall back to the // "0" here would directly contradict visible rows. Fall back to the
// actual displayed population (same fix as ListingsCard's count-strip). // actual displayed population (same fix as ListingsCard's count-strip).
count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—", count:
e != null
? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length)
: "—",
median: e != null ? fmtMln(e.median_price_rub) : "—", median: e != null ? fmtMln(e.median_price_rub) : "—",
ppm: ppm:
e != null && Number.isFinite(e.median_price_per_m2) e != null && Number.isFinite(e.median_price_per_m2)

View file

@ -10,11 +10,7 @@ export interface QuotaStatus {
} }
export type HouseType = export type HouseType =
| "panel" "panel" | "brick" | "monolith" | "monolith_brick" | "other";
| "brick"
| "monolith"
| "monolith_brick"
| "other";
export type RepairState = "needs_repair" | "standard" | "good" | "excellent"; export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
@ -33,11 +29,19 @@ export const REPAIR_STATES: readonly RepairState[] = [
"good", "good",
"excellent", "excellent",
]; ];
export function asHouseType(v: string | null | undefined): HouseType | undefined { export function asHouseType(
return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined; v: string | null | undefined,
): HouseType | undefined {
return v && (HOUSE_TYPES as readonly string[]).includes(v)
? (v as HouseType)
: undefined;
} }
export function asRepairState(v: string | null | undefined): RepairState | undefined { export function asRepairState(
return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined; v: string | null | undefined,
): RepairState | undefined {
return v && (REPAIR_STATES as readonly string[]).includes(v)
? (v as RepairState)
: undefined;
} }
export type ConfidenceLevel = "low" | "medium" | "high"; export type ConfidenceLevel = "low" | "medium" | "high";
@ -133,7 +137,7 @@ export interface CianValuationSummary {
rent_price_rub: number | null; rent_price_rub: number | null;
chart: Array<{ date: string; price: number }>; chart: Array<{ date: string; price: number }>;
chart_change_pct: number | null; chart_change_pct: number | null;
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null; chart_change_direction: "increase" | "decrease" | "neutral" | null;
} }
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback. // Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
@ -183,6 +187,10 @@ export interface AggregatedEstimate {
confidence: ConfidenceLevel; confidence: ConfidenceLevel;
confidence_explanation: string | null; confidence_explanation: string | null;
n_analogs: number; n_analogs: number;
// #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля
// аналогов дешевле». null/отсутствует = когорта < 15 либо старая оценка;
// показывать только вместе с n_analogs. НЕ location_index_pct (тот про район).
market_percentile?: number | null;
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных) insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена // fix (never-block estimate) — оценка теперь показывается всегда, пока цена
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по // посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по