From be68ba8f549e4d5426c61bca30b4df39107175c2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:29:09 +0300 Subject: [PATCH] fix(site-finder): non-overlapping L2/L3 ready_dt windows, stop double-count of 13-24mo supply (#1355) --- .../app/services/site_finder/supply_layers.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/app/services/site_finder/supply_layers.py b/backend/app/services/site_finder/supply_layers.py index f045dd58..02e92796 100644 --- a/backend/app/services/site_finder/supply_layers.py +++ b/backend/app/services/site_finder/supply_layers.py @@ -81,9 +81,14 @@ _EKB_REGION_CODE: int = 66 # Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations). _L2_MEDIUM_MIN_COVERAGE: float = 0.6 +# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы: +# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo] +# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞) +# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap. +_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL) # L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже # почти-на-рынке предложение (ловится L1/L2), не «future». -_L3_FUTURE_HORIZON_MONTHS: int = 12 +_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес # L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго # 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт. @@ -215,6 +220,10 @@ _L1_OPEN_SQL = text( # free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим; # documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с # free_flats) для confidence. NO join на objective_lots / complexes — намеренно. +# +# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL — +# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2. +# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞). _L2_HIDDEN_SQL = text( """ WITH latest AS ( @@ -222,7 +231,8 @@ _L2_HIDDEN_SQL = text( o.obj_id, o.district_name, o.flat_count, - o.free_flats + o.free_flats, + o.ready_dt FROM domrf_kn_objects o WHERE o.region_cd = :region_cd AND o.district_name IS NOT NULL @@ -240,6 +250,8 @@ _L2_HIDDEN_SQL = text( GREATEST(0, COALESCE(flat_count, 0) - free_flats) ) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units FROM latest + WHERE ready_dt IS NULL + OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval) GROUP BY district_name """ ) @@ -368,7 +380,11 @@ def compute_layer2_hidden( source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic). units_estimate всегда ≥ 0 (clamp). Пустые данные → []. Не бросает. """ - params: dict[str, Any] = {"region_cd": _EKB_REGION_CODE, "district": district} + params: dict[str, Any] = { + "region_cd": _EKB_REGION_CODE, + "district": district, + "near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months", + } rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2) out: list[SupplyLayerRow] = []