diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index c03e024e..5ab89505 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -276,7 +276,7 @@ async def geocode_missing( db.execute( text( f""" - SELECT id, address + SELECT id, address, city FROM {target} WHERE lat IS NULL AND COALESCE(address, '') != '' @@ -310,7 +310,13 @@ async def geocode_missing( ) break clean = _clean_address_for_geocode(row["address"]) - result = await geocode(clean, db) + # city (#2594 шаг 2/3) — известен вызывающему коду через listings.city + # (миграция 196) / deals.city (миграция 177), проставляется из контекста + # развёртки/импорта. Прокидываем как city_hint, а не полагаемся на то, что + # геокодер угадает город по тексту address (голый "ул. Победы, 30" без + # города в тексте иначе уходит в Екатеринбург). + city = row.get("city") + result = await geocode(clean, db, city_hint=city) if result is None: # Помечаем что пробовали — иначе ретрай на каждом cron. db.execute( diff --git a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py index f95ae077..9bd37f38 100644 --- a/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/app/tasks/backfill_listings_coords_geoportal.py @@ -10,15 +10,21 @@ Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер), работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28». -Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город -известен только из текста адреса. `ekb_geoportal_buildings` — EKB-only реестр: улица+дом -могут буквально совпасть между Екатеринбургом и другим городом области (например, -«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг -получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом -_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция, -что гейтит EKB-only тиры внутри geocoder.geocode()) — адрес, явно называющий другой город -региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для -geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC). +Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` — +EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом +области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки +города такой листинг получает екатеринбургские координаты, хотя находится в другом городе. +Гейт — ДВЕ проверки перед вызовом _geoportal_house_match: + 1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом, + листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из + контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается + на то, что город явно упомянут в самом тексте адреса. + 2. _names_non_ekb_city(address) (та же функция, что гейтит EKB-only тиры внутри + geocoder.geocode()) — СОХРАНЕНА как fallback для листингов, у которых city IS NULL + (записаны до миграции 196 или путём, ещё не проставляющим город, например admin + ad-hoc /admin/scrape) — там единственный сигнал о городе — текст адреса. +Оба пути пропуска считаются в skipped_non_ekb (адрес остаётся lat IS NULL для +geocode_missing_listings, oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC). Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно — это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за @@ -79,7 +85,7 @@ class BackfillCoordsResult: updated: int = 0 # реально обновлено (UPDATE rowcount) no_address: int = 0 # listing.address IS NULL / не распарсился no_match: int = 0 # адрес распарсился, но в реестре здания нет - skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт) + skipped_non_ekb: int = 0 # non-ЕКБ гейт: колонка city (#2594) ИЛИ текст адреса (#2583) errors: int = 0 # исключения при обработке отдельной записи duration_sec: float = field(default=0.0) @@ -179,7 +185,7 @@ def backfill_coords_from_geoportal( rows = ( db.execute( text(f""" - SELECT id, address + SELECT id, address, city FROM listings WHERE lat IS NULL AND geom IS NULL @@ -210,9 +216,23 @@ def backfill_coords_from_geoportal( res.no_address += 1 continue - # Городской гейт (#2583, H3) — ekb_geoportal_buildings EKB-only, - # улица+дом могут совпасть с другим городом области. Адрес, явно - # называющий другой город региона, пропускаем — остаётся + # Городской гейт по колонке (#2594 шаг 2/3) — ПЕРЕД матчем и ПЕРЕД + # текстовым гейтом. listings.city (миграция 196) проставляется из + # контекста развёртки скрапером — надёжнее текста адреса. Голый + # тагильский адрес без города в тексте ("ул. Победы, 30") раньше + # проходил только текстовый гейт и мог ложно сматчиться с + # одноимённым екатеринбургским домом в EKB-only реестре. Это окно + # идёт ПЕРЕД geocode_missing_listings — без гейта по колонке оно + # успевает испортить координаты первым. + city: str | None = row.get("city") + if city is not None and city != "Екатеринбург": + res.skipped_non_ekb += 1 + continue + + # Текстовый гейт (#2583, H3) — fallback для листингов, у которых + # колонка city пуста (записаны до миграции 196 либо путём, ещё не + # проставляющим город, напр. admin ad-hoc /admin/scrape). Адрес, + # явно называющий другой город региона, пропускаем — остаётся # lat IS NULL для oblast-aware geocode_missing_listings. if _names_non_ekb_city(address): res.skipped_non_ekb += 1 diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 38776907..5bad11f3 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -5,15 +5,21 @@ - Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110) — wired into in-app scheduler, window 06:00-09:00 UTC. -Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings). +Pattern: dedup по паре (address, city) — 1 уникальная пара → 1 geocode call → UPDATE +всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется +скрапером из контекста развёртки — один и тот же текст адреса в разных городах +(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а +не схлопываться в один geocode-вызов и один UPDATE по тексту адреса). Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder). Отличие от /admin/geocode-missing (per-ID): - - Этот модуль группирует по address → меньше API calls (dedup). + - Этот модуль группирует по (address, city) → меньше API calls (dedup), но не + схлопывает разные города с одинаковым текстом адреса. - Поддерживает all sources включая Avito (после PR #487 убрали jitter). - Возвращает GeocodeBackfillResult с детальными counters. - Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days; - при geocode failure помечает tried_at=NOW() → адрес не переотбирается в этом же run. + при geocode failure помечает tried_at=NOW() → пара (address, city) не + переотбирается в этом же run. """ from __future__ import annotations @@ -53,13 +59,20 @@ async def geocode_missing_listings( """Geocode listings с NULL coords (любой source). Steps: - 1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL - GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size - (приоритет адресам с большим числом listings — больший ROI per geocode call) + 1. SELECT address, city FROM listings WHERE lat IS NULL AND address IS NOT NULL + GROUP BY address, city ORDER BY COUNT(*) DESC LIMIT batch_size + (приоритет парам address+city с большим числом listings — больший ROI per + geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3: + один и тот же текст адреса в разных городах — разные записи) - 2. Для каждого address: - - geocode(address, db) — auto-cache (hit или miss) - - Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL + 2. Для каждой пары (address, city): + - geocode(address, db, city_hint=city) — auto-cache (hit или miss) + - Если есть результат: UPDATE listings SET lat, lon + WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL + (IS NOT DISTINCT FROM, а не `=` — стандартная SQL NULL-семантика: `city = NULL` + никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы + вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как + совпадение, оставаясь строгим при непустом city — нужная нам симметрия) - PostGIS trigger (listings_set_geom_trg) автоматически обновит geom 3. Log progress каждые 50 addresses. @@ -74,25 +87,30 @@ async def geocode_missing_listings( start = time.monotonic() result = GeocodeBackfillResult() - # 1. Найти top-N адресов с NULL coords (DESC by occurrence count). - # Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены + # 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count). + # Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же + # текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) + # это разные записи с разными координатами, их нельзя схлопывать в один + # geocode-вызов. GROUP BY address, city трактует NULL city как отдельную + # группу (стандартная SQL-семантика группировки NULL как равных друг другу). + # Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне - # failed-адреса не переотбираются бесконечно. + # failed-пары не переотбираются бесконечно. rows = ( db.execute( text( """ - SELECT address, COUNT(*) AS listings_count + SELECT address, city, COUNT(*) AS listings_count FROM listings WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR geocode_tried_at < NOW() - INTERVAL '7 days') - GROUP BY address - ORDER BY listings_count DESC, address ASC + GROUP BY address, city + ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST LIMIT :limit """ ), @@ -117,23 +135,28 @@ async def geocode_missing_listings( for idx, row in enumerate(rows): address: str = row["address"] + city: str | None = row.get("city") listings_count: int = row["listings_count"] result.addresses_processed += 1 try: - geo = await geocode(address, db) + geo = await geocode(address, db, city_hint=city) except Exception as exc: logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc) result.addresses_failed += 1 if not dry_run: - # Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах - # этого же прогона (loop-safe backoff 7 дней). + # Пометить tried_at чтобы пара (address, city) не переотбиралась + # в следующих batch'ах этого же прогона (loop-safe backoff 7 дней). + # IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное + # `=` не поймает NULL-город и не должно задеть другой город с тем + # же текстом адреса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" - " WHERE address = :addr AND lat IS NULL" + " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" + " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) db.commit() continue @@ -141,8 +164,9 @@ async def geocode_missing_listings( if geo is None: result.addresses_failed += 1 logger.info( - "geocode_missing: NOT FOUND '%s' (used in %d listings)", + "geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)", address[:60], + city, listings_count, ) if not dry_run: @@ -150,9 +174,10 @@ async def geocode_missing_listings( db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" - " WHERE address = :addr AND lat IS NULL" + " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" + " AND lat IS NULL" ), - {"addr": address}, + {"addr": address, "city": city}, ) db.commit() continue @@ -183,16 +208,25 @@ async def geocode_missing_listings( # UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически. # geo_precision и geocode_tried_at проставляются одновременно с координатами. + # city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из + # которой был geocode-запрос; иначе тот же текст адреса в другом городе + # (city IS NULL или другой явный город) перезаписался бы чужими координатами. update_result = db.execute( text( """ UPDATE listings SET lat = :lat, lon = :lon, geo_precision = :precision, geocode_tried_at = NOW() - WHERE address = :addr AND lat IS NULL + WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL """ ), - {"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address}, + { + "lat": geo.lat, + "lon": geo.lon, + "precision": precision, + "addr": address, + "city": city, + }, ) db.commit() result.listings_updated += update_result.rowcount diff --git a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py index 23be177c..709ab7e0 100644 --- a/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py +++ b/tradein-mvp/backend/tests/tasks/test_backfill_listings_coords_geoportal.py @@ -273,6 +273,92 @@ def test_ekb_address_still_matched_with_real_gate() -> None: mock_geo.assert_called_once_with(db, "проспект ленина", "1") +# ── городской гейт по колонке listings.city (#2594, миграция 196, шаг 2/3) ────── + + +def test_city_column_non_ekb_skips_before_text_gate_and_match() -> None: + """listings.city='Нижний Тагил', но address НЕ называет город в тексте + ("ул. Победы, 30" — bare form). Текстовый гейт (_names_non_ekb_city) пропустил + бы этот адрес дальше (город нигде явно не назван в тексте), но колонка city — + надёжный сигнал из контекста развёртки скрапера — должна перехватить его + раньше матча против EKB-only ekb_geoportal_buildings, иначе адрес получил бы + ложные екатеринбургские координаты (issue #2594).""" + rows = [{"id": 200, "address": "ул. Победы, 30", "city": "Нижний Тагил"}] + db = _make_db([rows, []]) + + # Sanity: текстовый гейт САМ ПО СЕБЕ не поймал бы этот bare-адрес. + assert _names_non_ekb_city("ул. Победы, 30") is False + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, # ложный ЕКБ-матч, если бы гейт по колонке не сработал + ) as mock_geo, + patch( + "app.tasks.backfill_listings_coords_geoportal._parse_street_house", + return_value=("победы", "30"), + ) as mock_parse, + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 1 + assert res.matched == 0 + assert res.updated == 0 + # Ни парсер, ни geoportal-матчер не должны были вызываться — гейт по колонке + # стоит раньше текстового гейта и раньше парсинга/матча. + mock_parse.assert_not_called() + mock_geo.assert_not_called() + update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])] + assert len(update_calls) == 0 + + +def test_city_column_ekb_still_matched_not_a_regression() -> None: + """listings.city='Екатеринбург' — гейт по колонке пропускает дальше, как раньше.""" + rows = [{"id": 201, "address": "ул. Победы, 30", "city": "Екатеринбург"}] + db = _make_db([rows, []]) + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ) as mock_geo, + patch( + "app.tasks.backfill_listings_coords_geoportal._parse_street_house", + return_value=("победы", "30"), + ), + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 0 + assert res.matched == 1 + assert res.updated == 1 + mock_geo.assert_called_once_with(db, "победы", "30") + + +def test_city_column_null_falls_back_to_text_gate() -> None: + """listings.city IS NULL (записан до миграции 196) — гейт по колонке молчит, + решение остаётся за текстовым гейтом _names_non_ekb_city (не деградация #2583).""" + rows = [{"id": 202, "address": "г. Нижний Тагил, проспект Ленина, 1", "city": None}] + db = _make_db([rows, []]) + + with ( + patch( + "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match", + return_value=_HIT, + ) as mock_geo, + patch("app.tasks.backfill_listings_coords_geoportal._parse_street_house") as mock_parse, + ): + res = backfill_coords_from_geoportal(db, batch_size=500) + + assert res.candidates == 1 + assert res.skipped_non_ekb == 1 # словил текстовый гейт (город назван в тексте) + assert res.matched == 0 + mock_parse.assert_not_called() + mock_geo.assert_not_called() + + # ── idempotency ─────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index b8ccdc75..ac3c6ede 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -409,6 +409,157 @@ async def test_run_geocode_missing_listings_mark_failed_on_exception() -> None: mock_runs.mark_done.assert_not_called() +# ── (address, city) pair grouping / city_hint (#2594 шаг 2/3) ──────────────── + + +@pytest.mark.asyncio +async def test_geocode_missing_same_address_different_city_independent_calls_and_updates() -> None: + """Ключевой сценарий #2594: два ряда с ОДИНАКОВЫМ текстом address, но РАЗНЫМИ + city — каждый должен получить свой geocode-вызов (city_hint) и свой UPDATE, не + затрагивающий другую пару. Раньше группировка была только по address: SELECT + группировал по тексту, а UPDATE бил по WHERE address = :addr без city — второй + UPDATE (для Нижнего Тагила) перезаписал бы координаты, уже проставленные первым + (для Екатеринбурга), и наоборот. + """ + rows = [ + {"address": "ул. Победы, 30", "city": "Екатеринбург", "listings_count": 1}, + {"address": "ул. Победы, 30", "city": "Нижний Тагил", "listings_count": 1}, + ] + + ekb_geo = GeocodeResult( + lat=56.838, + lon=60.605, + full_address="Екатеринбург, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + tagil_geo = GeocodeResult( + lat=57.910, + lon=59.985, + full_address="Нижний Тагил, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_ekb = MagicMock() + update_ekb.rowcount = 1 + update_tagil = MagicMock() + update_tagil.rowcount = 1 + db.execute.side_effect = [select_result, update_ekb, update_tagil] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + side_effect=[ekb_geo, tagil_geo], + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + # 2 отдельных geocode-вызова — по одному на пару (address, city), не 1 на address. + assert mock_geo.call_count == 2 + ekb_call = mock_geo.call_args_list[0] + tagil_call = mock_geo.call_args_list[1] + assert ekb_call.args[0] == "ул. Победы, 30" + assert ekb_call.kwargs["city_hint"] == "Екатеринбург" + assert tagil_call.args[0] == "ул. Победы, 30" + assert tagil_call.kwargs["city_hint"] == "Нижний Тагил" + + # 2 отдельных UPDATE, каждый со своим city в WHERE — не задевает другую пару. + update_calls = db.execute.call_args_list[1:] + assert len(update_calls) == 2 + expected = [("Екатеринбург", 56.838), ("Нижний Тагил", 57.910)] + for call, (expected_city, expected_lat) in zip(update_calls, expected, strict=True): + sql = str(call.args[0]) + params = call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["addr"] == "ул. Победы, 30" + assert params["city"] == expected_city + assert params["lat"] == pytest.approx(expected_lat) + + assert result.addresses_processed == 2 + assert result.addresses_geocoded == 2 + assert result.listings_updated == 2 + + +@pytest.mark.asyncio +async def test_geocode_missing_null_city_group_uses_is_not_distinct_from() -> None: + """city IS NULL — своя группа. city_hint=None передаётся геокодеру, UPDATE + использует IS NOT DISTINCT FROM (обычный `=` никогда не совпал бы с NULL — + группа NULL-city вообще не обновилась бы обычным equality-сравнением).""" + rows = [{"address": "ул. Дружинина, 33", "city": None, "listings_count": 2}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + update_result.rowcount = 2 + db.execute.side_effect = [select_result, update_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=_make_geocode_result("nominatim"), + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + mock_geo.assert_called_once_with("ул. Дружинина, 33", db, city_hint=None) + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + params = update_call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["city"] is None + assert result.listings_updated == 2 + + +@pytest.mark.asyncio +async def test_geocode_missing_select_groups_by_address_and_city() -> None: + """SELECT содержит GROUP BY address, city — НЕ только по address (#2594).""" + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + db.execute.return_value = select_result + + with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock): + await geocode_missing_listings(db, batch_size=10) + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "GROUP BY address, city" in sql_text + assert "SELECT address, city, COUNT(*)" in sql_text + + +@pytest.mark.asyncio +async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> None: + """Failed geocode (geo=None) для (address, city) → UPDATE tried_at ограничен + ЭТОЙ парой (IS NOT DISTINCT FROM city), не всеми строками с тем же address.""" + rows = [{"address": "несуществующий адрес", "city": "Нижний Тагил", "listings_count": 1}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + tried_at_result = MagicMock() + db.execute.side_effect = [select_result, tried_at_result] + + with patch( + "app.tasks.geocode_missing.geocode", + new_callable=AsyncMock, + return_value=None, + ) as mock_geo: + result = await geocode_missing_listings(db, batch_size=200) + + mock_geo.assert_called_once_with("несуществующий адрес", db, city_hint="Нижний Тагил") + assert result.addresses_failed == 1 + + update_call = db.execute.call_args_list[1] + sql = str(update_call.args[0]) + params = update_call.args[1] + assert "IS NOT DISTINCT FROM" in sql + assert params["city"] == "Нижний Тагил" + + # ── Integration-style: estimator Avito exclusion removed ───────────────────── @@ -494,3 +645,65 @@ def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None: data = resp.json() assert "status" in data assert "addresses_total" in data + + +# ── admin.geocode_missing (per-ID endpoint) city_hint (#2594 шаг 2/3) ──────── + + +@pytest.mark.asyncio +async def test_admin_geocode_missing_passes_city_hint() -> None: + """POST /admin/geocode-missing читает city из SELECT и передаёт как city_hint. + + Раньше endpoint читал только row["address"] и звал geocode(clean, db) без + города — голый тагильский адрес без города в тексте уходил в Екатеринбург. + """ + from app.api.v1 import admin as admin_module + + rows = [{"id": 55, "address": "ул. Победы, 30", "city": "Нижний Тагил"}] + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = rows + update_result = MagicMock() + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, update_result, remaining_result] + + geo = GeocodeResult( + lat=57.910, + lon=59.985, + full_address="Нижний Тагил, ул. Победы, 30", + provider="nominatim", # type: ignore[arg-type] + confidence="exact", + ) + + with patch( + "app.api.v1.admin.geocode", + new_callable=AsyncMock, + return_value=geo, + ) as mock_geo: + result = await admin_module.geocode_missing(db, limit=100, target="listings") + + mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил") + assert result["geocoded"] == 1 + assert result["skipped"] == 0 + + +@pytest.mark.asyncio +async def test_admin_geocode_missing_select_includes_city_column() -> None: + """SELECT в admin.geocode_missing содержит колонку city (#2594).""" + from app.api.v1 import admin as admin_module + + db = MagicMock() + select_result = MagicMock() + select_result.mappings.return_value.all.return_value = [] + remaining_result = MagicMock() + remaining_result.scalar.return_value = 0 + db.execute.side_effect = [select_result, remaining_result] + + with patch("app.api.v1.admin.geocode", new_callable=AsyncMock): + await admin_module.geocode_missing(db, limit=100, target="listings") + + first_call = db.execute.call_args_list[0] + sql_text = str(first_call[0][0]) + assert "SELECT id, address, city" in sql_text