gendesign/tradein-mvp/backend/app/services/regions.py
bot-backend 0529bb249c
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
feat(tradein/geo): реестр регионов — единственный источник гео-границ (#3051, шард 1a)
Границы покрытия лежали литералами в трёх файлах (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>
2026-08-26 18:39:28 +05:00

191 lines
10 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 и логирует/маркирует),
а не молча считать дальше без источника.
"""
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 'нет ни одного'}"
)