Compare commits
1 commit
668f8c6ffb
...
4e6ed8c044
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6ed8c044 |
3 changed files with 1 additions and 189 deletions
|
|
@ -413,18 +413,8 @@ _SUPPLY_ONLY_LOTS_SQL = text("""
|
||||||
WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text
|
WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text
|
||||||
ELSE '4+'
|
ELSE '4+'
|
||||||
END AS rb,
|
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
|
CASE
|
||||||
WHEN area_pd IS NULL THEN 'н/д'
|
WHEN area_pd IS NULL THEN '<25'
|
||||||
WHEN area_pd < 25 THEN '<25'
|
WHEN area_pd < 25 THEN '<25'
|
||||||
WHEN area_pd < 40 THEN '25-40'
|
WHEN area_pd < 40 THEN '25-40'
|
||||||
WHEN area_pd < 60 THEN '40-60'
|
WHEN area_pd < 60 THEN '40-60'
|
||||||
|
|
|
||||||
|
|
@ -135,15 +135,6 @@ tests/sql/test_2464_default_profile_unique.py::test_without_migration_two_defaul
|
||||||
tests/sql/test_2464_default_profile_unique.py::test_reassigning_default_still_works
|
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
|
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
|
|
||||||
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
||||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
"""Лоты без площади — отдельная корзина, а не «<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}"
|
|
||||||
Loading…
Add table
Reference in a new issue