feat(mera/estimate): предзаполнять этажность, год и тип дома из справочника (UI) #3259

Merged
lekss361 merged 1 commit from feat/3257-house-facts-prefill-ui into main 2026-08-29 20:39:27 +00:00
4 changed files with 343 additions and 6 deletions

View file

@ -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<AbortController | null>(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<HTMLButtonElement>(null);
const balYesRef = useRef<HTMLButtonElement>(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({
<label htmlFor="pp-total-floors" style={hintLabel}>
ВСЕГО ЭТАЖЕЙ
</label>
<span style={optHint}>если знаете</span>
<span style={housePrefill.totalFloors ? prefillHint : optHint}>
{housePrefill.totalFloors ? "из справочника" : "если знаете"}
</span>
</div>
<input
id="pp-total-floors"
@ -1892,6 +1992,10 @@ export default function ParamsPanel({
}
onChange={(e) => {
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({
<label htmlFor="pp-year" style={hintLabel}>
ГОД ПОСТРОЙКИ
</label>
<span style={optHint}>опц.</span>
<span style={housePrefill.year ? prefillHint : optHint}>
{housePrefill.year ? "из справочника" : "опц."}
</span>
</div>
<input
id="pp-year"
@ -1946,7 +2052,13 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={year}
onChange={(e) => 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({
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ТИП ДОМА</span>
<span style={optHint}>опц.</span>
<span style={housePrefill.houseType ? prefillHint : optHint}>
{housePrefill.houseType ? "из справочника" : "опц."}
</span>
</div>
<Dd
open={openDd === "houseType"}
onToggle={() => 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}

View file

@ -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<typeof import("@/lib/trade-in-api")>();
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(
<QueryClientProvider client={client}>
<ParamsPanel onSubmit={vi.fn()} />
</QueryClientProvider>,
);
}
/** Печатает в адрес и кликает подсказку срабатывает 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");
});
});

View file

@ -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<HouseFactsResponse> {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) });
if (fiasId) params.set("fias_id", fiasId);
return apiFetch<HouseFactsResponse>(
`${GEOCODE_BASE}/house-facts?${params.toString()}`,
{ signal },
);
}

View file

@ -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 из