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..17740970 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,12 @@ import {
type MapMarker,
} from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
+import {
+ CITY_LABELS,
+ DEFAULT_CITY,
+ UNCONFIRMED_CITY_LABEL,
+ detectCityInText,
+} from "@/lib/city-registry";
import type {
GeocodeSuggestion,
HouseType,
@@ -45,7 +51,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 +529,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 +569,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 +602,31 @@ function initRepairLabel(rs: RepairState | undefined): string {
return rs ? REPAIR_RU[rs] : "Не указано";
}
+// Город формы (#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.
@@ -603,6 +652,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 +665,16 @@ 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));
+ // Гейт на отправку 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) : "",
);
@@ -676,7 +736,15 @@ export default function ParamsPanel({
// БАЛКОН radiogroup focus targets (roving tabindex).
const balNoRef = useRef(null);
const balYesRef = useRef(null);
- const suggest = useGeocodeSuggest(addressQuery);
+ // #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);
@@ -686,6 +754,18 @@ export default function ParamsPanel({
setAddressActive(-1); // a new query invalidates the previous highlight
if (fieldErrors.address)
setFieldErrors((prev) => ({ ...prev, address: undefined }));
+ // Autodetect (#2576): если пользователь сам печатает город в тексте
+ // адреса ("Нижний Тагил, Ленина 1"), подхватываем его в дропдаун сразу —
+ // не ждём выбора подсказки. Найденное совпадение — реальное подтверждение
+ // (пользователь сам его написал), поэтому это ЕДИНСТВЕННОЕ место, где
+ // cityConfirmed включается без явного клика по дропдауну. Отсутствие
+ // совпадения НЕ трогает city/cityConfirmed — предыдущее подтверждение
+ // (если было) остаётся в силе при мелкой правке текста.
+ const detected = detectCityInText(v);
+ if (detected) {
+ setCity(detected);
+ setCityConfirmed(true);
+ }
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
};
@@ -697,6 +777,30 @@ export default function ParamsPanel({
setFias(s.fias_id ?? null);
setSuggestOpen(false);
setAddressActive(-1);
+ // Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный
+ // сигнал подтверждения (даже сильнее, чем просто набранный текст).
+ // full_address обычно содержит город независимо от того, был ли отправлен
+ // city_hint (провайдер возвращает его как часть резолвленного адреса) —
+ // так первый же pick из НЕподтверждённого поиска (bias на всю область)
+ // подтверждает город без отдельного клика по дропдауну.
+ const detected = detectCityInText(s.full_address);
+ if (detected) {
+ setCity(detected);
+ setCityConfirmed(true);
+ }
+ };
+
+ // Явный выбор города из дропдауна — прямое подтверждение пользователем
+ // (deep-review R2: единственный способ гарантированно включить cityConfirmed
+ // независимо от текста адреса). Сбрасывает ранее выбранные координаты/ФИАС —
+ // тот же текст адреса под другим городом это уже другая точка (зеркалит
+ // handleAddressChange's stale-guard для ручной правки).
+ const selectCity = (v: string) => {
+ setCity(v);
+ setCityConfirmed(true);
+ setOpenDd(null);
+ setCoords(null);
+ setFias(null);
};
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
@@ -830,6 +934,12 @@ export default function ParamsPanel({
lon: coords?.lon ?? null,
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
target_fias_id: fias,
+ // #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.)
@@ -1384,12 +1494,45 @@ export default function ParamsPanel({
}}
>
-
+
+ {/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
+ отдельная строка: не отнимает вертикальное место у и так плотной
+ панели. Explicit pick сразу подтверждает город (city_hint
+ отправляется дальше). До подтверждения показывает
+ UNCONFIRMED_CITY_LABEL, НЕ "Екатеринбург" (deep-review R2) —
+ иначе дропдаун выдавал бы наш внутренний best-guess за
+ подтверждённый пользователем выбор, хотя ничего ещё не
+ отправлялось. Для ЕКБ-сценария это не лишний клик: как только
+ пользователь печатает/выбирает адрес, detectCityInText обычно
+ находит город сам (см. handleAddressChange/pickSuggestion) и
+ лейбл сменится на «Екатеринбург» автоматически. Список городов
+ растёт — см. city-registry.ts. */}
+
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
is debounced into `addressQuery`; the dropdown below mirrors the
@@ -1508,6 +1651,33 @@ export default function ParamsPanel({
{fieldErrors.address}
)}
+ {/* #2576 — честная, не блокирующая подсказка: последняя оценка
+ вернулась с target_city_ambiguous, город определил геокодер
+ сам. role="status" (не alert) — это информация, не ошибка.
+ Deep-review R3: текст различает cityConfirmed — если бэкенд
+ не получил city_hint (`cityConfirmed === false`), `city` в
+ этот момент внутренний best-guess ("Екатеринбург"), а НЕ то,
+ что реально нашёл геокодер (в ответе вообще нет угаданного
+ города, только сам булев признак) — называть конкретный город
+ в этом случае было бы той же нечестностью, которую фикс
+ убирал из запроса, только в тексте плашки. */}
+ {cityAmbiguous && !fieldErrors.address && (
+
+ {cityConfirmed ? (
+ <>
+ Город определён автоматически — результат может
+ относиться к другому населённому пункту области. Если это
+ не {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..b8cd75f2
--- /dev/null
+++ b/tradein-mvp/frontend/src/lib/city-registry.ts
@@ -0,0 +1,93 @@
+/**
+ * city-registry — единый источник правды по городам Свердловской области,
+ * поддержанным сбором «Меры» (#2576 этап B: расширение на область).
+ *
+ * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
+ * города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
+ * уверенно неверную цену по одноимённой екатеринбургской улице (backend fix
+ * #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). Parity-риск (deep-review R2, дёшево): эти 6 лейблов
+ * должны оставаться подмножеством `SVERDLOVSK_OBLAST_CITIES`
+ * (tradein-mvp/backend/app/services/geocoder.py) — та же кириллица в нижнем
+ * регистре. Расхождение не ломается типами (`city_hint` — свободный текст,
+ * бэкенд его не валидирует по списку), но подсунет город без реального
+ * покрытия скрейпером. Перед добавлением 7-го города — сверить оба списка
+ * вручную (codegen/тест на это пока нет, backend — другой PR).
+ */
+
+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: "Серов" },
+];
+
+/**
+ * Дефолт формы — Екатеринбург (визуальный/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);
+
+/**
+ * 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: старые