From 2779ef5e79f4cb6b01ff67e34b65b2f22ee32b4c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 17:08:46 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(tradein/ui):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B2=20=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20+=20=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=BA=20=D0=BD=D0=B5=D0=BE=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20(#2576?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше интерфейс город вообще не передавал — backend (#2580) больше не подставляет "Екатеринбург" молча, из-за чего житель Нижнего Тагила, вводя «Ленина, 1», получал бы результат по одноимённой екатеринбургской улице. - Новый справочник src/lib/city-registry.ts (растущий список городов области, сейчас: Екатеринбург, Нижний Тагил, Каменск-Уральский, Первоуральск, Верхняя Пышма, Серов) — DEFAULT_CITY = Екатеринбург, чтобы ЕКБ-сценарий не требовал никаких лишних действий. - ParamsPanel: компактный дропдаун «Город» рядом с лейблом адреса (переиспользует существующий
HUD-комбобокс) + автоопределение города из набранного текста/выбранной подсказки (detectCityInText, word-boundary safe — не путает "Серов" с "ул. Серова" в ЕКБ). city_hint уходит в geocode/suggest и в POST /trade-in/estimate. - useGeocodeSuggest(query, cityHint, limit) — city_hint в query-параметрах и в queryKey, чтобы смена города рефетчила подсказки. - Честная подсказка в ParamsPanel, когда estimate.target_city_ambiguous===true: спокойный (не danger) текст «Город определён автоматически — результат может относиться к другому населённому пункту области. Если это не {city}, выберите верный город выше и повторите оценку.» — не блокирует форму. - types/trade-in.ts: TradeInEstimateInput.city_hint, AggregatedEstimate.target_city_ambiguous (зеркалит backend PR #2580, ещё не смёржен — codegen не запускался, поля добавлены вручную по контракту схемы). tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в несвязанных файлах). --- tradein-mvp/frontend/src/app/v2/page.tsx | 3 + .../components/trade-in/v2/ParamsPanel.tsx | 115 ++++++++++++++++-- tradein-mvp/frontend/src/lib/city-registry.ts | 68 +++++++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 39 ++++-- tradein-mvp/frontend/src/types/trade-in.ts | 13 ++ 5 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/city-registry.ts diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index fa443482..61400634 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -1029,6 +1029,9 @@ export default function TradeInV2Page() { error={apiError} initialValues={initialValues} markers={markers} + // #2576 — city_hint contract (backend PR #2580): honest + // heads-up when the geocoder picked the city itself. + cityAmbiguous={estimate?.target_city_ambiguous ?? false} /> {middleContent} {/* #2275: on mobile ObjectSummary is rendered fluid in the diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 251e66e3..753d3949 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -38,6 +38,11 @@ import { type MapMarker, } from "./mappers"; import { useGeocodeSuggest } from "@/lib/trade-in-api"; +import { + CITY_LABELS, + DEFAULT_CITY, + detectCityInText, +} from "@/lib/city-registry"; import type { GeocodeSuggestion, HouseType, @@ -45,7 +50,7 @@ import type { TradeInEstimateInput, } from "@/types/trade-in"; -type DdKey = "rooms" | "houseType" | "repair" | "radius" | null; +type DdKey = "rooms" | "houseType" | "repair" | "radius" | "city" | null; // Russian plural picker (one / few / many) for SR announcements. function pluralRu(n: number, one: string, few: string, many: string): string { @@ -523,6 +528,18 @@ const errorText: CSSProperties = { color: tokens.danger, }; +// #2576 — honest, calm (не danger) heads-up когда бэкенд вернул +// target_city_ambiguous: true: город определил геокодер, не пользователь. +// Тот же тон, что и другие честные caption'ы в этой панели (не паника, не +// ошибка формы — просто прозрачность + предложение уточнить). +const cityAmbiguousText: CSSProperties = { + marginTop: 4, + fontSize: 10.5, + letterSpacing: 0.3, + lineHeight: 1.4, + color: tokens.warn, +}; + // РАДИУС dropdown panel — mirrors the
HUD panel (surface.w98 + soft blue // shadow), sized to the narrow radius trigger and dropped just beneath it. const radiusPanel: CSSProperties = { @@ -551,6 +568,12 @@ interface ParamsPanelProps { error?: string | null; /** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */ initialValues?: Partial; + /** + * #2576: true когда последняя оценка вернулась с `target_city_ambiguous` — + * геокодер сам угадал город (пользователь его не назвал явно). Рисует + * честную некритичную подсказку у поля адреса, не блокирует форму. + */ + cityAmbiguous?: boolean; /** Analog price pins, projected from the real estimate via mapMarkers() onto * the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see * mapMarkers() comment in ./mappers.ts). Kept in the prop contract for @@ -578,6 +601,14 @@ function initRepairLabel(rs: RepairState | undefined): string { return rs ? REPAIR_RU[rs] : "Не указано"; } +// Город формы (#2576). Restore-by-id / re-estimate: best-effort detect из +// сохранённого адреса (иначе сброс на Екатеринбург молча увёл бы восстановленный +// отчёт другого города обратно на ЕКБ-подсказки). Новая форма без initialValues +// → DEFAULT_CITY ("Екатеринбург") — ЕКБ-сценарий без лишних действий. +function initCityLabel(address: string | undefined): string { + return detectCityInText(address) ?? DEFAULT_CITY.label; +} + // РАДИУС 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. @@ -603,6 +634,7 @@ export default function ParamsPanel({ hasEstimate = false, error = null, initialValues, + cityAmbiguous = false, // markers intentionally not destructured — see the ParamsPanelProps.markers // doc comment: its %-positions belong to the retired decorative SVG grid and // do not correspond to real lat/lon on the Leaflet map below. @@ -615,6 +647,10 @@ export default function ParamsPanel({ const radiusListId = useId(); const [radiusActive, setRadiusActive] = useState(-1); const [address, setAddress] = useState(initialValues?.address ?? ""); + // Город формы (#2576 city_hint) — см. initCityLabel doc comment. Independent + // control от адреса: меняется через дропдаун ИЛИ автоопределяется из + // набранного/выбранного текста (см. handleAddressChange/pickSuggestion). + const [city, setCity] = useState(initCityLabel(initialValues?.address)); const [area, setArea] = useState( initialValues?.area_m2 != null ? String(initialValues.area_m2) : "", ); @@ -676,7 +712,11 @@ export default function ParamsPanel({ // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); - const suggest = useGeocodeSuggest(addressQuery); + // #2576: подсказки скопированы по выбранному городу (city_hint) — без него + // геокодер больше не подставляет Екатеринбург молча, а без hint'а + // автокомплит для остальной области смещается на всю область вместо + // конкретного города. Переключение city обязано рефетчить (city в queryKey). + const suggest = useGeocodeSuggest(addressQuery, city); const handleAddressChange = (v: string) => { setAddress(v); @@ -686,6 +726,12 @@ export default function ParamsPanel({ setAddressActive(-1); // a new query invalidates the previous highlight if (fieldErrors.address) setFieldErrors((prev) => ({ ...prev, address: undefined })); + // Autodetect (#2576): если пользователь сам печатает город в тексте + // адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу — + // не ждём выбора подсказки. Не дёргаем setCity без совпадения (город + // остаётся тем, что выбран ранее — обычно ЕКБ по умолчанию). + const detected = detectCityInText(v); + if (detected) setCity(detected); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setAddressQuery(v), 200); }; @@ -697,6 +743,21 @@ export default function ParamsPanel({ setFias(s.fias_id ?? null); setSuggestOpen(false); setAddressActive(-1); + // Autodetect (#2576): подсказка может называть город, отличный от + // текущего выбора (bias мягкий, не жёсткий фильтр — см. + // backend/app/services/geocoder.py::_yandex_bias) — подхватываем его. + const detected = detectCityInText(s.full_address); + if (detected) setCity(detected); + }; + + // Явный выбор города из дропдауна. Сбрасывает ранее выбранные + // координаты/ФИАС — тот же текст адреса под другим городом это уже другая + // точка (зеркалит handleAddressChange's stale-guard для ручной правки). + const selectCity = (v: string) => { + setCity(v); + setOpenDd(null); + setCoords(null); + setFias(null); }; // Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on @@ -830,6 +891,10 @@ export default function ParamsPanel({ lon: coords?.lon ?? null, // Детерминированный ключ дома — только из выбранной подсказки (иначе null). target_fias_id: fias, + // #2576 — город, выбранный/автоопределённый в форме (city-registry.ts). + // Дефолт "Екатеринбург" воспроизводит прежнее молчаливое поведение + // геокодера, так что ЕКБ-сценарий не меняется. + city_hint: city, // "Авто" → null → backend keeps its two-tier default (1000 m primary / // 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по // умолчанию — это сузило бы поиск аналогов и убрало fallback.) @@ -1384,12 +1449,38 @@ export default function ParamsPanel({ }} >
- + + {/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не + отдельная строка: не отнимает вертикальное место у и так плотной + панели. Дефолт "Екатеринбург" — тот же город, что геокодер + раньше подставлял молча, так что ЕКБ-сценарий не требует + никакого лишнего действия. Список растёт — см. city-registry.ts. */} +
+
toggle("city")} + value={city} + options={CITY_LABELS} + onSelect={selectCity} + mono={false} + triggerFontSize={11} + optionFontSize={11.5} + triggerHeight={22} + variant="dashed" + ariaLabel="Город" + /> + +
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text is debounced into `addressQuery`; the dropdown below mirrors the @@ -1508,6 +1599,16 @@ export default function ParamsPanel({ {fieldErrors.address}
)} + {/* #2576 — честная, не блокирующая подсказка: последняя оценка + вернулась с target_city_ambiguous, город определил геокодер + сам. role="status" (не alert) — это информация, не ошибка. */} + {cityAmbiguous && !fieldErrors.address && ( +
+ Город определён автоматически — результат может относиться к + другому населённому пункту области. Если это не {city}, + выберите верный город выше и повторите оценку. +
+ )} diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts new file mode 100644 index 00000000..752fe558 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -0,0 +1,68 @@ +/** + * city-registry — единый источник правды по городам Свердловской области, + * поддержанным сбором «Меры» (#2576 этап B: расширение на область). + * + * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного + * города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал + * уверенно неверную цену по одноимённой екатеринбургской улице (backend fix + * #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint` + * + честный `AggregatedEstimate.target_city_ambiguous`). + * + * Список городов растёт по мере расширения покрытия скрейперами — не хардкодь + * города россыпью по компонентам, добавляй сюда (мирроит паттерн source-registry.ts). + */ + +export interface OblastCity { + /** Стабильный id (React key / будущая аналитика) — НЕ отправляется бэкенду. */ + readonly id: string; + /** + * Человекочитаемое имя — ровно то, что отправляется бэкенду как `city_hint` + * (backend/app/services/geocoder.py::_resolve_city_for_geocode принимает + * произвольный текст города, не enum/slug). + */ + readonly label: string; +} + +// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок +// остальных — по охвату сбора, не алфавитный. +export const OBLAST_CITIES: readonly OblastCity[] = [ + { id: "ekaterinburg", label: "Екатеринбург" }, + { id: "nizhny_tagil", label: "Нижний Тагил" }, + { id: "kamensk_uralsky", label: "Каменск-Уральский" }, + { id: "pervouralsk", label: "Первоуральск" }, + { id: "verkhnyaya_pyshma", label: "Верхняя Пышма" }, + { id: "serov", label: "Серов" }, +]; + +/** + * Дефолт формы — Екатеринбург. Держит основной сценарий без дополнительных + * действий пользователя: `city_hint` отправляется всегда, но для ЕКБ это тот + * же текст, что геокодер раньше подставлял молча, так что happy path не + * меняется ни на пиксель. + */ +export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; + +/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ +export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); + +/** + * Best-effort поиск известного города области как ЦЕЛОГО токена в + * произвольном тексте (набранный адрес / `full_address` выбранной подсказки) + * — автоопределение города наравне с явным выбором из дропдауна. + * + * Токен обязан быть delimited началом/концом строки, запятой или пробелом — + * иначе "Серов" ложно сматчился бы внутри "ул. Серова" (реальная + * екатеринбургская улица, названная в честь лётчика Серова) и увёл бы выбор + * города от Екатеринбурга на Серов. JS `\b` не работает вокруг кириллицы + * (только ASCII `\w`), поэтому границы токена — явные символьные классы, как + * в v2/mappers.ts::STREET_RE. + */ +export function detectCityInText(text: string | null | undefined): string | undefined { + if (!text) return undefined; + return OBLAST_CITIES.find((c) => cityTokenRe(c.label).test(text))?.label; +} + +function cityTokenRe(label: string): RegExp { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[,\\s])${escaped}([,\\s]|$)`, "i"); +} diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index b3982e11..9e9a4dc0 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -257,22 +257,35 @@ export function useEstimateHistory(limit = 50) { } /** - * GET /api/v1/geocode/suggest?q=&limit= - * Автокомплит адресов в пределах ЕКБ для поля адреса (ParamsPanel). Debounce- - * friendly: вызывающий компонент дебаунсит строку query, хук кешируется по - * queryKey; `enabled` срабатывает только начиная с 3 символов (бэкенд min 2, - * берём 3 чтобы не дёргать на 1-2 символа). `select` разворачивает обёртку - * {items} → GeocodeSuggestion[]; keepPreviousData убирает мерцание списка между - * последовательными запросами. + * GET /api/v1/geocode/suggest?q=&limit=&city_hint= + * Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel). + * Debounce-friendly: вызывающий компонент дебаунсит строку query, хук + * кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов + * (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select` + * разворачивает обёртку {items} → GeocodeSuggestion[]; keepPreviousData + * убирает мерцание списка между последовательными запросами. + * + * `cityHint` — #2576 (backend PR #2580): без него геокодер больше НЕ + * подставляет "Екатеринбург" молча (см. src/lib/city-registry.ts — форма + * держит дефолт "Екатеринбург", так что ЕКБ-сценарий не деградирует). Часть + * queryKey — переключение города в форме обязано рефетчить подсказки. */ -export function useGeocodeSuggest(query: string, limit = 8) { +export function useGeocodeSuggest( + query: string, + cityHint?: string | null, + limit = 8, +) { const q = query.trim(); + const hint = (cityHint ?? "").trim(); return useQuery({ - queryKey: ["trade-in", "geocode-suggest", q, limit], - queryFn: () => - apiFetch( - `${GEOCODE_BASE}/suggest?q=${encodeURIComponent(q)}&limit=${limit}`, - ), + queryKey: ["trade-in", "geocode-suggest", q, hint, limit], + queryFn: () => { + const params = new URLSearchParams({ q, limit: String(limit) }); + if (hint) params.set("city_hint", hint); + return apiFetch( + `${GEOCODE_BASE}/suggest?${params.toString()}`, + ); + }, select: (r) => r.items, enabled: q.length >= 3, staleTime: 5 * 60_000, diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 296882dc..10f63135 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -75,6 +75,13 @@ export interface TradeInEstimateInput { // до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки; // сбрасывается в null при ручной правке адреса или сдвиге пина на карте. target_fias_id?: string | null; + // #2576 (backend PR #2580): город, выбранный пользователем в форме (см. + // src/lib/city-registry.ts) — без него геокодер больше НЕ подставляет + // "Екатеринбург" молча (житель другого города области раньше получал + // уверенно неверную цену по одноимённой екатеринбургской улице). Дефолт формы + // уже "Екатеринбург" (city-registry.ts::DEFAULT_CITY), так что ЕКБ-сценарий + // отправляет ровно то, что геокодер раньше подставлял сам. + city_hint?: string | null; } export interface AnalogLot { @@ -169,6 +176,12 @@ export interface AggregatedEstimate { target_address: string | null; target_lat: number | null; target_lon: number | null; + // #2576 (backend PR #2580): true если ни адрес, ни `TradeInEstimateInput + // .city_hint` не называли город явно — итоговый город (и, соответственно, + // набор аналогов/цена) определил геокодер-провайдер, а не пользователь. + // Ephemeral (не персистится в БД, только для текущего ответа) — optional, + // т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат. + target_city_ambiguous?: boolean; sources_used: string[]; // ['avito', 'cian', 'rosreestr'] // #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean), // счётчики аналогов по источнику, момент создания оценки. Все optional: старые From a8d29fa031fedb83945aa83b882fb9126d765578 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:15:40 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D1=8C=20city=5Fhint=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=8F=D0=B2=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=20=D0=B8=D0=B7=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B0=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review R2 на #2580/#2576: предыдущий коммит слал city_hint="Екатеринбург" даже когда дропдаун не тронут — бэкенд трактует ЛЮБОЙ city_hint как «пользователь назвал город» (city_specified=True), так что target_city_ambiguous становился false практически всегда, а необнаруженный житель Нижнего Тагила («Ленина, 1» без явного упоминания города) молча резолвился бы в Екатеринбург — ровно баг, который чинил backend, только переехавший из geocoder.py в city-registry.ts. Вариант A (по рекомендации ревьюера): город реально известен (и поэтому отправляется в city_hint) ТОЛЬКО когда пользователь явно выбрал его в дропдауне ИЛИ detectCityInText нашёл совпадение в наборном тексте / выбранной подсказке. Нетронутый дефолт → city_hint не уходит вовсе (ни в geocode/suggest, ни в POST /estimate) — тогда backend честно возвращает target_city_ambiguous и не форсит ЕКБ-bias без запроса. - Новое состояние `cityConfirmed` (ParamsPanel.tsx) — гейт на отправку, раздельный от `city` (best-guess для отображения/текста плашки). true после explicit dropdown pick ИЛИ автодетекта из текста/подсказки; sticky — мелкая правка адреса без нового совпадения его не сбрасывает. - До подтверждения дропдаун показывает `UNCONFIRMED_CITY_LABEL` ("Определить автоматически"), не статичное "Екатеринбург" — не выдаёт внутренний best-guess за подтверждённый пользователем выбор. - useGeocodeSuggest получает city_hint только при cityConfirmed=true — для нетронутой формы автокомплит тоже больше не форсит ЕКБ-bias молча, а видит кандидатов из всей области (в т.ч. Нижний Тагил) — это и есть тот сценарий из заголовка эпика. - ЕКБ happy path не усложнён: как только пользователь печатает город в адресе или (обычный путь) выбирает любую подсказку из автокомплита, detectCityInText почти всегда находит "Екатеринбург" в full_address (провайдер возвращает город как часть резолвленного адреса независимо от того, был ли отправлен hint) — дропдаун сам переключается на "Екатеринбург" и cityConfirmed становится true без отдельного клика. Требует лишнего действия только редкий путь "напечатал произвольный адрес без города и нажал Enter, не выбрав ни одной подсказки". - city-registry.ts: явный комментарий-ссылка на бэкендовый гэзеттир `SVERDLOVSK_OBLAST_CITIES` (tradein-mvp/backend/app/services/geocoder.py) — parity-риск при добавлении нового города остаётся видимым с фронтовой стороны (backend/тесты не трогаю — другой PR, вне моего scope). tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в несвязанных файлах, как и в предыдущем коммите). --- .../components/trade-in/v2/ParamsPanel.tsx | 106 +++++++++++++----- tradein-mvp/frontend/src/lib/city-registry.ts | 35 +++++- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 753d3949..0555150b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -41,6 +41,7 @@ import { useGeocodeSuggest } from "@/lib/trade-in-api"; import { CITY_LABELS, DEFAULT_CITY, + UNCONFIRMED_CITY_LABEL, detectCityInText, } from "@/lib/city-registry"; import type { @@ -601,14 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string { return rs ? REPAIR_RU[rs] : "Не указано"; } -// Город формы (#2576). Restore-by-id / re-estimate: best-effort detect из -// сохранённого адреса (иначе сброс на Екатеринбург молча увёл бы восстановленный -// отчёт другого города обратно на ЕКБ-подсказки). Новая форма без initialValues -// → DEFAULT_CITY ("Екатеринбург") — ЕКБ-сценарий без лишних действий. +// Город формы (#2576) — ДВЕ раздельных величины, не одна: +// - `city` (initCityLabel) — best-guess ДЛЯ ОТОБРАЖЕНИЯ/подстановки после +// подтверждения. Restore-by-id: detect из сохранённого адреса (иначе сброс +// на Екатеринбург молча увёл бы восстановленный отчёт другого города назад +// на ЕКБ-подсказки); новая форма без initialValues → DEFAULT_CITY. +// - `cityConfirmed` (initCityConfirmed, ниже) — ГЕЙТ на отправку city_hint. +// Deep-review R2 (#2576): слать `city` в city_hint ВСЕГДА (независимо от того, +// подтверждён он или нет) обнуляло весь смысл фичи — бэкенд трактует любой +// city_hint как «пользователь назвал город», так что необнаруженный житель +// Нижнего Тагила (дропдаун не тронут, город не упомянут в тексте) молча +// резолвился бы в Екатеринбург, а target_city_ambiguous был бы false +// практически всегда. `city` НИКОГДА не отправляется сам по себе — только +// вместе с cityConfirmed=true (см. handleSubmit / useGeocodeSuggest ниже). function initCityLabel(address: string | undefined): string { return detectCityInText(address) ?? DEFAULT_CITY.label; } +// true ⇔ город реально известен (restore: обнаружен в сохранённом адресе). +// false для свежей формы — «Екатеринбург» в `city` в этот момент ТОЛЬКО +// внутренний best-guess (для честного текста плашки), НЕ подтверждённое +// значение; дропдаун показывает UNCONFIRMED_CITY_LABEL, а не "Екатеринбург". +function initCityConfirmed(address: string | undefined): boolean { + return detectCityInText(address) != null; +} + // РАДИУС 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. @@ -651,6 +669,12 @@ export default function ParamsPanel({ // control от адреса: меняется через дропдаун ИЛИ автоопределяется из // набранного/выбранного текста (см. handleAddressChange/pickSuggestion). const [city, setCity] = useState(initCityLabel(initialValues?.address)); + // Гейт на отправку city_hint (deep-review R2) — см. initCityConfirmed doc + // comment. true только после явного выбора в дропдауне или совпадения + // detectCityInText; нетронутый дефолт держит его false. + const [cityConfirmed, setCityConfirmed] = useState( + initCityConfirmed(initialValues?.address), + ); const [area, setArea] = useState( initialValues?.area_m2 != null ? String(initialValues.area_m2) : "", ); @@ -712,11 +736,15 @@ export default function ParamsPanel({ // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); - // #2576: подсказки скопированы по выбранному городу (city_hint) — без него - // геокодер больше не подставляет Екатеринбург молча, а без hint'а - // автокомплит для остальной области смещается на всю область вместо - // конкретного города. Переключение city обязано рефетчить (city в queryKey). - const suggest = useGeocodeSuggest(addressQuery, city); + // #2576 / deep-review R2: подсказки скопированы по городу ТОЛЬКО когда он + // подтверждён (cityConfirmed) — иначе suggest() уходит без city_hint, и + // бэкенд смещает bias на всю область (OBLAST66_VIEWBOX), а не молча на ЕКБ. + // Это и есть исправление: нетронутая форма для «Ленина 1» теперь видит + // кандидатов из ВСЕЙ области (в т.ч. Нижний Тагил), а не только ЕКБ. + // cityHintForApi в зависимостях useGeocodeSuggest — подтверждение города + // обязано рефетчить (city/cityConfirmed оба в queryKey через этот аргумент). + const cityHintForApi = cityConfirmed ? city : undefined; + const suggest = useGeocodeSuggest(addressQuery, cityHintForApi); const handleAddressChange = (v: string) => { setAddress(v); @@ -728,10 +756,16 @@ export default function ParamsPanel({ setFieldErrors((prev) => ({ ...prev, address: undefined })); // Autodetect (#2576): если пользователь сам печатает город в тексте // адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу — - // не ждём выбора подсказки. Не дёргаем setCity без совпадения (город - // остаётся тем, что выбран ранее — обычно ЕКБ по умолчанию). + // не ждём выбора подсказки. Найденное совпадение — реальное подтверждение + // (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где + // cityConfirmed включается без явного клика по дропдауну. Отсутствие + // совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение + // (если было) остаётся в силе при мелкой правке текста. const detected = detectCityInText(v); - if (detected) setCity(detected); + if (detected) { + setCity(detected); + setCityConfirmed(true); + } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setAddressQuery(v), 200); }; @@ -743,18 +777,27 @@ export default function ParamsPanel({ setFias(s.fias_id ?? null); setSuggestOpen(false); setAddressActive(-1); - // Autodetect (#2576): подсказка может называть город, отличный от - // текущего выбора (bias мягкий, не жёсткий фильтр — см. - // backend/app/services/geocoder.py::_yandex_bias) — подхватываем его. + // Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный + // сигнал подтверждения (даже сильнее, чем просто набранный текст). + // full_address обычно содержит город независимо от того, был ли отправлен + // city_hint (провайдер возвращает его как часть резолвленного адреса) — + // так первый же pick из НЕподтверждённого поиска (bias на всю область) + // подтверждает город без отдельного клика по дропдауну. const detected = detectCityInText(s.full_address); - if (detected) setCity(detected); + if (detected) { + setCity(detected); + setCityConfirmed(true); + } }; - // Явный выбор города из дропдауна. Сбрасывает ранее выбранные - // координаты/ФИАС — тот же текст адреса под другим городом это уже другая - // точка (зеркалит handleAddressChange's stale-guard для ручной правки). + // Явный выбор города из дропдауна — прямое подтверждение пользователем + // (deep-review R2: единственный способ гарантированно включить cityConfirmed + // независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС — + // тот же текст адреса под другим городом это уже другая точка (зеркалит + // handleAddressChange's stale-guard для ручной правки). const selectCity = (v: string) => { setCity(v); + setCityConfirmed(true); setOpenDd(null); setCoords(null); setFias(null); @@ -891,10 +934,12 @@ export default function ParamsPanel({ lon: coords?.lon ?? null, // Детерминированный ключ дома — только из выбранной подсказки (иначе null). target_fias_id: fias, - // #2576 — город, выбранный/автоопределённый в форме (city-registry.ts). - // Дефолт "Екатеринбург" воспроизводит прежнее молчаливое поведение - // геокодера, так что ЕКБ-сценарий не меняется. - city_hint: city, + // #2576 / deep-review R2 — город уходит ТОЛЬКО когда подтверждён + // (явный выбор в дропдауне или совпадение в тексте/подсказке); иначе + // city_hint не отправляется вовсе (undefined → ключ выпадает из JSON), + // и бэкенд честно помечает target_city_ambiguous=true вместо того + // чтобы получить непроверенный "Екатеринбург" и промолчать. + city_hint: cityConfirmed ? city : undefined, // "Авто" → null → backend keeps its two-tier default (1000 m primary / // 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по // умолчанию — это сузило бы поиск аналогов и убрало fallback.) @@ -1462,14 +1507,21 @@ export default function ParamsPanel({ {/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не отдельная строка: не отнимает вертикальное место у и так плотной - панели. Дефолт "Екатеринбург" — тот же город, что геокодер - раньше подставлял молча, так что ЕКБ-сценарий не требует - никакого лишнего действия. Список растёт — см. city-registry.ts. */} + панели. Explicit pick сразу подтверждает город (city_hint + отправляется дальше). До подтверждения показывает + UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) — + иначе дропдаун выдавал бы наш внутренний best-guess за + подтверждённый пользователем выбор, хотя ничего ещё не + отправлялось. Для ЕКБ-сценария это не лишний клик: как только + пользователь печатает/выбирает адрес, detectCityInText обычно + находит город сам (см. handleAddressChange/pickSuggestion) и + лейбл сменится на «Екатеринбург» автоматически. Список городов + растёт — см. city-registry.ts. */}
toggle("city")} - value={city} + value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL} options={CITY_LABELS} onSelect={selectCity} mono={false} diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index 752fe558..b8cd75f2 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -8,8 +8,25 @@ * #2576, PR #2580: `city_hint` query-параметр + `TradeInEstimateInput.city_hint` * + честный `AggregatedEstimate.target_city_ambiguous`). * + * ВАЖНО (deep-review R2 на #2576 UI-часть): `city_hint` — сигнал «пользователь + * НАЗВАЛ город», а не «форма его подставила». Если слать дефолт всегда, бэкенд + * трактует ЛЮБОЙ запрос как «город указан» → `target_city_ambiguous` перестаёт + * быть честным, а необнаруженный Нижний Тагил (дропдаун не тронут, город не + * упомянут в тексте) молча резолвится как Екатеринбург — ровно баг, который + * чинил backend. Поэтому вызывающий код (ParamsPanel.tsx) обязан слать + * `city_hint` ТОЛЬКО когда город реально известен (явный выбор в дропдауне ИЛИ + * `detectCityInText` нашёл совпадение) — см. `cityConfirmed` в ParamsPanel.tsx. + * Нетронутый дефолт → город НЕ отправляется. + * * Список городов растёт по мере расширения покрытия скрейперами — не хардкодь - * города россыпью по компонентам, добавляй сюда (мирроит паттерн source-registry.ts). + * города россыпью по компонентам, добавляй сюда (мирроит паттерн + * source-registry.ts). Parity-риск (deep-review R2, дёшево): эти 6 лейблов + * должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES` + * (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем + * регистре. Расхождение не ломается типами (`city_hint` — свободный текст, + * бэкенд его не валидирует по списку), но подсунет город без реального + * покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка + * вручную (codegen/тест на это пока нет, backend — другой PR). */ export interface OblastCity { @@ -35,13 +52,21 @@ export const OBLAST_CITIES: readonly OblastCity[] = [ ]; /** - * Дефолт формы — Екатеринбург. Держит основной сценарий без дополнительных - * действий пользователя: `city_hint` отправляется всегда, но для ЕКБ это тот - * же текст, что геокодер раньше подставлял молча, так что happy path не - * меняется ни на пиксель. + * Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и + * подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по + * умолчанию" — см. предупреждение в шапке файла: пока город не подтверждён + * (`cityConfirmed` в ParamsPanel.tsx), в запрос ничего не уходит. */ export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; +/** + * Лейбл дропдауна ДО подтверждения города (свежая форма, город ещё не + * выбран/определён). Честнее статичного "Екатеринбург": не выдаёт наш + * best-guess за подтверждённый выбор пользователя (deep-review R2 — + * `city_hint` не должен отправляться в этом состоянии). + */ +export const UNCONFIRMED_CITY_LABEL = "Определить автоматически"; + /** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); From 49ee85aae2d4de3b61f57ce010fc5b8ef086185a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:42:40 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D0=B5=20=D0=BD=D0=B5=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20(#2576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review R3: плашка city_ambiguous всегда подставляла {city} — внутреннее состояние с дефолтом "Екатеринбург" (initCityLabel), а не то, что реально определил бэкенд (в ответе только булев target_city_ambiguous, угаданного города там нет). Ровно в целевом сценарии фикса — нетронутая форма, «Ленина 1», cityConfirmed=false — текст утверждал «если это не Екатеринбург» независимо от реального результата (там мог быть Нижний Тагил) — та же нечестность, которую предыдущий коммит убирал из запроса, только в тексте. Текст плашки теперь ветвится по cityConfirmed: - cityConfirmed=true (город реально был подтверждён и отправлен) — прежний текст с конкретным {city} уместен, не меняю. - cityConfirmed=false (это и есть путь, где cityAmbiguous обычно и срабатывает после предыдущего коммита) — нейтральная формулировка без упоминания конкретного города: «Если это неверно, выберите город выше и повторите оценку.» tsc --noEmit / next lint / next build — чисто (те же 2 pre-existing warning в несвязанных файлах). --- .../components/trade-in/v2/ParamsPanel.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 0555150b..17740970 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -1653,12 +1653,29 @@ export default function ParamsPanel({ )} {/* #2576 — честная, не блокирующая подсказка: последняя оценка вернулась с target_city_ambiguous, город определил геокодер - сам. role="status" (не alert) — это информация, не ошибка. */} + сам. role="status" (не alert) — это информация, не ошибка. + Deep-review R3: текст различает cityConfirmed — если бэкенд + не получил city_hint (`cityConfirmed === false`), `city` в + этот момент внутренний best-guess ("Екатеринбург"), а НЕ то, + что реально нашёл геокодер (в ответе вообще нет угаданного + города, только сам булев признак) — называть конкретный город + в этом случае было бы той же нечестностью, которую фикс + убирал из запроса, только в тексте плашки. */} {cityAmbiguous && !fieldErrors.address && (
- Город определён автоматически — результат может относиться к - другому населённому пункту области. Если это не {city}, - выберите верный город выше и повторите оценку. + {cityConfirmed ? ( + <> + Город определён автоматически — результат может + относиться к другому населённому пункту области. Если это + не {city}, выберите верный город выше и повторите оценку. + + ) : ( + <> + Город определён автоматически — результат может + относиться к другому населённому пункту области. Если это + неверно, выберите город выше и повторите оценку. + + )}
)}