Трек 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.
205 lines
12 KiB
Python
205 lines
12 KiB
Python
"""Реестр регионов покрытия (#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 'нет ни одного'}"
|
||
)
|