feat(tradein/frontend): pass suggest fias_id as target_fias_id in estimate request #2186

Merged
lekss361 merged 1 commit from feat/tradein-fias-estimate-frontend into main 2026-07-02 18:08:28 +00:00
4 changed files with 39 additions and 2 deletions

View file

@ -22,6 +22,9 @@ interface SuggestItem {
lat: number; lat: number;
lon: number; lon: number;
kind: string; kind: string;
// ФИАС-идентификатор дома (заполнен только для house-level кандидатов
// DaData-тира; иначе null/absent). Пробрасывается наверх через onPickFias.
fias_id?: string | null;
} }
interface SuggestResponse { interface SuggestResponse {
@ -43,6 +46,9 @@ interface Props {
value: string; value: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onPickCoords?: (coords: { lat: number; lon: number } | null) => void; onPickCoords?: (coords: { lat: number; lon: number } | null) => void;
// ФИАС-ключ выбранного дома. Зеркалит поведение onPickCoords: значение при
// выборе house-level подсказки, null при ручной правке текста.
onPickFias?: (fiasId: string | null) => void;
placeholder?: string; placeholder?: string;
inputId?: string; inputId?: string;
} }
@ -81,10 +87,11 @@ function presetsToItems(): SuggestItem[] {
lat: 0, lat: 0,
lon: 0, lon: 0,
kind: p.kind, kind: p.kind,
fias_id: null,
})); }));
} }
export function AddressInput({ value, onChange, onPickCoords, placeholder, inputId = "addr" }: Props) { export function AddressInput({ value, onChange, onPickCoords, onPickFias, placeholder, inputId = "addr" }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [items, setItems] = useState<SuggestItem[]>([]); const [items, setItems] = useState<SuggestItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -150,6 +157,7 @@ export function AddressInput({ value, onChange, onPickCoords, placeholder, input
item.lat !== 0 && item.lat !== 0 &&
item.lon !== 0; item.lon !== 0;
onPickCoords?.(hasCoords ? { lat: item.lat, lon: item.lon } : null); onPickCoords?.(hasCoords ? { lat: item.lat, lon: item.lon } : null);
onPickFias?.(item.fias_id ?? null);
setOpen(false); setOpen(false);
setShowPresets(false); setShowPresets(false);
setItems([]); setItems([]);
@ -197,6 +205,7 @@ export function AddressInput({ value, onChange, onPickCoords, placeholder, input
onChange(e.target.value); onChange(e.target.value);
lastPickedRef.current = ""; // юзер начал редактировать — снимаем guard lastPickedRef.current = ""; // юзер начал редактировать — снимаем guard
onPickCoords?.(null); // ручное редактирование сбрасывает ранее выбранные coords onPickCoords?.(null); // ручное редактирование сбрасывает ранее выбранные coords
onPickFias?.(null); // ...и ранее выбранный ФИАС (иначе stale перебьёт новый адрес)
setShowPresets(false); setShowPresets(false);
}} }}
onFocus={() => { onFocus={() => {

View file

@ -76,6 +76,7 @@ function validate(f: FormState): string | null {
function toPayload( function toPayload(
f: FormState, f: FormState,
coords: { lat: number; lon: number } | null, coords: { lat: number; lon: number } | null,
fias: string | null,
): TradeInEstimateInput { ): TradeInEstimateInput {
const out: TradeInEstimateInput = { const out: TradeInEstimateInput = {
address: f.address.trim(), address: f.address.trim(),
@ -89,6 +90,9 @@ function toPayload(
out.lat = coords.lat; out.lat = coords.lat;
out.lon = coords.lon; out.lon = coords.lon;
} }
// Только когда адрес выбран из подсказки (иначе fias === null): даём бэкенду
// детерминированный ключ дома вместо/до геокодинга строки.
if (fias) out.target_fias_id = fias;
if (f.year_built !== "") out.year_built = Number(f.year_built); if (f.year_built !== "") out.year_built = Number(f.year_built);
const ht = asHouseType(f.house_type); const ht = asHouseType(f.house_type);
if (ht) out.house_type = ht; if (ht) out.house_type = ht;
@ -111,6 +115,10 @@ export function EstimateForm({
}: Props) { }: Props) {
const [form, setForm] = useState<FormState>(INITIAL); const [form, setForm] = useState<FormState>(INITIAL);
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(null); const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(null);
// ФИАС-ключ дома из выбранной подсказки. Живёт рядом с coords и сбрасывается
// по тем же событиям: ручная правка адреса (внутри AddressInput → onPickFias(null))
// и выбор точки на карте (MapPicker onPick ниже).
const [fias, setFias] = useState<string | null>(null);
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const [mapOpen, setMapOpen] = useState(false); const [mapOpen, setMapOpen] = useState(false);
// CRM-блок свёрнут по умолчанию — экономит ~190px высоты, чтобы форма влезала // CRM-блок свёрнут по умолчанию — экономит ~190px высоты, чтобы форма влезала
@ -132,7 +140,7 @@ export function EstimateForm({
e.preventDefault(); e.preventDefault();
setTouched(true); setTouched(true);
if (canSubmit) { if (canSubmit) {
onSubmit(toPayload(form, coords)); onSubmit(toPayload(form, coords, fias));
} }
} }
@ -163,6 +171,7 @@ export function EstimateForm({
value={form.address} value={form.address}
onChange={(val) => setForm((s) => ({ ...s, address: val }))} onChange={(val) => setForm((s) => ({ ...s, address: val }))}
onPickCoords={setCoords} onPickCoords={setCoords}
onPickFias={setFias}
placeholder="ул. Малышева, 30 · Куйбышева, 48…" placeholder="ул. Малышева, 30 · Куйбышева, 48…"
/> />
</div> </div>
@ -522,6 +531,7 @@ export function EstimateForm({
onPick={(addr, mapCoords) => { onPick={(addr, mapCoords) => {
setForm((s) => ({ ...s, address: addr })); setForm((s) => ({ ...s, address: addr }));
setCoords(mapCoords ?? null); setCoords(mapCoords ?? null);
setFias(null); // точка с карты ≠ house-level ФИАС → сбрасываем
}} }}
onClose={() => setMapOpen(false)} onClose={() => setMapOpen(false)}
/> />

View file

@ -542,6 +542,12 @@ export default function ParamsPanel({
? { lat: initialValues.lat, lon: initialValues.lon } ? { lat: initialValues.lat, lon: initialValues.lon }
: null, : null,
); );
// ФИАС-ключ дома из выбранной подсказки. Живёт рядом с coords и сбрасывается
// по тем же событиям (ручная правка адреса → handleAddressChange). Отправляется
// только когда адрес выбран из suggest — иначе stale fias перебил бы новый адрес.
const [fias, setFias] = useState<string | null>(
initialValues?.target_fias_id ?? null,
);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// БАЛКОН radiogroup focus targets (roving tabindex). // БАЛКОН radiogroup focus targets (roving tabindex).
const balNoRef = useRef<HTMLButtonElement>(null); const balNoRef = useRef<HTMLButtonElement>(null);
@ -551,6 +557,7 @@ export default function ParamsPanel({
const handleAddressChange = (v: string) => { const handleAddressChange = (v: string) => {
setAddress(v); setAddress(v);
setCoords(null); // a manual edit invalidates a previously picked point setCoords(null); // a manual edit invalidates a previously picked point
setFias(null); // ...and the previously picked house ФИАС (stale-guard)
setSuggestOpen(v.trim().length >= 3); setSuggestOpen(v.trim().length >= 3);
setAddressActive(-1); // a new query invalidates the previous highlight setAddressActive(-1); // a new query invalidates the previous highlight
if (fieldErrors.address) if (fieldErrors.address)
@ -563,6 +570,7 @@ export default function ParamsPanel({
setAddress(s.full_address); setAddress(s.full_address);
setAddressQuery(s.full_address); setAddressQuery(s.full_address);
setCoords({ lat: s.lat, lon: s.lon }); setCoords({ lat: s.lat, lon: s.lon });
setFias(s.fias_id ?? null);
setSuggestOpen(false); setSuggestOpen(false);
setAddressActive(-1); setAddressActive(-1);
}; };
@ -696,6 +704,8 @@ export default function ParamsPanel({
has_balcony: balcony ?? undefined, has_balcony: balcony ?? undefined,
lat: coords?.lat ?? null, lat: coords?.lat ?? null,
lon: coords?.lon ?? null, lon: coords?.lon ?? null,
// Детерминированный ключ дома — только из выбранной подсказки (иначе null).
target_fias_id: fias,
// "Авто" → null → backend keeps its two-tier default (1000 m primary / // "Авто" → null → backend keeps its two-tier default (1000 m primary /
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по // 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
// умолчанию — это сузило бы поиск аналогов и убрало fallback.) // умолчанию — это сузило бы поиск аналогов и убрало fallback.)

View file

@ -71,6 +71,11 @@ export interface TradeInEstimateInput {
// Радиус поиска аналогов/сделок в метрах (опциональный override дефолта бэкенда). // Радиус поиска аналогов/сделок в метрах (опциональный override дефолта бэкенда).
// Зеркалит новое поле request-схемы; absent → бэкенд применяет свой дефолт. // Зеркалит новое поле request-схемы; absent → бэкенд применяет свой дефолт.
radius_m?: number | null; radius_m?: number | null;
// ФИАС-идентификатор дома из выбранной подсказки автокомплита (house-level,
// DaData-тир). Детерминированный ключ дома — бэкенд использует его ПЕРВЫМ,
// до геокодинга строки. Отправляется ТОЛЬКО когда адрес выбран из подсказки;
// сбрасывается в null при ручной правке адреса или сдвиге пина на карте.
target_fias_id?: string | null;
} }
export interface AnalogLot { export interface AnalogLot {
@ -424,6 +429,9 @@ export interface GeocodeSuggestion {
lat: number; lat: number;
lon: number; lon: number;
kind: string; // тип объекта (house / street / …) kind: string; // тип объекта (house / street / …)
// ФИАС-идентификатор дома (заполнен только для house-level кандидатов
// DaData-тира; для street/locality и не-DaData-источников — null/absent).
fias_id?: string | null;
} }
export interface GeocodeSuggestResponse { export interface GeocodeSuggestResponse {