fix(market_metrics): disambiguate ROLLUP grand-total via GROUPING() (#1214) #1258

Merged
bot-backend merged 1 commit from fix/market-metrics-rollup-grand-total-disambiguate into main 2026-06-13 05:57:20 +00:00
2 changed files with 51 additions and 16 deletions

View file

@ -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

View file

@ -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"}):