diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 14faf117..86226793 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -148,17 +148,20 @@ _INLINE_VELOCITY_SQL = text(""" # ── SQL: supply по (room_bucket, area_bin) за последний снимок ─────────────── # Один батч-запрос вместо N — возвращает map (rb, ab) → count. # room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py. +# Fix #1229: bucket'ы нормализованы под velocity-вокабуляр objective_corpus_room_month +# = {studio, 1, 2, 3, 4+}. Раньше supply отдельно отдавал euro-1/euro-2 для rooms=2 +# (area<35 / area<50) — эти ключи никогда не матчились с velocity (Объектив +# euro-форматов не выделяет, см. _INLINE_VELOCITY_SQL passthrough room_bucket). +# Последствия: 1) supply '2' искусственно занижен (rooms=2 + area<50 уходили в +# euro-*) → sold_pct/is_oversold двушек завышены; 2) euro-* строки supply +# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его +# начнёт отдавать velocity-сторона. _SUPPLY_BATCH_SQL = text(""" SELECT CASE WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio' WHEN f.rooms = 0 THEN 'studio' - -- Fix SF-08: euro-форматы — DOM.РФ маркирует малогабаритные квартиры как 2-комн. - -- rooms=2 + area<35 → euro-1 (студия с отдельной кухней ~26м²) - -- rooms=2 + area<50 → euro-2 (~35-50м², евро-двушка) - WHEN f.rooms = 2 AND f.total_area < 35 THEN 'euro-1' - WHEN f.rooms = 2 AND f.total_area < 50 THEN 'euro-2' WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text WHEN f.rooms >= 4 THEN '4+' ELSE '1' diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 0720feaa..c88a6dfc 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -342,6 +342,57 @@ def test_total_sold_in_window_matches_deals_window() -> None: assert resp.top_layouts[0].total_sold_in_window == int(deals) +# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ──────────────── + + +def test_supply_velocity_buckets_match_for_2_rooms() -> None: + """Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади. + + Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 → эти + строки выпадали из знаменателя bucket '2' → sold_pct/is_oversold двушек + были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct + рассчитывается от полного supply. + + Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в + один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply → 40% sold. + """ + deals = 20.0 + id_rows = [_obj_id_row(7)] + vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])] + supply_rows = [_supply_row("2", "40-60", 50)] + db = _make_db( + coord=_coord_row(), + id_rows=id_rows, + vel_rows=vel_rows, + supply_rows=supply_rows, + ) + req = _request(time_window="last_quarter") + resp = get_best_layouts(db, CAD_NUM, req) + + assert len(resp.top_layouts) == 1 + row = resp.top_layouts[0] + assert row.room_bucket == "2" + assert row.supply_units_in_radius == 50 + # 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше + # → sold_pct > 40 или is_oversold=True). + assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3) + assert row.is_oversold is False + + +def test_supply_does_not_emit_euro_buckets() -> None: + """Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'. + + Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply + без согласования с velocity-стороной (objective_corpus_room_month отдаёт + {studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет. + """ + from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL + + sql_text = str(_SUPPLY_BATCH_SQL.text) + assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229" + assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229" + + # ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────