From 1b2a26bb9ab0d872a8ab3d3ff1ff1e734e1df0bc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 21:41:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=BB=D0=BE=D1=82=D1=8B=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B2=D0=BE=D1=8F=20=D0=BA=D0=BE=D1=80=D0=B7?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=C2=AB<25=20?= =?UTF-8?q?=D0=BC=C2=B2=C2=BB=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину `'<25'`, что и настоящие студии. Замер прода 20.08.2026 (последний снапшот на физлот, premise_kind='квартира', не проданные): в продаже 181 353 без area_pd 11 557 (6.4 %) реально < 25 м² 7 013 Корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких лотов в блоке «По предложению (без темпа продаж)». Зеркала у такого отображения не было: `layout_signature.area_bin` принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади не группирует — то есть `NULL → '<25'` было выдумкой, а не переносом чужого правила. Исключать такие лоты нельзя: они реально в продаже, и без них предложение занизилось бы на 6.4 %. Поэтому отдельная корзина «н/д». Медиана площади у неё выйдет NULL (PERCENTILE_CONT игнорирует NULL) — честно. Схема не меняется: area_bin остаётся str, OpenAPI прежний. Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица objective_lots затеняет боевую в пределах сессии, запрос берётся из модуля дословно, прод-данные не читаются. Двусторонне: против origin/main корзины распределяются как {'<25': 2, '25-40': 1, '40-60': 1} — конкретное неверное значение, ни одного TypeError/ImportError. Контроли (сумма лотов сохраняется, обычные корзины не меняются) зелёные с обеих сторон. pytest backend/tests/sql/ — 38 passed. Co-Authored-By: Claude Opus 5 --- .../app/services/site_finder/best_layouts.py | 12 +- backend/tests/skip_allowlist.txt | 10 ++ .../tests/sql/test_2464_area_bin_unknown.py | 169 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 backend/tests/sql/test_2464_area_bin_unknown.py diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index aaee692b..f7567a49 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -413,8 +413,18 @@ _SUPPLY_ONLY_LOTS_SQL = text(""" WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text ELSE '4+' END AS rb, + -- #2464: площадь неизвестна — это ОТДЕЛЬНАЯ корзина, а не «<25». + -- Прежде NULL сваливался к настоящим студиям: на проде 20.08.2026 + -- в продаже 11 557 квартир без area_pd против 7 013 реально + -- меньших 25 м², то есть корзина «<25» на 62 % состояла из + -- неизвестного и завышала долю мелких лотов в структуре остатков. + -- Зеркала у этого отображения не было: layout_signature.area_bin + -- принимает float и NULL-ветки не имеет вовсе. + -- Исключать такие лоты нельзя — они реально в продаже, и без них + -- предложение занижалось бы на 6.4 %. Медиана площади у этой + -- корзины выйдет NULL (PERCENTILE_CONT игнорирует NULL) — честно. CASE - WHEN area_pd IS NULL THEN '<25' + WHEN area_pd IS NULL THEN 'н/д' WHEN area_pd < 25 THEN '<25' WHEN area_pd < 40 THEN '25-40' WHEN area_pd < 60 THEN '40-60' diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 3070379f..4c6f7706 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -134,3 +134,13 @@ tests/sql/test_2464_default_profile_unique.py::test_second_default_is_rejected_b tests/sql/test_2464_default_profile_unique.py::test_without_migration_two_defaults_slip_through tests/sql/test_2464_default_profile_unique.py::test_reassigning_default_still_works tests/sql/test_2464_default_profile_unique.py::test_different_users_keep_their_own_defaults + +# ── #2464: корзина площади «н/д» в supply-only (best_layouts) ───────────────── +# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ таблицу objective_lots, которая +# затеняет боевую в пределах сессии, и прогоняют НАСТОЯЩИЙ _SUPPLY_ONLY_LOTS_SQL +# на засеянных лотах. В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины +# без БД и без туннеля. +tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as_tiny +tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket +tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved +tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged diff --git a/backend/tests/sql/test_2464_area_bin_unknown.py b/backend/tests/sql/test_2464_area_bin_unknown.py new file mode 100644 index 00000000..0fd59e2c --- /dev/null +++ b/backend/tests/sql/test_2464_area_bin_unknown.py @@ -0,0 +1,169 @@ +"""Лоты без площади — отдельная корзина, а не «<25 м²» (#2464). + +`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину `'<25'`, +что и настоящие студии. Замер прода 20.08.2026 (последний снапшот на физлот, +premise_kind='квартира', не проданные): + + в продаже 181 353 + без area_pd 11 557 (6.4 %) + реально < 25 м² 7 013 + +То есть корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких +лотов в структуре остатков — это блок «По предложению (без темпа продаж)» +отчёта. Зеркала у такого отображения не было: `layout_signature.area_bin` +принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади не +группирует. Исключать лоты тоже нельзя — они реально в продаже. + +Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица `objective_lots` +затеняет боевую в пределах сессии (temp-схема идёт раньше public в search_path), +поэтому запрос из модуля выполняется дословно, а прод-данные не читаются. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_TEMP_TABLE = """ +CREATE TEMP TABLE objective_lots ( + id bigserial PRIMARY KEY, + project_name text, + corpus_name text, + section text, + floor integer, + lot_number text, + rooms_int smallint, + area_pd numeric, + price_per_m2_rub numeric, + is_sold boolean, + status text, + premise_kind text, + snapshot_date date +) ON COMMIT DROP +""" + +# Один проект, четыре лота: без площади, настоящая студия, 30 м², 50 м². +_ROWS = [ + ("ЖК Пример", "к1", "с1", 3, "101", 1, None, 200000, "квартира"), + ("ЖК Пример", "к1", "с1", 3, "102", 1, Decimal("22.0"), 200000, "квартира"), + ("ЖК Пример", "к1", "с1", 4, "201", 1, Decimal("30.0"), 210000, "квартира"), + ("ЖК Пример", "к1", "с1", 5, "301", 2, Decimal("50.0"), 220000, "квартира"), +] + + +def _buckets() -> dict[str, int]: + """Выполнить настоящий запрос модуля на временной таблице → {корзина: лотов}.""" + from app.services.site_finder.best_layouts import ( + _SUPPLY_ONLY_LOTS_SQL, + _SUPPLY_ONLY_PREMISE_KIND, + _SUPPLY_ONLY_PRICE_MAX, + _SUPPLY_ONLY_PRICE_MIN, + ) + + session = sessionmaker(bind=create_engine(_dsn()))() + try: + session.execute(text(_TEMP_TABLE)) + for pn, corp, sec, fl, lot, rooms, area, price, kind in _ROWS: + session.execute( + text( + "INSERT INTO objective_lots (project_name, corpus_name, section, floor," + " lot_number, rooms_int, area_pd, price_per_m2_rub, is_sold, status," + " premise_kind, snapshot_date) VALUES (:pn,:corp,:sec,:fl,:lot,:rooms," + ":area,:price,false,'в продаже',:kind,CURRENT_DATE)" + ), + { + "pn": pn, + "corp": corp, + "sec": sec, + "fl": fl, + "lot": lot, + "rooms": rooms, + "area": area, + "price": price, + "kind": kind, + }, + ) + rows = ( + session.execute( + _SUPPLY_ONLY_LOTS_SQL, + { + "names": ["ЖК Пример"], + "premise_kind": _SUPPLY_ONLY_PREMISE_KIND, + "price_min": _SUPPLY_ONLY_PRICE_MIN, + "price_max": _SUPPLY_ONLY_PRICE_MAX, + }, + ) + .mappings() + .all() + ) + return {str(r["area_bin"]): int(r["lots_count"]) for r in rows} + finally: + session.rollback() + session.close() + + +def test_lot_without_area_is_not_counted_as_tiny() -> None: + """Головной: лот без площади не должен попадать в корзину настоящих студий. + + На origin/main корзина «<25» содержит 2 лота вместо одного — второй это + лот с area_pd IS NULL. + """ + b = _buckets() + assert b.get("<25") == 1, ( + f"в корзину «<25» попал лот без площади: {b} — на проде так туда " + "уезжают 11 557 квартир при 7 013 настоящих" + ) + + +def test_unknown_area_gets_its_own_bucket() -> None: + """Лот без площади обязан быть виден отдельно, а не исчезнуть. + + Исключение занизило бы предложение на 6.4 %: лоты реально в продаже. + """ + b = _buckets() + assert b.get("н/д") == 1, f"корзины «н/д» нет — лот без площади потерян: {b}" + + +def test_total_lots_preserved() -> None: + """Контроль сохранности: сумма по корзинам равна числу лотов в продаже.""" + b = _buckets() + assert sum(b.values()) == len(_ROWS), f"лоты потеряны или задвоены: {b}" + + +def test_normal_buckets_unchanged() -> None: + """Контроль от переусердствования: обычные площади раскладываются как раньше.""" + b = _buckets() + assert b.get("25-40") == 1, f"корзина 25-40: {b}" + assert b.get("40-60") == 1, f"корзина 40-60: {b}" -- 2.45.3