fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/- zoom + building image + deglue word+word
All checks were successful
CI / changes (pull_request) Successful in 7s
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

Live-browser audit (round 2): РАДИУС/+- dead, building photo missing.
- РАДИУС: backend BE-2 — optional radius_m (int 100–5000) on TradeInEstimateInput,
  threaded into estimate_quality comp search (base + fallback). None preserves the
  exact two-tier default (1000m primary / 2000m fallback) byte-identically. FE: РАДИУС
  dropdown with **Авто** default (sends no radius_m → legacy behaviour, NOT a silent
  500m narrowing) + 300/500/1000/2000 overrides; included as radius_m on submit; outer
  map ring tracks the value.
- Map +/− buttons: now a real zoom (transform: scale() on the map content layer, step
  .25, clamp .6–2.5); controls/bracket stay unscaled.
- building.png: next/image didn't apply basePath → 400; switched to plain <img> with
  NEXT_PUBLIC_BASE_PATH-prefixed src (→ 200) + onError fallback. HERO АДРЕС card шоу.
- deglueAddr: also splits word+word glue ('БольшаковаГеологическая'→'Большакова
  Геологическая'), still preserves house letters (14А, д.5К).

next build green (/v2 34.3 kB); estimator None-default byte-identical (ge=100 rules out
falsy-0); code-reviewer  after the Авто-default fix (avoided a default-radius regression).
This commit is contained in:
bot-backend 2026-06-28 18:03:07 +03:00
parent fb25180929
commit e23dabe4f5
6 changed files with 136 additions and 30 deletions

View file

@ -27,6 +27,11 @@ class TradeInEstimateInput(BaseModel):
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе). # geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90) lat: float | None = Field(default=None, ge=-90, le=90)
lon: float | None = Field(default=None, ge=-180, le=180) lon: float | None = Field(default=None, ge=-180, le=180)
# #2044: опциональный радиус анализа (контрол РАДИУС на /trade-in/v2), метры.
# None → текущее дефолтное поведение (1000 м поиск аналогов, 2000 м fallback).
# Задан → и первичный, и fallback-поиск аналогов/сделок используют ровно этот
# радиус (без авто-расширения). Диапазон 1005000 м — sane guard.
radius_m: int | None = Field(default=None, ge=100, le=5000)
# CRM-поля (#395) — операционные, на расчёт оценки не влияют # CRM-поля (#395) — операционные, на расчёт оценки не влияют
ownership_type: str | None = Field(default=None, max_length=100) ownership_type: str | None = Field(default=None, max_length=100)
has_mortgage: bool | None = None has_mortgage: bool | None = None

View file

@ -2592,6 +2592,13 @@ async def estimate_quality(
# a) 1km + ±15% area (без cohort — drop fallback) # a) 1km + ±15% area (без cohort — drop fallback)
# b) 2km + ±15% area (fallback_used = True) # b) 2km + ±15% area (fallback_used = True)
# c) 2km + ±25% area (fallback_used = True, area_widened = True) # c) 2km + ±25% area (fallback_used = True, area_widened = True)
#
# #2044: опциональный override радиуса анализа (контрол РАДИУС). None →
# точное текущее поведение (DEFAULT 1000 м первичный, FALLBACK 2000 м
# расширение). Задан → и первичный, и fallback-поиск используют ровно этот
# радиус (он же — максимум, без авто-расширения за пределы выбранного).
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M
cohort_range = _target_cohort_range(target_year) cohort_range = _target_cohort_range(target_year)
if cohort_range is not None: if cohort_range is not None:
@ -2602,7 +2609,7 @@ async def estimate_quality(
lon=geo.lon, lon=geo.lon,
rooms=payload.rooms, rooms=payload.rooms,
area=payload.area_m2, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M, radius_m=base_radius_m,
full_address=geo.full_address, full_address=geo.full_address,
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
@ -2626,7 +2633,7 @@ async def estimate_quality(
lon=geo.lon, lon=geo.lon,
rooms=payload.rooms, rooms=payload.rooms,
area=payload.area_m2, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M, radius_m=base_radius_m,
full_address=geo.full_address, full_address=geo.full_address,
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
@ -2642,7 +2649,7 @@ async def estimate_quality(
lon=geo.lon, lon=geo.lon,
rooms=payload.rooms, rooms=payload.rooms,
area=payload.area_m2, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M, radius_m=fallback_radius_m,
full_address=geo.full_address, full_address=geo.full_address,
target_house_id=target_house_id, target_house_id=target_house_id,
year_built=target_year, year_built=target_year,
@ -2663,7 +2670,7 @@ async def estimate_quality(
lon=geo.lon, lon=geo.lon,
rooms=payload.rooms, rooms=payload.rooms,
area=payload.area_m2, area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M, radius_m=fallback_radius_m,
area_tolerance=0.25, area_tolerance=0.25,
full_address=geo.full_address, full_address=geo.full_address,
target_house_id=target_house_id, target_house_id=target_house_id,
@ -2877,7 +2884,7 @@ async def estimate_quality(
lon=geo.lon, lon=geo.lon,
rooms=payload.rooms, rooms=payload.rooms,
area=payload.area_m2, area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M, radius_m=base_radius_m,
) )
# 6. Сохраняем в trade_in_estimates # 6. Сохраняем в trade_in_estimates

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, type CSSProperties } from "react"; import { useState, type CSSProperties } from "react";
import Image from "next/image";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
@ -13,6 +12,11 @@ import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures. // Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object }; const HERO_FIXTURE: HeroBarData = { report, object };
// next/image does NOT prepend the configured basePath ("/trade-in") to a
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
// <img> with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200.
const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// estimate ids are server-issued UUIDs — reject anything else before it lands // estimate ids are server-issued UUIDs — reject anything else before it lands
// in the PDF request path so a tampered id cannot be injected. // in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE = const PDF_UUID_RE =
@ -244,14 +248,15 @@ export default function HeroBar({
}} }}
> >
{!imgFailed && ( {!imgFailed && (
<Image <img
src="/trade-in-v2/building.png" src={`${BP}/trade-in-v2/building.png`}
alt="" alt=""
fill
sizes="560px"
unoptimized
onError={() => setImgFailed(true)} onError={() => setImgFailed(true)}
style={{ style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain", objectFit: "contain",
objectPosition: "right center", objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)", filter: "saturate(.9) brightness(1.04)",

View file

@ -7,8 +7,9 @@
// styles are UNCHANGED — only the data plumbing differs (display <div>s became // styles are UNCHANGED — only the data plumbing differs (display <div>s became
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown // <input>s styled identically, dropdowns now feed real enum values). RU dropdown
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers. // labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled // РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
// (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>. // the CRM dropdown still has no backend → kept visually but disabled. Hover/active
// + @keyframes live in a pp-prefixed local <style>.
import { useRef, useState, type CSSProperties } from "react"; import { useRef, useState, type CSSProperties } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
@ -28,7 +29,7 @@ import type {
TradeInEstimateInput, TradeInEstimateInput,
} from "@/types/trade-in"; } from "@/types/trade-in";
type DdKey = "rooms" | "houseType" | "repair" | null; type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
interface DdProps { interface DdProps {
open: boolean; open: boolean;
@ -273,6 +274,21 @@ const analogPin: CSSProperties = {
color: tokens.ink, color: tokens.ink,
}; };
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow), sized to the narrow radius trigger and dropped just beneath it.
const radiusPanel: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
top: "calc(100% + 4px)",
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
};
interface ParamsPanelProps { interface ParamsPanelProps {
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so /** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
* the still-unwired v2 page renders without props (tsc-safe). */ * the still-unwired v2 page renders without props (tsc-safe). */
@ -305,6 +321,19 @@ function initRepairLabel(rs: RepairState | undefined): string {
return rs ? REPAIR_RU[rs] : "Хороший"; return rs ? REPAIR_RU[rs] : "Хороший";
} }
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
// default), so a re-submit never silently narrows the search.
function initRadiusLabel(radiusM: number | null | undefined): string {
if (radiusM == null) return "Авто";
const label = `${radiusM} м`;
return RADIUS_OPTIONS.includes(label) ? label : "Авто";
}
export default function ParamsPanel({ export default function ParamsPanel({
onSubmit, onSubmit,
isPending = false, isPending = false,
@ -313,6 +342,9 @@ export default function ParamsPanel({
markers = [], markers = [],
}: ParamsPanelProps) { }: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null); const [openDd, setOpenDd] = useState<DdKey>(null);
// Map zoom — applied as transform: scale() on the map content layer (not the
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp .62.5.
const [zoom, setZoom] = useState(1);
const [address, setAddress] = useState(initialValues?.address ?? ""); const [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState( const [area, setArea] = useState(
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "", initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
@ -335,6 +367,9 @@ export default function ParamsPanel({
const [repair, setRepair] = useState( const [repair, setRepair] = useState(
initRepairLabel(initialValues?.repair_state), initRepairLabel(initialValues?.repair_state),
); );
const [radius, setRadius] = useState(
initRadiusLabel(initialValues?.radius_m),
);
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true); const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
@ -368,9 +403,6 @@ export default function ParamsPanel({
setSuggestOpen(false); setSuggestOpen(false);
}; };
// TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) —
// дропдаун оставлен визуально, но non-functional.
const radius = "500 м";
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально, // TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
// но неактивен (выбор ничего не делает). // но неактивен (выбор ничего не делает).
const crm = dropdownOptions.crm[0]; const crm = dropdownOptions.crm[0];
@ -411,6 +443,10 @@ export default function ParamsPanel({
has_balcony: balcony, has_balcony: balcony,
lat: coords?.lat ?? null, lat: coords?.lat ?? null,
lon: coords?.lon ?? null, lon: coords?.lon ?? null,
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
radius_m: radius === "Авто" ? null : parseInt(radius, 10),
}); });
}; };
@ -438,6 +474,14 @@ export default function ParamsPanel({
transition: "all .15s", transition: "all .15s",
}; };
// Outer radius ring tracks the selected РАДИУС (subtle, best-effort): 500 м
// keeps the design's r=112 exactly; other values scale gently (and clamp) so the
// ring never blows past the map box. The inner two rings stay fixed.
const radiusM = parseInt(radius, 10);
const outerRingR = Number.isFinite(radiusM)
? Math.round(Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))))
: 112;
return ( return (
<div <div
style={{ style={{
@ -524,6 +568,18 @@ export default function ParamsPanel({
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
{/* zoomable map content — scaled by the +/ controls. The map controls
and corner bracket below are SIBLINGS (outside this layer) so they
never scale. zoom=1 scale() identity pixel-identical to design. */}
<div
style={{
position: "absolute",
inset: 0,
transform: `scale(${zoom})`,
transformOrigin: "center",
transition: "transform .15s ease-out",
}}
>
<svg <svg
viewBox="0 0 440 210" viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
@ -552,7 +608,7 @@ export default function ParamsPanel({
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4"> <g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} /> <circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} /> <circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r="112" opacity={0.28} /> <circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
</g> </g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} /> <circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg> </svg>
@ -615,6 +671,7 @@ export default function ParamsPanel({
<span style={{ color: tokens.muted }}>{m.sub}</span> <span style={{ color: tokens.muted }}>{m.sub}</span>
</div> </div>
))} ))}
</div>
{/* map controls */} {/* map controls */}
<div <div
@ -642,6 +699,7 @@ export default function ParamsPanel({
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
}} }}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
> >
+ +
</div> </div>
@ -660,6 +718,7 @@ export default function ParamsPanel({
fontSize: 15, fontSize: 15,
color: tokens.muted, color: tokens.muted,
}} }}
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
> >
</div> </div>
@ -715,26 +774,50 @@ export default function ParamsPanel({
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}> <span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
РАДИУС АНАЛИЗА РАДИУС АНАЛИЗА
</span> </span>
{/* РАДИУС non-functional (no backend for radius selection yet). Markup {/* РАДИУС selectable analysis radius (wired: parsed to radius_m on
kept; trigger is static (no toggle / dropdown panel). TODO(wire). */} submit). Trigger markup unchanged; now toggles a Dd-style option panel
and uses the shared pp-dd-trigger hover cue. */}
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<div <div
className="pp-dd-trigger"
onClick={() => toggle("radius")}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
background: tokens.surface.w60, background: tokens.surface.w60,
border: `1px solid ${tokens.line}`,
borderRadius: 5, borderRadius: 5,
padding: "6px 10px", padding: "6px 10px",
fontSize: 12, fontSize: 12,
fontFamily: tokens.font.mono, fontFamily: tokens.font.mono,
cursor: "default", cursor: "pointer",
}} }}
> >
<span>{radius}</span> <span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span> <span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</div> </div>
{openDd === "radius" && (
<div style={radiusPanel}>
{RADIUS_OPTIONS.map((o) => (
<div
key={o}
className="pp-dd-opt"
onClick={() => pick(setRadius)(o)}
style={{
padding: "7px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
borderRadius: 4,
cursor: "pointer",
color: o === radius ? tokens.accent : tokens.ink,
fontWeight: o === radius ? 600 : 400,
}}
>
{o}
</div>
))}
</div>
)}
</div> </div>
<div <div
style={{ style={{

View file

@ -1040,16 +1040,19 @@ function dealMeta(l: AnalogLot): string {
} }
/** /**
* Finding #7 de-glue addresses. Some geocoded analog/deal addresses run a * Finding #7 de-glue addresses. Some geocoded analog/deal addresses run two
* metro/word straight onto the house number ("…, 69/2Геологическая"). Insert a * tokens together with no separator either a word glued onto the house number
* space where a digit is immediately followed by the start of a Cyrillic WORD * ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая").
* (capital + lowercase). NB: deliberately NOT the looser /(\d)([А-ЯЁ])/ that * Insert a space wherever a digit OR a lowercase letter is immediately followed
* would also split legitimate single-letter house litereras (14А, 5Б, д.5К), * by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing
* which are valid ЕКБ house numbers. Pure display cleanup; never touches the * lowercase in [А-ЯЁ][а-яё] is deliberate it means we do NOT split legitimate
* comma-splitting parseAddress. * single-letter house litereras (14А, 5Б, д.5К capital NOT followed by
* lowercase) and do NOT touch already-separated tokens ("ул.Белинского" the
* capital is preceded by ".", not a glue char). Pure display cleanup; never
* touches the comma-splitting parseAddress.
*/ */
function deglueAddr(addr: string): string { function deglueAddr(addr: string): string {
return addr.replace(/(\d)([А-ЯЁ][а-яё])/g, "$1 $2"); return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2");
} }
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ───────────────────────────────────────── // ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────

View file

@ -68,6 +68,9 @@ export interface TradeInEstimateInput {
// для адресов, которые DaData/Yandex не резолвят по строке. // для адресов, которые DaData/Yandex не резолвят по строке.
lat?: number | null; lat?: number | null;
lon?: number | null; lon?: number | null;
// Радиус поиска аналогов/сделок в метрах (опциональный override дефолта бэкенда).
// Зеркалит новое поле request-схемы; absent → бэкенд применяет свой дефолт.
radius_m?: number | null;
} }
export interface AnalogLot { export interface AnalogLot {