From def21dc796bda3c4766f5db800e5d46cdd88e747 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 13 Sep 2026 13:30:54 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20location=5Findex=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BC=D0=B5=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D1=8B=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D1=80=D1=91=D1=82=20=D0=BF=D1=80=D0=BE=20POI=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=9C=D0=BE=D1=81=D0=BA=D0=B2=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дефект 1: bbox продукт-ядра Москвы (55.55-55.95/37.30-37.90) — прямоугольник, не административная граница, и захватывает соседние Химки/Реутов/Котельники/ Люберцы (region_code=50). Обе SQL-медианы (_MEDIAN_PPM2_LOCAL_SQL/CITYWIDE_SQL) фильтровали листинги только по bbox, без region_code — прод-замер 13.09.2026: городская медиана Москвы получалась заниженной на 7.9% (422 222 вместо 458 208 ₽/м²) из-за примеси 16 801 подмосковного лота. У областных строк city IS NULL, поэтому фильтровать по имени города нельзя — добавлен bind-параметр region_code, заполняемый из уже определённого для точки Region.code. Для 66 сохранено byte-for-byte: `OR region_code IS NULL` оставляет в пуле некоторое количество legacy-листингов без региональной метки — ровно то же множество строк, что и старый запрос без фильтра вовсе (проверено на проде: внутри bbox 66 других region_code, кроме 66 и NULL, не существует). Дефект 2: гейт _fetch_nearby_poi решал poi_status по count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850 екатеринбургских строк, всегда > 0) — для Москвы это давало poi_status="ok" с пустым списком «что рядом», хотя внутри московского продукт-ядра POI нет вообще. Гейт теперь считает count в bbox_product_core региона точки; при 0 — тот же статус "unavailable", что и раньше (терминология не расширяется, trade_in.py трактует его как «нет данных» без изменений). Тесты: tests/services/test_location_index.py — добавлены проверки bind- параметра region_code в обеих median-SQL, региональной изоляции gate'а POI (bbox запроса совпадает с bbox_product_core переданного региона), резолва московской точки в region 77, и end-to-end сценария для Москвы (region_code долетает в запрос, poi_status="unavailable"). 43/43 pytest зелёные, ruff check + ruff format --check чисто. --- .../backend/app/services/location_index.py | 72 +++++++++-- .../tests/services/test_location_index.py | 113 +++++++++++++++++- 2 files changed, 169 insertions(+), 16 deletions(-) diff --git a/tradein-mvp/backend/app/services/location_index.py b/tradein-mvp/backend/app/services/location_index.py index 6b6c9c7e..d63f2f37 100644 --- a/tradein-mvp/backend/app/services/location_index.py +++ b/tradein-mvp/backend/app/services/location_index.py @@ -142,7 +142,8 @@ class LocationIndexResult(BaseModel): sample_size: int radius_m: int nearby_poi: list[NearbyPoi] - poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена) + poi_status: str # "ok" | "unavailable" (osm_poi_ekb_local пуста/не отрефрешена + # ДЛЯ ПРОДУКТ-ЯДРА РЕГИОНА точки — не глобальный count(*) по таблице) def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: @@ -207,9 +208,21 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float: # упирается в отказ редко — прод-симуляция на 246 реальных точках оценок: # insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246. # -# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если -# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index -# явно ограничил владелец продукта. +# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО в продукт-ядре региона, +# даже если сам продукт уже скрейпит соседние города/области (city-sweep): географию +# location_index явно ограничил владелец продукта. +# +# region_code (дефект Москвы, прод-замер 13.09.2026): bbox продукт-ядра — геометрический +# прямоугольник, а границы регионов НЕ прямоугольны — в московское ядро (55.55..55.95 / +# 37.30..37.90) попадают Химки/Реутов/Котельники/Люберцы (region_code=50). Внутри ядра 77 +# лежало 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. _MEDIAN_PPM2_LOCAL_SQL = text( """ SELECT @@ -223,6 +236,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 lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -248,6 +262,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 lat BETWEEN CAST(:bbox_south AS double precision) AND CAST(:bbox_north AS double precision) AND lon BETWEEN CAST(:bbox_west AS double precision) @@ -255,6 +270,17 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text( """ ) +_POI_COUNT_IN_REGION_SQL = text( + """ + SELECT count(*) AS n + FROM osm_poi_ekb_local + WHERE lat BETWEEN CAST(:bbox_south AS double precision) + AND CAST(:bbox_north AS double precision) + AND lon BETWEEN CAST(:bbox_west AS double precision) + AND CAST(:bbox_east AS double precision) + """ +) + _NEAREST_POI_SQL = text( """ SELECT @@ -293,6 +319,7 @@ def _local_median_ppm2( "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, + "region_code": region.code, "bbox_south": lat_min, "bbox_north": lat_max, "bbox_west": lon_min, @@ -317,6 +344,7 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float | "fresh_days": LISTINGS_FRESH_DAYS, "price_min": _PRICE_PER_M2_SANITY_MIN, "price_max": _PRICE_PER_M2_SANITY_MAX, + "region_code": region.code, "bbox_south": lat_min, "bbox_north": lat_max, "bbox_west": lon_min, @@ -333,18 +361,38 @@ def _citywide_median_ppm2(db: Any, region: regions_mod.Region) -> tuple[float | def _fetch_nearby_poi( - db: Any, lat: float, lon: float, radius_m: int, top_n: int + db: Any, lat: float, lon: float, radius_m: int, top_n: int, region: regions_mod.Region ) -> tuple[list[NearbyPoi], str]: """Top-N POI поблизости — качественная справка «что рядом», не числовой показатель. - Graceful fallback: osm_poi_ekb_local пуста (рефреш ещё не запускался на этом окружении) - → ([], "unavailable") вместо 500 или сфабрикованного списка. + Graceful fallback → ([], "unavailable") вместо 500 или сфабрикованного списка. Дефект + (прод 13.09.2026): гейт раньше смотрел на count(*) ПО ВСЕЙ osm_poi_ekb_local (4 850 + строк, все екатеринбургские) — для Москвы это count > 0 (таблица не пуста), и статус + молча становился "ok" с пустым списком, хотя внутри московского продукт-ядра лежит 0 + POI. Гейт обязан смотреть на наличие POI В РЕЛЕВАНТНОЙ области (bbox_product_core + региона точки), а не на размер таблицы целиком. Загрузка московских POI — отдельная + задача; здесь только честная деградация тем же статусом "unavailable" (терминология + не расширяется — trade_in.py и так трактует его как «нет данных», см. #2131). """ - total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0 - if total == 0: + lat_min, lat_max, lon_min, lon_max = region.bbox_product_core + in_region = ( + db.execute( + _POI_COUNT_IN_REGION_SQL, + { + "bbox_south": lat_min, + "bbox_north": lat_max, + "bbox_west": lon_min, + "bbox_east": lon_max, + }, + ).scalar() + or 0 + ) + if in_region == 0: logger.warning( - "location_index: osm_poi_ekb_local is empty (refresh job not yet run on this " - "environment) — nearby_poi unavailable, no fabricated factors" + "location_index: osm_poi_ekb_local has no rows in region=%s product core " + "(empty or refresh job not yet run for this region) — nearby_poi unavailable, " + "no fabricated factors", + region.code, ) return [], "unavailable" @@ -423,7 +471,7 @@ def compute_location_index( poi_status="unavailable", ) - nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n) + nearby_poi, poi_status = _fetch_nearby_poi(db, lat, lon, poi_radius_m, poi_top_n, region) city_median, city_n = _citywide_median_ppm2(db, region) if city_median is None or city_n < MIN_SAMPLE_SIZE: diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index 6a720c27..05718c78 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -25,6 +25,12 @@ from app.services import location_index as lc # A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area). _LAT_IN_EKB = 56.838 _LON_IN_EKB = 60.605 +_REGION_66 = lc.regions_mod.REGIONS[66] + +# A point well inside Moscow's product core (within MKAD) — #3051 region 77. +_LAT_IN_MSK = 55.75 +_LON_IN_MSK = 37.62 +_REGION_77 = lc.regions_mod.REGIONS[77] # ── Pure functions ────────────────────────────────────────────────────────── @@ -118,6 +124,7 @@ def test_no_psycopg_v3_colon_colon_cast() -> None: lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL, lc._NEAREST_POI_SQL, + lc._POI_COUNT_IN_REGION_SQL, ): assert not re.search(r":\w+::", str(sql.text)) @@ -142,6 +149,39 @@ def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None: assert "bbox_west" in sql_text and "bbox_east" in sql_text +# ── #msk-defect-1: медианы обязаны фильтроваться по региону, не только по bbox ── + + +def test_median_queries_filter_by_region_code() -> None: + """Прод-замер 13.09.2026: bbox продукт-ядра Москвы геометрически захватывает соседние + region_code=50 (Химки/Реутов/Котельники/Люберцы) — без фильтра по региону городская + медиана Москвы считалась с примесью 16 801 подмосковного лота и была занижена на 7.9% + (422 222 вместо 458 208 ₽/м²). У областных строк city IS NULL — фильтровать можно + только по region_code, не по имени города.""" + 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_local_and_citywide_median_bind_region_code_of_the_given_region() -> None: + """region_code реально долетает bind-параметром и берётся ИЗ переданного региона, а не + захардкожен — иначе фикс работал бы только для 66 и молча ломался на следующем регионе.""" + db66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000})]) + lc._citywide_median_ppm2(db66, _REGION_66) + assert db66.executed[0][1]["region_code"] == 66 + + db77 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 458_208.0, "n": 82_719})]) + lc._citywide_median_ppm2(db77, _REGION_77) + assert db77.executed[0][1]["region_code"] == 77 + + local66 = _FakeDB([_FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25})]) + lc._local_median_ppm2(local66, _LAT_IN_EKB, _LON_IN_EKB, 800, _REGION_66) + assert local66.executed[0][1]["region_code"] == 66 + + # ── #2660: свежесть + сегмент (зеркало _COMMON_WHERE эстиматора) ───────────── @@ -246,7 +286,9 @@ class _FakeDB: def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None: db = _FakeDB([_FakeResult(scalar_value=0)]) - poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7) + poi, status = lc._fetch_nearby_poi( + db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66 + ) assert poi == [] assert status == "unavailable" assert len(db.executed) == 1 # only the count probe ran @@ -254,7 +296,9 @@ def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None: def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None: db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])]) - poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7) + poi, status = lc._fetch_nearby_poi( + db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66 + ) assert poi == [] assert status == "ok" @@ -267,7 +311,7 @@ def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None: {"name": "Аптека", "category": "pharmacy", "distance_m": 50.0}, ] db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)]) - poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7) + poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66) assert status == "ok" assert len(poi) == 4 # metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not @@ -281,7 +325,7 @@ def test_fetch_nearby_poi_limits_to_top_n() -> None: for i in range(20) ] db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)]) - poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7) + poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7, _REGION_66) assert len(poi) == 7 @@ -407,3 +451,64 @@ def test_compute_location_index_poi_unavailable_does_not_block_index() -> None: assert result.poi_status == "unavailable" assert result.nearby_poi == [] assert result.location_index_pct == 15.0 + + +# ── #msk-defect-1/2: Москва (region 77) — не течёт в EKB, честная POI-деградация ── + + +def test_covered_region_resolves_moscow_center_to_region_77() -> None: + """МКАД-центр Москвы должен попадать в продукт-ядро 77, а не в 50 (см. комментарий у + regions._POINT_LOOKUP_ORDER — компактный bbox_region проверяется раньше объемлющего).""" + region = lc._covered_region(_LAT_IN_MSK, _LON_IN_MSK) + assert region is not None and region.code == 77 + + +def test_fetch_nearby_poi_gate_uses_regions_own_bbox_not_whole_table() -> None: + """Дефект (прод 13.09.2026): гейт раньше делал `count(*)` по ВСЕЙ osm_poi_ekb_local + (4 850 екатеринбургских строк, всегда > 0) — Москва получала бы poi_status="ok" с + пустым списком. Теперь count скоупится в bbox_product_core региона точки: для 77 + внутри его ядра POI нет (0), для 66 — те же 4 850 строк, что и раньше.""" + db_msk = _FakeDB([_FakeResult(scalar_value=0)]) + poi, status = lc._fetch_nearby_poi( + db_msk, _LAT_IN_MSK, _LON_IN_MSK, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_77 + ) + assert poi == [] + assert status == "unavailable" # честная деградация, ТА ЖЕ терминология, что и раньше + # bbox в запросе — Москвы, не Екатеринбурга (это и есть суть фикса). + poi_count_params = db_msk.executed[0][1] + lat_min, lat_max, lon_min, lon_max = _REGION_77.bbox_product_core + assert poi_count_params["bbox_south"] == lat_min + assert poi_count_params["bbox_north"] == lat_max + assert poi_count_params["bbox_west"] == lon_min + assert poi_count_params["bbox_east"] == lon_max + + +def test_fetch_nearby_poi_gate_still_scopes_to_ekb_bbox_for_region_66() -> None: + """Regression: для 66 гейт обязан продолжать спрашивать про bbox ЕКБ — byte-for-byte, + просто теперь явно скоупенный, а не «случайно совпадающий» с count(*) по всей таблице.""" + db = _FakeDB([_FakeResult(scalar_value=4850), _FakeResult(mapping_rows=[])]) + lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7, _REGION_66) + poi_count_params = db.executed[0][1] + lat_min, lat_max, lon_min, lon_max = _REGION_66.bbox_product_core + assert poi_count_params["bbox_south"] == lat_min + assert poi_count_params["bbox_north"] == lat_max + assert poi_count_params["bbox_west"] == lon_min + assert poi_count_params["bbox_east"] == lon_max + + +def test_compute_location_index_moscow_point_isolates_region_and_degrades_honestly() -> None: + """End-to-end #3051 Москва: citywide/local запросы обязаны нести region_code=77 (не 66, + не отсутствие фильтра), а poi_status обязан быть честным "unavailable" — НЕ "ok" с + пустым списком, как было до фикса дефекта 2.""" + db = _FakeDB( + [ + _FakeResult(scalar_value=0), # poi-count in Moscow core: 0 + _FakeResult(mapping_one={"median_ppm2": None, "n": 0}), # citywide: no MSK data yet + ] + ) + result = lc.compute_location_index(db, lat=_LAT_IN_MSK, lon=_LON_IN_MSK) + assert result.status == "insufficient_data" # честно: листингов Москвы пока нет + assert result.poi_status == "unavailable" + assert result.nearby_poi == [] + citywide_params = db.executed[1][1] + assert citywide_params["region_code"] == 77 -- 2.45.3 From 3fae2b4ed8abd7a8faae749e3bf02b3a20bd7f7e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 13 Sep 2026 13:45:32 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20OR=20region=5Fcode=20IS=20NULL=20=D0=B8=D0=B7=20?= =?UTF-8?q?median-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: -- 2.45.3