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
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:
parent
2779ef5e79
commit
a8d29fa031
2 changed files with 109 additions and 32 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue