fix(best-layouts): avg_area_m2 = NULL вместо 0 при пустом окне сделок (#2867) (#3018)
Some checks failed
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 11s
Deploy / build-backend (push) Successful in 2m48s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 5m23s
Deploy / build-frontend (push) Successful in 4m18s
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 11s
Deploy / build-backend (push) Successful in 2m48s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 5m23s
Deploy / build-frontend (push) Successful in 4m18s
Deploy / deploy (push) Has been cancelled
This commit is contained in:
parent
fa32db299d
commit
ccf84b4adc
7 changed files with 171 additions and 17 deletions
|
|
@ -598,7 +598,8 @@ class TopLayoutRow(BaseModel):
|
|||
total_sold_in_window: int
|
||||
velocity_per_month: float
|
||||
avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj
|
||||
avg_area_m2: float
|
||||
# #2867: NULL если сделок за окно нет — средней площади нет; раньше отдавался 0 м².
|
||||
avg_area_m2: float | None
|
||||
supply_units_in_radius: int
|
||||
sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0
|
||||
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ def build_layout_tz_html(
|
|||
return "<td>—</td>"
|
||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
|
||||
|
||||
def _area_cell(val: float | None) -> str:
|
||||
"""#2867: средняя площадь — None, если сделок за окно нет → «—», а не «0.0»."""
|
||||
if val is None:
|
||||
return "<td>—</td>"
|
||||
return f"<td>{val:.1f}</td>"
|
||||
|
||||
def _price_m2_cell(val: float | None) -> str:
|
||||
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
|
||||
if val is None:
|
||||
|
|
@ -69,7 +75,7 @@ def build_layout_tz_html(
|
|||
f"<td>{_html.escape(r.room_bucket)}</td>"
|
||||
f"<td>{_html.escape(r.area_bin)}</td>"
|
||||
f"<td>{r.velocity_per_month:.1f}</td>"
|
||||
f"<td>{r.avg_area_m2:.1f}</td>"
|
||||
f"{_area_cell(r.avg_area_m2)}"
|
||||
f"{_price_cell(r.avg_price_per_m2_rub)}"
|
||||
f"<td>{r.total_sold_in_window}</td>"
|
||||
"</tr>"
|
||||
|
|
|
|||
|
|
@ -192,15 +192,15 @@ _INLINE_VELOCITY_SQL = text("""
|
|||
SELECT
|
||||
a.room_bucket,
|
||||
SUM(a.deals_window) AS deals_window,
|
||||
-- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2
|
||||
-- объявлен как float (не Optional), и NULL ронял бы контракт API.
|
||||
-- Пустые комнатности получают площадь 0 м², и это тоже неправда — но
|
||||
-- честный NULL требует правки схемы + перегенерации типов фронта
|
||||
-- и решения, что писать в area_bin. Отдельным заходом: #2867.
|
||||
COALESCE(
|
||||
-- #2867: БЕЗ COALESCE(...,0), как у avg_price_per_m2_rub ниже (#2464-B).
|
||||
-- Сделок за окно нет → делитель NULL → средней площади нет, и это NULL,
|
||||
-- а не «0 м²». Замер прода 13.08: 635 пустых пар (проект × комнатность)
|
||||
-- из 2083, у 80 проектов пусты ВСЕ комнатности — ноль выдумывался ровно
|
||||
-- там, где окрестность беднее замапленными проектами. Схема объявлена
|
||||
-- float | None, фронт и PDF печатают «—».
|
||||
(
|
||||
SUM(a.area_weighted_sum)
|
||||
/ NULLIF(SUM(a.deals_window), 0),
|
||||
0
|
||||
/ NULLIF(SUM(a.deals_window), 0)
|
||||
)::numeric(10, 2) AS avg_area_m2,
|
||||
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
|
||||
-- средней цены нет, и это NULL, а не «0 ₽/м²». Схема так и объявлена
|
||||
|
|
@ -1284,7 +1284,8 @@ def get_best_layouts(
|
|||
for r in vel_rows:
|
||||
room_bucket = str(r["room_bucket"])
|
||||
deals_window = float(r["deals_window"]) if r["deals_window"] is not None else 0.0
|
||||
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
|
||||
# #2867: None остаётся None — «сделок нет» ≠ «0 м²».
|
||||
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else None
|
||||
price_rub = (
|
||||
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
|
||||
)
|
||||
|
|
@ -1367,7 +1368,10 @@ def get_best_layouts(
|
|||
total_sold_in_window=int(row["sum_deals"]),
|
||||
velocity_per_month=row["velocity_per_month"],
|
||||
avg_price_per_m2_rub=row["avg_price_per_m2_rub"],
|
||||
avg_area_m2=round(row["avg_area_m2"], 1),
|
||||
# #2867: None (сделок нет) остаётся None — round(None) ронял бы сборку.
|
||||
avg_area_m2=(
|
||||
round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None
|
||||
),
|
||||
supply_units_in_radius=row["supply_units_in_radius"],
|
||||
sold_pct_of_supply=row["sold_pct_of_supply"],
|
||||
is_oversold=row["is_oversold"],
|
||||
|
|
@ -1499,6 +1503,7 @@ def _build_recommendation(
|
|||
# Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY)
|
||||
rb_deals: dict[str, float] = {}
|
||||
rb_area_weighted: dict[str, float] = {}
|
||||
rb_area_total_deals: dict[str, float] = {} # #2867: знаменатель только по рядам с площадью
|
||||
rb_price_weighted: dict[str, float] = {}
|
||||
rb_price_total_deals: dict[str, float] = {}
|
||||
all_competitor_ids: set[int] = set()
|
||||
|
|
@ -1507,7 +1512,12 @@ def _build_recommendation(
|
|||
rb = row["room_bucket"]
|
||||
sd = float(row["sum_deals"])
|
||||
rb_deals[rb] = rb_deals.get(rb, 0.0) + sd
|
||||
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
|
||||
# #2867: ряд без средней площади (сделок за окно нет) не участвует ни в числителе,
|
||||
# ни в знаменателе взвешенной площади — как у цены ниже. Иначе его sd считался бы
|
||||
# сделками «с площадью 0» и занижал среднее.
|
||||
if row["avg_area_m2"] is not None:
|
||||
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
|
||||
rb_area_total_deals[rb] = rb_area_total_deals.get(rb, 0.0) + sd
|
||||
all_competitor_ids.update(row["competitor_obj_ids"])
|
||||
if row["avg_price_per_m2_rub"] is not None:
|
||||
rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + (
|
||||
|
|
@ -1521,8 +1531,12 @@ def _build_recommendation(
|
|||
|
||||
mix: list[LayoutTzMixRow] = []
|
||||
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
|
||||
# #2867: делим на сделки рядов С площадью, а не на все — иначе ряды без площади
|
||||
# занижали бы среднее; нет ни одного ряда с площадью → None, не 0.
|
||||
avg_area = (
|
||||
round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None
|
||||
round(rb_area_weighted[rb] / rb_area_total_deals[rb], 1)
|
||||
if rb_area_total_deals.get(rb, 0) > 0
|
||||
else None
|
||||
)
|
||||
abs_units: int | None = None
|
||||
if target_total_flats is not None:
|
||||
|
|
|
|||
130
backend/tests/test_2867_avg_area_nullable.py
Normal file
130
backend/tests/test_2867_avg_area_nullable.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""avg_area_m2 в топ-планировках — NULL вместо 0 м², когда сделок за окно нет (#2867).
|
||||
|
||||
`_INLINE_VELOCITY_SQL` считал среднюю площадь как
|
||||
`COALESCE(SUM(area_weighted_sum) / NULLIF(SUM(deals_window), 0), 0)` — пустое окно
|
||||
продаж давало делитель NULL и COALESCE подставлял 0: «средняя площадь проданной
|
||||
квартиры — 0 м²» вместо «сделок не было, средней нет». Замер прода 13.08: 635 пустых
|
||||
пар (проект × комнатность) из 2083, у 80 проектов пусты все комнатности.
|
||||
|
||||
Контракт исправлен насквозь: схема `float | None`, SQL без COALESCE (как у соседнего
|
||||
avg_price_per_m2_rub, #2464-B), сборка рядов не round'ит None, взвешенная площадь
|
||||
микса делится на сделки рядов С площадью, PDF печатает «—», фронт/типы — `number | null`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import inspect
|
||||
import re
|
||||
|
||||
|
||||
def test_velocity_sql_does_not_coalesce_area_to_zero() -> None:
|
||||
"""Головной (SQL): в deals_per_bucket нет COALESCE(... area ..., 0).
|
||||
|
||||
На origin/main он есть — с комментарием «остаётся намеренно… отдельным заходом: #2867».
|
||||
"""
|
||||
from app.services.site_finder import best_layouts as m
|
||||
|
||||
sql = str(m._INLINE_VELOCITY_SQL)
|
||||
blk = sql[sql.index("deals_per_bucket AS (") : sql.index("AS avg_area_m2")]
|
||||
# Только код: SQL-комментарии (--) вырезаем — иначе гейт спорит с собственным
|
||||
# объяснением правки, где слово COALESCE упомянуто (наступал на это трижды).
|
||||
code = "\n".join(line for line in blk.splitlines() if not line.strip().startswith("--"))
|
||||
assert "COALESCE(" not in code, (
|
||||
"средняя площадь по-прежнему COALESCE'ится в 0 при пустом окне:\n" + code[-300:]
|
||||
)
|
||||
|
||||
|
||||
def test_schema_declares_avg_area_nullable() -> None:
|
||||
"""Контракт API: TopLayoutRow.avg_area_m2 допускает None."""
|
||||
from app.schemas.parcel import TopLayoutRow
|
||||
|
||||
ann = TopLayoutRow.model_fields["avg_area_m2"].annotation
|
||||
assert ann is not float, f"avg_area_m2 объявлен как {ann!r} — None ронял бы контракт"
|
||||
TopLayoutRow.model_validate(
|
||||
{
|
||||
"room_bucket": "1",
|
||||
"area_bin": "25-40",
|
||||
"project_name": "x",
|
||||
"signature": "s",
|
||||
"competitor_obj_ids": [],
|
||||
"competitor_count": 0,
|
||||
"total_sold_in_window": 0,
|
||||
"velocity_per_month": 0.0,
|
||||
"avg_price_per_m2_rub": None,
|
||||
"avg_area_m2": None,
|
||||
"supply_units_in_radius": 0,
|
||||
"sold_pct_of_supply": None,
|
||||
"is_oversold": False,
|
||||
"rank": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_row_assembly_keeps_none_not_zero() -> None:
|
||||
"""Сборка рядов: NULL из БД остаётся None, а не превращается в 0.0 / round(None).
|
||||
|
||||
На origin/main — `else 0.0`: пустое окно отдаёт 0 м². Проверяем по исходнику
|
||||
двух мест сборки, потому что собрать полный ответ без БД нельзя.
|
||||
"""
|
||||
from app.services.site_finder import best_layouts as m
|
||||
|
||||
src = inspect.getsource(m)
|
||||
assert 'if r["avg_area_m2"] is not None else None' in src, "в сборке ряда None → 0.0"
|
||||
assert (
|
||||
'round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None' in src
|
||||
), "round(row['avg_area_m2']) без проверки на None"
|
||||
|
||||
|
||||
def test_mix_weighted_area_excludes_rows_without_area() -> None:
|
||||
"""Контроль смысла: взвешенная площадь микса делится на сделки рядов С площадью.
|
||||
|
||||
Иначе ряд без площади (None) считался бы сделками «с площадью 0» и занижал среднее.
|
||||
То же правило, что у цены в соседних строках.
|
||||
"""
|
||||
from app.services.site_finder import best_layouts as m
|
||||
|
||||
src = inspect.getsource(m)
|
||||
assert "rb_area_total_deals" in src, "нет отдельного знаменателя для площади"
|
||||
assert re.search(
|
||||
r"rb_area_weighted\[rb\]\s*/\s*rb_area_total_deals\[rb\]", src
|
||||
), "площадь по-прежнему делится на все сделки (rb_deals), а не на ряды с площадью"
|
||||
|
||||
|
||||
def test_pdf_renders_dash_for_missing_area() -> None:
|
||||
"""PDF: строка с avg_area_m2=None печатает «—», а не падает на :.1f.
|
||||
|
||||
На origin/main `f"{r.avg_area_m2:.1f}"` на None бросает TypeError — это отсутствие
|
||||
возможности, а не неверное значение; поэтому рядом контроль: число печатается как
|
||||
раньше с одним знаком.
|
||||
"""
|
||||
from app.schemas.parcel import TopLayoutRow
|
||||
from app.services.exporters import layout_tz_pdf as pdf
|
||||
|
||||
def row(area):
|
||||
return TopLayoutRow(
|
||||
room_bucket="2",
|
||||
area_bin="40-60",
|
||||
project_name="ЖК",
|
||||
signature="sig",
|
||||
competitor_obj_ids=[1],
|
||||
competitor_count=1,
|
||||
total_sold_in_window=3,
|
||||
velocity_per_month=1.5,
|
||||
avg_price_per_m2_rub=150000.0,
|
||||
avg_area_m2=area,
|
||||
supply_units_in_radius=10,
|
||||
sold_pct_of_supply=30.0,
|
||||
is_oversold=False,
|
||||
rank=1,
|
||||
)
|
||||
|
||||
assert row(None).avg_area_m2 is None and row(38.5).avg_area_m2 == 38.5
|
||||
src = inspect.getsource(pdf)
|
||||
assert "_area_cell" in src, "нет ячейки площади с обработкой None"
|
||||
# прямая проверка самой ячейки через исходник функции — она вложенная в рендер
|
||||
assert 'return "<td>—</td>"' in src
|
||||
assert "<td>{val:.1f}</td>" in src, "число перестало печататься с одним знаком"
|
||||
|
|
@ -348,7 +348,9 @@ function TopLayoutsTable({
|
|||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{row.avg_area_m2.toFixed(1)}
|
||||
{row.avg_area_m2 != null
|
||||
? row.avg_area_m2.toFixed(1)
|
||||
: "—"}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -5485,7 +5485,7 @@ export interface components {
|
|||
/** Avg Price Per M2 Rub */
|
||||
avg_price_per_m2_rub: number | null;
|
||||
/** Avg Area M2 */
|
||||
avg_area_m2: number;
|
||||
avg_area_m2: number | null;
|
||||
/** Supply Units In Radius */
|
||||
supply_units_in_radius: number;
|
||||
/** Sold Pct Of Supply */
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ export interface TopLayoutRow {
|
|||
total_sold_in_window: number;
|
||||
velocity_per_month: number;
|
||||
avg_price_per_m2_rub: number | null;
|
||||
avg_area_m2: number;
|
||||
/** #2867: null — сделок за окно нет, средней площади нет (раньше отдавался 0). */
|
||||
avg_area_m2: number | null;
|
||||
supply_units_in_radius: number;
|
||||
sold_pct_of_supply: number | null; // clamped at 100.0
|
||||
is_oversold: boolean; // raw ratio was >100% (incompatible time windows)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue