Ночная очередь geocode_missing_listings была на 98.5% забита is_active=false
объявлениями чужих регионов (Новосибирск/Казань/Челябинск/Тюмень/Ижевск...) без
улицы и дома. ORDER BY listings_count DESC ставил такой мусор в начало очереди
(у 'Новосибирская обл.,Новосибирск' — 214 listings, у реального адреса — 1-2),
поэтому Nominatim-бюджет (1 req/sec) съедался мусором и до активных адресов
дело не доходило: 8 ночных прогонов подряд saved=0.
Добавлен AND is_active в SELECT. UPDATE (lat/lon и оба tried_at) намеренно
оставлены без этого фильтра — координаты и backoff-метка принадлежат паре
(address, city) как тексту, не конкретному listing; is_active=false дубликат
той же пары и так навсегда исключён из будущих SELECT, а unfiltered UPDATE
проставляет ему ответ бесплатно (Nominatim-вызов уже оплачен активным
листингом) на случай реактивации.
Refs #2604
Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3).
listings.city (миграция 196) заполняется скрапером из контекста развёртки, но
три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали
колонку - голый адрес без города в тексте ("ул. Победы, 30", тагильский)
уходил в Екатеринбург.
- app/tasks/geocode_missing.py: группировка по (address, city) вместо
address, city_hint в geocode(), UPDATE/tried_at-пометка по паре через
city IS NOT DISTINCT FROM :city (обычный `=` не поймал бы NULL-город и
не даёт нужной симметрии между группами).
- app/tasks/backfill_listings_coords_geoportal.py: гейт по колонке city
ПЕРЕД матчем против EKB-only ekb_geoportal_buildings, ПЕРЕД текстовым
гейтом _names_non_ekb_city (сохранён как fallback для city IS NULL).
Это окно идёт раньше geocode_missing_listings, поэтому раньше успевало
испортить координаты первым.
- app/api/v1/admin.py: per-ID endpoint /geocode-missing читает city из
SELECT (listings.city / deals.city) и передаёт как city_hint.
geocoder.py не тронут (запрещено ТЗ).
Тесты: falsification-прогон (stash impl, тесты остаются) - 7 failed / 36
passed на старом коде, все 7 - новые тесты на новое поведение; после
stash pop - 43 passed / 0 failed. Полный pytest tradein-mvp/backend:
2970 passed, 1 failed (pre-existing tests/test_search_api.py::test_search_cache_hit,
несвязан), 9 skipped.