From 4af1b142253e9a1c54f4e3c68a66a47560200082 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Wed, 16 Sep 2026 16:42:06 +0000 Subject: [PATCH] =?UTF-8?q?22=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=9C=D0=BE=D1=81=D0=BA=D0=BE=D0=B2=D1=81=D0=BA=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D0=B8=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8=20(#3536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/trade_in.py | 83 ++++++++-- .../tests/test_coverage_probe_endpoint.py | 144 ++++++++++++------ .../backend/tests/test_public_mera_api.py | 11 +- tradein-mvp/frontend/src/lib/city-registry.ts | 61 +++++++- 4 files changed, 231 insertions(+), 68 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index e011040a..f16525eb 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -2844,6 +2844,46 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль COVERAGE_GREEN_MIN_N = 8 COVERAGE_YELLOW_MIN_N = 12 +# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в +# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на +# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь +# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по +# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное +# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87, +# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85, +# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78, +# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73, +# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68, +# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же +# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже +# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже +# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог +# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты. +COVERAGE_MO_CITIES = ( + "Видное", + "Балашиха", + "Железнодорожный", + "Люберцы", + "Электросталь", + "Химки", + "Домодедово", + "Ногинск", + "Реутов", + "Долгопрудный", + "Красногорск", + "Одинцово", + "Подольск", + "Сергиев Посад", + "Королёв", + "Мытищи", + "Орехово-Зуево", + "Серпухов", + "Раменское", + "Наро-Фоминск", + "Щёлково", + "Коломна", +) + # Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в # COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше — # это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется @@ -3040,6 +3080,7 @@ def _fold_city(name: str) -> str: _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = { **{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES}, **{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES}, + **{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES}, **{ _fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N) for k in COVERAGE_MOSCOW_CENTROID_KEYS @@ -3086,6 +3127,36 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = { "Первоуральск": (56.9083, 59.9483), "Ревда": (56.7986, 59.9298), "Серов": (59.6047, 60.5772), + # Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с + # COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из + # 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города + # раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс + # ближайшего центроида без порога, теперь у них есть и собственный порог). + # Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в + # packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же + # координаты, что использует скрейпер (резолв Nominatim). + "Видное": (55.5519, 37.7133), + "Балашиха": (55.7969, 37.9386), + "Железнодорожный": (55.7444, 38.0128), + "Люберцы": (55.6767, 37.8931), + "Электросталь": (55.7845, 38.4449), + "Химки": (55.8894, 37.4450), + "Домодедово": (55.4368, 37.7681), + "Ногинск": (55.8554, 38.4412), + "Реутов": (55.7614, 37.8564), + "Долгопрудный": (55.9386, 37.5100), + "Красногорск": (55.8317, 37.3300), + "Одинцово": (55.6789, 37.2639), + "Подольск": (55.4312, 37.5450), + "Сергиев Посад": (56.3153, 38.1360), + "Королёв": (55.9142, 37.8256), + "Мытищи": (55.9116, 37.7308), + "Орехово-Зуево": (55.8062, 38.9810), + "Серпухов": (54.9155, 37.4196), + "Раменское": (55.5709, 38.2282), + "Наро-Фоминск": (55.3843, 36.7235), + "Щёлково": (55.9206, 37.9916), + "Коломна": (55.0939, 38.7670), # Москва — сетка из 67 центроидов, одно display-имя и один порог на все. # Как сетка получена, почему точек именно столько, где проходит граница # с областью и какова остаточная цена — см. большой комментарий над @@ -3127,30 +3198,18 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = { "Поварово": (56.0667, 37.0667), "Дедовск": (55.8672, 37.1200), "Нахабино": (55.8500, 37.1833), - "Реутов": (55.7614, 37.8564), - "Подольск": (55.4312, 37.5450), "Апрелевка": (55.5500, 37.0700), "Немчиновка": (55.7050, 37.3450), - "Химки": (55.8894, 37.4450), - "Одинцово": (55.6789, 37.2639), "Лобня": (56.0100, 37.4750), - "Мытищи": (55.9116, 37.7308), "Котельники": (55.6553, 37.8619), - "Красногорск": (55.8317, 37.3300), - "Люберцы": (55.6767, 37.8931), "Дзержинский": (55.6294, 37.8500), "Развилка": (55.5842, 37.7392), "Климовск": (55.3667, 37.5333), - "Балашиха": (55.7969, 37.9386), "Истра": (55.9167, 36.8667), - "Долгопрудный": (55.9386, 37.5100), - "Королёв": (55.9142, 37.8256), - "Видное": (55.5519, 37.7133), "Селятино": (55.5081, 36.9825), "Томилино": (55.6528, 37.9472), "Некрасовский": (56.0500, 37.5500), "Барвиха": (55.7333, 37.2333), - "Железнодорожный": (55.7444, 38.0128), } diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py index 4a6b4df1..caddd740 100644 --- a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -535,19 +535,14 @@ def test_sverdlovsk_resolution_untouched_by_moscow_centroids( # Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов # (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву». +# +# 2026-09-16: 12 из исходных 16 (Химки, Мытищи, Люберцы, Балашиха, Красногорск, +# Одинцово, Подольск, Реутов, Королёв, Долгопрудный, Видное, Железнодорожный) +# получили собственный порог покрытия — см. COVERAGE_MO_CITIES в +# app/api/v1/trade_in.py — и больше не резолвятся в «город не определён». +# Здесь остаются только 4 города, которые НЕ замерялись и остаются +# отрицательными точками. _MOSCOW_OBLAST_CITIES = { - "Химки": (55.8894, 37.4450), - "Мытищи": (55.9116, 37.7308), - "Люберцы": (55.6767, 37.8931), - "Балашиха": (55.7969, 37.9386), - "Красногорск": (55.8317, 37.3300), - "Одинцово": (55.6789, 37.2639), - "Подольск": (55.4312, 37.5450), - "Реутов": (55.7614, 37.8564), - "Королёв": (55.9142, 37.8256), - "Долгопрудный": (55.9386, 37.5100), - "Видное": (55.5519, 37.7133), - "Железнодорожный": (55.7444, 38.0128), "Лобня": (56.0100, 37.4750), "Дзержинский": (55.6294, 37.8500), "Котельники": (55.6553, 37.8619), @@ -576,7 +571,11 @@ def test_every_negative_centroid_resolves_to_no_city() -> None: _resolve_coverage_city, ) - assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32 + # 32 - 12: 12 городов (Реутов, Подольск, Химки, Одинцово, Мытищи, + # Красногорск, Люберцы, Балашиха, Долгопрудный, Королёв, Видное, + # Железнодорожный) переехали в позитивный список 2026-09-16 — + # COVERAGE_MO_CITIES, см. app/api/v1/trade_in.py. + assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 20 for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items(): assert _resolve_coverage_city(lat, lon) == ("", 0, False), name @@ -618,12 +617,17 @@ def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> Non def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None: - """Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу.""" + """Истра через ручку: 200 и not_covered, а не 500 от KeyError по порогу. + + Была "Химки" до 2026-09-16 — теперь Химки сама покрытый город + (COVERAGE_MO_CITIES), взята следующая точка, которая всё ещё "не + определена" (см. комментарий над _MOSCOW_OBLAST_CITIES). + """ db = _db_mock_returning(_row(40, 30.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) - lat, lon = _MOSCOW_OBLAST_CITIES["Химки"] + lat, lon = _MOSCOW_OBLAST_CITIES["Истра"] resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}) assert resp.status_code == 200, resp.text data = resp.json() @@ -678,6 +682,7 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids( """ from app.api.v1 import trade_in as trade_in_module + mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES} sverdlovsk = { k: v for k, v in trade_in_module._CITY_CENTROIDS_DEG.items() @@ -685,6 +690,7 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids( not in { trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS } + and trade_in_module._fold_city(k) not in mo_cities_folded } assert len(sverdlovsk) == 9 @@ -753,22 +759,26 @@ _MOSCOW_CONTROLS = { "Троицк (ТАО)": (55.4853, 37.3050), } -# Областной контрольный набор (32 города). 16 из них имеют собственный -# отрицательный центроид и выигрывают им; остальные 16 держатся только -# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей -# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км -# резолвились как «Москва». Это и есть дефект #1. +# Областной контрольный набор. Изначально 32 города (16 из _MOSCOW_OBLAST_CITIES +# + 16 ниже) — часть держалась только радиусом московских точек, включая +# Наро-Фоминск (9.95 км до ближайшей точки сетки), Кубинку (17.2) и Чехов +# (22.3), которые при глобальных 25 км резолвились как «Москва». Это и есть +# дефект #1. +# +# 2026-09-16: Наро-Фоминск, Домодедово, Жуковский, Щёлково и Пушкино убраны +# из набора. Наро-Фоминск/Домодедово/Щёлково сами стали покрытыми городами +# (COVERAGE_MO_CITIES). Жуковский и Пушкино не входят в COVERAGE_MO_CITIES, но +# оказались внутри 25-километрового радиуса новых соседних центроидов +# (Раменское и Королёв соответственно) и больше не резолвятся в «город не +# определён». Пушкино используется отдельно (см. `_PUSHKINO_COORDS`) — +# test_phantom_exclave_no_longer_gives_moscow_to_pushkino проверяет именно то, +# что ближайший центроид для него теперь Королёв, а не пустота. _MOSCOW_OBLAST_CONTROLS = { **_MOSCOW_OBLAST_CITIES, - "Наро-Фоминск": (55.3869, 36.7331), "Кубинка": (55.5794, 36.7000), "Чехов": (55.1477, 37.4770), "Голицыно": (55.6167, 36.9833), "Лыткарино": (55.5833, 37.9000), - "Домодедово": (55.4400, 37.7594), - "Жуковский": (55.5952, 38.1200), - "Щёлково": (55.9200, 38.0000), - "Пушкино": (56.0100, 37.8500), "Звенигород": (55.7300, 36.8500), "Апрелевка": (55.5500, 37.0700), "Селятино": (55.5081, 36.9825), @@ -778,6 +788,10 @@ _MOSCOW_OBLAST_CONTROLS = { "Барвиха": (55.7333, 37.2333), } +# Пушкино вынесено из _MOSCOW_OBLAST_CONTROLS 2026-09-16 (см. комментарий +# выше) — используется test_phantom_exclave_no_longer_gives_moscow_to_pushkino. +_PUSHKINO_COORDS = (56.0100, 37.8500) + # Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие # конкурс подмосковной точке. Проверяются по координатам из самого модуля, # ключ здесь — только адрес строки. @@ -854,10 +868,10 @@ def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: Fas def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None: - """Все 32 областных контроля → «город не определён», и ручка отдаёт 200.""" + """Все 15 областных контролей → «город не определён», и ручка отдаёт 200.""" from app.api.v1.trade_in import _resolve_coverage_city - assert len(_MOSCOW_OBLAST_CONTROLS) == 32 + assert len(_MOSCOW_OBLAST_CONTROLS) == 15 failed = { name: _resolve_coverage_city(lat, lon) for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items() @@ -878,15 +892,18 @@ def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastA def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None: - """Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов. + """Дефект #1 предметно: Кубинка, Чехов. - С одним глобальным радиусом 25 км круги плотной сетки складывались, и все - трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и - 22.3 км. С радиусом-свойством (8 км у московских точек) — «не определён». + С одним глобальным радиусом 25 км круги плотной сетки складывались, и обе + получали «Москву», хотя до ближайшей точки сетки им 17.2 и 22.3 км. С + радиусом-свойством (8 км у московских точек) — «не определён». + + Наро-Фоминск убран из проверки 2026-09-16: сам стал покрытым городом + (COVERAGE_MO_CITIES), дефект #1 больше не демонстрирует. """ from app.api.v1.trade_in import _resolve_coverage_city - for name in ("Наро-Фоминск", "Кубинка", "Чехов"): + for name in ("Кубинка", "Чехов"): lat, lon = _MOSCOW_OBLAST_CONTROLS[name] assert _legacy_resolve(lat, lon) == ("Москва", 12, True), ( f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем" @@ -897,13 +914,15 @@ def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None: def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None: """Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской. - Наро-Фоминск в 9.95 км от ближайшей точки московской сетки — «не - определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) — - «Екатеринбург». Глобальной константой такое поведение невыразимо. + Кубинка в 17.2 км от ближайшей точки московской сетки — «не определён» + (была "Наро-Фоминск" до 2026-09-16 — тот сам стал покрытым городом, + COVERAGE_MO_CITIES). Точка в 20 км южнее центра Екатеринбурга (то есть + ДАЛЬШЕ) — «Екатеринбург». Глобальной константой такое поведение + невыразимо. """ from app.api.v1 import trade_in as trade_in_module - lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"] + lat, lon = _MOSCOW_OBLAST_CONTROLS["Кубинка"] km_to_grid = min( trade_in_module._haversine_km(lat, lon, glat, glon) for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values() @@ -942,10 +961,12 @@ def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None: moscow_keys = { trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS } + mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES} sverdlovsk = { k: v for k, v in trade_in_module._CITY_CENTROIDS_DEG.items() if trade_in_module._fold_city(k) not in moscow_keys + and trade_in_module._fold_city(k) not in mo_cities_folded } assert len(sverdlovsk) == 9 for clat, clon in sverdlovsk.values(): @@ -973,14 +994,18 @@ def test_phantom_exclave_no_longer_gives_moscow_to_pushkino( ) -> None: """Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой». - Возвращаем призрак в таблицу — Пушкино снова резолвится в «Москву»: это - доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё. + 2026-09-16: Королёв (COVERAGE_MO_CITIES) стал позитивным центроидом в + ~10.76 км от Пушкино — ближе прежнего "not_covered", поэтому базовый + ответ без призрака теперь «Королёв», а не пустота. Возвращаем призрак в + таблицу — он в 3.06 км, ближе Королёва, и Пушкино снова резолвится в + «Москву»: это по-прежнему доказывает, что дефект чинит именно выброс + точки, а не что-нибудь ещё. """ from app.api.v1 import trade_in as trade_in_module - pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"] + pushkino = _PUSHKINO_COORDS assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True) - assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False) + assert trade_in_module._resolve_coverage_city(*pushkino) == ("Королёв", 12, True) ghost_key = "Москва (Восточный, эксклав)" monkeypatch.setattr( @@ -1017,12 +1042,31 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids( ) -> None: """Каждая добавленная точка что-то возвращает: без неё район теряется. - Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать - её из таблицы — «город не определён» (район уходит подмосковному центру). - Мёртвых строк в добавке нет. + Для всех 12 точек: в самой точке резолвер отдаёт «Москву». Без неё точка + проигрывает следующему ближайшему центроиду — раньше это всегда было + «город не определён», но с 2026-09-16 (COVERAGE_MO_CITIES) для 9 из 12 + точек следующий ближайший — новый позитивный центроид подмосковного + города, а не пустота. Инвариант, который всё ещё держит тест: ни одна + точка не деградирует ОБРАТНО в «Москву» — иначе строка была бы мёртвой. """ from app.api.v1 import trade_in as trade_in_module + expected_fallback = { + "Москва (Восточный)": ("Балашиха", 12, True), + "Москва (Левобережный)": ("Химки", 12, True), + "Москва (Орехово-Борисово Южное)": ("", 0, False), + "Москва (Северный)": ("Долгопрудный", 12, True), + "Москва (Митино-запад)": ("Красногорск", 12, True), + "Москва (Ивановское)": ("Реутов", 12, True), + "Москва (Жулебино)": ("Люберцы", 12, True), + "Москва (Можайский-запад)": ("", 0, False), + "Москва (Новокосино)": ("Реутов", 12, True), + "Москва (Капотня)": ("", 0, False), + "Москва (Куркино)": ("Химки", 12, True), + "Москва (Некрасовка-восток)": ("Люберцы", 12, True), + } + assert set(expected_fallback) == set(_ADDED_GRID_KEYS) + full = trade_in_module._CITY_CENTROIDS_DEG for key in _ADDED_GRID_KEYS: lat, lon = full[key] @@ -1032,9 +1076,11 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids( "_CITY_CENTROIDS_DEG", {k: v for k, v in full.items() if k != key}, ) - assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), ( - f"{key} ничего не возвращает — строка мёртвая" + fallback = trade_in_module._resolve_coverage_city(lat, lon) + assert fallback == expected_fallback[key], ( + f"{key}: ожидали {expected_fallback[key]}, получили {fallback}" ) + assert fallback[0] != "Москва", f"{key} деградировала обратно в «Москву»" monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full) @@ -1047,10 +1093,16 @@ def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it( расстоянии означает «город не определён»: точка в глухом углу области, где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered и 200, а не 500 по отсутствующему порогу. + + Координаты сменены 2026-09-16: прежняя точка (55.30, 38.75) после + добавления COVERAGE_MO_CITIES стала ближе к Коломне (22.9 км, позитивный + центроид), чем к любой отрицательной точке — тест проверял бы Коломну, а + не бесконечный радиус. Новая точка ближе всего к «Поварово» (26.27 км, + отрицательный центроид). """ from app.api.v1 import trade_in as trade_in_module - far_lat, far_lon = 55.30, 38.75 + far_lat, far_lon = 56.30, 37.00 nearest_km, nearest_name = min( (trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name) for name, (clat, clon) in ( diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 44a38206..195c5be7 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -561,6 +561,7 @@ def test_offered_cities_match_coverage_cities() -> None: from app.api.v1.trade_in import ( COVERAGE_GREEN_CITIES, + COVERAGE_MO_CITIES, COVERAGE_MOSCOW_DISPLAY, COVERAGE_YELLOW_CITIES, _fold_city, @@ -580,7 +581,12 @@ def test_offered_cities_match_coverage_cities() -> None: offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))} answerable = { _fold_city(c) - for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, COVERAGE_MOSCOW_DISPLAY) + for c in ( + *COVERAGE_GREEN_CITIES, + *COVERAGE_YELLOW_CITIES, + *COVERAGE_MO_CITIES, + COVERAGE_MOSCOW_DISPLAY, + ) } assert offered == answerable, ( @@ -595,12 +601,13 @@ def test_every_coverage_city_has_a_centroid() -> None: from app.api.v1.trade_in import ( _CITY_CENTROIDS_DEG, COVERAGE_GREEN_CITIES, + COVERAGE_MO_CITIES, COVERAGE_YELLOW_CITIES, _fold_city, ) centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG} - for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES): + for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, *COVERAGE_MO_CITIES): assert _fold_city(city) in centroids, ( f"{city} есть в порогах, но нет в центроидах — по координатам он " "никогда не резолвится, и порог мёртвый" diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index 0ee29985..bd615586 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -1,6 +1,9 @@ /** * city-registry — единый источник правды по городам покрытия «Меры»: - * Свердловская область (регион 66) и Москва (регион 77, PR-B). + * Свердловская область (регион 66), Москва (регион 77, PR-B) и Московская + * область (регион 50, 22 города — расширение 2026-09-16: замер плотности + * когорты на проде показал, что каждый город области плотнее уже-жёлтой + * Москвы, см. `COVERAGE_MO_CITIES` в backend/app/api/v1/trade_in.py). * * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного * города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал @@ -49,11 +52,12 @@ export interface OblastCity { */ readonly label: string; /** - * Регион: 66 — Свердловская область, 77 — Москва. НЕ отправляется бэкенду — - * нужен только фронту, чтобы отличить «частичное покрытие области» от - * отдельного региона. Большинство потребителей читают только id/label. + * Регион: 66 — Свердловская область, 77 — Москва, 50 — Московская область. + * НЕ отправляется бэкенду — нужен только фронту, чтобы отличить «частичное + * покрытие области» от отдельного региона. Большинство потребителей читают + * только id/label. */ - readonly region: 66 | 77; + readonly region: 66 | 77 | 50; } // ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок @@ -69,12 +73,41 @@ export const OBLAST_CITIES: readonly OblastCity[] = [ { id: "revda", label: "Ревда", region: 66 }, { id: "serov", label: "Серов", region: 66 }, { id: "moscow", label: "Москва", region: 77 }, + // Московская область (регион 50) — 22 города, расширение 2026-09-16. + // Все получают жёлтый тир (COVERAGE_YELLOW_MIN_N): замер 120 проб/город, + // радиус 8 км, когорта = is_active + точные комнаты + площадь ±15% + + // scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты 17-51, + // доля проб с ≥12 аналогов 0.63-0.92 — у каждого города выше, чем у уже + // жёлтой Москвы (медиана 14, доля 0.57). Порядок — по убыванию медианы. + { id: "vidnoe", label: "Видное", region: 50 }, + { id: "balashikha", label: "Балашиха", region: 50 }, + { id: "zheleznodorozhny", label: "Железнодорожный", region: 50 }, + { id: "lyubertsy", label: "Люберцы", region: 50 }, + { id: "elektrostal", label: "Электросталь", region: 50 }, + { id: "khimki", label: "Химки", region: 50 }, + { id: "domodedovo", label: "Домодедово", region: 50 }, + { id: "noginsk", label: "Ногинск", region: 50 }, + { id: "reutov", label: "Реутов", region: 50 }, + { id: "dolgoprudny", label: "Долгопрудный", region: 50 }, + { id: "krasnogorsk", label: "Красногорск", region: 50 }, + { id: "odintsovo", label: "Одинцово", region: 50 }, + { id: "podolsk", label: "Подольск", region: 50 }, + { id: "sergiev_posad", label: "Сергиев Посад", region: 50 }, + { id: "korolev", label: "Королёв", region: 50 }, + { id: "mytishchi", label: "Мытищи", region: 50 }, + { id: "orekhovo_zuevo", label: "Орехово-Зуево", region: 50 }, + { id: "serpukhov", label: "Серпухов", region: 50 }, + { id: "ramenskoye", label: "Раменское", region: 50 }, + { id: "naro_fominsk", label: "Наро-Фоминск", region: 50 }, + { id: "schelkovo", label: "Щёлково", region: 50 }, + { id: "kolomna", label: "Коломна", region: 50 }, ]; /** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */ -export const REGION_LABELS: Record<66 | 77, string> = { +export const REGION_LABELS: Record<66 | 77 | 50, string> = { 66: "Свердловская область", 77: "Москва", + 50: "Московская область", }; /** Только область (регион 66) — прежнее множество, до добавления Москвы. */ @@ -82,8 +115,20 @@ export const OBLAST_66_CITIES: readonly OblastCity[] = OBLAST_CITIES.filter( (c) => c.region === 66, ); -/** Москва одним объектом — тем же паттерном, что `DEFAULT_CITY = OBLAST_CITIES[0]` ниже. */ -export const MOSCOW_CITY: OblastCity = OBLAST_CITIES[OBLAST_CITIES.length - 1]; +/** + * Москва одним объектом. Раньше бралась по индексу последнего элемента + * `OBLAST_CITIES` (работало, пока Москва была последней в списке) — после + * добавления 22 городов Московской области (регион 50) последним элементом + * стала Коломна, индекс молча указывал бы не на тот город. Ищем по + * стабильному `id`, а не по позиции в массиве. + */ +export const MOSCOW_CITY: OblastCity = (() => { + const city = OBLAST_CITIES.find((c) => c.id === "moscow"); + if (!city) { + throw new Error("city-registry: 'moscow' отсутствует в OBLAST_CITIES"); + } + return city; +})(); /** * Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и