diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py
index 118f527a..078dde50 100644
--- a/backend/app/schemas/parcel.py
+++ b/backend/app/schemas/parcel.py
@@ -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% (несопоставимые окна)
diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py
index 4061dabd..581f200d 100644
--- a/backend/app/services/exporters/layout_tz_pdf.py
+++ b/backend/app/services/exporters/layout_tz_pdf.py
@@ -50,6 +50,12 @@ def build_layout_tz_html(
return "
— | "
return f"{val:,.0f}".replace(",", " ") + " ₽ | "
+ def _area_cell(val: float | None) -> str:
+ """#2867: средняя площадь — None, если сделок за окно нет → «—», а не «0.0»."""
+ if val is None:
+ return "— | "
+ return f"{val:.1f} | "
+
def _price_m2_cell(val: float | None) -> str:
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
if val is None:
@@ -69,7 +75,7 @@ def build_layout_tz_html(
f"{_html.escape(r.room_bucket)} | "
f"{_html.escape(r.area_bin)} | "
f"{r.velocity_per_month:.1f} | "
- f"{r.avg_area_m2:.1f} | "
+ f"{_area_cell(r.avg_area_m2)}"
f"{_price_cell(r.avg_price_per_m2_rub)}"
f"{r.total_sold_in_window} | "
""
diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py
index f7567a49..ea7045e5 100644
--- a/backend/app/services/site_finder/best_layouts.py
+++ b/backend/app/services/site_finder/best_layouts.py
@@ -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:
diff --git a/backend/tests/test_2867_avg_area_nullable.py b/backend/tests/test_2867_avg_area_nullable.py
new file mode 100644
index 00000000..b9a96cfb
--- /dev/null
+++ b/backend/tests/test_2867_avg_area_nullable.py
@@ -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 "— | "' in src
+ assert "{val:.1f} | " in src, "число перестало печататься с одним знаком"
diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx
index c42d70fa..620bd97d 100644
--- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx
+++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx
@@ -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)
+ : "—"}
100% (incompatible time windows)
|