Some checks failed
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m39s
Границы покрытия лежали литералами в трёх файлах (location_index / geocoder / matching.normalize), и каждая молча отвергла бы Москву. Новый модуль app.services.regions — лист дерева импортов — держит per-регион bbox'ы (tight/wide/region/product_core), города, city_token и набор доступных тиров обогащения; потребители держат прежние имена как алиасы на объекты реестра (identity закреплена тестом — копии, разъезжающиеся при правке, невозможны). Регион 66 — байт-в-байт прежние литералы (закреплено тестом: этот PR только переносит границы, менять их = отдельное решение). Регион 77 (Москва): МКАД- ядро + генеральный bbox с Новой Москвой и Зеленоградом; тиров обогащения НЕТ ни одного — и это явный факт реестра с готовой формулировкой (unsupported_tier_reason), а не молчаливое «посчитаем без источника». Приёмка #3051: точка 55.75/37.62 больше не out_of_coverage — location_index узнаёт регион 77 и считает в его ядре (сегодня листингов Москвы нет → честный insufficient_data). Область 50 отложена по решению в #2996. Не здесь (следующие шарды): city_fias_id сквозняком (п.2), doc_type в deals (п.3), region_code у houses (п.4), депромоут описаний (п.5), параметры загрузчиков (п.6). Гейт ЕКБ-тиров геокодера (#2582) уже деградирует правильно для Москвы — fail-closed открывает их только при подтверждённом ЕКБ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
10 KiB
Python
191 lines
10 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 и логирует/маркирует),
|
||
а не молча считать дальше без источника.
|
||
"""
|
||
|
||
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]
|
||
|
||
|
||
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(),
|
||
),
|
||
}
|
||
|
||
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 'нет ни одного'}"
|
||
)
|