Merge pull request 'fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/− zoom + building image + deglue word+word' (#2060) from feat/tradein-v2-audit-fixes-2 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / build-frontend (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 6m40s
Deploy Trade-In / deploy (push) Successful in 1m7s

This commit is contained in:
lekss361 2026-06-28 15:04:08 +00:00
commit bcbbb53495
6 changed files with 136 additions and 30 deletions

View file

@ -27,6 +27,11 @@ class TradeInEstimateInput(BaseModel):
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90)
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) — операционные, на расчёт оценки не влияют
ownership_type: str | None = Field(default=None, max_length=100)
has_mortgage: bool | None = None

View file

@ -2592,6 +2592,13 @@ async def estimate_quality(
# a) 1km + ±15% area (без cohort — drop fallback)
# b) 2km + ±15% area (fallback_used = 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)
if cohort_range is not None:
@ -2602,7 +2609,7 @@ async def estimate_quality(
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M,
radius_m=base_radius_m,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
@ -2626,7 +2633,7 @@ async def estimate_quality(
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M,
radius_m=base_radius_m,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
@ -2642,7 +2649,7 @@ async def estimate_quality(
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M,
radius_m=fallback_radius_m,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
@ -2663,7 +2670,7 @@ async def estimate_quality(
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=FALLBACK_RADIUS_M,
radius_m=fallback_radius_m,
area_tolerance=0.25,
full_address=geo.full_address,
target_house_id=target_house_id,
@ -2877,7 +2884,7 @@ async def estimate_quality(
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=DEFAULT_RADIUS_M,
radius_m=base_radius_m,
)
# 6. Сохраняем в trade_in_estimates

View file

@ -1,7 +1,6 @@
"use client";
import { useState, type CSSProperties } from "react";
import Image from "next/image";
import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl";
@ -13,6 +12,11 @@ import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
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
// in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE =
@ -244,14 +248,15 @@ export default function HeroBar({
}}
>
{!imgFailed && (
<Image
src="/trade-in-v2/building.png"
<img
src={`${BP}/trade-in-v2/building.png`}
alt=""
fill
sizes="560px"
unoptimized
onError={() => setImgFailed(true)}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)",

View file

@ -7,8 +7,9 @@
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled
// (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>.
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
// 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 { tokens } from "./tokens";
@ -28,7 +29,7 @@ import type {
TradeInEstimateInput,
} from "@/types/trade-in";
type DdKey = "rooms" | "houseType" | "repair" | null;
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
interface DdProps {
open: boolean;
@ -273,6 +274,21 @@ const analogPin: CSSProperties = {
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 {
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
* 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] : "Хороший";
}
// РАДИУС 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({
onSubmit,
isPending = false,
@ -313,6 +342,9 @@ export default function ParamsPanel({
markers = [],
}: ParamsPanelProps) {
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 [area, setArea] = useState(
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
@ -335,6 +367,9 @@ export default function ParamsPanel({
const [repair, setRepair] = useState(
initRepairLabel(initialValues?.repair_state),
);
const [radius, setRadius] = useState(
initRadiusLabel(initialValues?.radius_m),
);
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
const [validationError, setValidationError] = useState<string | null>(null);
@ -368,9 +403,6 @@ export default function ParamsPanel({
setSuggestOpen(false);
};
// TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) —
// дропдаун оставлен визуально, но non-functional.
const radius = "500 м";
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
// но неактивен (выбор ничего не делает).
const crm = dropdownOptions.crm[0];
@ -411,6 +443,10 @@ export default function ParamsPanel({
has_balcony: balcony,
lat: coords?.lat ?? 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",
};
// 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 (
<div
style={{
@ -523,6 +567,18 @@ export default function ParamsPanel({
background: tokens.mapBg,
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
viewBox="0 0 440 210"
@ -552,7 +608,7 @@ export default function ParamsPanel({
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<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="112" opacity={0.28} />
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
@ -615,6 +671,7 @@ export default function ParamsPanel({
<span style={{ color: tokens.muted }}>{m.sub}</span>
</div>
))}
</div>
{/* map controls */}
<div
@ -642,6 +699,7 @@ export default function ParamsPanel({
fontSize: 15,
color: tokens.muted,
}}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
>
+
</div>
@ -660,6 +718,7 @@ export default function ParamsPanel({
fontSize: 15,
color: tokens.muted,
}}
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
>
</div>
@ -715,26 +774,50 @@ export default function ParamsPanel({
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
РАДИУС АНАЛИЗА
</span>
{/* РАДИУС non-functional (no backend for radius selection yet). Markup
kept; trigger is static (no toggle / dropdown panel). TODO(wire). */}
{/* РАДИУС selectable analysis radius (wired: parsed to radius_m on
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
className="pp-dd-trigger"
onClick={() => toggle("radius")}
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: tokens.surface.w60,
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "6px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
cursor: "default",
cursor: "pointer",
}}
>
<span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</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
style={{

View file

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

View file

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