From 894ebe994658efee04e83e0cc68630be9a469ad3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 5 Jul 2026 23:30:03 +0500 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20flat=5Fcount-NULL=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D1=8B=20=D0=B8=D0=B7=20L2=20hidd?= =?UTF-8?q?en-supply=20SQL=20(#2445-A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2-агрегат молча коалесил flat_count NULL в 0 → GREATEST(0, -free_flats)=0 для объектов с реально НЕИЗВЕСТНЫМ числом заявленных единиц, при этом всё равно считая их в знаменателе покрытия n_with_free_flats. Результат: инфлейт покрытия + ложные «0 скрытых» вместо «неизвестно». Гейтим оба (n_with_free_flats COUNT и hidden_units SUM) на flat_count IS NOT NULL AND free_flats IS NOT NULL — как уже делает Python-хелпер _hidden_units() и sibling-запросы 60_/64_v_zk_rosreestr_velocity.sql. Знаменатель n_objects_total не тронут → покрытие честно падает. Refs #2445 --- .../app/services/site_finder/supply_layers.py | 44 +++++++++++++++--- .../site_finder/test_supply_layers.py | 45 +++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/backend/app/services/site_finder/supply_layers.py b/backend/app/services/site_finder/supply_layers.py index 4f33c8da..8444a66f 100644 --- a/backend/app/services/site_finder/supply_layers.py +++ b/backend/app/services/site_finder/supply_layers.py @@ -46,6 +46,9 @@ Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пус м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ — чище, чем выдуманный sold-proxy) → coverage снижается → confidence падает с medium до low. + Тот же exclusion — И при flat_count IS NULL (#2445-A3: заполняется независимо от + free_flats, м.124) — иначе GREATEST(0, COALESCE(flat_count,0) − free_flats) молча + давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage. • L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57 spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь — БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73). @@ -244,6 +247,21 @@ _L1_OPEN_SQL = text( # documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с # free_flats) для confidence. NO join на objective_lots / complexes — намеренно. # +# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats +# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL: +# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count, +# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units, +# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в +# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг, +# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS +# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг +# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на +# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL +# полностью исключается (как из числителя coverage, так и из hidden_units), а не +# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql / +# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0` +# на этой же таблице. +# # Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL — # объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2. # Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞). @@ -268,10 +286,14 @@ _L2_HIDDEN_SQL = text( SELECT district_name, COUNT(*) AS n_objects_total, - COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats, + COUNT(*) FILTER ( + WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL + ) AS n_with_free_flats, COALESCE(SUM( - GREATEST(0, COALESCE(flat_count, 0) - free_flats) - ) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units + GREATEST(0, flat_count - free_flats) + ) FILTER ( + WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL + ), 0) AS hidden_units FROM latest WHERE ready_dt IS NULL OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval) @@ -397,8 +419,11 @@ def compute_layer2_hidden( Per dom.рф-объект hidden = max(0, flat_count − free_flats); Σ к району. ОБЕ колонки на одной строке domrf_kn_objects → НЕ нужен domrf↔objective маппинг (см. docstring - модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки → не вычислить; - documented limitation) — снижают coverage → confidence medium→low. + модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются + (#2445-A3: обе колонки nullable и заполняются независимо — объект с известным + free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой + объект НЕ считается «покрытым» и не входит в coverage-числитель) — снижают coverage + → confidence medium→low. source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic). units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает. @@ -413,11 +438,16 @@ def compute_layer2_hidden( out: list[SupplyLayerRow] = [] for r in rows: n_total = int(r["n_objects_total"] or 0) + # n_with_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны + # (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим. + # Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь + # "покрыто и вычислимо", не только "free_flats заполнен". n_with = int(r["n_with_free_flats"] or 0) # hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся. hidden = max(0, int(r["hidden_units"] or 0)) if n_with <= 0: - # Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»). + # Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats + # известны) → нечего объявлять скрытым (а не «0»). continue confidence = _l2_confidence(n_total, n_with) out.append( @@ -434,7 +464,7 @@ def compute_layer2_hidden( confidence=confidence, method=( f"domrf_kn_objects: Σ max(0, flat_count − free_flats); " - f"coverage {n_with}/{n_total} objects with free_flats" + f"coverage {n_with}/{n_total} objects with flat_count & free_flats known" ), snapshot_date=snapshot_date, ) diff --git a/backend/tests/services/site_finder/test_supply_layers.py b/backend/tests/services/site_finder/test_supply_layers.py index e9e245fd..048ab910 100644 --- a/backend/tests/services/site_finder/test_supply_layers.py +++ b/backend/tests/services/site_finder/test_supply_layers.py @@ -379,6 +379,51 @@ class TestLayer2Hidden: sql = _executed_sql(db) assert "free_flats IS NOT NULL" in sql + def test_sql_excludes_null_flat_count_from_both_filters(self) -> None: + # #2445-A3 (audit fix): flat_count IS NULL — заполняется НЕЗАВИСИМО от free_flats + # (м.124 "NULL = не заполнено"). Раньше GREATEST(0, COALESCE(flat_count,0) - + # free_flats) молча давало 0 hidden для объекта с генуинно неизвестным + # flat_count, И этот объект всё равно засчитывался в n_with_free_flats (гейт был + # только free_flats IS NOT NULL) → раздутый coverage-деноминатор. Fix гейтит ОБЕ + # агрегатные колонки (COUNT-денoминатор и SUM) на flat_count IS NOT NULL тоже — + # зеркалит _hidden_units() (возвращает None при flat_count IS None) и sibling + # convention 60_v_zk_rosreestr_velocity.sql / 64_v_zk_rosreestr_velocity.sql + # ("o.flat_count IS NOT NULL AND o.flat_count > 0" на этой же таблице). + db = _mock_db([]) + compute_layer2_hidden(db, district="X") + sql = _executed_sql(db) + norm = " ".join(sql.split()) + # Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны + # гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL. + assert ( + norm.count("flat_count IS NOT NULL") == 2 + ), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}" + assert ( + norm.count("free_flats IS NOT NULL") == 2 + ), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}" + + def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end( + self, + ) -> None: + # End-to-end (mocked-row) mirror of test_none_flat_count_returns_none, but at the + # SQL-aggregate boundary: simulate what the CORRECTED SQL would return for a + # district containing exactly one object with free_flats=5 but flat_count IS NULL + # (partially-backfilled snapshot). That object must be excluded from BOTH + # n_with_free_flats (coverage numerator) and hidden_units (SUM) by the SQL itself + # — so the row the Python layer receives already reflects n_with=0, hidden=0 for + # a district containing only that one ambiguous object. + rows = [ + { + "district_name": "X", + "n_objects_total": 1, # the one flat_count-NULL object still counts here + "n_with_free_flats": 0, # excluded: flat_count IS NULL despite free_flats=5 + "hidden_units": 0, # excluded from SUM too — NOT GREATEST(0, -5) miscounted + } + ] + db = _mock_db(rows) + # n_with <= 0 → whole row skipped (no fabricated "0 hidden" district row). + assert compute_layer2_hidden(db, district="X") == [] + def test_sql_dedups_latest_snapshot(self) -> None: # КРИТИЧНО: domrf_kn_objects = UNIQUE(obj_id, snapshot_date), накапливает снапшоты. # Без DISTINCT ON (obj_id) + snapshot_date DESC наивный SUM умножил бы hidden на