From 79fc8c45ff26495b120d80a6df0caf234ac8cd5c Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 29 Aug 2026 20:39:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(mera/estimate):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D1=82=D1=8C=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C,=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B8=20=D1=82=D0=B8=D0=BF=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=B0=20(UI)=20(#3259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/trade-in/v2/ParamsPanel.tsx | 133 ++++++++++++- .../v2/__tests__/ParamsPanel.prefill.test.tsx | 177 ++++++++++++++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 27 +++ tradein-mvp/frontend/src/types/trade-in.ts | 12 ++ 4 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.prefill.test.tsx 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 aef80107..0525f0f4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -35,7 +35,7 @@ import { REPAIR_RU, type MapMarker, } from "./mappers"; -import { useGeocodeSuggest } from "@/lib/trade-in-api"; +import { fetchHouseFacts, useGeocodeSuggest } from "@/lib/trade-in-api"; import { createMap, loadLeaflet, @@ -49,6 +49,7 @@ import { UNCONFIRMED_CITY_LABEL, detectCityInText, } from "@/lib/city-registry"; +import { asHouseType } from "@/types/trade-in"; import type { GeocodeSuggestion, HouseType, @@ -401,6 +402,15 @@ const optHint: CSSProperties = { color: tokens.muted4, }; +// #3257 — сдержанная подсказка «из справочника» рядом с полями, которые +// автопредзаполнились house-facts по выбранному адресу (см. loadHouseFacts). +// Тот же slot, что и optHint ("если знаете"/"опц."), просто акцентный цвет — +// не новая сущность, а честное «откуда это число» без паники/ошибки. +const prefillHint: CSSProperties = { + ...optHint, + color: tokens.accent, +}; + const fieldRow: CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", @@ -769,6 +779,90 @@ export default function ParamsPanel({ }, [], ); + + // House-facts prefill (#3257) — этажность/год/тип дома из справочника домов + // сразу после выбора адреса из автокомплита. `housePrefill` помечает, какие + // из этих трёх полей несут именно автоподставленное значение (не ручной + // ввод) — используется и для UI-подсказки «из справочника», и как гейт для + // перезаписи при повторном выборе адреса (ручная правка снимает пометку и + // больше никогда не перезаписывается новым ответом). + const [housePrefill, setHousePrefill] = useState<{ + totalFloors: boolean; + year: boolean; + houseType: boolean; + }>({ totalFloors: false, year: false, houseType: false }); + // "Свежие" значения полей/пометок, читаемые внутри async-колбэка + // fetchHouseFacts().then() ниже — тот может резолвиться через несколько + // рендеров (сеть), плоские closure-переменные к тому моменту устареют. + const totalFloorsRef = useRef(totalFloors); + totalFloorsRef.current = totalFloors; + const yearRef = useRef(year); + yearRef.current = year; + const houseTypeRef = useRef(houseType); + houseTypeRef.current = houseType; + const housePrefillRef = useRef(housePrefill); + housePrefillRef.current = housePrefill; + // Гонка запросов: второй выбранный адрес не должен применить ответ на + // первый, устаревший. `houseFactsGenRef` — счётчик поколений (инкремент на + // каждый вызов, ответ применяется только если поколение всё ещё текущее); + // AbortController в паре — отменяет фактический fetch устаревшего запроса. + const houseFactsGenRef = useRef(0); + const houseFactsAbortRef = useRef(null); + // Не звать setState после размонтирования панели (тот же класс бага, что и + // #3226 debounceRef выше) — при unmount отменяем последний летящий запрос; + // сам .then()/.catch() всё равно гейтится houseFactsGenRef на всякий случай. + useEffect( + () => () => { + houseFactsAbortRef.current?.abort(); + }, + [], + ); + + const loadHouseFacts = ( + lat: number, + lon: number, + fiasId: string | null, + ) => { + houseFactsAbortRef.current?.abort(); + const controller = new AbortController(); + houseFactsAbortRef.current = controller; + const gen = ++houseFactsGenRef.current; + fetchHouseFacts(lat, lon, fiasId, controller.signal) + .then((data) => { + if (gen !== houseFactsGenRef.current) return; // устаревший ответ + if (!data.found) return; // «дома нет в справочнике» — тихий no-op + if ( + data.total_floors != null && + (totalFloorsRef.current.trim() === "" || + housePrefillRef.current.totalFloors) + ) { + setTotalFloors(String(data.total_floors)); + setHousePrefill((prev) => ({ ...prev, totalFloors: true })); + } + if ( + data.year_built != null && + (yearRef.current.trim() === "" || housePrefillRef.current.year) + ) { + setYear(String(data.year_built)); + setHousePrefill((prev) => ({ ...prev, year: true })); + } + const ht = asHouseType(data.house_type); + if ( + ht && + (houseTypeRef.current === "Не указано" || + housePrefillRef.current.houseType) + ) { + setHouseType(HOUSE_TYPE_RU[ht]); + setHousePrefill((prev) => ({ ...prev, houseType: true })); + } + }) + .catch((err) => { + // Сбой сети / отменённый устаревший запрос — предзаполнение это + // удобство, а не функциональность формы: никаких алертов/красных + // сообщений, максимум debug-лог. + console.debug("house-facts prefill failed", err); + }); + }; // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); @@ -813,6 +907,10 @@ export default function ParamsPanel({ setFias(s.fias_id ?? null); setSuggestOpen(false); setAddressActive(-1); + // #3257 — предзаполняем этажность/год/тип дома из справочника домов для + // только что выбранного адреса (см. loadHouseFacts выше: race-safe через + // поколение + AbortController, тихий no-op на found=false/сеть). + loadHouseFacts(s.lat, s.lon, s.fias_id ?? null); // Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный // сигнал подтверждения (даже сильнее, чем просто набранный текст). // full_address обычно содержит город независимо от того, был ли отправлен @@ -1878,7 +1976,9 @@ export default function ParamsPanel({ - если знаете + + {housePrefill.totalFloors ? "из справочника" : "если знаете"} + { setTotalFloors(e.target.value.replace(/\D/g, "")); + // Ручная правка — больше не «из справочника». + setHousePrefill((prev) => + prev.totalFloors ? { ...prev, totalFloors: false } : prev, + ); if (fieldErrors.floor || fieldErrors.totalFloors) setFieldErrors((prev) => ({ ...prev, @@ -1938,7 +2042,9 @@ export default function ParamsPanel({ - опц. + + {housePrefill.year ? "из справочника" : "опц."} + setYear(e.target.value.replace(/\D/g, ""))} + onChange={(e) => { + setYear(e.target.value.replace(/\D/g, "")); + // Ручная правка — больше не «из справочника». + setHousePrefill((prev) => + prev.year ? { ...prev, year: false } : prev, + ); + }} onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} @@ -1957,14 +2069,23 @@ export default function ParamsPanel({
ТИП ДОМА - опц. + + {housePrefill.houseType ? "из справочника" : "опц."} +
toggle("houseType")} value={houseType} options={dropdownOptions.houseType} - onSelect={pick(setHouseType)} + onSelect={(v) => { + setHouseType(v); + setOpenDd(null); + // Ручной выбор — больше не «из справочника». + setHousePrefill((prev) => + prev.houseType ? { ...prev, houseType: false } : prev, + ); + }} mono={false} triggerFontSize={13} optionFontSize={12} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.prefill.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.prefill.test.tsx new file mode 100644 index 00000000..b5a9f21d --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.prefill.test.tsx @@ -0,0 +1,177 @@ +// #3257: предзаполнение этажности/года/типа дома из справочника `houses` после +// выбора адреса из автокомплита. Здесь закреплены три свойства, которые ломаются +// молча и потому опаснее всего: +// 1) подставляем в ПУСТОЕ поле, +// 2) НЕ затираем то, что человек ввёл руками, +// 3) ответ на УСТАРЕВШИЙ адрес не применяется (пользователь успел выбрать другой). + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ParamsPanel from "../ParamsPanel"; + +const { fetchHouseFacts, useGeocodeSuggest } = vi.hoisted(() => ({ + fetchHouseFacts: vi.fn(), + useGeocodeSuggest: vi.fn(), +})); + +vi.mock("@/lib/trade-in-api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchHouseFacts, useGeocodeSuggest }; +}); + +const SUGGESTION = { + label: "пр-кт Ленина, 5", + full_address: "Екатеринбург, пр-кт Ленина, 5", + lat: 56.838, + lon: 60.595, + kind: "house", + fias_id: "abc-123", +}; + +function renderPanel() { + // useGeocodeSuggest отдаёт УЖЕ выбранный массив (select: r => r.items), + // а не сырой ответ — мок должен повторять именно эту форму. + useGeocodeSuggest.mockReturnValue({ + data: [SUGGESTION], + isFetching: false, + isLoading: false, + isError: false, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +/** Печатает в адрес и кликает подсказку → срабатывает pickSuggestion. + * + * Дропдаун открыт только когда `addressQuery` уже проставлен, а он идёт через + * debounce 200 мс — поэтому таймеры фейковые и время прокручивается вручную. + */ +async function pickAddress(container: HTMLElement) { + const address = container.querySelector("#pp-address") as HTMLInputElement; + fireEvent.change(address, { target: { value: "Екатеринбург, Ленина 5" } }); + await act(async () => { + vi.advanceTimersByTime(250); + }); + // Выбор висит на onMouseDown (не onClick): preventDefault там срабатывает + // раньше onBlur инпута, иначе список закрылся бы до выбора. + fireEvent.mouseDown(screen.getAllByText(SUGGESTION.label)[0]); + await act(async () => {}); +} + +/** Прокрутить микротаски: промисы моков резолвятся вне таймеров. */ +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +const totalFloorsInput = (c: HTMLElement) => + c.querySelector("#pp-total-floors") as HTMLInputElement; + +describe("ParamsPanel — предзаполнение из справочника (#3257)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("подставляет этажность в пустое поле и помечает её как справочную", async () => { + fetchHouseFacts.mockResolvedValue({ + found: true, + total_floors: 9, + year_built: 1967, + house_type: null, + source: "houses", + }); + const { container } = renderPanel(); + await pickAddress(container); + await flush(); + + expect(totalFloorsInput(container).value).toBe("9"); + expect(screen.getAllByText("из справочника").length).toBeGreaterThan(0); + }); + + it("НЕ затирает этажность, введённую человеком", async () => { + fetchHouseFacts.mockResolvedValue({ + found: true, + total_floors: 9, + year_built: null, + house_type: null, + source: "houses", + }); + const { container } = renderPanel(); + fireEvent.change(totalFloorsInput(container), { target: { value: "16" } }); + await pickAddress(container); + await flush(); + + expect(fetchHouseFacts).toHaveBeenCalled(); + expect(totalFloorsInput(container).value).toBe("16"); + }); + + it("found=false — поле остаётся пустым, без ошибок", async () => { + fetchHouseFacts.mockResolvedValue({ + found: false, + total_floors: null, + year_built: null, + house_type: null, + source: null, + }); + const { container } = renderPanel(); + await pickAddress(container); + await flush(); + + expect(fetchHouseFacts).toHaveBeenCalled(); + expect(totalFloorsInput(container).value).toBe(""); + }); + + it("ответ на устаревший адрес игнорируется (гонка двух выборов)", async () => { + // Первый запрос отвечает ПОЗЖЕ второго — если бы гейта поколений не было, + // в поле осталось бы 99 от уже неактуального адреса. + let resolveFirst: ((v: unknown) => void) | undefined; + fetchHouseFacts + .mockImplementationOnce( + () => + new Promise((res) => { + resolveFirst = res; + }), + ) + .mockResolvedValueOnce({ + found: true, + total_floors: 12, + year_built: null, + house_type: null, + source: "houses", + }); + + const { container } = renderPanel(); + await pickAddress(container); + await pickAddress(container); + await flush(); + + expect(fetchHouseFacts).toHaveBeenCalledTimes(2); + expect(totalFloorsInput(container).value).toBe("12"); + + resolveFirst?.({ + found: true, + total_floors: 99, + year_built: null, + house_type: null, + source: "houses", + }); + await flush(); + // Устаревший ответ пришёл вторым и не должен ничего переписать. + expect(totalFloorsInput(container).value).toBe("12"); + }); +}); diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index 9e9a4dc0..961ee2b8 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -10,6 +10,7 @@ import type { GeocodeSuggestion, GeocodeSuggestResponse, HouseAnalyticsResponse, + HouseFactsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, LocationIndexResponse, @@ -293,3 +294,29 @@ export function useGeocodeSuggest( placeholderData: keepPreviousData, }); } + +/** + * GET /api/v1/geocode/house-facts?lat=&lon=&fias_id= + * Справочник домов — этажность/год постройки/тип дома для предзаполнения + * ParamsPanel сразу после выбора адреса из автокомплита (issue #3257). + * `found:false` — дома нет в справочнике, честный ответ, не ошибка. + * + * Плоская функция (не useQuery/useMutation): вызывается императивно из + * pickSuggestion (событие выбора подсказки), которая сама управляет + * отменой устаревшего запроса через `signal` (race guard) — TanStack + * mutationFn в v5 не прокидывает AbortSignal, а декларативный useQuery не + * подходит для разового fire-on-event вызова. + */ +export function fetchHouseFacts( + lat: number, + lon: number, + fiasId: string | null, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }); + if (fiasId) params.set("fias_id", fiasId); + return apiFetch( + `${GEOCODE_BASE}/house-facts?${params.toString()}`, + { signal }, + ); +} diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 9de256e9..518adb4a 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -507,6 +507,18 @@ export interface GeocodeSuggestResponse { items: GeocodeSuggestion[]; } +// ── House facts (endpoint: GET /geocode/house-facts?lat=&lon=&fias_id=) ── +// Справочник домов (houses) — предзаполнение параметров квартиры (этажность/ +// год/тип дома) сразу после выбора адреса из автокомплита (issue #3257). +// found=false — нормальный ответ «дома нет в справочнике», не ошибка. +export interface HouseFactsResponse { + found: boolean; + total_floors: number | null; + year_built: number | null; + house_type: string | null; + source: "houses" | null; +} + // ── Trade-in lead capture (endpoint: POST /trade-in/lead) ── // Issue #2377 (sub-issue родителя #1971) — заменяет mailto-стаб в HeroTransparency // на реальную заявку. Зеркалит TradeInLeadInput из