From fe7d9d757b65725f43320b15fac5cfb4d16b5f34 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 15:07:52 +0500 Subject: [PATCH] =?UTF-8?q?fix(best-layouts):=20avg=5Farea=5Fm2=20=3D=20NU?= =?UTF-8?q?LL=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=200=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=BC=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: в deals_per_bucket средняя площадь считалась через COALESCE(SUM/NULLIF(...),0) — при нуле сделок за окно строка показывала «0.0 м²» как измеренное значение, и это же 0 участвовал во взвешенной средней по room_bucket (mix), занижая её. Соседняя avg_price_per_m2_rub уже отдаёт NULL (#2464-B) — выравниваем контракт. Что: SQL без COALESCE(...,0); схема TopLayoutRow.avg_area_m2: float|None; сборка строк и mix-взвешивание пропускают None (свой знаменатель rb_area_total_deals); PDF-ТЗ рисует «—»; фронт null → «—»; api-types.ts регенерирован (1 строка). Тест test_2867_avg_area_nullable.py: 5/5 красных на origin/main по значению (COALESCE в SQL, float-схема, 0 в сборке, 0 в mix, 0.0 в PDF), 5/5 зелёных с правкой; смежные best_layouts/layout_tz — 102 passed. Closes #2867 --- backend/app/schemas/parcel.py | 3 +- .../app/services/exporters/layout_tz_pdf.py | 8 +- .../app/services/site_finder/best_layouts.py | 38 +++-- backend/tests/test_2867_avg_area_nullable.py | 130 ++++++++++++++++++ .../site-finder/BestLayoutsBlock.tsx | 4 +- frontend/src/lib/api-types.ts | 2 +- frontend/src/types/best-layouts.ts | 3 +- 7 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_2867_avg_area_nullable.py 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) -- 2.45.3