From 3fae2b4ed8abd7a8faae749e3bf02b3a20bd7f7e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 13 Sep 2026 13:45:32 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20OR=20region=5Fcode=20IS=20NULL=20=D0=B8=D0=B7=20median?= =?UTF-8?q?-SQL=20location=5Findex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строгое region_code = CAST(:region_code AS integer), без OR-ветки на 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 статистически ничто. Мягкий OR NULL был бы лазейкой того же класса, что и сам дефект: в listings 16 930 NULL-region строк Avito, 24 из них уже лежат в bbox Москвы (сегодня не проходят по свежести, но могут завтра) — OR NULL тихо открыл бы им дорогу обратно в московскую медиану тем же путём, каким туда раньше попадала Московская область. Тесты: test_median_queries_do_not_admit_null_region_code (обе median-SQL не содержат "OR region_code" / "region_code IS NULL"), комментарий перед SQL переписан под новое решение с этими числами. --- .../backend/app/services/location_index.py | 20 ++++++++++++------- .../tests/services/test_location_index.py | 18 ++++++++++++++--- 2 files changed, 28 insertions(+), 10 deletions(-) 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: