diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index 4af7c1ab..ae525d5f 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -320,7 +320,14 @@ _SALES_WINDOW_SQL = text( SELECT COUNT(*) AS units_sold_window, COALESCE(SUM(area_pd), 0) AS area_sold_window, - rooms_int + rooms_int, + -- #1214: ROLLUP grand-total и NULL-группа дают rooms_int IS NULL обе. + -- Различаем через GROUPING(): =1 для grand-total, =0 для NULL-комнатной + -- группы. Без этого один проданный лот с rooms_int IS NULL даёт ДВЕ + -- строки rooms_int IS NULL (NULL-группа + итог), и MixedAggregate-план + -- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным + -- счётом → unit_velocity/absorption занижены, MoS завышен. + GROUPING(rooms_int) AS is_total FROM objective_lots ol WHERE ol.premise_kind = :premise_kind AND ( @@ -477,9 +484,13 @@ def _query_sales_window( ) -> tuple[int, float, dict[str, int]]: """Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}). - GROUP BY ROLLUP: строка с rooms_int IS NULL — это grand-total (берём как - units/area), остальные строки — разбивка по комнатности (для liquidity / - demand_concentration). На ошибке/пусто → (0, 0.0, {}). + GROUP BY ROLLUP с GROUPING(rooms_int) AS is_total (#1214): + • is_total=1 → grand-total (units/area за все комнаты); + • is_total=0 и rooms_int IS NULL → разбивка для лотов БЕЗ rooms — кладём + в by_room['unknown'] (а не путаем с total); + • is_total=0 и rooms_int не NULL → разбивка по конкретной комнатности. + by_room аккумулирует через += чтобы при будущих доп.NULL-вариантах не + затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}). """ try: rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all() @@ -495,12 +506,17 @@ def _query_sales_window( for r in rows: cnt = int(r["units_sold_window"] or 0) area = float(r["area_sold_window"] or 0.0) - if r["rooms_int"] is None: - # ROLLUP grand-total. + if int(r["is_total"]) == 1: + # ROLLUP grand-total — единственная строка с GROUPING=1. units_total = cnt area_total = area + elif r["rooms_int"] is None: + # Лоты с rooms_int IS NULL (ETL пишет NULL для «неопределённого типа») + # — отдельный бакет, не путаем с total. + by_room["unknown"] = by_room.get("unknown", 0) + cnt else: - by_room[_room_bucket(int(r["rooms_int"]))] = cnt + bucket = _room_bucket(int(r["rooms_int"])) + by_room[bucket] = by_room.get(bucket, 0) + cnt return units_total, area_total, by_room diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py index 833e0faa..ebeaf48a 100644 --- a/backend/tests/services/site_finder/test_market_metrics.py +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -341,12 +341,13 @@ _FULL_STOCK = { "obj_count": 3, "n_long_unsold": 42, } -# ROLLUP: grand-total (rooms_int=None) + per-room buckets. +# ROLLUP с GROUPING(rooms_int) AS is_total (#1214): +# is_total=1 → grand-total строка; is_total=0 → разбивка по rooms_int. _FULL_SALES = [ - {"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None}, - {"units_sold_window": 30, "area_sold_window": 900.0, "rooms_int": 1}, - {"units_sold_window": 20, "area_sold_window": 1200.0, "rooms_int": 2}, - {"units_sold_window": 10, "area_sold_window": 780.0, "rooms_int": 0}, + {"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None, "is_total": 1}, + {"units_sold_window": 30, "area_sold_window": 900.0, "rooms_int": 1, "is_total": 0}, + {"units_sold_window": 20, "area_sold_window": 1200.0, "rooms_int": 2, "is_total": 0}, + {"units_sold_window": 10, "area_sold_window": 780.0, "rooms_int": 0, "is_total": 0}, ] @@ -544,7 +545,9 @@ class TestComputeMarketMetricsThinData: "n_long_unsold": 5, } # только grand-total строка с 0 продаж - sales = [{"units_sold_window": 0, "area_sold_window": 0.0, "rooms_int": None}] + sales = [ + {"units_sold_window": 0, "area_sold_window": 0.0, "rooms_int": None, "is_total": 1} + ] db = _mock_db(stock, sales) with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}): m = compute_market_metrics(db, district="Тихий") @@ -622,10 +625,26 @@ class TestSalesWindowSource: "units_sold_window": real_window_units, "area_sold_window": 110_000.0, "rooms_int": None, + "is_total": 1, + }, + { + "units_sold_window": 1_000, + "area_sold_window": 40_000.0, + "rooms_int": 1, + "is_total": 0, + }, + { + "units_sold_window": 800, + "area_sold_window": 44_000.0, + "rooms_int": 2, + "is_total": 0, + }, + { + "units_sold_window": 508, + "area_sold_window": 26_000.0, + "rooms_int": 3, + "is_total": 0, }, - {"units_sold_window": 1_000, "area_sold_window": 40_000.0, "rooms_int": 1}, - {"units_sold_window": 800, "area_sold_window": 44_000.0, "rooms_int": 2}, - {"units_sold_window": 508, "area_sold_window": 26_000.0, "rooms_int": 3}, ] db = _mock_db(stock, sales) with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):