feat(mera/estimate): ручка фактов дома для предзаполнения формы + гейт «этаж не выше дома» #3257

Merged
lekss361 merged 1 commit from feat/3257-house-facts-prefill into main 2026-08-29 20:34:01 +00:00
Owner

Summary

Пункт 5 плана сессии, backend-половина. Предзаполнение формы оценки из справочника houses (наполнен из ГИС ЖКХ + DOM.РФ: total_floors 98.3%, year_built 86.8%) + гейт на невозможный ввод.

1. GET /api/v1/geocode/house-facts

Параметры: lat, lon (обязательны), fias_id (опционально).
Ответ: {found, total_floors, year_built, house_type, source}.

Переиспользует _lookup_house_facts из estimator (#3234) — тот же поиск, что и при расчёте: по target_house_id, если ФИАС разрезолвился, иначе ближайший дом в 60 м. Значит форма предзаполнится ровно тем, что движок и возьмёт, — расхождения между показанным и посчитанным не будет.

Дома нет в справочнике → 200 с found=false, а не 404: это не ошибка запроса, а честный ответ об отсутствии данных.

Почему отдельная ручка, а не поле в /suggest. Suggest дёргается на каждое нажатие клавиши (пусть и с debounce), и запрос к houses на каждый из 8 кандидатов на каждый keystroke был бы неоправданной нагрузкой на БД. /house-facts вызывается ОДИН раз — после того как пользователь выбрал конкретный адрес.

Резолв дома по ФИАС обёрнут в try/except с rollback: предзаполнение формы не должно ронять запрос в 500, а незакрытая ошибка отравила бы сессию для последующего geo-фолбэка. Тот же best-effort, что и у _lookup_house_facts ниже по цепочке.

2. Гейт floor <= total_floors

TradeInEstimateInput отклоняет floor > total_floors, когда заданы оба поля. Частичный ввод легален — пользователь ещё печатает форму. Раньше «12 этаж в 9-этажном доме» уходил в расчёт молча и портил подбор аналогов по этажу (floor-вес в _compute_same_building_anchor считает относительную вертикальную позицию floor/total_floors — при 12/9 она больше единицы).

Test plan

  • pytest tests/test_3257_house_facts_endpoint.py tests/test_3234_house_facts_fallback.py tests/test_geocode_reverse_api.py24 passed
  • валидатор проверен вручную, не вхолостую: отклоняет 12/9, пропускает 9/9 и частичный ввод (floor=12, total_floors=None)
  • все три guid-колонки (gar_house_guid, house_fias_id, zhkh_house_guid) существуют на проде — проверено запросом к information_schema
  • ruff check + ruff format --check чисто
  • CI зелёный

Дальше

Фронтовая половина (дёрнуть ручку после выбора адреса, предзаполнить пустые поля с возможностью переопределить) — отдельным PR.

## Summary Пункт 5 плана сессии, backend-половина. Предзаполнение формы оценки из справочника `houses` (наполнен из ГИС ЖКХ + DOM.РФ: `total_floors` 98.3%, `year_built` 86.8%) + гейт на невозможный ввод. ### 1. `GET /api/v1/geocode/house-facts` Параметры: `lat`, `lon` (обязательны), `fias_id` (опционально). Ответ: `{found, total_floors, year_built, house_type, source}`. Переиспользует `_lookup_house_facts` из estimator (#3234) — **тот же поиск, что и при расчёте**: по `target_house_id`, если ФИАС разрезолвился, иначе ближайший дом в 60 м. Значит форма предзаполнится ровно тем, что движок и возьмёт, — расхождения между показанным и посчитанным не будет. Дома нет в справочнике → **200 с `found=false`**, а не 404: это не ошибка запроса, а честный ответ об отсутствии данных. **Почему отдельная ручка, а не поле в `/suggest`.** Suggest дёргается на каждое нажатие клавиши (пусть и с debounce), и запрос к `houses` на каждый из 8 кандидатов на каждый keystroke был бы неоправданной нагрузкой на БД. `/house-facts` вызывается ОДИН раз — после того как пользователь выбрал конкретный адрес. Резолв дома по ФИАС обёрнут в `try/except` с `rollback`: предзаполнение формы не должно ронять запрос в 500, а незакрытая ошибка отравила бы сессию для последующего geo-фолбэка. Тот же best-effort, что и у `_lookup_house_facts` ниже по цепочке. ### 2. Гейт `floor <= total_floors` `TradeInEstimateInput` отклоняет `floor > total_floors`, когда заданы **оба** поля. Частичный ввод легален — пользователь ещё печатает форму. Раньше «12 этаж в 9-этажном доме» уходил в расчёт молча и портил подбор аналогов по этажу (floor-вес в `_compute_same_building_anchor` считает относительную вертикальную позицию `floor/total_floors` — при 12/9 она больше единицы). ## Test plan - [x] `pytest tests/test_3257_house_facts_endpoint.py tests/test_3234_house_facts_fallback.py tests/test_geocode_reverse_api.py` — **24 passed** - [x] валидатор проверен вручную, не вхолостую: отклоняет 12/9, пропускает 9/9 и частичный ввод (`floor=12, total_floors=None`) - [x] все три guid-колонки (`gar_house_guid`, `house_fias_id`, `zhkh_house_guid`) существуют на проде — проверено запросом к information_schema - [x] `ruff check` + `ruff format --check` чисто - [ ] CI зелёный ## Дальше Фронтовая половина (дёрнуть ручку после выбора адреса, предзаполнить пустые поля с возможностью переопределить) — отдельным PR.
lekss361 added 1 commit 2026-08-29 20:24:35 +00:00
feat(mera/estimate): ручка фактов дома для предзаполнения формы + гейт «этаж не выше дома»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 / backend-tests (pull_request) Successful in 4m54s
2b2e742d92
Две правки под предзаполнение формы оценки из справочника houses (наполнен из
ГИС ЖКХ + DOM.РФ: total_floors 98.3%, year_built 86.8%).

1. GET /api/v1/geocode/house-facts?lat=&lon=[&fias_id=] отдаёт
   {found, total_floors, year_built, house_type, source}. Переиспользует
   _lookup_house_facts из estimator (#3234) — тот же поиск, что и при расчёте:
   по target_house_id если ФИАС разрезолвился, иначе ближайший дом в 60 м.
   Дома нет в справочнике → 200 с found=false, а не 404: это не ошибка запроса,
   а честный ответ об отсутствии данных.

   Отдельной ручкой, а не полем в /suggest, намеренно: suggest дёргается на
   каждое нажатие клавиши, и запрос к houses на каждый из 8 кандидатов на каждый
   keystroke был бы неоправданной нагрузкой. house-facts зовётся ОДИН раз, после
   выбора конкретного адреса.

   Резолв дома по ФИАС обёрнут в try/except с rollback — предзаполнение формы не
   должно ронять запрос в 500, а незакрытая ошибка отравила бы сессию для
   последующего geo-фолбэка. Тот же best-effort, что у _lookup_house_facts.

2. TradeInEstimateInput отклоняет floor > total_floors, когда заданы ОБА поля.
   Частичный ввод легален — пользователь ещё печатает форму. Раньше «12 этаж в
   9-этажном доме» уходил в расчёт молча и портил подбор аналогов по этажу.

Тесты: 10 штук в test_3257_house_facts_endpoint.py. Валидатор проверен вручную —
отклоняет 12/9, пропускает 9/9 и частичный ввод.
Author
Owner

Уточнение к пункту 2, нашлось при разведке фронта.

Фронт уже валидирует floor > total_floorsParamsPanel.tsx (~строка 958), и в комментарии там прямо записано, почему: «prod incident 6d0c268e: floor=17, total_floors=9 was accepted silently — form + backend both let it through».

То есть после того инцидента закрыли только форму, а бэкенд остался открыт. Этот PR закрывает вторую половину. Практически это значит, что мимо формы (прямой POST, автотест, интеграция) невозможный ввод по-прежнему проходил и портил floor-вес в _compute_same_building_anchor, который считает относительную вертикальную позицию floor/total_floors — при 17/9 она равна 1.89.

Уточнение к пункту 2, нашлось при разведке фронта. Фронт **уже** валидирует `floor > total_floors` — `ParamsPanel.tsx` (~строка 958), и в комментарии там прямо записано, почему: *«prod incident 6d0c268e: floor=17, total_floors=9 was accepted silently — form + backend both let it through»*. То есть после того инцидента закрыли только форму, а бэкенд остался открыт. Этот PR закрывает вторую половину. Практически это значит, что мимо формы (прямой POST, автотест, интеграция) невозможный ввод по-прежнему проходил и портил floor-вес в `_compute_same_building_anchor`, который считает относительную вертикальную позицию `floor/total_floors` — при 17/9 она равна 1.89.
Author
Owner

Добавил фронтовую половину сюда же — отдельный PR не понадобился, кодогенерация типов относится к site-finder, а не к tradein-mvp, так что связки нет.

Что на фронте. После выбора адреса из автокомплита pickSuggestion дёргает /house-facts и заполняет поля, которых пользователь не касался. Рядом с подставленным значением вместо обычной подсказки поля показывается «из справочника» — правит руками, пометка снимается.

Три вещи, которые тут ломаются молча, поэтому сделаны явно:

  1. Не затираем ввод. Только пустое поле либо то, что раньше заполнили мы сами.
  2. Гонка. Человек успевает выбрать второй адрес, пока летит первый запрос. Гейт по счётчику поколений + AbortController — ответ на устаревший адрес не применяется, даже если пришёл последним.
  3. Не зовём setState после размонтирования. В этом файле уже был ровно такой баг (#3226, debounce-таймер переживал unmount), так что при размонтировании отменяем летящий запрос.

found=false и сетевые сбои — тихий no-op.

Тесты: 4 кейса, и они проверены мутацией — снятие гейта поколений и снятие условия «только пустое поле» роняют ровно по одному тесту каждое. Без этой проверки такие тесты легко оказываются пустыми.

npx tsc --noEmit и eslint чисто, vitest по всей папке v2/__tests__ — 24 passed.

Добавил фронтовую половину сюда же — отдельный PR не понадобился, кодогенерация типов относится к site-finder, а не к tradein-mvp, так что связки нет. **Что на фронте.** После выбора адреса из автокомплита `pickSuggestion` дёргает `/house-facts` и заполняет поля, которых пользователь не касался. Рядом с подставленным значением вместо обычной подсказки поля показывается «из справочника» — правит руками, пометка снимается. Три вещи, которые тут ломаются молча, поэтому сделаны явно: 1. **Не затираем ввод.** Только пустое поле либо то, что раньше заполнили мы сами. 2. **Гонка.** Человек успевает выбрать второй адрес, пока летит первый запрос. Гейт по счётчику поколений + `AbortController` — ответ на устаревший адрес не применяется, даже если пришёл последним. 3. **Не зовём setState после размонтирования.** В этом файле уже был ровно такой баг (#3226, debounce-таймер переживал unmount), так что при размонтировании отменяем летящий запрос. `found=false` и сетевые сбои — тихий no-op. **Тесты:** 4 кейса, и они проверены мутацией — снятие гейта поколений и снятие условия «только пустое поле» роняют ровно по одному тесту каждое. Без этой проверки такие тесты легко оказываются пустыми. `npx tsc --noEmit` и `eslint` чисто, `vitest` по всей папке `v2/__tests__` — 24 passed.
lekss361 merged commit 1d45ac0747 into main 2026-08-29 20:34:01 +00:00
lekss361 deleted branch feat/3257-house-facts-prefill 2026-08-29 20:34:01 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3257
No description provided.