From 6106ee1be80b876c7f3056e7dba233364b23afbf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 17:42:36 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D1=81=D0=BA=D0=BE=D0=B2=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=8F=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B5=D0=BC=D1=83=20=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D1=83=20=D0=A1=D0=B1=D0=B5=D1=80=D0=B8=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D1=89=D0=B5=D1=80=D0=BE=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D0=B9=D1=81=D0=BA=D0=BE=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Загрузчик научился тянуть REF_AREA=50 раньше, и 12.09.2026 ряд приехал на прод: real_estate_deals, сегмент «Вторичный», 116 месячных точек с 2017-01 по 2026-08 — та же глубина, что у Москвы и Свердловской области. До этой правки код 50 в карте _SBER_REGION_SERIES отсутствовал, поэтому поправка по времени для области считалась по ряду «Россия», то есть ошибалась на всё расхождение области со страной. Теперь ошибка остаётся только внутрирегиональной. Область автоматически попадает в SBER_REQUIRED_REGIONS, то есть пропажа её ряда станет видимой монитору свежести — это и есть желаемое, данные под наблюдением. Фикстуры монитора, которые перечисляли ряды литералами, перевязаны на карту эстиматора: они краснели от самого факта появления третьего обязательного региона, хотя проверяют не его. Тест загрузчика, утверждавший, что ряд загружен, но эстиматором НЕ используется, заменён на сцепку имён: имя у загрузчика и у эстиматора обязано совпадать буква в букву, иначе один читает пустоту, а второй требует ряд, которого никто не пишет. Комментарий в реестре регионов о том, что кода 50 в карте ещё нет, поправлен. Набор тиров обогащения у области остаётся пустым: у Москвы он тоже пуст, а свой ряд она читает — поправка по времени идёт мимо enrichment_tiers. --- tradein-mvp/backend/app/services/estimator.py | 7 ++++ tradein-mvp/backend/app/services/regions.py | 8 ++-- tradein-mvp/backend/tests/test_sber_index.py | 24 +++++++---- .../tests/test_sber_region_series_3051.py | 40 ++++++++++++++----- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index b0eaf826..a1da6726 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -328,6 +328,13 @@ SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд р _SBER_REGION_SERIES: dict[int, str] = { 66: SBER_TIME_ADJUST_REGION, 77: "Москва", + # Московская область — свой ряд, а не фолбэк на «Россию». Загрузчик научился + # тянуть REF_AREA=50 (services/sber_index), и 12.09.2026 ряд приехал на прод: + # real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же глубина, + # что у Москвы и обл.66. До этой строки область считалась по общероссийскому + # ряду, то есть коридор ошибался на расхождение области со страной; теперь + # ошибка только внутрирегиональная. + 50: "Московская область", } # Ряд для региона ВНЕ карты. Выбран общероссийский, а не отказ от поправки (1.0): diff --git a/tradein-mvp/backend/app/services/regions.py b/tradein-mvp/backend/app/services/regions.py index 70837a26..3307f72d 100644 --- a/tradein-mvp/backend/app/services/regions.py +++ b/tradein-mvp/backend/app/services/regions.py @@ -233,9 +233,11 @@ REGIONS: dict[int, Region] = { ), # Тиров обогащения у области пока НЕТ ни одного: IMV/квартальный # индекс/кадастр/POI не заведены (проверено — frozenset() пуст - # намеренно, не заглушка). Ряд Сбериндекса по области загружен в - # источник (#3498), но в карте _SBER_REGION_SERIES эстиматора кода 50 - # ЕЩЁ НЕТ — тир пока недоступен, добавляется отдельным решением. + # намеренно, не заглушка). Ряд Сбериндекса по области с 12.09.2026 в + # карте _SBER_REGION_SERIES эстиматора есть, но тиром он от этого не + # становится: у Москвы набор тиров тоже пуст, а свой ряд она читает — + # поправка по времени идёт мимо enrichment_tiers (TIER_SBER_INDEX нигде + # за пределами этого реестра не спрашивают). enrichment_tiers=frozenset(), # Источники по области несут настоящий city (Химки, Балашиха — не # муниципальный округ/поселение, в отличие от Москвы) — перезаписывать diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py index 6c566f37..58773412 100644 --- a/tradein-mvp/backend/tests/test_sber_index.py +++ b/tradein-mvp/backend/tests/test_sber_index.py @@ -539,16 +539,24 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu ) -def test_moscow_oblast_ref_area_is_loaded_but_not_yet_used_by_estimator() -> None: - """Область грузится заранее: ряд наполняется месяцами, задним числом не берётся. +def test_moscow_oblast_ref_area_feeds_the_estimator_under_the_same_name() -> None: + """Имя ряда у загрузчика и у эстиматора обязано совпадать буква в букву. - Одновременно проверяем, что заведение ряда НЕ включает область в оценку и НЕ - поднимает тревогу монитора свежести: обе величины считаются от карты - эстиматора (_SBER_REGION_SERIES), а не от карты загрузчика. + Загрузчик завёл REF_AREA=50 раньше (#3498), когда карта эстиматора кода 50 + ещё не знала и область считалась по «России». 12.09.2026 ряд приехал на прод + (real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08), и код 50 попал + в `_SBER_REGION_SERIES`. С этого момента расхождение имён перестаёт быть + безобидным: эстиматор молча читал бы пустой ряд, а монитор свежести требовал + бы ряд, которого загрузчик не пишет. Тест сцепляет обе стороны по имени. """ - from app.services.estimator import SBER_MONITORED_REGIONS, SBER_REQUIRED_REGIONS + from app.services.estimator import ( + SBER_MONITORED_REGIONS, + SBER_REQUIRED_REGIONS, + sber_region_series_name, + ) from app.services.sber_index import SBER_REF_AREAS assert SBER_REF_AREAS["50"] == "Московская область" - assert "Московская область" not in SBER_REQUIRED_REGIONS - assert "Московская область" not in SBER_MONITORED_REGIONS + assert sber_region_series_name(50) == SBER_REF_AREAS["50"] + assert "Московская область" in SBER_REQUIRED_REGIONS + assert "Московская область" in SBER_MONITORED_REGIONS diff --git a/tradein-mvp/backend/tests/test_sber_region_series_3051.py b/tradein-mvp/backend/tests/test_sber_region_series_3051.py index 85301f26..ef75b030 100644 --- a/tradein-mvp/backend/tests/test_sber_region_series_3051.py +++ b/tradein-mvp/backend/tests/test_sber_region_series_3051.py @@ -88,6 +88,21 @@ def test_series_names_agree_with_region_registry() -> None: assert regions_mod.REGIONS[code].name == name +def test_resolver_maps_50_to_moscow_oblast_not_russia() -> None: + """Область получает СВОЙ ряд, а не общероссийский фолбэк. + + Загрузчик научился тянуть REF_AREA=50 (#3498), и 12.09.2026 ряд приехал на + прод: real_estate_deals/«Вторичный», 116 месяцев 2017-01..2026-08 — та же + глубина, что у Москвы и обл.66. Пока кода 50 в карте не было, коридор + области считался по «России», то есть ошибался на всё расхождение области + со страной. Тест красный на прежней карте ПО ЗНАЧЕНИЮ: там вернулась бы + «Россия». + """ + assert est.sber_region_series_name(50) == "Московская область" + assert est.sber_region_series_name(50) != est.SBER_TIME_ADJUST_FALLBACK_REGION + assert "Московская область" in est.SBER_REQUIRED_REGIONS + + def test_monitored_regions_cover_every_series_the_estimator_can_read() -> None: assert set(est.SBER_MONITORED_REGIONS) == set(est._SBER_REGION_SERIES.values()) | { est.SBER_TIME_ADJUST_FALLBACK_REGION @@ -275,8 +290,15 @@ def test_monitor_verdict_for_sverdlovsk_unchanged(monkeypatch: pytest.MonkeyPatc def _without_moscow() -> _MonDB: - """Источник переименовал «Москва» → ряда с прежним именем больше нет.""" - return _MonDB({"Свердловская область": _JUN_2026, "Россия": _JUN_2026}) + """Источник переименовал «Москва» → ряда с прежним именем больше нет. + + Остальные обязательные ряды на месте — иначе тесты ниже считали бы пропажу + Москвы вместе с чужими пропажами. Строятся от карты эстиматора, чтобы новый + регион в ней не ронял эти тесты по счётчику (ровно так они и покраснели, + когда в карту приехала область). + """ + present = {r: _JUN_2026 for r in est.SBER_MONITORED_REGIONS if r != "Москва"} + return _MonDB(present) def test_missing_moscow_does_not_suppress_the_sverdlovsk_verdict( @@ -352,7 +374,7 @@ def test_missing_fallback_series_does_not_fail_the_monitor( ) -> None: """«Россия» — фолбэк: сегодня по нему не считается ни одна сделка → не сбой.""" calls = _patch_runs(monkeypatch) - db = _MonDB({"Свердловская область": _JUN_2026, "Москва": _JUN_2026}) + db = _MonDB({r: _JUN_2026 for r in est.SBER_REQUIRED_REGIONS}) out = mon.check_sber_freshness(db, run_id=4, params={}, now=_NOW) # type: ignore[arg-type] assert calls["failed"] is None assert out["regions_missing"] == 1 @@ -457,15 +479,17 @@ def test_duplicate_series_name_does_not_pin_regions_missing( def test_early_exit_fills_the_new_counters(monkeypatch: pytest.MonkeyPatch) -> None: """КРАСНЫЙ ДО ПРАВКИ: в раннем выходе новые ключи отдавали нули. - Пропажа ДВУХ обязательных рядов была неотличима от пропажи одного свердловского, - а найденный ряд не попадал в age_days_max вовсе. + Пропажа ВСЕХ обязательных рядов была неотличима от пропажи одного + свердловского, а найденный ряд не попадал в age_days_max вовсе. Счётчик + сверяем с длиной карты, а не с литералом: новый регион в ней — не повод + этому тесту краснеть. """ calls = _patch_runs(monkeypatch) db = _MonDB({est.SBER_TIME_ADJUST_FALLBACK_REGION: _JUN_2026}) out = mon.check_sber_freshness(db, run_id=11, params={}, now=_NOW) # type: ignore[arg-type] assert calls["done"] is None assert calls["failed"] is not None - assert out["regions_missing"] == 2 + assert out["regions_missing"] == len(est.SBER_REQUIRED_REGIONS) assert out["alert_regions_missing"] == 1 # ДО ПРАВКИ: 0 assert out["age_days_max"] == 72 # ДО ПРАВКИ: 0 — найденный ряд был не виден # прежняя семантика раннего выхода не тронута @@ -492,9 +516,7 @@ class _MonDBDriverFault(_MonDB): def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _Result: if self._deactivated: - raise PendingRollbackError( - "Can't reconnect until invalid transaction is rolled back" - ) + raise PendingRollbackError("Can't reconnect until invalid transaction is rolled back") params = params or {} if params.get("city") == self._broken: self._deactivated = True -- 2.45.3