From a8d29fa031fedb83945aa83b882fb9126d765578 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 18:15:40 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/ui):=20=D0=BD=D0=B5=20=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20city=5Fhint=20=D0=BF=D0=BE=20=D1=83=D0=BC?= =?UTF-8?q?=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D0=BB=D0=B8=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B4=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=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);