fix(market_metrics): disambiguate ROLLUP grand-total via GROUPING() (#1214)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m36s
Deploy / build-worker (push) Has been cancelled

_SALES_WINDOW_SQL делал GROUP BY ROLLUP (rooms_int), rooms_int nullable
(ETL пишет NULL для «неопределённого типа», sales_series.py:399 явно
обрабатывает None). Проданный лот с rooms_int IS NULL даёт ДВЕ строки
rooms_int IS NULL (NULL-группа + grand-total итог), неразличимые в
Python (оба if r["rooms_int"] is None).

MixedAggregate-план PG16 эмитит grand-total ПЕРВЫМ (среди hash-строк),
NULL-группа после → loop затирает units_total частичным счётом (живой
тест на PG16: 2000 → 200). Эффект: unit_velocity / absorption_rate
занижены, months_of_supply завышен → base_pace в demand_supply_forecast
неверный (recommendation.py:586) → reports/scoring врёт.

Patch:
- SQL: добавить GROUPING(rooms_int) AS is_total (=1 для grand-total).
- Python: ветвить по is_total, NULL-комнатную группу класть в
  by_room['unknown'] (отдельный бакет), аккумулировать через +=
  вместо assign (защита от будущих NULL-вариантов).
- Тесты: моки получили "is_total" поле (1 для grand-total, 0 иначе).

71/71 market_metrics тестов зелёные. ruff clean.

Closes #1214
This commit is contained in:
Light1YT 2026-06-13 10:43:46 +05:00 committed by bot-backend
parent 8f3e461959
commit bc2d393b05
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"}):