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

This commit is contained in:
bot-backend 2026-08-21 10:27:02 +00:00
parent fa32db299d
commit ccf84b4adc
7 changed files with 171 additions and 17 deletions

View file

@ -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% (несопоставимые окна)

View file

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

View file

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

View 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, "число перестало печататься с одним знаком"

View file

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

View file

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

View file

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