fix(tradein/geocode): передавать город объявления как city_hint в геокодирование (#2594) #2601

Merged
lekss361 merged 1 commit from fix/tradein-geocode-city-hint into main 2026-07-31 21:40:37 +00:00
5 changed files with 400 additions and 41 deletions

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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 ───────────────────────────────────────────────────────────────

View file

@ -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