gendesign/tradein-mvp/backend/app/services/regions.py
bot-backend 84ee8e5990 feat(tradein/rosreestr): параметризовать импорт ДКП по региону, deals.doc_type (#3051)
Трек 2 подготовки Mera к Москве. import_rosreestr_dkp принимает region_code из
params (default 66 — байт-в-байт прежнее поведение), валидирует его через
app.services.regions.REGIONS. Регион с canonical_city (77 — Москва, Росреестр
отдаёт округ/поселение вместо города) подставляет city/address через одну
SQL-ветку на bind-параметре :canonical_city, а не Python if/else на код региона;
city IS NOT NULL не фильтруется для такого региона (иначе теряется ~10% строк),
исходные city/okato/quarter_cad_number/district уходят в raw_payload.

Чекпоинт курсора (_resume_dkp_cursor) стал per-region: source для поиска
предыдущего прогона строится через _dkp_source_for_region (66 сохраняет
легаси-имя 'rosreestr_dkp_import', остальные — суффикс кода) — иначе прогон по
77 либо никогда не резюмился бы (source-литерал не матчил), либо, при более
наивном фиксе, унёс бы курсор чужого региона.

product_handlers регистрирует wildcard rosreestr_dkp_import_* (по образцу
deactivate_stale_*/avito_city_sweep_*), deploy/import-rosreestr.sh получил
REGION_CODE env (bash-путь не region-generic — city-override только в Python).

Migration 288: deals.doc_type + backfill 'ДКП' для source=rosreestr, foreign
table gendesign_rosreestr_deals расширена okato/quarter_cad_number/district
(проверено live на прод-БД), выключенный seed rosreestr_dkp_import_77.
2026-09-08 22:58:26 +03:00

205 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Реестр регионов покрытия (#3051, часть 1/3 эпика #2989 «Москва»).
ЕДИНСТВЕННОЕ место, где живут границы покрытия: bbox'ы, города, city-token и
набор доступных тиров обогащения. До реестра границы лежали константами в трёх
файлах (location_index / geocoder / matching.normalize), и каждая молча
отвергла бы Москву; расширение покрытия означало правку россыпи литералов
(ровно так дыра #2656 в свежести и появилась — «в главном месте есть, в
соседних забыли»).
Регион 50 (Московская область) отложен сознательно — обоснование в #2996:
10 121 текстовое имя города против 612 у Москвы, вся мина имён — в области.
Модуль — ЛИСТ дерева импортов: не импортирует ничего из app.* (его читают
geocoder / location_index / matching.normalize, циклы недопустимы).
"""
from __future__ import annotations
from dataclasses import dataclass
# bbox = (lat_min, lat_max, lon_min, lon_max) — тот же порядок, что исторический
# geocoder.EKB_BBOX_TIGHT (см. is_within_bbox ниже).
BBox = tuple[float, float, float, float]
@dataclass(frozen=True)
class Region:
"""Один регион покрытия продукта.
bbox_tight — ядро города: geocoder-фильтрация фуззи-матчей провайдеров
(не принять соседний город за совпадение по опечатке).
bbox_wide — город + легитимное приграничье: ingest-guard координат,
ПРИШЕДШИХ ИЗВНЕ (detail-страницы площадок). Содержит tight.
bbox_region — генеральный bbox региона: fallback-accept для провайдеров без
структурного region-поля. Содержит wide.
bbox_product_core — гео-охват ПРОДУКТА в этом регионе: location_index
считает свои медианы/POI только внутри него, точка вне —
out_of_coverage. У 66 УЖЕ (не равен) tight: исторический bbox
location_index (56.70..56.95/60.50..60.75), синхронизирован с
EKB_BBOX Overpass-загрузчика POI основного gendesign-бэкенда
(комментарий в обе стороны, см. site_finder/poi_loader.py).
city_token — нормализованный токен главного города (нижний регистр, е==ё
нормализует потребитель — matching.normalize).
cities — узнаваемые города региона (для city_hint / prefix-логики
геокодера). НЕ исчерпывающий список — основные центры.
enrichment_tiers — какие тиры обогащения РЕАЛЬНО доступны региону.
Регион без тира должен деградировать ЯВНО (потребитель
спрашивает unsupported_tier_reason и логирует/маркирует),
а не молча считать дальше без источника.
canonical_city — #3051: имя города, которым ПЕРЕЗАПИСЫВАЕТСЯ `city`
строк, приходящих из источника без надёжного city-поля
(Росреестр по Москве отдаёт муниципальный округ/поселение
вместо города — «Раменки», «Сосенское» — а не «Москва»).
None — источник несёт свой city как есть, без override
(регион 66: byte-for-byte прежнее поведение). Not-None —
потребитель (import_rosreestr_dkp) подставляет это имя
вместо city источника и НЕ фильтрует по city IS NOT NULL
(иначе на 77 теряется ~10% строк с пустым city).
"""
code: int
name: str
bbox_tight: BBox
bbox_wide: BBox
bbox_region: BBox
bbox_product_core: BBox
city_token: str
cities: frozenset[str]
enrichment_tiers: frozenset[str]
canonical_city: str | None = None
def is_within_bbox(lat: float, lon: float, bbox: BBox) -> bool:
"""True если (lat, lon) внутри bbox (inclusive)."""
lat_min, lat_max, lon_min, lon_max = bbox
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
# Тиры обогащения (строковые ключи — по label'ам _with_budget в estimator).
TIER_AVITO_IMV = "avito_imv"
TIER_YANDEX_VALUATION = "yandex_valuation"
TIER_CIAN_VALUATION = "cian_valuation"
TIER_QUARTER_INDEX = "quarter_index"
TIER_SBER_INDEX = "sber_index"
REGIONS: dict[int, Region] = {
66: Region(
code=66,
name="Свердловская область",
# Значения — байт-в-байт прежние литералы из location_index/geocoder
# (менять их здесь = менять поведение, это отдельное решение):
# tight был geocoder.EKB_BBOX_TIGHT, wide — EKB_BBOX_WIDE,
# region — OBLAST66_BBOX, product_core — bbox location_index.
bbox_tight=(56.65, 56.95, 60.40, 60.85),
bbox_wide=(56.6, 57.1, 60.3, 60.9),
bbox_region=(55.8, 62.2, 56.7, 66.6),
bbox_product_core=(56.70, 56.95, 60.50, 60.75),
city_token="екатеринбург",
cities=frozenset(
{
"екатеринбург",
"нижний тагил",
"каменск-уральский",
"первоуральск",
"верхняя пышма",
"серов",
"новоуральск",
"асбест",
"полевской",
"ревда",
"берёзовский",
"березовский",
"среднеуральск",
"верхняя салда",
"качканар",
"краснотурьинск",
"алапаевск",
"ирбит",
"сухой лог",
"богданович",
"заречный",
"невьянск",
"сысерть",
"арамиль",
}
),
enrichment_tiers=frozenset(
{
TIER_AVITO_IMV,
TIER_YANDEX_VALUATION,
TIER_CIAN_VALUATION,
TIER_QUARTER_INDEX,
TIER_SBER_INDEX,
}
),
),
77: Region(
code=77,
name="Москва",
# Ядро — Москва в пределах МКАД (55.57..55.91 / 37.36..37.85) с тем же
# запасом на фуззи, что у ЕКБ-tight относительно города.
bbox_tight=(55.55, 55.95, 37.30, 37.90),
# Продукт-ядро Москвы = tight (МКАД): медианы/POI считаем в ядре,
# Новая Москва дозреет отдельным решением вместе с данными.
# Wide — МКАД + ближний пояс (Зеленоград ловится region-bbox'ом).
bbox_wide=(55.45, 56.05, 37.10, 38.10),
# Генеральный — Москва в административных границах, включая Новую
# Москву (юго-запад до ~55.14) и Зеленоград (север до ~56.05).
bbox_region=(55.10, 56.10, 36.80, 38.10),
bbox_product_core=(55.55, 55.95, 37.30, 37.90),
city_token="москва",
cities=frozenset({"москва", "зеленоград", "троицк", "щербинка", "московский"}),
# Тиров обогащения у Москвы пока НЕТ ни одного: IMV/квартальный индекс/
# sber_index покрывают регион 66. Пустое множество здесь — не заглушка,
# а ФАКТ, который потребители обязаны озвучивать (см. класс-докстринг).
enrichment_tiers=frozenset(),
# #3051: Росреестр по Москве отдаёт в city муниципальный округ/поселение
# ("муниципальный округ Раменки", "поселение Сосенское"), не сам город —
# import_rosreestr_dkp подставляет каноничное имя вместо city источника.
canonical_city="Москва",
),
}
DEFAULT_REGION_CODE = 66
def region_for_point(lat: float, lon: float) -> Region | None:
"""Регион покрытия, которому принадлежит точка (по bbox_region), или None.
Регионы географически не пересекаются; порядок обхода детерминирован кодом
региона на случай, если когда-нибудь пересекутся (первый по коду выигрывает
— и это станет видно в тестах реестра, а не в проде).
"""
for code in sorted(REGIONS):
if is_within_bbox(lat, lon, REGIONS[code].bbox_region):
return REGIONS[code]
return None
def region_by_city(city: str | None) -> Region | None:
"""Регион, в чьём списке городов есть `city` (нормализованный нижний
регистр, е/ё не различаются). None — город не узнан ни одним регионом."""
if not city:
return None
token = " ".join(city.lower().replace("ё", "е").split())
for code in sorted(REGIONS):
r = REGIONS[code]
if token in r.cities or any(token == c.replace("ё", "е") for c in r.cities):
return r
return None
def unsupported_tier_reason(region: Region, tier: str) -> str | None:
"""None — тир доступен региону; иначе готовая строка для лога/маркера.
Смысл — «регион без тира деградирует явно»: вызывающий код обязан
залогировать/пометить причину, а не молча посчитать без источника.
"""
if tier in region.enrichment_tiers:
return None
return (
f"тир '{tier}' недоступен региону {region.code} ({region.name}) — "
f"оценка идёт без него; доступные тиры: "
f"{sorted(region.enrichment_tiers) or 'нет ни одного'}"
)