feat(tradein): бэкфилл координат листингов из ekb_geoportal_buildings (street+house match)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-06-20 17:08:46 +03:00
parent 8bf3d24190
commit d35e61be16
2 changed files with 673 additions and 0 deletions

View file

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

View file

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