feat(tradein): бэкфилл координат листингов из ekb_geoportal_buildings (street+house match) #1841
2 changed files with 673 additions and 0 deletions
|
|
@ -0,0 +1,309 @@
|
|||
"""Бэкфилл координат листингов из реестра зданий ЕКБ (ekb_geoportal_buildings).
|
||||
|
||||
Назначение: проставить lat/lon/geom для листингов, у которых геокодер не отработал
|
||||
(lat IS NULL), используя точный street_norm+house_norm матч по таблице ekb_geoportal_buildings.
|
||||
|
||||
Нормализация совпадает с ekb_geoportal_ingest:
|
||||
street_norm = lower(trim(street))
|
||||
house_norm = lower(house) без внутренних пробелов («7 б» → «7б»)
|
||||
|
||||
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
||||
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
||||
|
||||
Запуск:
|
||||
python -m app.tasks.backfill_listings_coords_geoportal
|
||||
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
||||
|
||||
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
||||
Повторный прогон не затирает уже проставленные координаты.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.geocoder import _geoportal_house_match, _parse_street_house
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Размер батча на SELECT + commit-интервал.
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
DEFAULT_LIMIT: int | None = None # None = без ограничений (весь backlog)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackfillCoordsResult:
|
||||
"""Счётчики прогона бэкфилла."""
|
||||
|
||||
candidates: int = 0 # листингов-кандидатов (lat IS NULL)
|
||||
matched: int = 0 # сматчено с ekb_geoportal_buildings
|
||||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||
no_address: int = 0 # listing.address IS NULL / не распарсился
|
||||
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
||||
errors: int = 0 # исключения при обработке отдельной записи
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
||||
def _update_listing_coords(
|
||||
db: Session,
|
||||
listing_id: int,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> int:
|
||||
"""UPDATE lat/lon/geom для одного листинга (только если lat IS NULL — идемпотентно).
|
||||
|
||||
geom проставляется через ST_SetSRID(ST_MakePoint(...), 4326). Не тригерит
|
||||
отдельный trigger — явный UPDATE.
|
||||
|
||||
Возвращает rowcount (1 = обновлено, 0 = уже было заполнено или не найдено).
|
||||
"""
|
||||
result = db.execute(
|
||||
text("""
|
||||
UPDATE listings
|
||||
SET lat = CAST(:lat AS double precision),
|
||||
lon = CAST(:lon AS double precision),
|
||||
geom = ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
AND lat IS NULL
|
||||
"""),
|
||||
{"lat": lat, "lon": lon, "id": listing_id},
|
||||
)
|
||||
return result.rowcount # type: ignore[return-value]
|
||||
|
||||
|
||||
def backfill_coords_from_geoportal(
|
||||
db: Session,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
sources: list[str] | None = None,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> BackfillCoordsResult:
|
||||
"""Бэкфилл координат листингов из ekb_geoportal_buildings.
|
||||
|
||||
Алгоритм:
|
||||
1. SELECT листингов с lat IS NULL + ЕКБ-скоуп (опционально), батчами batch_size.
|
||||
2. Для каждого: распарсить street+house через _parse_street_house (geocoder),
|
||||
нормализовать (как ekb_geoportal_ingest) и сматчить в ekb_geoportal_buildings.
|
||||
3. Матч есть → UPDATE lat/lon/geom (только при lat IS NULL — идемпотентно).
|
||||
4. commit каждые batch_size записей, SAVEPOINT-защита на батч.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy сессия (tradein БД).
|
||||
limit: ограничение общего числа обрабатываемых листингов (None = всё).
|
||||
sources: список source-значений для фильтрации (None = все источники).
|
||||
batch_size: размер батча SELECT + интервал commit.
|
||||
|
||||
Returns:
|
||||
BackfillCoordsResult со счётчиками.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
res = BackfillCoordsResult()
|
||||
|
||||
offset = 0
|
||||
total_limit = limit # общий лимит листингов, None = без ограничений
|
||||
|
||||
while True:
|
||||
# Сколько брать в этой итерации
|
||||
fetch_n = batch_size
|
||||
if total_limit is not None:
|
||||
remaining = total_limit - res.candidates
|
||||
if remaining <= 0:
|
||||
break
|
||||
fetch_n = min(batch_size, remaining)
|
||||
|
||||
# Собираем WHERE с опциональным source-фильтром
|
||||
source_clause = ""
|
||||
params: dict = {"limit": fetch_n, "offset": offset}
|
||||
if sources:
|
||||
source_clause = " AND source = ANY(CAST(:sources AS text[]))"
|
||||
params["sources"] = sources
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(f"""
|
||||
SELECT id, address
|
||||
FROM listings
|
||||
WHERE lat IS NULL
|
||||
AND geom IS NULL
|
||||
AND address IS NOT NULL
|
||||
{source_clause}
|
||||
ORDER BY id
|
||||
LIMIT CAST(:limit AS integer) OFFSET CAST(:offset AS integer)
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
res.candidates += len(rows)
|
||||
batch_updated = 0
|
||||
|
||||
try:
|
||||
with db.begin_nested():
|
||||
for row in rows:
|
||||
listing_id: int = row["id"]
|
||||
address: str | None = row["address"]
|
||||
|
||||
if not address or not address.strip():
|
||||
res.no_address += 1
|
||||
continue
|
||||
|
||||
# Парсинг адреса — переиспользуем парсер geocoder'а
|
||||
parsed = _parse_street_house(address)
|
||||
if parsed is None:
|
||||
res.no_address += 1
|
||||
continue
|
||||
|
||||
street, house = parsed
|
||||
|
||||
# Матч в ekb_geoportal_buildings (точный по street_norm+house_norm)
|
||||
try:
|
||||
hit = _geoportal_house_match(db, street, house)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"backfill_coords: geoportal match raised for listing_id=%d addr=%r",
|
||||
listing_id,
|
||||
address[:80],
|
||||
exc_info=True,
|
||||
)
|
||||
res.errors += 1
|
||||
continue
|
||||
|
||||
if hit is None:
|
||||
res.no_match += 1
|
||||
continue
|
||||
|
||||
res.matched += 1
|
||||
|
||||
# UPDATE (идемпотентно — WHERE lat IS NULL)
|
||||
try:
|
||||
rc = _update_listing_coords(db, listing_id, hit.lat, hit.lon)
|
||||
res.updated += rc
|
||||
batch_updated += rc
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"backfill_coords: UPDATE failed for listing_id=%d",
|
||||
listing_id,
|
||||
exc_info=True,
|
||||
)
|
||||
res.errors += 1
|
||||
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"backfill_coords: batch SAVEPOINT failed (offset=%d), batch rolled back",
|
||||
offset,
|
||||
exc_info=True,
|
||||
)
|
||||
res.errors += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"backfill_coords: batch offset=%d rows=%d matched=%d updated=%d "
|
||||
"(total: candidates=%d matched=%d updated=%d no_addr=%d no_match=%d err=%d)",
|
||||
offset,
|
||||
len(rows),
|
||||
batch_updated,
|
||||
batch_updated,
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.errors,
|
||||
)
|
||||
|
||||
# Если вернули меньше чем просили — конец данных
|
||||
if len(rows) < fetch_n:
|
||||
break
|
||||
|
||||
offset += len(rows)
|
||||
|
||||
res.duration_sec = time.monotonic() - start
|
||||
|
||||
logger.info(
|
||||
"backfill_coords: DONE — candidates=%d matched=%d updated=%d "
|
||||
"no_address=%d no_match=%d errors=%d duration=%.1fs",
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Бэкфилл координат листингов из ekb_geoportal_buildings"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Максимальное число листингов (default: без ограничений)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=DEFAULT_BATCH_SIZE,
|
||||
help=f"Размер батча (default: {DEFAULT_BATCH_SIZE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sources",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Фильтр по source (avito/cian/yandex); без флага — все источники",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = backfill_coords_from_geoportal(
|
||||
db,
|
||||
limit=args.limit,
|
||||
sources=args.sources,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info(
|
||||
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
|
||||
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
|
||||
result.candidates,
|
||||
result.matched,
|
||||
result.updated,
|
||||
result.no_address,
|
||||
result.no_match,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
"""Unit tests for backfill_listings_coords_geoportal task.
|
||||
|
||||
Покрывает:
|
||||
- Листинг с адресом «ул. Малышева, 30» + geoportal-строка → coords проставляются.
|
||||
- Листинг без матча в geoportal → не трогается (no_match++).
|
||||
- Листинг с lat уже заполненным (не попадает в SELECT WHERE lat IS NULL) → skip.
|
||||
- Листинг с нераспарсимым адресом (NULL / мусор) → no_address++.
|
||||
- Пустой SELECT → нулевой результат.
|
||||
- limit/sources параметры прокидываются в SQL.
|
||||
- Идемпотентность: UPDATE с WHERE lat IS NULL не затирает уже заполненное.
|
||||
- SAVEPOINT откат батча при исключении в DB.execute.
|
||||
|
||||
Все сетевые вызовы и DB замоканы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.services.geocoder import GeocodeSuggestion # noqa: E402
|
||||
from app.tasks.backfill_listings_coords_geoportal import ( # noqa: E402
|
||||
BackfillCoordsResult,
|
||||
backfill_coords_from_geoportal,
|
||||
)
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_HIT = GeocodeSuggestion(
|
||||
label="Малышева, 30",
|
||||
full_address="Малышева, 30",
|
||||
lat=56.838,
|
||||
lon=60.605,
|
||||
kind="house",
|
||||
)
|
||||
|
||||
|
||||
def _make_db(select_pages: list[list[dict]], update_rowcount: int = 1) -> MagicMock:
|
||||
"""Mock Session. SELECT-вызовы возвращают страницы из очереди по порядку.
|
||||
|
||||
UPDATE и прочие execute()-вызовы возвращают generic-мок с rowcount=update_rowcount.
|
||||
Очередь SELECT определяется по наличию .mappings()-обращений внутри задачи:
|
||||
задача вызывает .mappings().all() только для SELECT-запроса (первый execute
|
||||
в каждой итерации батча). UPDATE-вызовы внутри begin_nested не используют
|
||||
.mappings() — это позволяет различить их.
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
nested_ctx = MagicMock()
|
||||
nested_ctx.__enter__ = MagicMock(return_value=None)
|
||||
nested_ctx.__exit__ = MagicMock(return_value=False)
|
||||
db.begin_nested.return_value = nested_ctx
|
||||
|
||||
# SELECT-очередь (только для .mappings().all()-вызовов)
|
||||
select_queue: list[list[dict]] = list(select_pages)
|
||||
|
||||
# generic UPDATE-мок
|
||||
upd = MagicMock()
|
||||
upd.rowcount = update_rowcount
|
||||
|
||||
# Отдельный счётчик: первый execute каждого батча = SELECT (mappings)
|
||||
# Определяем по тексту SQL: SELECT vs UPDATE
|
||||
def _side(*args, **kwargs):
|
||||
sql = str(args[0]) if args else ""
|
||||
if "SELECT" in sql or "select" in sql:
|
||||
if select_queue:
|
||||
page = select_queue.pop(0)
|
||||
else:
|
||||
page = []
|
||||
sel = MagicMock()
|
||||
sel.mappings.return_value.all.return_value = page
|
||||
return sel
|
||||
# UPDATE / прочее
|
||||
return upd
|
||||
|
||||
db.execute.side_effect = _side
|
||||
return db
|
||||
|
||||
|
||||
# ── basic match ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_match_updates_coords() -> None:
|
||||
"""Листинг с адресом Малышева 30 → geoportal матч → lat/lon/geom UPDATE."""
|
||||
rows = [{"id": 1, "address": "г. Екатеринбург, ул. Малышева, 30, кв. 28"}]
|
||||
db = _make_db([rows, []]) # второй SELECT пустой → конец
|
||||
|
||||
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.matched == 1
|
||||
assert res.updated == 1
|
||||
assert res.no_address == 0
|
||||
assert res.no_match == 0
|
||||
assert res.errors == 0
|
||||
mock_geo.assert_called_once_with(db, "малышева", "30")
|
||||
# UPDATE должен быть вызван с правильными lat/lon
|
||||
update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])]
|
||||
assert len(update_calls) == 1
|
||||
params = update_calls[0].args[1]
|
||||
assert params["lat"] == pytest.approx(56.838)
|
||||
assert params["lon"] == pytest.approx(60.605)
|
||||
assert params["id"] == 1
|
||||
|
||||
|
||||
def test_no_match_does_not_update() -> None:
|
||||
"""Geoportal не нашёл → no_match++, UPDATE не вызывается."""
|
||||
rows = [{"id": 2, "address": "ул. Несуществующая, 999"}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=("несуществующая", "999"),
|
||||
),
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.candidates == 1
|
||||
assert res.matched == 0
|
||||
assert res.updated == 0
|
||||
assert res.no_match == 1
|
||||
# UPDATE не вызывался (только SELECT)
|
||||
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_listing_with_coords_excluded_by_where() -> None:
|
||||
"""SELECT WHERE lat IS NULL не вернёт листинги с координатами.
|
||||
|
||||
Моделируем это пустым SELECT → нулевой результат.
|
||||
"""
|
||||
db = _make_db([[]]) # SELECT сразу пустой
|
||||
|
||||
with patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo:
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.candidates == 0
|
||||
assert res.matched == 0
|
||||
assert res.updated == 0
|
||||
mock_geo.assert_not_called()
|
||||
|
||||
|
||||
def test_null_address_counted_as_no_address() -> None:
|
||||
"""Листинг с адресом None (NULL в БД) → no_address++."""
|
||||
rows = [{"id": 3, "address": None}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
with patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo:
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.no_address == 1
|
||||
assert res.matched == 0
|
||||
mock_geo.assert_not_called()
|
||||
|
||||
|
||||
def test_unparseable_address_counted_as_no_address() -> None:
|
||||
"""_parse_street_house вернул None → no_address++, geoportal не вызывается."""
|
||||
rows = [{"id": 4, "address": "полный мусор без дома"}]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=None,
|
||||
),
|
||||
patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo,
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.no_address == 1
|
||||
assert res.matched == 0
|
||||
mock_geo.assert_not_called()
|
||||
|
||||
|
||||
# ── idempotency ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_idempotent_where_lat_is_null() -> None:
|
||||
"""UPDATE содержит WHERE lat IS NULL — повторный прогон не затрёт существующие coords."""
|
||||
rows = [{"id": 5, "address": "ул. Малышева, 30"}]
|
||||
db = _make_db([rows, []])
|
||||
# rowcount=0 симулирует «уже было заполнено»
|
||||
db.execute.side_effect = None
|
||||
calls_log: list = []
|
||||
|
||||
nested_ctx = MagicMock()
|
||||
nested_ctx.__enter__ = MagicMock(return_value=None)
|
||||
nested_ctx.__exit__ = MagicMock(return_value=False)
|
||||
db.begin_nested.return_value = nested_ctx
|
||||
|
||||
select_page1 = MagicMock()
|
||||
select_page1.mappings.return_value.all.return_value = rows
|
||||
select_page2 = MagicMock()
|
||||
select_page2.mappings.return_value.all.return_value = []
|
||||
|
||||
upd_already_filled = MagicMock()
|
||||
upd_already_filled.rowcount = 0 # lat уже был заполнен
|
||||
|
||||
execute_returns = [select_page1, upd_already_filled, select_page2]
|
||||
|
||||
def _side(*args, **kwargs):
|
||||
calls_log.append(str(args[0]))
|
||||
return execute_returns.pop(0)
|
||||
|
||||
db.execute.side_effect = _side
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=_HIT,
|
||||
),
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=("малышева", "30"),
|
||||
),
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
# matched=1 (geoportal нашёл), но updated=0 (WHERE lat IS NULL вернул 0)
|
||||
assert res.matched == 1
|
||||
assert res.updated == 0
|
||||
# Проверяем что UPDATE SQL содержит WHERE lat IS NULL
|
||||
update_sqls = [s for s in calls_log if "UPDATE" in s]
|
||||
assert len(update_sqls) == 1
|
||||
assert "lat IS NULL" in update_sqls[0]
|
||||
|
||||
|
||||
# ── limit / sources ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_limit_stops_processing() -> None:
|
||||
"""limit=1 → берём не более 1 листинга."""
|
||||
rows = [{"id": 10, "address": "ул. Малышева, 30"}]
|
||||
db = _make_db([rows]) # только одна страница
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=("малышева", "30"),
|
||||
),
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, limit=1, batch_size=500)
|
||||
|
||||
assert res.candidates == 1
|
||||
|
||||
|
||||
def test_sources_filter_passed_to_sql() -> None:
|
||||
"""sources=['avito'] → SQL содержит source = ANY(...)."""
|
||||
db = MagicMock()
|
||||
sel = MagicMock()
|
||||
sel.mappings.return_value.all.return_value = []
|
||||
db.execute.return_value = sel
|
||||
|
||||
nested_ctx = MagicMock()
|
||||
nested_ctx.__enter__ = MagicMock(return_value=None)
|
||||
nested_ctx.__exit__ = MagicMock(return_value=False)
|
||||
db.begin_nested.return_value = nested_ctx
|
||||
|
||||
backfill_coords_from_geoportal(db, sources=["avito"], batch_size=500)
|
||||
|
||||
first_call = db.execute.call_args_list[0]
|
||||
sql_text = str(first_call.args[0])
|
||||
params = first_call.args[1]
|
||||
assert "ANY" in sql_text
|
||||
assert params["sources"] == ["avito"]
|
||||
|
||||
|
||||
# ── error handling ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_geoportal_match_exception_counted_as_error() -> None:
|
||||
"""_geoportal_house_match raises → errors++, обработка продолжается."""
|
||||
rows = [
|
||||
{"id": 20, "address": "ул. Сломанная, 1"},
|
||||
{"id": 21, "address": "ул. Малышева, 30"},
|
||||
]
|
||||
db = _make_db([rows, []])
|
||||
|
||||
side_effects = [RuntimeError("DB error"), _HIT]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
side_effect=side_effects,
|
||||
),
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=("малышева", "30"),
|
||||
),
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert res.errors == 1
|
||||
assert res.matched == 1
|
||||
assert res.candidates == 2
|
||||
|
||||
|
||||
def test_empty_select_returns_zero_result() -> None:
|
||||
"""Нет листингов с lat IS NULL → нулевой результат."""
|
||||
db = _make_db([[]])
|
||||
|
||||
with patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo:
|
||||
res = backfill_coords_from_geoportal(db, batch_size=500)
|
||||
|
||||
assert isinstance(res, BackfillCoordsResult)
|
||||
assert res.candidates == 0
|
||||
assert res.matched == 0
|
||||
assert res.updated == 0
|
||||
mock_geo.assert_not_called()
|
||||
|
||||
|
||||
# ── batch pagination ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_batch_pagination_two_pages() -> None:
|
||||
"""batch_size=2: 2+1 листингов → 2 страницы SELECT, 3 листинга кандидата."""
|
||||
page1 = [
|
||||
{"id": 30, "address": "ул. Малышева, 30"},
|
||||
{"id": 31, "address": "ул. Ленина, 10"},
|
||||
]
|
||||
page2 = [{"id": 32, "address": "ул. Горького, 5"}]
|
||||
db = _make_db([page1, page2, []])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.tasks.backfill_listings_coords_geoportal._parse_street_house",
|
||||
return_value=("малышева", "30"),
|
||||
),
|
||||
):
|
||||
res = backfill_coords_from_geoportal(db, batch_size=2)
|
||||
|
||||
assert res.candidates == 3
|
||||
assert res.no_match == 3
|
||||
Loading…
Add table
Reference in a new issue