From b1727ca39c3f8235b1a6a91df0f2693c2cc745c8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 10 Sep 2026 18:50:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(msk):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D1=81=D1=8B=D1=80=D1=8C=D1=8F=20=D0=BF=D0=BE=20=D0=9C?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B2=D0=B5=20=D0=B2=20listings=20=D0=B8=20r?= =?UTF-8?q?egion-aware=20=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end. 1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py). Переиспользует штатный save_listings из кита: писатель уже параметризован регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot один в один, так что импорт сводится к сборке модели и вызову писателя. Москва отбирается по префиксу административного округа в адресе, а не по bbox. Причина: адрес Циан не содержит города, а границы региона 77 захватывают ближний пояс области. Замер по проду: с округом 35 552, все внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера. listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload. Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000, отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса. В проде есть гвард (estimator.py): в аналоги идут строки только с listing_segment IS NULL или 'vtorichka' — копирование метки как есть выбросило бы 29 000 строк из подбора. Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни города, ни округа, координат нет ни у одной из 50 335 карточек, отличить область от Москвы нечем. Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией, 35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не дублирует — idempotency на dedup_hash, проверено. 2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode). Раньше suggest вообще не принимал регион: DaData звалась с жёстким region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано как известный баг. Механику по регионам переиспользовали из geocode(), вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных. 3. Оценка перестала геокодировать Москву свердловским скоупом (estimator). geocode() звалась без региона, то есть с дефолтом 66, и московский адрес возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими подсказками. Регион запроса определяется по координатам через реестр, затем по city_hint, затем дефолт. Fast-path клиентских координат стал региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы. Регресс-нейтральность по Свердловской области — главный критерий всех трёх кусков. Тесты: 1160 passed по затронутым областям. Известные ограничения. Границы 77 захватывают ближний пояс области, Химки резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh --- tradein-mvp/backend/app/api/v1/geocode.py | 22 +- tradein-mvp/backend/app/services/estimator.py | 41 ++- tradein-mvp/backend/app/services/geocoder.py | 134 ++++++-- .../backend/app/tasks/msk_raw_import.py | 322 ++++++++++++++++++ .../tests/test_3051_estimate_region_scope.py | 132 +++++++ .../tests/test_3051_suggest_region_scope.py | 181 ++++++++++ .../tests/test_estimator_client_coords.py | 9 +- .../backend/tests/test_geocoder_city_hint.py | 10 +- .../backend/tests/test_msk_raw_import.py | 196 +++++++++++ 9 files changed, 1010 insertions(+), 37 deletions(-) create mode 100644 tradein-mvp/backend/app/tasks/msk_raw_import.py create mode 100644 tradein-mvp/backend/tests/test_3051_estimate_region_scope.py create mode 100644 tradein-mvp/backend/tests/test_3051_suggest_region_scope.py create mode 100644 tradein-mvp/backend/tests/test_msk_raw_import.py diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index 99ecfd9b..c1f18b9f 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -81,9 +81,20 @@ async def suggest_addresses( ), ), ] = None, + region_code: Annotated[ + int, + Query( + description=( + "Регион покрытия (#3051). Дефолт 66 — Свердловская область, прежнее " + "поведение для существующих клиентов. 77 — Москва: без него DaData " + "и Nominatim получают свердловский hard-констрейнт и молча " + "возвращают ПУСТО на московском адресе." + ), + ), + ] = 66, ) -> SuggestResponse: - """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, - остаётся быстрым fast-path). + """Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область; + ЕКБ — основной трафик, остаётся быстрым fast-path). Используется в EstimateForm для подсказок пока пользователь печатает. Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX), @@ -93,8 +104,13 @@ async def suggest_addresses( /api/v1/geocode/suggest?q=Малышева /api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет /api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил + /api/v1/geocode/suggest?q=Тверская+6®ion_code=77 # Москва """ - items = await suggest(q, db=db, limit=limit, city_hint=city_hint) + try: + items = await suggest(q, db=db, limit=limit, city_hint=city_hint, region_code=region_code) + except ValueError as exc: + # Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента. + raise HTTPException(status_code=422, detail=str(exc)) from exc return SuggestResponse( items=[ SuggestItem( diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 12f0aa31..1f71e853 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -77,7 +77,6 @@ from app.services.geocoder import ( SVERDLOVSK_OBLAST_CITIES, GeocodeResult, geocode, - is_within_oblast66_bbox, ) from app.services.house_metadata import get_house_metadata from app.services.matching.houses import match_house_readonly, match_or_create_house @@ -4084,6 +4083,28 @@ def _price_from_inputs( # ── Public ─────────────────────────────────────────────────────────────────── +def _request_region_code(payload: TradeInEstimateInput) -> int: + """Регион ЗАПРОСА на оценку (#3051) — чем ограничивать геокодер. + + Приоритет: координаты payload (фронт уже разрезолвил адрес + автокомплитом/картой — самый надёжный источник) → `city_hint`, если это + узнаваемое имя города какого-то региона реестра → дефолт 66. + + Регресс-нейтральность 66: точка вне охвата, неизвестный/пустой city_hint и + любой свердловский city_hint одинаково дают 66 — ровно прежнее поведение + (geocode() и так ходил с region_code=66). Отличие есть ТОЛЬКО для московских + данных, которые раньше молча отвергались провайдерами. + """ + if payload.lat is not None and payload.lon is not None: + region = regions_mod.region_for_point(payload.lat, payload.lon) + if region is not None: + return region.code + region = regions_mod.region_by_city(payload.city_hint) + if region is not None: + return region.code + return regions_mod.DEFAULT_REGION_CODE + + async def estimate_quality( payload: TradeInEstimateInput, db: Session, @@ -4141,10 +4162,15 @@ async def estimate_quality( # get this perf fast-path instead of always paying the geocode() round-trip. Perf-only, # not a correctness gate — narrower fast-path just meant more oblast requests fell # through to geocode() (still correct, just slower). + # #3051: проверка стала региононезависимой — координаты принимаются, если + # попадают в bbox_region ЛЮБОГО региона реестра (regions.region_for_point). + # Для обл.66 это тот же bbox (OBLAST66_BBOX == REGIONS[66].bbox_region), т.е. + # поведение байт-в-байт прежнее; добавились только координаты Москвы, + # которые раньше зря платили round-trip в geocode() со свердловской рамкой. if ( payload.lat is not None and payload.lon is not None - and is_within_oblast66_bbox(payload.lat, payload.lon) + and regions_mod.region_for_point(payload.lat, payload.lon) is not None ): geo = GeocodeResult( lat=payload.lat, @@ -4163,8 +4189,17 @@ async def estimate_quality( # подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально: # фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous # честно сигнализирует об этом ниже. + # #3051: региональный скоуп геокодера. Оба тира geocode() ограничены + # регионом ЖЁСТКО (DaData locations.region, Nominatim viewbox+bounded=1), + # поэтому без region_code московский адрес возвращал None → пустая оценка + # с reason='address_not_geocoded'. Для 66 значение прежнее (дефолт). geo = await _with_budget( - geocode(payload.address, db, city_hint=payload.city_hint), + geocode( + payload.address, + db, + city_hint=payload.city_hint, + region_code=_request_region_code(payload), + ), settings.estimate_geocode_budget_s, label="geocode", ) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index b882558c..6d29ff8b 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -757,6 +757,30 @@ def _region_viewbox(region: Region) -> str: return f"{lon_min},{lat_max},{lon_max},{lat_min}" +def _viewbox_for_region(region_code: int) -> str: + """Nominatim `viewbox` по коду региона — ЕДИНАЯ точка для всех тиров. + + `region_code=66` → литеральная `OBLAST66_VIEWBOX["viewbox"]`: значение + историческое, из bbox не выводится, поэтому byte-identical прежнему + поведению. Прочие регионы — рамка из реестра (`_region_viewbox`). + """ + if region_code == 66: + return OBLAST66_VIEWBOX["viewbox"] + return _region_viewbox(_ALL_REGIONS[region_code]) + + +def _region_default_city(region_code: int) -> str: + """Главный город региона — текстовый суффикс запроса, когда город не назван. + + `region_code=66` → литеральный "Екатеринбург" (byte-identical dual-query + #2580/C2). Прочие — `canonical_city` реестра, иначе `city_token` с заглавной. + """ + if region_code == 66: + return "Екатеринбург" + region = _ALL_REGIONS[region_code] + return region.canonical_city or region.city_token.capitalize() + + async def _nominatim_query( client: httpx.AsyncClient, address: str, region_code: int = 66 ) -> dict | None: @@ -775,7 +799,7 @@ async def _nominatim_query( поведению (те же bbox-значения и та же viewbox-строка). """ region = _ALL_REGIONS[region_code] - viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region) + viewbox = _viewbox_for_region(region_code) await _nominatim_throttle() response = await client.get( "https://nominatim.openstreetmap.org/search", @@ -933,8 +957,24 @@ class GeocodeSuggestion: # 'locality' вместо 'city' — consistent с Nominatim-веткой). _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"} +# region_code → значение поля DaData `region` (БЕЗ типа: «Свердловская», а не +# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов +# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится, +# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim. +_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"} -async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: + +def _dadata_region_name(region_code: int) -> str: + """Имя региона для hard-констрейнта DaData. Неизвестный код → ValueError.""" + try: + return _DADATA_REGION_NAMES[region_code] + except KeyError as exc: + raise ValueError(f"dadata suggest: unknown region_code={region_code!r}") from exc + + +async def _dadata_suggest( + query: str, limit: int = 8, region_code: int = 66 +) -> list[GeocodeSuggestion]: """Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion. Дроп candidate'ов без координат (DaData возвращает их для широких categories @@ -945,9 +985,8 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/ Серов/etc никогда не появились бы в подсказках. """ - raw = await dadata.suggest_addresses( - query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION - ) + region_name = _dadata_region_name(region_code) + raw = await dadata.suggest_addresses(query, limit=limit, city=None, region=region_name) if not raw: # Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в # 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок). @@ -957,7 +996,7 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] "dadata suggest: 0 кандидатов для %r при region=%r — " "проверь, что констрейнт совпадает с полем DaData `region` (без типа)", query[:60], - SVERDLOVSK_OBLAST_REGION, + region_name, ) out: list[GeocodeSuggestion] = [] for s in raw: @@ -980,8 +1019,14 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] return out -async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]: - """Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items.""" +async def _nominatim_query_multi( + client: httpx.AsyncClient, query: str, limit: int, region_code: int = 66 +) -> list[dict]: + """Один Nominatim search с рамкой региона `region_code`. Возвращает up to N items. + + `region_code=66` (дефолт) — byte-identical прежнему поведению: та же + viewbox-строка `OBLAST66_VIEWBOX` (см. `_viewbox_for_region`). + """ await _nominatim_throttle() response = await client.get( "https://nominatim.openstreetmap.org/search", @@ -990,7 +1035,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i "format": "json", "limit": str(limit), "countrycodes": "ru", - "viewbox": OBLAST66_VIEWBOX["viewbox"], + "viewbox": _viewbox_for_region(region_code), "bounded": "1", "addressdetails": "1", }, @@ -1027,7 +1072,12 @@ def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]: async def _nominatim_query_city_aware( - client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int + client: httpx.AsyncClient, + query: str, + city: str | None, + city_specified: bool, + limit: int, + region_code: int = 66, ) -> list[dict]: """Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город. @@ -1050,18 +1100,25 @@ async def _nominatim_query_city_aware( ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок). """ if city: - return await _nominatim_query_multi(client, f"{query}, {city}", limit) + return await _nominatim_query_multi( + client, f"{query}, {city}", limit, region_code=region_code + ) if city_specified: - return await _nominatim_query_multi(client, query, limit) - ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) - bare_data = await _nominatim_query_multi(client, query, limit) - return _dedupe_nominatim_items(ekb_data, bare_data)[:limit] + return await _nominatim_query_multi(client, query, limit, region_code=region_code) + # Город неизвестен — dual-query с суффиксом главного города региона + # (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`). + default_city = _region_default_city(region_code) + city_data = await _nominatim_query_multi( + client, f"{query}, {default_city}", limit, region_code=region_code + ) + bare_data = await _nominatim_query_multi(client, query, limit, region_code=region_code) + return _dedupe_nominatim_items(city_data, bare_data)[:limit] # reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True) async def _nominatim_suggest( - query: str, limit: int = 8, city_hint: str | None = None + query: str, limit: int = 8, city_hint: str | None = None, region_code: int = 66 ) -> list[GeocodeSuggestion]: """Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный запрос ничего не находит). @@ -1079,17 +1136,26 @@ async def _nominatim_suggest( "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", } - city, city_specified = _resolve_city_for_geocode(query, city_hint) + city, city_specified = _resolve_city_for_geocode(query, city_hint, region_code) async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: # Tier 1: оригинальный query - data = await _nominatim_query_city_aware(client, query, city, city_specified, limit) + data = await _nominatim_query_city_aware( + client, query, city, city_specified, limit, region_code=region_code + ) # Tier 2: typo-варианты если оригинал пустой if not data: for variant in _typo_variants(query, limit=3): - variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint) + variant_city, variant_specified = _resolve_city_for_geocode( + variant, city_hint, region_code + ) data = await _nominatim_query_city_aware( - client, variant, variant_city, variant_specified, limit + client, + variant, + variant_city, + variant_specified, + limit, + region_code=region_code, ) if data: logger.info("nominatim suggest typo-fixed: %s → %s", query, variant) @@ -1765,7 +1831,11 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = async def suggest( - query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None + query: str, + db: Session | None = None, + limit: int = 8, + city_hint: str | None = None, + region_code: int = 66, ) -> list[GeocodeSuggestion]: """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → []. @@ -1779,10 +1849,22 @@ async def suggest( (#2593: Yandex Geocoder, который был primary external provider до DaData, удалён). DaData region-constraint уже охватывает всю область (не только ЕКБ) — city_hint ей не нужен. + region_code: регион покрытия (дефолт 66, #3051) — какой регион уходит в + hard-констрейнты провайдеров: DaData `region` (`_dadata_region_name`) и + Nominatim `viewbox`+bounded (`_viewbox_for_region`). БЕЗ него московский + адрес молча схлопывался в пустой список: оба констрейнта — ФИЛЬТРЫ, а не + boost, и «не тот регион» неотличимо от «адрес не найден». Локальные + ЕКБ-тиры (кадастр) для region_code != 66 пропускаются целиком — данных + по другим регионам в FDW физически нет. Дефолт — byte-identical + прежнему поведению по Свердловской области. Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). """ if not query or len(query.strip()) < 2: return [] + try: + _ALL_REGIONS[region_code] + except KeyError as exc: + raise ValueError(f"suggest: unknown region_code={region_code!r}") from exc # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов. # EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно @@ -1792,7 +1874,11 @@ async def suggest( # иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed` # и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим — # они уже oblast-aware. - if db is not None and _ekb_local_tiers_allowed(query, city_hint): + # #3051: `region_code != 66` закрывает кадастровый тир ДО `_ekb_local_tiers_allowed` + # — gendesign_cad_buildings содержит только ЕКБ, звать его для Москвы значит + # платить FDW-round-trip ради гарантированного нуля (тот же гейт в + # `_geocode_resolve`; сигнатуру `_ekb_local_tiers_allowed` умышленно не трогаем). + if db is not None and region_code == 66 and _ekb_local_tiers_allowed(query, city_hint): # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits. parsed = _parse_street_house(query.strip()) @@ -1810,7 +1896,7 @@ async def suggest( # лучший fit для РФ адресов. if settings.dadata_api_token: try: - dadata_results = await _dadata_suggest(query, limit) + dadata_results = await _dadata_suggest(query, limit, region_code) if dadata_results: return dadata_results except Exception: @@ -1818,7 +1904,7 @@ async def suggest( # Tier 3: Nominatim (последний fallback — OSM, без ключа) try: - return await _nominatim_suggest(query, limit, city_hint=city_hint) + return await _nominatim_suggest(query, limit, city_hint=city_hint, region_code=region_code) except Exception: logger.exception("nominatim suggest failed") return [] diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py new file mode 100644 index 00000000..cfdf4730 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -0,0 +1,322 @@ +"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`. + +Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка +несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и +поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем +`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`. + +Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато +начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и +опознаётся. Замер по проду (60 464 карточки): с округом — 35 551, ВСЕ внутри +bbox региона 77; без округа внутри bbox — 17 576 (это Московская область, регион +50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox — +7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» — артефакт +парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва». + +Отбор Москвы (source=avito) НЕВОЗМОЖЕН по адресу: у Авито адрес — голая улица с +домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ +карточки. Поэтому: + * префиксный фильтр к Авито не применяется — он отбросил бы 100% строк; + * запись Авито требует явного `--allow-unfiltered`: молча залить в регион 77 + вперемешку Москву и область — хуже, чем не залить ничего; + * строки Авито лягут БЕЗ geom (lat/lon пусты) — они не попадут в radius-подбор + аналогов estimator'а, пока их не догеокодит `geocode_missing`. + +Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как +есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`, +то есть по ссылке на ЖК, а не по продаже застройщиком. Замер по всем 60 464: +`raw_payload.is_from_developer` = true у НУЛЯ карточек, false у 29 000, +отсутствует у 31 464 — застройщик в этом корпусе не продаёт ничего, это вся +вторичка. В estimator'е стоит гвард (`estimator.py:5992-5995`): в аналоги идут +только строки с `listing_segment IS NULL` или 'vtorichka'. Скопируй мы метку +кита — 29 000 карточек выпали бы из подбора. Поэтому метка считается заново: +is_from_developer is True → 'novostroyki', иначе → 'vtorichka'. + +Идемпотентность — на стороне `save_listings`: он делает upsert +`ON CONFLICT (dedup_hash) DO UPDATE` плюс reconcile-UPDATE по +`(source, source_id)` на случай дрейфа хеша. `dedup_hash` = sha256(source + +source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), цена в ключ не +входит. Повторный прогон поэтому обновляет те же строки, а не плодит дубли; +курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону +одинаковы. + +Запуск: + python -m app.tasks.msk_raw_import --dry-run + python -m app.tasks.msk_raw_import --limit 500 + python -m app.tasks.msk_raw_import --source avito --allow-unfiltered +""" + +from __future__ import annotations + +import argparse +import logging +import re +from dataclasses import dataclass + +from pydantic import ValidationError +from scraper_kit.base import ScrapedLot, save_listings +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services.scraper_adapters import RealMatcherAdapter + +logger = logging.getLogger(__name__) + +MOSCOW_REGION_CODE = 77 +MOSCOW_CITY = "Москва" +DEFAULT_BATCH_SIZE = 500 + +# Префиксы административных округов Москвы — единственный признак города в адресе +# карточки Циана (сам город в адрес не попадает). +MOSCOW_OKRUGS = ( + "ЦАО", + "САО", + "СВАО", + "ВАО", + "ЮВАО", + "ЮАО", + "ЮЗАО", + "ЗАО", + "СЗАО", + "ЗелАО", + "НАО", + "ТАО", +) +# Lookahead вместо \b: следом за округом идёт запятая/пробел, но НЕ буква — иначе +# «ЗАО» матчило бы начало гипотетического «ЗАОзёрная». +_MOSCOW_OKRUG_RE = re.compile( + r"^(?:" + "|".join(MOSCOW_OKRUGS) + r")(?![А-Яа-яЁёA-Za-z])", +) +# Артефакт парсера: адрес вида «Екатеринбург (Cian)» в московском корпусе. +_ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE) + +# Вью-источники. Только whitelist: имя подставляется в SQL текстом, параметром +# идентификатор не передать. +SOURCE_VIEWS = { + "cian": "msk_raw.cian_latest", + "avito": "msk_raw.avito_latest", +} + +_PAGE_SQL = """ +SELECT id, payload +FROM {view} +WHERE id > :after +ORDER BY id +LIMIT :limit +""" + + +@dataclass +class ImportCounters: + """Разбор прогона. Числа обязаны сходиться, см. `check()`.""" + + read: int = 0 + skipped_artifact: int = 0 + skipped_not_moscow: int = 0 + skipped_invalid: int = 0 + selected: int = 0 + inserted: int = 0 + updated: int = 0 + + @property + def written(self) -> int: + return self.inserted + self.updated + + @property + def writer_skipped(self) -> int: + """Отобрано, но писатель строку не тронул. + + `save_listings` возвращает только (inserted, updated); неизменные строки, + уже виденные сегодня, он пропускает своим гейтом (#2992). Остаток честно + показываем отдельно, а не растворяем в «записано». + """ + return self.selected - self.written + + def check(self) -> bool: + return ( + self.read + == self.selected + + self.skipped_artifact + + self.skipped_not_moscow + + self.skipped_invalid + ) + + +def is_artifact_address(address: str | None) -> bool: + """Адрес чужого города в московском корпусе (артефакт парсера).""" + return bool(address) and _ARTIFACT_RE.search(address) is not None + + +def is_moscow_address(address: str | None) -> bool: + """Москва опознаётся префиксом административного округа.""" + if not address: + return False + return _MOSCOW_OKRUG_RE.match(address.strip()) is not None + + +def recompute_listing_segment(payload: dict) -> str: + """Заново считаем сегмент: 'novostroyki' только при продаже застройщиком. + + Обоснование — в докстринге модуля: метка кита означает лишь ссылку на ЖК. + """ + raw = payload.get("raw_payload") or {} + if not isinstance(raw, dict): + return "vtorichka" + return "novostroyki" if raw.get("is_from_developer") is True else "vtorichka" + + +def build_lot(payload: dict) -> ScrapedLot: + """`payload` → `ScrapedLot` с пересчитанным сегментом. + + Ключи, которых в модели нет, отбрасываем явно (по `model_fields`), а не + полагаемся на настройку extra у pydantic-модели. + """ + known = {k: v for k, v in payload.items() if k in ScrapedLot.model_fields} + known["listing_segment"] = recompute_listing_segment(payload) + return ScrapedLot(**known) + + +def _iter_pages(db: Session, view: str, *, batch_size: int, limit: int | None): + """Keyset-пагинация по `id` — весь корпус в память не тянем.""" + after = 0 + taken = 0 + sql = text(_PAGE_SQL.format(view=view)) + while True: + page_size = batch_size + if limit is not None: + page_size = min(batch_size, limit - taken) + if page_size <= 0: + return + rows = db.execute(sql, {"after": after, "limit": page_size}).mappings().all() + if not rows: + return + after = rows[-1]["id"] + taken += len(rows) + yield rows + + +def import_msk_raw( + db: Session, + *, + source: str = "cian", + batch_size: int = DEFAULT_BATCH_SIZE, + limit: int | None = None, + dry_run: bool = False, + allow_unfiltered: bool = False, +) -> ImportCounters: + """Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче.""" + view = SOURCE_VIEWS[source] + counters = ImportCounters() + matcher = RealMatcherAdapter() + + filter_by_okrug = source == "cian" + if not filter_by_okrug: + # У Авито в адресе нет ни города, ни округа, и нет координат — отсечь + # область нечем. Пишем только по явному разрешению. + if not (dry_run or allow_unfiltered): + raise SystemExit( + f"source={source}: адрес не содержит признака города, Москву от " + "области не отличить. Нужен --allow-unfiltered (или --dry-run)." + ) + logger.warning( + "source=%s: фильтр по округу НЕ применяется (в адресе нет города); " + "строки лягут без geom — координат нет ни у одной карточки", + source, + ) + + for rows in _iter_pages(db, view, batch_size=batch_size, limit=limit): + lots: list[ScrapedLot] = [] + for row in rows: + counters.read += 1 + payload = row["payload"] or {} + address = payload.get("address") + if is_artifact_address(address): + counters.skipped_artifact += 1 + continue + if filter_by_okrug and not is_moscow_address(address): + counters.skipped_not_moscow += 1 + continue + try: + lots.append(build_lot(payload)) + except ValidationError as exc: + counters.skipped_invalid += 1 + logger.warning("msk_raw id=%s не собрался в ScrapedLot: %s", row["id"], exc) + + counters.selected += len(lots) + if dry_run or not lots: + continue + + inserted, updated = save_listings( + db, + lots, + matcher=matcher, + region_code=MOSCOW_REGION_CODE, + city=MOSCOW_CITY, + ) + counters.inserted += inserted + counters.updated += updated + db.commit() # батч зафиксирован — обрыв не отматывает всю работу + logger.info( + "msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)", + source, + counters.read, + counters.selected, + counters.written, + counters.inserted, + counters.updated, + ) + + logger.info( + "msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d " + "(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d " + "артефакт=%d невалидный payload=%d | сходится=%s", + source, + " (dry-run)" if dry_run else "", + counters.read, + counters.selected, + counters.written, + counters.inserted, + counters.updated, + counters.writer_skipped if not dry_run else 0, + counters.skipped_not_moscow, + counters.skipped_artifact, + counters.skipped_invalid, + counters.check(), + ) + return counters + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)") + parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian") + parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) + parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек") + parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики") + parser.add_argument( + "--allow-unfiltered", + action="store_true", + help="разрешить запись источника без признака города в адресе (avito)", + ) + args = parser.parse_args() + + db = SessionLocal() + try: + import_msk_raw( + db, + source=args.source, + batch_size=args.batch_size, + limit=args.limit, + dry_run=args.dry_run, + allow_unfiltered=args.allow_unfiltered, + ) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py b/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py new file mode 100644 index 00000000..9d7dfcb2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py @@ -0,0 +1,132 @@ +"""#3051: скоуп региона в estimate — fast-path и вызов geocode(). + +ПОЧЕМУ ЭТО ТЕСТ. Оба тира geocode() ограничены регионом ЖЁСТКИМ фильтром +(DaData `locations.region`, Nominatim `viewbox`+`bounded=1`), а не бустом: +промах региона не даёт ошибки — выдача схлопывается в пустую, и estimate +возвращает `_empty_estimate(reason='address_not_geocoded')`, неотличимую от +«такого адреса нет». Значит проверять надо не результат, а ЧТО именно уходит +в geocode(). Второй предмет — регресс-нейтральность 66: без московских данных +поведение обязано быть прежним. + +Сеть и БД не дёргаем: geocode и _empty_estimate мокаются, db — MagicMock. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +import pytest + +from app.schemas.trade_in import TradeInEstimateInput +from app.services.estimator import _request_region_code, estimate_quality + +pytestmark = pytest.mark.anyio + +# Тверская 6 (Москва) и Малышева 30 (Екатеринбург) — точки внутри bbox_region +# соответствующих регионов реестра. +MSK = (55.7605, 37.6100) +EKB = (56.8380, 60.6000) + + +def _payload(**kw) -> TradeInEstimateInput: + base = {"address": "Тверская 6", "area_m2": 50.0, "rooms": 2} + base.update(kw) + return TradeInEstimateInput(**base) + + +# ── _request_region_code: приоритет координаты → city_hint → 66 ────────────── + + +def test_region_from_moscow_coords() -> None: + assert _request_region_code(_payload(lat=MSK[0], lon=MSK[1])) == 77 + + +def test_region_from_ekb_coords() -> None: + assert _request_region_code(_payload(lat=EKB[0], lon=EKB[1])) == 66 + + +def test_region_without_coords_defaults_to_66() -> None: + """Нет координат и нет узнаваемого города — прежний дефолт 66.""" + assert _request_region_code(_payload()) == 66 + assert _request_region_code(_payload(city_hint="Урюпинск")) == 66 + + +def test_region_from_city_hint() -> None: + assert _request_region_code(_payload(city_hint="Москва")) == 77 + assert _request_region_code(_payload(city_hint="Нижний Тагил")) == 66 + + +def test_region_coords_outside_any_region_default_66() -> None: + """Точка вне охвата (Сочи) → 66, а НЕ None: NULL обнулил бы фильтр.""" + assert _request_region_code(_payload(lat=43.6, lon=39.7)) == 66 + + +# ── fast-path клиентских координат: региононезависимость ───────────────────── + + +async def _run_estimate(payload: TradeInEstimateInput): + """estimate_quality до первой развилки: geocode → None → _empty_estimate. + + Если fast-path принял клиентские координаты, функция идёт дальше, в счёт по + БД, и спотыкается о MagicMock-сессию — это ожидаемо и подавляется: предмет + проверки здесь ровно один, БЫЛ ли вызван geocode() и с каким регионом. + """ + geocode_mock = AsyncMock(return_value=None) + empty_mock = MagicMock(return_value="EMPTY") + result = None + with ( + patch("app.services.estimator.geocode", new=geocode_mock), + patch("app.services.estimator._empty_estimate", new=empty_mock), + ): + try: + result = await estimate_quality(payload, MagicMock()) + except Exception: # дальше по функции живая БД, см. докстринг + pass + return geocode_mock, empty_mock, result + + +async def test_fast_path_accepts_moscow_coords() -> None: + """Московские координаты принимаются как клиентские — geocode не зовём.""" + geocode_mock, _, _ = await _run_estimate(_payload(lat=MSK[0], lon=MSK[1])) + geocode_mock.assert_not_awaited() + + +async def test_fast_path_accepts_ekb_coords_unchanged() -> None: + """Регресс 66: координаты области по-прежнему минуют geocode().""" + geocode_mock, _, _ = await _run_estimate(_payload(lat=EKB[0], lon=EKB[1])) + geocode_mock.assert_not_awaited() + + +async def test_fast_path_ignores_coords_outside_coverage() -> None: + """Точка вне охвата (Сочи) — как и раньше, честный geocode().""" + geocode_mock, _, _ = await _run_estimate(_payload(lat=43.6, lon=39.7)) + geocode_mock.assert_awaited_once() + + +# ── geocode() получает регион запроса ──────────────────────────────────────── + + +async def test_geocode_gets_region_77_for_moscow() -> None: + geocode_mock, empty_mock, result = await _run_estimate( + _payload(address="Тверская 6", city_hint="Москва") + ) + assert geocode_mock.await_args.kwargs["region_code"] == 77 + assert result == "EMPTY" + assert empty_mock.call_args.kwargs["reason"] == "address_not_geocoded" + + +async def test_geocode_gets_region_66_for_ekb() -> None: + geocode_mock, _, _ = await _run_estimate( + _payload(address="Малышева 30", city_hint="Екатеринбург") + ) + assert geocode_mock.await_args.kwargs["region_code"] == 66 + + +async def test_geocode_default_region_66_without_hints() -> None: + """Регресс-нейтральность: без координат и city_hint — прежний скоуп 66.""" + geocode_mock, _, _ = await _run_estimate(_payload(address="Малышева 30")) + assert geocode_mock.await_args.kwargs["region_code"] == 66 + assert geocode_mock.await_args.kwargs["city_hint"] is None diff --git a/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py b/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py new file mode 100644 index 00000000..5b251ce6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py @@ -0,0 +1,181 @@ +"""#3051: `suggest()` умеет регион — и по умолчанию остаётся свердловским. + +ПОЧЕМУ ЭТО ВООБЩЕ ТЕСТ. Оба внешних тира подсказок ограничены регионом +ЖЁСТКИМ фильтром, а не бустом: DaData `locations.region` и Nominatim +`viewbox`+`bounded=1`. Промах региона не даёт ни ошибки, ни warning'а от +провайдера — выдача схлопывается в ПУСТОЙ список, неотличимый от «такого +адреса нет». Ровно так московский адрес молча возвращал ноль подсказок при +свердловском констрейнте. Значит проверять надо не результат, а то, ЧТО +именно уходит провайдеру. + +Второй, более важный предмет проверки — регресс-нейтральность: вызов без +`region_code` обязан слать провайдерам те же самые значения, что и до правки. + +Сеть не дёргаем: тиры мокаются по образцу `test_geocoder_city_hint`. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +import pytest + +from app.services.geocoder import ( + OBLAST66_VIEWBOX, + SVERDLOVSK_OBLAST_REGION, + _dadata_suggest, + _nominatim_suggest, + _viewbox_for_region, + suggest, +) +from app.services.regions import REGIONS + +pytestmark = pytest.mark.anyio + + +# ── DaData-тир: имя региона в hard-констрейнте ─────────────────────────────── + + +async def test_dadata_suggest_default_region_unchanged() -> None: + """Без region_code — прежняя константа «Свердловская» (БЕЗ типа).""" + mock = AsyncMock(return_value=[]) + with patch("app.services.geocoder.dadata.suggest_addresses", new=mock): + assert await _dadata_suggest("Малышева 30", limit=5) == [] + assert mock.await_args.kwargs["region"] == SVERDLOVSK_OBLAST_REGION + assert mock.await_args.kwargs["region"] == "Свердловская" + assert mock.await_args.kwargs["city"] is None + + +async def test_dadata_suggest_region_77_sends_moscow() -> None: + """region_code=77 — в DaData уходит «Москва», а не свердловский констрейнт.""" + mock = AsyncMock(return_value=[]) + with patch("app.services.geocoder.dadata.suggest_addresses", new=mock): + assert await _dadata_suggest("Тверская 6", limit=5, region_code=77) == [] + assert mock.await_args.kwargs["region"] == "Москва" + + +async def test_dadata_suggest_unknown_region_raises() -> None: + """Регион вне реестра — явная ошибка, а не молчаливый пустой список.""" + with patch("app.services.geocoder.dadata.suggest_addresses", new=AsyncMock(return_value=[])): + with pytest.raises(ValueError, match="unknown region_code"): + await _dadata_suggest("Ленина 1", limit=5, region_code=99) + + +# ── Nominatim-тир: рамка региона ───────────────────────────────────────────── + + +def test_viewbox_for_region_66_is_literal_constant() -> None: + """Для 66 рамка — историческая константа, не пересчёт из bbox реестра.""" + assert _viewbox_for_region(66) == OBLAST66_VIEWBOX["viewbox"] + + +def test_viewbox_for_region_77_covers_moscow() -> None: + """Рамка 77 строится из bbox_region реестра: lon_min,lat_max,lon_max,lat_min.""" + lat_min, lat_max, lon_min, lon_max = REGIONS[77].bbox_region + assert _viewbox_for_region(77) == f"{lon_min},{lat_max},{lon_max},{lat_min}" + assert _viewbox_for_region(77) != OBLAST66_VIEWBOX["viewbox"] + + +async def test_nominatim_suggest_default_viewbox_and_suffix_unchanged() -> None: + """Дефолтный вызов: свердловская рамка + ЕКБ-суффикс dual-query (#2580 C2).""" + seen: list[tuple[str, str]] = [] + + async def fake_get(url, params=None, **_kw): + seen.append((params["q"], params["viewbox"])) + response = MagicMock() + response.json.return_value = [] + response.raise_for_status.return_value = None + return response + + with ( + patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)), + patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()), + ): + await _nominatim_suggest("Ленина, 1", limit=5) + + queries = [q for q, _ in seen] + assert "Ленина, 1, Екатеринбург" in queries + assert "Ленина, 1" in queries + assert {vb for _, vb in seen} == {OBLAST66_VIEWBOX["viewbox"]} + + +async def test_nominatim_suggest_region_77_sends_moscow_frame() -> None: + """region_code=77: московская рамка и московский суффикс, ЕКБ не упоминается.""" + seen: list[tuple[str, str]] = [] + + async def fake_get(url, params=None, **_kw): + seen.append((params["q"], params["viewbox"])) + response = MagicMock() + response.json.return_value = [] + response.raise_for_status.return_value = None + return response + + with ( + patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)), + patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()), + ): + await _nominatim_suggest("Тверская, 6", limit=5, region_code=77) + + queries = [q for q, _ in seen] + assert "Тверская, 6, Москва" in queries + assert not any("Екатеринбург" in q for q in queries) + assert {vb for _, vb in seen} == {_viewbox_for_region(77)} + + +# ── suggest(): прокидывание региона и гейт локальных ЕКБ-тиров ─────────────── + + +async def test_suggest_passes_region_to_both_tiers() -> None: + """region_code доезжает и до DaData, и до Nominatim-фолбэка.""" + dadata_mock = AsyncMock(return_value=[]) + nominatim_mock = AsyncMock(return_value=[]) + with ( + patch("app.services.geocoder._dadata_suggest", new=dadata_mock), + patch("app.services.geocoder._nominatim_suggest", new=nominatim_mock), + patch("app.services.geocoder.settings") as mock_settings, + ): + mock_settings.dadata_api_token = "token" + await suggest("Тверская 6", db=None, limit=5, region_code=77) + + assert dadata_mock.await_args.args[2] == 77 + assert nominatim_mock.await_args.kwargs["region_code"] == 77 + + +async def test_suggest_region_77_skips_cadastral_tier() -> None: + """Кадастровый тир (ЕКБ-FDW) для 77 не зовётся вовсе — данных там нет.""" + db = MagicMock() + with ( + patch("app.services.geocoder._cadastral_house_match") as house_mock, + patch("app.services.geocoder._cadastral_forward_sync") as forward_mock, + patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])), + patch("app.services.geocoder.settings") as mock_settings, + ): + mock_settings.dadata_api_token = None + await suggest("Тверская 6", db=db, limit=5, region_code=77) + + house_mock.assert_not_called() + forward_mock.assert_not_called() + + +async def test_suggest_default_still_uses_cadastral_tier() -> None: + """Регресс-контроль: дефолтный (66) вызов кадастровый тир по-прежнему зовёт.""" + db = MagicMock() + with ( + patch("app.services.geocoder._cadastral_house_match", return_value=None) as house_mock, + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as forward_mock, + patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])), + patch("app.services.geocoder.settings") as mock_settings, + ): + mock_settings.dadata_api_token = None + await suggest("Малышева 30", db=db, limit=5) + + assert house_mock.called or forward_mock.called + + +async def test_suggest_unknown_region_raises() -> None: + """Неизвестный регион — ValueError до похода к провайдерам.""" + with pytest.raises(ValueError, match="unknown region_code"): + await suggest("Ленина 1", db=None, limit=5, region_code=99) diff --git a/tradein-mvp/backend/tests/test_estimator_client_coords.py b/tradein-mvp/backend/tests/test_estimator_client_coords.py index 80411698..6122c3ef 100644 --- a/tradein-mvp/backend/tests/test_estimator_client_coords.py +++ b/tradein-mvp/backend/tests/test_estimator_client_coords.py @@ -90,11 +90,16 @@ def test_client_coords_in_ekb_skip_geocode() -> None: def test_out_of_bbox_coords_ignored_geocode_called() -> None: - """Moscow coords are outside the EKB bbox → ignored, geocode() IS called.""" + """Coords outside EVERY covered region → ignored, geocode() IS called. + + #3051: Moscow (55.75/37.61) used to be the out-of-bbox sample here, but region + 77 is now part of the coverage registry and its coords go through the same + client-coords fast path as oblast 66 ones. Sochi is outside both. + """ from app.services.estimator import estimate_quality db = MagicMock() - payload = _make_payload(lat=55.75, lon=37.61) # Moscow — out of EKB bbox + payload = _make_payload(lat=43.60, lon=39.73) # Sochi — outside all regions geocode_mock = AsyncMock(return_value=_make_fake_geo()) async def _run() -> None: diff --git a/tradein-mvp/backend/tests/test_geocoder_city_hint.py b/tradein-mvp/backend/tests/test_geocoder_city_hint.py index aa7d5f6b..db037064 100644 --- a/tradein-mvp/backend/tests/test_geocoder_city_hint.py +++ b/tradein-mvp/backend/tests/test_geocoder_city_hint.py @@ -129,7 +129,7 @@ async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None """ calls: list[str] = [] - async def fake_query_multi(client, query, limit): + async def fake_query_multi(client, query, limit, **_kw): calls.append(query) return [] @@ -150,7 +150,7 @@ async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None async def test_nominatim_suggest_city_hint_suffix() -> None: calls: list[str] = [] - async def fake_query_multi(client, query, limit): + async def fake_query_multi(client, query, limit, **_kw): calls.append(query) return [] @@ -165,7 +165,7 @@ async def test_nominatim_suggest_city_hint_suffix() -> None: async def test_nominatim_suggest_explicit_ekb_unchanged() -> None: calls: list[str] = [] - async def fake_query_multi(client, query, limit): + async def fake_query_multi(client, query, limit, **_kw): calls.append(query) return [] @@ -202,7 +202,7 @@ async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None: "address": {"road": "улица Победы", "house_number": "20"}, } - async def fake_query_multi(client, query, limit): + async def fake_query_multi(client, query, limit, **_kw): if query.endswith(", Екатеринбург"): return [ekb_item] return [pyshma_item] @@ -237,7 +237,7 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None: "address": {"road": "ул. Малышева", "house_number": "30"}, } - async def fake_query_multi(client, query, limit): + async def fake_query_multi(client, query, limit, **_kw): return [same_item] with ( diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py new file mode 100644 index 00000000..a5d68c4b --- /dev/null +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -0,0 +1,196 @@ +"""Тесты импорта московского сырья `msk_raw` в listings. + +Изоляция — как у соседей (tests/tasks/test_backfill_house_coords_from_listings.py): +DATABASE_URL проставляется до импорта модуля, в сеть и в прод-БД тест не ходит. +Сессия — заглушка, отдающая заранее заготовленные payload'ы; писатель +(`save_listings`) подменяется monkeypatch'ем, так что проверяется ровно наш отбор +и пересчёт метки, а не поведение кита. + +Главные риски задачи, которые тут и караулятся: + * в регион 77 уедет Московская область (адрес без округа); + * артефакт парсера («Екатеринбург (Cian)») растворится в «не Москва»; + * метка кита 'novostroyki' будет скопирована как есть и выкинет 29 000 карточек + из подбора аналогов (гвард estimator'а берёт только NULL/'vtorichka'). +""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import msk_raw_import as mod + + +def _payload(**over: Any) -> dict[str, Any]: + """Минимальный сериализованный ScrapedLot, как он лежит в msk_raw.payload.""" + base: dict[str, Any] = { + "source": "cian", + "source_url": "https://www.cian.ru/sale/flat/330200428/", + "source_id": "330200428", + "address": "ЦАО, р-н Арбат, улица Арбат, 10", + "price_rub": 25_000_000, + "rooms": 2, + "area_m2": 55.0, + "listing_segment": "novostroyki", # метка кита — её и пересчитываем + "raw_payload": {"is_from_developer": False}, + } + base.update(over) + return base + + +# ── отбор по адресу ────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("okrug", mod.MOSCOW_OKRUGS) +def test_every_okrug_prefix_is_moscow(okrug: str) -> None: + assert mod.is_moscow_address(f"{okrug}, р-н Хамовники, улица Льва Толстого, 16") + + +def test_oblast_address_without_okrug_is_not_moscow() -> None: + """Адрес без округа — Московская область (регион 50), его в импорт не берём.""" + assert not mod.is_moscow_address("Химки, микрорайон Новогорск, Ивановская улица, 5") + assert not mod.is_moscow_address("городской округ Мытищи, Олимпийский проспект, 29") + + +def test_okrug_must_be_a_whole_token() -> None: + """«ЗАО» в начале улицы — не округ; иначе область просочилась бы в регион 77.""" + assert not mod.is_moscow_address("ЗАОзёрная улица, 25") + + +def test_ekaterinburg_artifact_is_its_own_reason() -> None: + assert mod.is_artifact_address("Екатеринбург (Cian)") + assert not mod.is_moscow_address("Екатеринбург (Cian)") + + +# ── пересчёт listing_segment ───────────────────────────────────────────────── + + +def test_segment_recomputed_to_vtorichka_when_developer_false() -> None: + lot = mod.build_lot(_payload()) + assert lot.listing_segment == "vtorichka" + + +def test_segment_recomputed_to_vtorichka_when_flag_absent() -> None: + """Отсутствие флага — не признак новостройки (у 31 464 карточек его нет вовсе).""" + lot = mod.build_lot(_payload(raw_payload={})) + assert lot.listing_segment == "vtorichka" + assert mod.build_lot(_payload(raw_payload=None)).listing_segment == "vtorichka" + + +def test_segment_is_novostroyki_only_when_developer_sells() -> None: + lot = mod.build_lot(_payload(raw_payload={"is_from_developer": True})) + assert lot.listing_segment == "novostroyki" + + +def test_unknown_payload_keys_are_dropped() -> None: + """В payload может приехать ключ, которого в модели нет — это не должно ронять импорт.""" + lot = mod.build_lot(_payload(some_future_field="x")) + assert lot.source_id == "330200428" + + +# ── прогон целиком ─────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def mappings(self) -> _FakeResult: + return self + + def all(self) -> list[dict[str, Any]]: + return self._rows + + +class _FakeSession: + """Отдаёт строки постранично по тому же keyset-условию, что и настоящая вью.""" + + def __init__(self, rows: list[dict[str, Any]]) -> None: + self.rows = rows + self.commits = 0 + + def execute(self, _sql: Any, params: dict[str, Any]) -> _FakeResult: + after, limit = params["after"], params["limit"] + return _FakeResult([r for r in self.rows if r["id"] > after][:limit]) + + def commit(self) -> None: + self.commits += 1 + + +def _rows() -> list[dict[str, Any]]: + return [ + {"id": 1, "payload": _payload(source_id="1")}, + {"id": 2, "payload": _payload(source_id="2", address="Химки, Ивановская улица, 5")}, + {"id": 3, "payload": _payload(source_id="3", address="Екатеринбург (Cian)")}, + {"id": 4, "payload": _payload(source_id="4", address="СВАО, улица Милашенкова, 7")}, + {"id": 5, "payload": _payload(source_id="5", price_rub=0)}, # невалидный: цена > 0 + ] + + +@pytest.fixture() +def captured(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + + def _fake_save(db: Any, lots: list[Any], **kwargs: Any) -> tuple[int, int]: + calls.append({"lots": lots, **kwargs}) + return len(lots), 0 + + monkeypatch.setattr(mod, "save_listings", _fake_save) + return calls + + +def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> None: + db = _FakeSession(_rows()) + counters = mod.import_msk_raw(db, batch_size=10) + + assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"] + assert counters.read == 5 + assert counters.selected == 2 + assert counters.skipped_not_moscow == 1 + assert counters.skipped_artifact == 1 + assert counters.skipped_invalid == 1 + assert counters.check(), "счётчики обязаны сходиться с прочитанным" + + +def test_writer_gets_region_77_and_city(captured: list[dict[str, Any]]) -> None: + mod.import_msk_raw(_FakeSession(_rows()), batch_size=10) + assert captured[0]["region_code"] == 77 + assert captured[0]["city"] == "Москва" + assert all(lot.listing_segment == "vtorichka" for lot in captured[0]["lots"]) + + +def test_commit_per_batch(captured: list[dict[str, Any]]) -> None: + """Обрыв на середине не должен терять уже перелитые батчи.""" + db = _FakeSession(_rows()) + mod.import_msk_raw(db, batch_size=1) + assert db.commits == 2 # ровно столько батчей содержали московские строки + + +def test_dry_run_writes_nothing(captured: list[dict[str, Any]]) -> None: + db = _FakeSession(_rows()) + counters = mod.import_msk_raw(db, batch_size=10, dry_run=True) + assert captured == [] + assert db.commits == 0 + assert counters.selected == 2 and counters.written == 0 + + +def test_limit_caps_rows_read(captured: list[dict[str, Any]]) -> None: + counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=2, limit=3) + assert counters.read == 3 + + +def test_avito_refuses_to_write_without_explicit_opt_in(captured: list[dict[str, Any]]) -> None: + """У Авито в адресе нет города, а координат нет ни у одной карточки — молча + залить область в регион 77 нельзя.""" + rows = [{"id": 1, "payload": _payload(source="avito", address="Варшавское ш.,62к1")}] + with pytest.raises(SystemExit): + mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10) + + counters = mod.import_msk_raw( + _FakeSession(rows), source="avito", batch_size=10, allow_unfiltered=True + ) + assert counters.selected == 1 # префиксный фильтр к Авито не применяется