Merge pull request 'fix(tradein/geocode): передавать город объявления как city_hint в геокодирование (#2594)' (#2601) from fix/tradein-geocode-city-hint into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 7m57s
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 7m57s
This commit is contained in:
commit
c38cf611cd
5 changed files with 400 additions and 41 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue