fix(tradein/geocode): передавать город объявления как city_hint в геокодирование (#2594) #2601
5 changed files with 400 additions and 41 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue