fix(tradein/ui): не слать city_hint по умолчанию — только явный выбор или город из адреса (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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
CI Trade-In / frontend-checks (pull_request) Successful in 1m15s

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 в
несвязанных файлах, как и в предыдущем коммите).
This commit is contained in:
bot-backend 2026-07-31 18:15:40 +03:00
parent 2779ef5e79
commit a8d29fa031
2 changed files with 109 additions and 32 deletions

View file

@ -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<HTMLButtonElement>(null);
const balYesRef = useRef<HTMLButtonElement>(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({
</label>
{/* Город (#2576) компактный дропдаун рядом с лейблом адреса, не
отдельная строка: не отнимает вертикальное место у и так плотной
панели. Дефолт "Екатеринбург" тот же город, что геокодер
раньше подставлял молча, так что ЕКБ-сценарий не требует
никакого лишнего действия. Список растёт см. city-registry.ts. */}
панели. Explicit pick сразу подтверждает город (city_hint
отправляется дальше). До подтверждения показывает
UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2)
иначе дропдаун выдавал бы наш внутренний best-guess за
подтверждённый пользователем выбор, хотя ничего ещё не
отправлялось. Для ЕКБ-сценария это не лишний клик: как только
пользователь печатает/выбирает адрес, detectCityInText обычно
находит город сам (см. handleAddressChange/pickSuggestion) и
лейбл сменится на «Екатеринбург» автоматически. Список городов
растёт см. city-registry.ts. */}
<div style={{ position: "relative", width: 176, flex: "0 0 auto" }}>
<Dd
open={openDd === "city"}
onToggle={() => toggle("city")}
value={city}
value={cityConfirmed ? city : UNCONFIRMED_CITY_LABEL}
options={CITY_LABELS}
onSelect={selectCity}
mono={false}

View file

@ -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);