geocode() ran EKB-only local tiers (2a _geoportal_house_match on
ekb_geoportal_buildings, 2c _cadastral_house_match, 2d
_cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the
already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX
+ region cross-check, shipped in c0cbdc2f). _parse_street_house drops the
city, so any non-EKB oblast street+house that collides with an EKB building
(e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited
to EKB coordinates.
Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES
gazetteer (minus Ekaterinburg) and word-boundary matching from
_has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly
names a different oblast city. Gate tiers 2a/2c/2d behind
`use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep
the exact same code path (byte-identical ordering/logic, only additive
gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which
already handle oblast-wide geocoding correctly.
Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows);
ekb_geoportal_buildings is 100% EKB by construction — gating loses no
meaningful local coverage for non-EKB cities.
Follow-up (not in this PR): geocode_cache has rows poisoned by the old
behavior (non-EKB addresses cached with EKB coordinates) — needs a purge.
Геокодер был жёстко EKB-bound и ВЫБРАСЫВАЛ корректные non-EKB геокоды (bbox-accept-фильтры) → блокировал оценку любого адреса области. Обобщено до region-66.
- OBLAST66_BBOX + is_within_oblast66_bbox (superset EKB-tight, EKB не затронут); EKB Yandex Tier1/2 fast-path сохранён.
- Все accept-фильтры (Yandex/Nominatim forward+suggest, geocode() accept+typo) → oblast-66.
- City-prefix по word-boundary _has_oblast_marker (не substring): EKB-улицы 'Серова 27'/'Ирбитская 5' и др. больше не роняют 'Екатеринбург,'-префикс (был silent wrong-city баг).
- Two-pass tie-break в accept-циклах: tight-EKB кандидат приоритетнее → EKB-результат идентичен прежнему.
- Region cross-check (Nominatim address.state / Yandex AdministrativeAreaName) отсекает соседние области (Тюмень и др.); bbox-fallback когда region отсутствует.
- DaData suggest: region-параметр (hard-filter), убран no-op restrict_value.
- +26 тестов: OBLAST66 superset TIGHT, far-town accept, Тюмень reject, marker +/- (street-collision), DaData region body.
Foundation для A2 (геокодинг non-EKB сделок) и live-оценки адресов области.
avito_detail.parse_detail_html писал data-map-lat/lon прямо в listings
UPDATE COALESCE без bbox-валидации — в отличие от geocoder.py, который
фильтрует везде. Итог: 406 active avito-листингов с координатами
Питер/Тюмень/Уфа просочились в ЕКБ-датасет (ложные аналоги через Tier A
address-match оценщика). Prod-verified 2026-06-23.
- geocoder: DRY-хелпер is_within_ekb_bbox(lat, lon, bbox) + именованные
EKB_BBOX_TIGHT (geocoder-фильтр, числа не изменены) и EKB_BBOX_WIDE
(ingest-guard); 2 inline tight-проверки → хелпер (поведение identical).
- avito_detail: после извлечения lat/lon → reset None + logger.warning
если вне wide-bbox; листинг падает в geocode-cron путь (COALESCE NULL
не затирает прежнее хорошее значение).
- tests: bbox-хелпер (ЕКБ True, Питер/Тюмень/Уфа False, inclusive,
WIDE⊇TIGHT инвариант) + parse_detail_html guard end-to-end.
WIDE строго содержит TIGHT — guard физически не режет ничего, что
geocoder сам бы пропустил. NULL lat (14935, geocode backlog) — отдельная
проблема, не в scope. One-time cleanup существующих 406 — отдельно
(database-expert, prod data mutation).
Refs #1871
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.
Обратный геокодинг (Nominatim /reverse) возвращал display_name,
который ведёт с названия ближайшей организации/POI — клик по
ЖК Малевич давал «NataliOlympic, 2к1, Трамвайный переулок…»
вместо адреса дома. Такой адрес не геокодировался обратно,
и оценка находила 0 аналогов.
Теперь reverse_geocode собирает адрес из Nominatim address-объекта
(улица + дом + город), display_name остаётся фолбэком. Follow-up к #415.
Кнопка справа от поля адреса → модалка с картой Екатеринбурга.
Клик по дому → reverse-геокодинг → адрес подставляется в форму.
- geocoder.reverse_geocode + GET /api/v1/geocode/reverse — Nominatim
/reverse (координаты → адрес).
- MapPicker.tsx — модалка с Leaflet + OSM-тайлами (Leaflet с CDN,
без npm-зависимости), circleMarker по клику.
- EstimateForm — кнопка-карта справа от AddressInput.
Карта на OSM (без API-ключа). При появлении ключа Yandex (#402)
можно перейти на Я.Карты.
Closes#415