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( db.execute(
text( text(
f""" f"""
SELECT id, address SELECT id, address, city
FROM {target} FROM {target}
WHERE lat IS NULL WHERE lat IS NULL
AND COALESCE(address, '') != '' AND COALESCE(address, '') != ''
@ -310,7 +310,13 @@ async def geocode_missing(
) )
break break
clean = _clean_address_for_geocode(row["address"]) 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: if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron. # Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute( db.execute(

View file

@ -10,15 +10,21 @@
Парсинг адреса _parse_street_house из app.services.geocoder (готовый парсер), Парсинг адреса _parse_street_house из app.services.geocoder (готовый парсер),
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28». работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
Городской гейт (#2583, находка H3): в `listings` НЕТ отдельной колонки города — город Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` —
известен только из текста адреса. `ekb_geoportal_buildings` EKB-only реестр: улица+дом EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом
могут буквально совпасть между Екатеринбургом и другим городом области (например, области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки
«проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки города такой листинг города такой листинг получает екатеринбургские координаты, хотя находится в другом городе.
получает екатеринбургские координаты, хотя находится в другом городе. Перед вызовом Гейт ДВЕ проверки перед вызовом _geoportal_house_match:
_geoportal_house_match каждый адрес проверяется через _names_non_ekb_city (та же функция, 1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом,
что гейтит EKB-only тиры внутри geocoder.geocode()) адрес, явно называющий другой город листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из
региона, пропускается (counted как skipped_non_ekb) и остаётся lat IS NULL для контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается
geocode_missing_listings (oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC). на то, что город явно упомянут в самом тексте адреса.
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()) оставлен намеренно Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
@ -79,7 +85,7 @@ class BackfillCoordsResult:
updated: int = 0 # реально обновлено (UPDATE rowcount) updated: int = 0 # реально обновлено (UPDATE rowcount)
no_address: int = 0 # listing.address IS NULL / не распарсился no_address: int = 0 # listing.address IS NULL / не распарсился
no_match: int = 0 # адрес распарсился, но в реестре здания нет no_match: int = 0 # адрес распарсился, но в реестре здания нет
skipped_non_ekb: int = 0 # адрес явно называет другой город области (#2583 гейт) skipped_non_ekb: int = 0 # non-ЕКБ гейт: колонка city (#2594) ИЛИ текст адреса (#2583)
errors: int = 0 # исключения при обработке отдельной записи errors: int = 0 # исключения при обработке отдельной записи
duration_sec: float = field(default=0.0) duration_sec: float = field(default=0.0)
@ -179,7 +185,7 @@ def backfill_coords_from_geoportal(
rows = ( rows = (
db.execute( db.execute(
text(f""" text(f"""
SELECT id, address SELECT id, address, city
FROM listings FROM listings
WHERE lat IS NULL WHERE lat IS NULL
AND geom IS NULL AND geom IS NULL
@ -210,9 +216,23 @@ def backfill_coords_from_geoportal(
res.no_address += 1 res.no_address += 1
continue 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. # lat IS NULL для oblast-aware geocode_missing_listings.
if _names_non_ekb_city(address): if _names_non_ekb_city(address):
res.skipped_non_ekb += 1 res.skipped_non_ekb += 1

View file

@ -5,15 +5,21 @@
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110) - Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
wired into in-app scheduler, window 06:00-09:00 UTC. 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). Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
Отличие от /admin/geocode-missing (per-ID): Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup). - Этот модуль группирует по (address, city) меньше API calls (dedup), но не
схлопывает разные города с одинаковым текстом адреса.
- Поддерживает all sources включая Avito (после PR #487 убрали jitter). - Поддерживает all sources включая Avito (после PR #487 убрали jitter).
- Возвращает GeocodeBackfillResult с детальными counters. - Возвращает GeocodeBackfillResult с детальными counters.
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days; - 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 from __future__ import annotations
@ -53,13 +59,20 @@ async def geocode_missing_listings(
"""Geocode listings с NULL coords (любой source). """Geocode listings с NULL coords (любой source).
Steps: Steps:
1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL 1. SELECT address, city FROM listings WHERE lat IS NULL AND address IS NOT NULL
GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size GROUP BY address, city ORDER BY COUNT(*) DESC LIMIT batch_size
(приоритет адресам с большим числом listings больший ROI per geocode call) (приоритет парам address+city с большим числом listings больший ROI per
geocode call; группировка по паре, НЕ только по address #2594 шаг 2/3:
один и тот же текст адреса в разных городах разные записи)
2. Для каждого address: 2. Для каждой пары (address, city):
- geocode(address, db) auto-cache (hit или miss) - geocode(address, db, city_hint=city) auto-cache (hit или miss)
- Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL - Если есть результат: 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 - PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
3. Log progress каждые 50 addresses. 3. Log progress каждые 50 addresses.
@ -74,25 +87,30 @@ async def geocode_missing_listings(
start = time.monotonic() start = time.monotonic()
result = GeocodeBackfillResult() 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 дней (возможен # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
# failed-адреса не переотбираются бесконечно. # failed-пары не переотбираются бесконечно.
rows = ( rows = (
db.execute( db.execute(
text( text(
""" """
SELECT address, COUNT(*) AS listings_count SELECT address, city, COUNT(*) AS listings_count
FROM listings FROM listings
WHERE lat IS NULL WHERE lat IS NULL
AND address IS NOT NULL AND address IS NOT NULL
AND length(trim(address)) >= 5 AND length(trim(address)) >= 5
AND (geocode_tried_at IS NULL AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - INTERVAL '7 days') OR geocode_tried_at < NOW() - INTERVAL '7 days')
GROUP BY address GROUP BY address, city
ORDER BY listings_count DESC, address ASC ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
LIMIT :limit LIMIT :limit
""" """
), ),
@ -117,23 +135,28 @@ async def geocode_missing_listings(
for idx, row in enumerate(rows): for idx, row in enumerate(rows):
address: str = row["address"] address: str = row["address"]
city: str | None = row.get("city")
listings_count: int = row["listings_count"] listings_count: int = row["listings_count"]
result.addresses_processed += 1 result.addresses_processed += 1
try: try:
geo = await geocode(address, db) geo = await geocode(address, db, city_hint=city)
except Exception as exc: except Exception as exc:
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc) logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
result.addresses_failed += 1 result.addresses_failed += 1
if not dry_run: if not dry_run:
# Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах # Пометить tried_at чтобы пара (address, city) не переотбиралась
# этого же прогона (loop-safe backoff 7 дней). # в следующих batch'ах этого же прогона (loop-safe backoff 7 дней).
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
# `=` не поймает NULL-город и не должно задеть другой город с тем
# же текстом адреса.
db.execute( db.execute(
text( text(
"UPDATE listings SET geocode_tried_at = NOW()" "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() db.commit()
continue continue
@ -141,8 +164,9 @@ async def geocode_missing_listings(
if geo is None: if geo is None:
result.addresses_failed += 1 result.addresses_failed += 1
logger.info( logger.info(
"geocode_missing: NOT FOUND '%s' (used in %d listings)", "geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
address[:60], address[:60],
city,
listings_count, listings_count,
) )
if not dry_run: if not dry_run:
@ -150,9 +174,10 @@ async def geocode_missing_listings(
db.execute( db.execute(
text( text(
"UPDATE listings SET geocode_tried_at = NOW()" "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() db.commit()
continue continue
@ -183,16 +208,25 @@ async def geocode_missing_listings(
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически. # UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
# geo_precision и geocode_tried_at проставляются одновременно с координатами. # geo_precision и geocode_tried_at проставляются одновременно с координатами.
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
# (city IS NULL или другой явный город) перезаписался бы чужими координатами.
update_result = db.execute( update_result = db.execute(
text( text(
""" """
UPDATE listings UPDATE listings
SET lat = :lat, lon = :lon, geo_precision = :precision, SET lat = :lat, lon = :lon, geo_precision = :precision,
geocode_tried_at = NOW() 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() db.commit()
result.listings_updated += update_result.rowcount 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") 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 ─────────────────────────────────────────────────────────────── # ── 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() 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 ───────────────────── # ── Integration-style: estimator Avito exclusion removed ─────────────────────
@ -494,3 +645,65 @@ def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None:
data = resp.json() data = resp.json()
assert "status" in data assert "status" in data
assert "addresses_total" 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