fix(site-finder): попап конкурента — NUMERIC-поля приходят строкой, рендерим цену и площадь
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m5s
CI / openapi-codegen-check (pull_request) Successful in 2m3s

Прод-verify #2113 вскрыл: avg_price_per_m2_rub и avg_area_pd приходят с бэкенда
СТРОКОЙ («160345», «47.5») — Pydantic v2 сериализует Decimal (ROUND(::numeric))
в строку. Гварды `typeof === "number"` / `Number.isFinite(str)` молча роняли поля:
цена конкурента вообще не показывалась, а добавленная в #2113 «Ср. площадь кв.»
не рендерилась.

- toFiniteNumber(v) — приводит number|string→number|null.
- formatPrice использует его (цена снова показывается).
- avg_area_pd рендерится через toFiniteNumber.
- Типы avg_price_per_m2_rub/avg_area_pd расширены до number|string|null (честно
  отражает рантайм).

Frontend-only, контракт не меняется.
This commit is contained in:
Light1YT 2026-06-30 18:57:48 +05:00
parent 02b5f0b5d1
commit 9d75ca4874
2 changed files with 23 additions and 13 deletions

View file

@ -75,9 +75,19 @@ function formatDistance(m: number | null | undefined): string | null {
return `${Math.round(m).toLocaleString("ru-RU")} м`;
}
function formatPrice(rub: number | null | undefined): string | null {
if (rub === null || rub === undefined || !Number.isFinite(rub)) return null;
return `${Math.round(rub).toLocaleString("ru-RU")} ₽/м²`;
// Бэкенд отдаёт NUMERIC-поля (avg_price_per_m2_rub, avg_area_pd) как СТРОКИ
// («160345», «47.5») — Pydantic v2 сериализует Decimal в строку. Приводим к числу,
// иначе typeof/Number.isFinite-гварды молча роняют поле (попап не показывал цену).
function toFiniteNumber(v: number | string | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = typeof v === "string" ? Number(v) : v;
return Number.isFinite(n) ? n : null;
}
function formatPrice(rub: number | string | null | undefined): string | null {
const n = toFiniteNumber(rub);
if (n === null) return null;
return `${Math.round(n).toLocaleString("ru-RU")} ₽/м²`;
}
function formatReadyDt(iso: string | null | undefined): string | null {
@ -406,12 +416,11 @@ export function MarketLayers({
{priceStr && (
<div style={{ color: "#374151" }}>{priceStr}</div>
)}
{typeof c.avg_area_pd === "number" &&
Number.isFinite(c.avg_area_pd) && (
<div style={{ color: "#374151" }}>
Ср. площадь кв.: {c.avg_area_pd} м²
</div>
)}
{toFiniteNumber(c.avg_area_pd) !== null && (
<div style={{ color: "#374151" }}>
Ср. площадь кв.: {toFiniteNumber(c.avg_area_pd)} м²
</div>
)}
{showSales && (
<div style={{ color: "#374151" }}>
Продано: {c.units_sold ?? 0}

View file

@ -50,13 +50,14 @@ export interface ParcelAnalysisCompetitor {
lon?: number | null;
// Рыночные метрики из objective_lots (LEFT JOIN → null без маппинга).
// Используются в popup-слое (цена/распроданность как velocity-прокси).
avg_price_per_m2_rub?: number | null;
// NUMERIC-поля приходят СТРОКОЙ (Pydantic v2 сериализует Decimal в строку),
// поэтому number | string — приводить к числу через toFiniteNumber перед форматом.
avg_price_per_m2_rub?: number | string | null;
units_sold?: number | null;
units_available?: number | null;
// #2111 — средняя площадь квартиры (м²) из objective_lots. Backend кладёт через
// loose competitors[] dict-passthrough. Optional + nullable: тонкий стаб/старый
// кэш могут не содержать; popup рендерит только при наличии.
avg_area_pd?: number | null;
// loose competitors[] dict-passthrough (Decimal → строка). Optional + nullable.
avg_area_pd?: number | string | null;
}
export interface ParcelAnalysisDistrict {