diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index d63f2f37..b825b421 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -218,11 +218,17 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # лежало 16 801 лот региона 50 (медиана 251 295 ₽/м²) и 82 719 лотов региона 77 (медиана # 458 208) — примесь заниженная городскую медиану до 422 222 (−7.9%). У областных строк # `city IS NULL`, фильтровать по имени города нельзя — только по region_code. -# `OR region_code IS NULL` — НЕ дыра для Москвы (внутри её bbox_product_core таких строк -# сегодня 0, проверено на проде), а сохранение прежнего поведения региона 66: там есть -# горстка NULL-region_code листингов (untagged legacy до бэкафилла), которые старый -# запрос (без фильтра по региону вообще) всегда учитывал. Без OR-ветки эта правка -# незаметно сдвинула бы медиану 66, хотя тикет требует byte-for-byte. +# +# СТРОГОЕ равенство, БЕЗ `OR region_code IS NULL`: строка без региона не считается ни за +# один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля — is_active, +# price sanity, geo_precision, свежесть 14 дней, сегмент, bbox ядра 66): 15 858 строк +# проходят фильтры, из них 3 с NULL region_code; медиана с ними 151 101 ₽/м², без них +# 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), статистически ничто для 66. Взамен — +# `region_code IS NULL` НЕ постоянная лазейка того же класса, что и сам дефект: в +# `listings` 16 930 NULL-region строк Avito, 24 из них лежат в bbox Москвы и сегодня не +# проходят по свежести — но могут завтра. Мягкий `OR NULL` тихо открыл бы им дорогу +# обратно в московскую медиану ровно тем же путём, каким туда попадала Московская +# область. Три рубля на 66 — цена закрытия дыры, а не повод её оставить. _MEDIAN_PPM2_LOCAL_SQL = text( """ SELECT @@ -236,7 +242,7 @@ _MEDIAN_PPM2_LOCAL_SQL = text( AND (geo_precision IS DISTINCT FROM 'city') AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND (listing_segment IS NULL OR listing_segment = 'vtorichka') - AND (region_code = CAST(:region_code AS integer) OR region_code IS NULL) + AND region_code = CAST(:region_code AS integer) AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -262,7 +268,7 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text( AND (geo_precision IS DISTINCT FROM 'city') AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND (listing_segment IS NULL OR listing_segment = 'vtorichka') - AND (region_code = CAST(:region_code AS integer) OR region_code IS NULL) + AND region_code = CAST(:region_code AS integer) AND lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 05718c78..e0e23ba3 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -161,9 +161,21 @@ def test_median_queries_filter_by_region_code() -> None: for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): sql_text = str(sql.text) assert "region_code = CAST(:region_code AS integer)" in sql_text - # NULL region_code остаётся в пуле — иначе региона 66 (где часть legacy-листингов - # не размечена) сместился бы, хотя тикет требует byte-for-byte для 66. - assert "OR region_code IS NULL" in sql_text + + +def test_median_queries_do_not_admit_null_region_code() -> None: + """Строгое равенство, БЕЗ `OR region_code IS NULL` — строка без региона не считается + ни за один регион. Второй прод-замер (13.09.2026, полный набор фильтров модуля, bbox + ядра 66): 15 858 строк проходят фильтры, 3 из них с NULL region_code; медиана с ними + 151 101 ₽/м², без них 151 103 — сдвиг 2 рубля на 151 тысяче (0.001%), для 66 + статистически ничто. Мягкий `OR NULL` был бы лазейкой того же класса, что и сам + дефект: в `listings` 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox + Москвы (сегодня не проходят по свежести, но могут завтра) — `OR NULL` тихо открыл бы + им дорогу обратно в московскую медиану.""" + for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): + sql_text = str(sql.text) + assert "region_code IS NULL" not in sql_text + assert "OR region_code" not in sql_text def test_local_and_citywide_median_bind_region_code_of_the_given_region() -> None: