gendesign/backend/tests/services/site_finder/test_best_layouts.py
bot-backend 73739671a9
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 15m25s
fix(site_finder): SAVEPOINT-isolate supply/market db.execute + kill 2 bare rollbacks (#2464)
Convert two bare db.rollback() calls on the shared per-request session
(best_layouts _fetch_supply_only_layouts + Step 2b core-attribution) to
db.begin_nested() — a bare rollback there discards ALL uncommitted work of
the request and orphans the outer SessionTransaction. Also wrap the swallow
sites in market_metrics (_query_stock, _query_sales_window, _price_sensitivity,
compute_offer_price_trend), best_layouts step-5 supply, future_supply
_query_supply_rows, own_portfolio _query_current/_query_future, and
custom_pois get_overlaps_for_scoring so a failed statement no longer poisons
the shared tx for later steps. Per-site regression tests assert fallback
preserved, begin_nested used, bare rollback NOT called, and session usable
after failure.

Refs #2464 (cluster A session-poisoning, Wave 2).
EOF2
echo "HANDLED"
2026-07-08 11:29:31 +05:00

1417 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit-тесты для get_best_layouts (Fix SF-01: honest time_window velocity).
Проверяет, что разные time_window → разные deals_window → разный velocity_per_month.
Mock-стратегия: патчим db.execute с side_effect, повторяя порядок вызовов
в get_best_layouts:
1. _PARCEL_CENTROID_SQL → .mappings().first()
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
3. _INLINE_VELOCITY_SQL → .mappings().all()
4. _SUPPLY_BATCH_SQL → .mappings().all()
Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
(flats_latest CTE), отдельного db.scalar(MAX(snapshot_date)) больше нет.
Ключевые asserts:
- last_month (1 мес) → velocity = deals_window / 1.0
- last_quarter (3 мес) → velocity = deals_window / 3.0
- last_year (12 мес) → velocity = deals_window / 12.0
- Разный deals_window при разных time_window → разный mix.
"""
from __future__ import annotations
import datetime as dt
from unittest.mock import MagicMock
import pytest
from app.schemas.parcel import BestLayoutsRequest
from app.services.site_finder.best_layouts import (
_COMPETITORS_IN_RADIUS_SQL,
_INLINE_VELOCITY_SQL,
_OBJECTIVE_DEVELOPERS_SQL,
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL,
_OBJECTIVE_PROJECT_NAMES_SQL,
_PARCEL_CENTROID_SQL,
_SUPPLY_BATCH_SQL,
_SUPPLY_ONLY_LOTS_SQL,
_TIME_WINDOW_PARAMS,
MAX_BUCKET_SHARE_PCT,
NAMELESS_CLUSTER_M,
_cap_and_redistribute,
_fetch_supply_only_layouts,
get_best_layouts,
group_radius_objects,
match_groups_to_projects,
normalize_complex_name,
)
_TODAY = dt.date.today()
CAD_NUM = "66:41:0303161:123"
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
return r
def _obj_id_row(
obj_id: int,
comm_name: str | None = None,
dev_name: str | None = None,
latitude: float | None = None,
longitude: float | None = None,
) -> dict[str, object]:
"""Строка из _COMPETITORS_IN_RADIUS_SQL.
Issue #2177: SQL теперь возвращает comm_name/dev_name/гео для группировки в
комплексы; get_best_layouts вызывает dict(r), поэтому возвращаем реальный dict
(SQLAlchemy RowMapping тоже dict-конвертируем). По умолчанию каждый obj_id
получает уникальное имя (`obj-<id>`) → каждый = своя группа, как раньше по
obj_id (существующие coverage-инварианты остаются в силе).
"""
return {
"obj_id": obj_id,
"comm_name": comm_name if comm_name is not None else f"obj-{obj_id}",
"dev_name": dev_name if dev_name is not None else f"dev-{obj_id}",
"latitude": latitude,
"longitude": longitude,
}
def _vel_row(
room_bucket: str = "2",
deals_window: float = 48.0,
avg_area: float = 55.0,
avg_price_rub: float | None = 120000.0,
obj_ids: list[int] | None = None,
project_names: list[str] | None = None,
lots_project_names: list[str] | None = None,
window_start: dt.date | None = None,
window_end: dt.date | None = None,
) -> MagicMock:
"""Строка из _INLINE_VELOCITY_SQL.
deals_window — реальные сделки за честное окно (не 24 мес).
project_names — matched_project_names (#2177 шаг 2b): project_name'ы с velocity в
этом bucket'е (объединение mapping core-match). None → [] (только obj_id-источник).
"""
oids = obj_ids if obj_ids is not None else [1]
pnames = project_names if project_names is not None else []
ws = window_start or _TODAY - dt.timedelta(days=90)
we = window_end or _TODAY
r = MagicMock()
r.__getitem__ = lambda self, k: {
"room_bucket": room_bucket,
"deals_window": deals_window,
"avg_area_m2": avg_area,
"avg_price_per_m2_rub": avg_price_rub,
"competitor_obj_ids": oids,
"competitor_count": len(oids),
"matched_project_names": pnames,
"window_start": ws,
"window_end": we,
}[k]
return r
def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
r = MagicMock()
r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
return r
def _supply_only_row(
project_name: str,
room_bucket: str = "1",
area_bin: str = "25-40",
lots_count: int = 12,
median_price_m2: float | None = 145000.0,
median_area_m2: float | None = 38.5,
) -> MagicMock:
"""Строка из _SUPPLY_ONLY_LOTS_SQL (#2177 шаг 3, .mappings().all())."""
r = MagicMock()
r.__getitem__ = lambda self, k: {
"project_name": project_name,
"room_bucket": room_bucket,
"area_bin": area_bin,
"lots_count": lots_count,
"median_price_m2": median_price_m2,
"median_area_m2": median_area_m2,
}[k]
return r
def _proj_name_rows(names: list[str]) -> list[tuple[str]]:
"""Строки из _OBJECTIVE_PROJECT_NAMES_SQL (.all() → row[0] = project_name)."""
return [(n,) for n in names]
def _dev_rows(devs_by_project: dict[str, list[str]]) -> list[tuple[str, list[str]]]:
"""Строки из _OBJECTIVE_DEVELOPERS_SQL (.all() → row[0]=project_name, row[1]=devs)."""
return [(pname, devs) for pname, devs in devs_by_project.items()]
def _make_db(
coord: MagicMock | None = None,
id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
project_names: list[str] | None = None,
lots_project_names: list[str] | None = None,
developers_by_project: dict[str, list[str]] | None = None,
supply_only_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session с диспатчем по SQL-стейтменту.
Порядок вызовов теперь НЕ позиционный (шаг 2b добавил objective-projects/
developers перед velocity) — роутим по идентичности SQL-констант, чтобы тесты
были устойчивы к порядку. db.execute():
• _PARCEL_CENTROID_SQL → .mappings().first()
• _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
• _OBJECTIVE_PROJECT_NAMES_SQL → .all() (core-attribution, #2177 шаг 2b)
• _OBJECTIVE_LOTS_PROJECT_NAMES_SQL → .all() (пул supply-only, шаг 3)
• _OBJECTIVE_DEVELOPERS_SQL → .all() (dev-confirm)
• _INLINE_VELOCITY_SQL → .mappings().all()
• _SUPPLY_BATCH_SQL → .mappings().all()
• _SUPPLY_ONLY_LOTS_SQL → .mappings().all() (#2177 шаг 3)
supply_only_rows фильтруются по bind :names (сматченные-без-velocity проекты),
чтобы тест на «группа С velocity → НЕ дублируется» проверял реальный контракт:
сервис НЕ должен передавать velocity-проект в этот SQL.
"""
db = MagicMock()
r_centroid = MagicMock()
r_centroid.mappings.return_value.first.return_value = coord
r_comp = MagicMock()
r_comp.mappings.return_value.all.return_value = id_rows or []
r_proj = MagicMock()
r_proj.all.return_value = _proj_name_rows(project_names or [])
# lots-имена (#2177 шаг 3 фикс пула): дефолт пустой — corpus-only поведение.
r_lots_proj = MagicMock()
r_lots_proj.all.return_value = _proj_name_rows(lots_project_names or [])
r_dev = MagicMock()
r_dev.all.return_value = _dev_rows(developers_by_project or {})
r_vel = MagicMock()
r_vel.mappings.return_value.all.return_value = vel_rows or []
r_supply = MagicMock()
r_supply.mappings.return_value.all.return_value = supply_rows or []
all_supply_only = supply_only_rows or []
dispatch = {
_PARCEL_CENTROID_SQL: r_centroid,
_COMPETITORS_IN_RADIUS_SQL: r_comp,
_OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
_OBJECTIVE_LOTS_PROJECT_NAMES_SQL: r_lots_proj,
_OBJECTIVE_DEVELOPERS_SQL: r_dev,
_INLINE_VELOCITY_SQL: r_vel,
_SUPPLY_BATCH_SQL: r_supply,
}
def _execute(stmt, params=None, *_args, **_kwargs):
if stmt is _SUPPLY_ONLY_LOTS_SQL:
names = set((params or {}).get("names") or [])
filtered = [r for r in all_supply_only if r["project_name"] in names]
res = MagicMock()
res.mappings.return_value.all.return_value = filtered
return res
return dispatch[stmt]
db.execute.side_effect = _execute
return db
def _request(**kwargs) -> BestLayoutsRequest:
defaults: dict = {
"radius_km": 1.0,
"time_window": "last_quarter",
"min_velocity_per_month": 0.0,
}
defaults.update(kwargs)
return BestLayoutsRequest(**defaults)
# ── Тесты TIME_WINDOW_PARAMS ──────────────────────────────────────────────────
def test_time_window_params_keys() -> None:
"""Все три time_window определены, months_in_window > 0."""
for key in ("last_month", "last_quarter", "last_year"):
assert key in _TIME_WINDOW_PARAMS
interval_str, months = _TIME_WINDOW_PARAMS[key]
assert isinstance(interval_str, str) and len(interval_str) > 0
assert months > 0
# ── Тест SF-01: разный deals_window → разный velocity ────────────────────────
def test_last_month_velocity_divisor_1() -> None:
"""time_window=last_month: velocity = deals_window / 1.0."""
deals = 30.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window="last_month")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].velocity_per_month == pytest.approx(30.0, rel=1e-3)
def test_last_quarter_velocity_divisor_3() -> None:
"""time_window=last_quarter: velocity = deals_window / 3.0."""
deals = 30.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].velocity_per_month == pytest.approx(10.0, rel=1e-3)
def test_last_year_velocity_divisor_12() -> None:
"""time_window=last_year: velocity = deals_window / 12.0."""
deals = 60.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window="last_year")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].velocity_per_month == pytest.approx(5.0, rel=1e-3)
def test_different_time_windows_produce_different_velocity() -> None:
"""Одни и те же deals_window → разная velocity_per_month для разных time_window.
Главный acceptance-тест SF-01: time_window влияет на velocity, не только на масштаб.
При одном и том же deals_window=30:
last_month → 30.0
last_quarter → 10.0
last_year → 2.5
"""
deals = 30.0
velocities: dict[str, float] = {}
for tw in ("last_month", "last_quarter", "last_year"):
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(1)],
vel_rows=[_vel_row("2", deals_window=deals, obj_ids=[1])],
)
req = _request(time_window=tw)
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1, f"No layouts for {tw}"
velocities[tw] = resp.top_layouts[0].velocity_per_month
# Все три значения различаются
vals = list(velocities.values())
assert vals[0] != vals[1] != vals[2], f"Velocities must differ: {velocities}"
# last_month > last_quarter > last_year (одинаковые deals, разный знаменатель)
assert velocities["last_month"] > velocities["last_quarter"] > velocities["last_year"]
# ── Тест: ranking по velocity и sum pct = 100 ────────────────────────────────
def test_ranking_and_pct_sum_100() -> None:
"""3 room_buckets → ranking по velocity, sum pct = 100."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
vel_rows = [
_vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]), # 9/3=3.0
_vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]), # 24/3=8.0
_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]), # 48/3=16.0
]
supply_rows = [
_supply_row("studio", "<25", 20),
_supply_row("1", "40-60", 60),
_supply_row("2", "40-60", 80),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
top = resp.top_layouts
assert len(top) == 3
# rank 1 = "2" (наибольший velocity 16.0)
assert top[0].room_bucket == "2"
assert top[0].rank == 1
assert top[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
# rank 2 = "1" (8.0)
assert top[1].room_bucket == "1"
assert top[1].velocity_per_month == pytest.approx(8.0, rel=1e-3)
# ранги уникальны
assert sorted(t.rank for t in top) == [1, 2, 3]
# sum pct = 100
mix = resp.recommendation_for_tz.mix
assert sum(m.pct for m in mix) == 100
# ── Тест: пустые конкуренты ───────────────────────────────────────────────────
def test_no_competitors_returns_empty_response() -> None:
"""Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
db = _make_db(coord=_coord_row(), id_rows=[], vel_rows=[])
req = _request()
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == []
assert resp.data_quality.confidence == "low"
assert resp.recommendation_for_tz.based_on_obj_count == 0
# ── Тест: centroid не найден ──────────────────────────────────────────────────
def test_centroid_not_found_raises_value_error() -> None:
"""Геометрия участка не найдена → ValueError."""
db = _make_db(coord=None)
req = _request()
with pytest.raises(ValueError, match="не найдена"):
get_best_layouts(db, "99:99:9999999:999", req)
# ── Тест: min_velocity фильтрует строки ──────────────────────────────────────
def test_min_velocity_filters_low_rows() -> None:
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.
last_quarter (3 мес):
studio: 9 / 3 = 3.0 < 5.0 → отфильтрован
1: 24 / 3 = 8.0 > 5.0 → остаётся
"""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("studio", deals_window=9.0, obj_ids=[1]),
_vel_row("1", deals_window=24.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter", min_velocity_per_month=5.0)
resp = get_best_layouts(db, CAD_NUM, req)
top = resp.top_layouts
assert len(top) == 1
assert top[0].room_bucket == "1"
assert top[0].velocity_per_month == pytest.approx(8.0, rel=1e-3)
# ── Тест: exclude_competitor_obj_ids ─────────────────────────────────────────
def test_exclude_competitor_obj_ids() -> None:
"""exclude_competitor_obj_ids=[20] при единственном конкуренте → пустой ответ."""
id_rows = [_obj_id_row(20)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
req = _request(exclude_competitor_obj_ids=[20])
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts == []
assert resp.data_quality.objects_total_in_radius == 1
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────
def test_total_sold_in_window_matches_deals_window() -> None:
"""total_sold_in_window в TopLayoutRow = deals_window (целое)."""
deals = 37.0
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(5)],
vel_rows=[_vel_row("3", deals_window=deals, obj_ids=[5])],
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].total_sold_in_window == int(deals)
# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ────────────────
def test_supply_velocity_buckets_match_for_2_rooms() -> None:
"""Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади.
Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 → эти
строки выпадали из знаменателя bucket '2' → sold_pct/is_oversold двушек
были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct
рассчитывается от полного supply.
Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в
один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply → 40% sold.
"""
deals = 20.0
id_rows = [_obj_id_row(7)]
vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])]
supply_rows = [_supply_row("2", "40-60", 50)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
row = resp.top_layouts[0]
assert row.room_bucket == "2"
assert row.supply_units_in_radius == 50
# 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше
# → sold_pct > 40 или is_oversold=True).
assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3)
assert row.is_oversold is False
def test_supply_does_not_emit_euro_buckets() -> None:
"""Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'.
Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply
без согласования с velocity-стороной (objective_corpus_room_month отдаёт
{studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229"
assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
def test_supply_dedups_objects_to_latest_snapshot() -> None:
"""Regression guard против object-snapshot fan-out (supply units ~8.5x inflation).
domrf_kn_objects — snapshot-dimension (UNIQUE (obj_id, snapshot_date), ~8
снимков/obj_id). Если supply снова заджойнит flats НАПРЯМУЮ к objects без
дедупа до одного снимка на obj_id, каждый flat посчитается ~8.5x →
supply_units_in_radius завышен, sold_pct_of_supply занижен, is_oversold
недо-фаерит (все три — user-facing). Фикс: objects-first MATERIALIZED CTE
с DISTINCT ON (obj_id) по последнему снимку, flats джойнятся к нему.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
# objects дедуплицируются до одного (последнего) снимка на obj_id
assert "DISTINCT ON (o.obj_id)" in sql_text, "supply не дедупит objects → fan-out вернётся"
assert "snapshot_date DESC" in sql_text, "DISTINCT ON должен брать ПОСЛЕДНИЙ снимок"
# objects-first CTE материализован — иначе планнер инлайнит → flats-first seq scan
assert "MATERIALIZED" in sql_text, "nearby CTE должен быть MATERIALIZED (план objects-first)"
# flats джойнятся к дедуплицированному nearby, НЕ напрямую к raw objects
assert "JOIN domrf_kn_objects" not in sql_text, "прямой flats→objects join = fan-out по снимкам"
def test_supply_joins_flats_per_object_latest_snapshot() -> None:
"""Fix #1956 regression guard: supply джойнит flats по ПЕР-ОБЪЕКТНОМУ снимку.
domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series (каждый ЖК скрейпится в свой день).
Регрессия #1944 джойнила flats по ОДНОЙ глобальной дате
`f.snapshot_date = MAX(snapshot_date по всей таблице)` → на ней присутствует
обычно 1 объект → supply_units_in_radius=0 для всех строк → frontend
«Срок продажи 0 мес» / «% продано —». Фикс: flats_latest CTE с
DISTINCT ON (obj_id) ... ORDER BY obj_id, snapshot_date DESC — у каждого
объекта свой последний снимок. Этот guard ловит возврат глобал-max джойна.
"""
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
sql_text = str(_SUPPLY_BATCH_SQL.text)
# per-object последний снимок flats
assert "flats_latest" in sql_text, "нет flats_latest CTE → supply снова на глобал-max снимке"
assert (
"DISTINCT ON (f.obj_id)" in sql_text
), "flats не дедупятся per-object → нет per-obj снимка"
# НЕ должно быть джойна по единой внешней дате-параметру (регрессия #1944)
assert ":latest_snap" not in sql_text, "глобальный :latest_snap вернулся → supply=0 регрессия"
assert (
"MAX(snapshot_date)" not in sql_text
), "глобальный MAX(snapshot_date) → supply=0 регрессия"
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
@pytest.mark.parametrize(
"pct_map, expect_pathological",
[
# 1. normal: одиночный bucket > 35, free достаточно capacity
({"1k": 50, "studio": 30, "2k": 20}, False),
# 2. heavy skew (3-bucket): surplus=40, capacity=20+25=45 — помещается
({"1k": 75, "studio": 15, "2k": 10}, False),
# 3. multiple buckets > 35
({"1k": 50, "studio": 40, "2k": 10}, False),
# 4. all > 35 — pathological
({"1k": 50, "studio": 50}, True),
# 5. граничный: один bucket ровно на cap — не clamp
({"1k": 35, "studio": 35, "2k": 30}, False),
# 6. single bucket 100% — pathological (нет free)
({"1k": 100}, True),
# 7. 2-bucket heavy: surplus=55, capacity=25 — pathological (не помещается)
({"1k": 90, "studio": 10}, True),
# 8. все ≤ cap — fast-path без изменений
({"1k": 30, "studio": 35, "2k": 35}, False),
# 9. 2-bucket: 70/30 → surplus=35, capacity=5 → pathological
({"1k": 70, "studio": 30}, True),
# 10. 2-bucket: 99/1 → surplus=64, capacity=34 → pathological
({"1k": 99, "studio": 1}, True),
],
)
def test_cap_and_redistribute_invariants(
pct_map: dict[str, int],
expect_pathological: bool,
) -> None:
"""Invariant: max(pct) ≤ cap И sum(pct) == 100 (или cap_skipped=True в pathological).
Pathological — `cap_skipped=True`, max МОЖЕТ быть > cap (геометрически surplus
не вмещается в free capacity).
"""
result, cap_skipped = _cap_and_redistribute(pct_map)
assert (
cap_skipped == expect_pathological
), f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
assert (
sum(result.values()) == 100
), f"sum={sum(result.values())} != 100 для {pct_map}{result}"
if not expect_pathological:
assert (
max(result.values()) <= MAX_BUCKET_SHARE_PCT
), f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map}{result}"
@pytest.mark.parametrize(
"deals, expect_pathological, label",
[
# 3-bucket с достаточной capacity — surplus помещается, cap соблюдён
({"1k": 75, "studio": 15, "2k": 10}, False, "{1k:75, studio:15, 2k:10}"),
({"1k": 80, "studio": 12, "2k": 8}, False, "{1k:80, studio:12, 2k:8}"),
({"1k": 60, "studio": 30, "2k": 10}, False, "{1k:60, studio:30, 2k:10}"),
({"a": 50, "b": 30, "c": 20}, False, "{50, 30, 20}"),
# 2-bucket — surplus геометрически не помещается, cap_skipped=True
({"1k": 90, "studio": 10}, True, "{1k:90, studio:10}"),
({"1k": 70, "studio": 30}, True, "{1k:70, studio:30}"),
({"1k": 99, "studio": 1}, True, "{1k:99, studio:1}"),
],
)
def test_cap_reproduced_failing_cases(
deals: dict[str, int], expect_pathological: bool, label: str
) -> None:
"""Review round-2 reproduced cases: 2-bucket — pathological, 3-bucket — fit cap."""
result, cap_skipped = _cap_and_redistribute(deals)
assert (
cap_skipped == expect_pathological
), f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
assert sum(result.values()) == 100, f"sum != 100 для {label}{result}"
if not expect_pathological:
assert (
max(result.values()) <= MAX_BUCKET_SHARE_PCT
), f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label}{result}"
def test_cap_iteration_count_bounded() -> None:
"""Round 2 regression: алгоритм завершается за ≤ len(pct_map)+1 итераций.
Round 1 bag: на 2-bucket {1k:70, studio:30} цикл осциллировал бесконечно.
Round 2 fix: capacity-aware redistribute + hard `for _ in range(N+1)` guard.
Этот тест гарантирует что вызов не зависает (pytest-timeout не нужен).
"""
import time
pathological_cases = [
{"1k": 70, "studio": 30},
{"1k": 99, "studio": 1},
{"1k": 90, "studio": 10},
{"1k": 50, "studio": 50},
]
for case in pathological_cases:
start = time.perf_counter()
result, cap_skipped = _cap_and_redistribute(case)
elapsed_ms = (time.perf_counter() - start) * 1000
assert elapsed_ms < 100, f"Завис ({elapsed_ms:.0f}ms) на {case}"
assert sum(result.values()) == 100, f"sum != 100 для {case}"
# 2-bucket с одним > cap всегда pathological (surplus > free capacity)
if case != {"1k": 50, "studio": 50}:
assert cap_skipped, f"Ожидали cap_skipped=True для {case}"
def test_cap_and_redistribute_no_dominant_unchanged() -> None:
"""Если все bucket'ы ≤ cap — результат идентичен входу (fast-path)."""
pct_map = {"studio": 20, "1": 35, "2": 30, "3": 15}
result, cap_skipped = _cap_and_redistribute(pct_map)
assert not cap_skipped
assert result == pct_map
def test_cap_and_redistribute_empty() -> None:
"""Пустой dict → возвращается как есть."""
result, cap_skipped = _cap_and_redistribute({})
assert result == {}
assert not cap_skipped
def test_cap_skipped_flag_propagates_to_recommendation() -> None:
"""Pathological case → cap_skipped=True в recommendation_for_tz ответа."""
# 2 bucket'а по 50% — pathological
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("studio", deals_window=50.0, obj_ids=[1]),
_vel_row("1", deals_window=50.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# С deals 50/50 → normalize_pct даёт {studio:50, 1:50} — оба выше cap
assert resp.recommendation_for_tz.cap_skipped is True
def test_cap_skipped_false_for_normal_case() -> None:
"""Normal case с capping → cap_skipped=False в recommendation_for_tz."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
vel_rows = [
_vel_row("1k", deals_window=75.0, obj_ids=[1]),
_vel_row("studio", deals_window=15.0, obj_ids=[2]),
_vel_row("2k", deals_window=10.0, obj_ids=[3]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.recommendation_for_tz.cap_skipped is False
mix = resp.recommendation_for_tz.mix
assert all(row.pct <= MAX_BUCKET_SHARE_PCT for row in mix)
assert sum(row.pct for row in mix) == 100
# ── Issue #2177: группировка obj_id в комплексы ──────────────────────────────
# Реальный прод-кейс: радиус 1 км от 66:41:0204016:10 = 12 obj_id, 5 комплексов.
# Координаты сконструированы под дистанционные ограничения из issue:
# • Симфония — 3 точки в ~200 м (name-группа, дистанция не важна);
# • Эфес безымянные — 3 точки в ~6 м друг от друга (< 300 м → один кластер),
# ~600 м от именованного «7 ключей» (> 300 м → отдельная группа).
_SYM1 = (56.850449, 60.600821)
_SYM2 = (56.850449, 60.604107)
_SYM3 = (56.852246, 60.600821)
_KLYUCHI = (56.85, 60.593429)
_EFES1 = (56.85, 60.583572)
_EFES2 = (56.85, 60.583671)
_EFES3 = (56.850045, 60.583621)
def _prod_radius_rows() -> list[dict[str, object]]:
"""12 obj_id прод-кейса (радиус 1 км от 66:41:0204016:10)."""
return [
# Симфония (Желдорипотека) — 3 obj_id, одинаковое comm_name+dev_name
_obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1),
_obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2),
_obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3),
# Квартет (Эталон) — 2 obj_id, повтор «Квартет» ×3 в строке
_obj_id_row(33955, "Квартет Квартет Квартет", "Эталон", 56.851, 60.602),
_obj_id_row(21801, "Квартет Квартет Квартет", "Эталон", 56.8512, 60.6022),
# Квартал Депо (Брусника) — 2 obj_id, «квартал» без кавычек = часть имени
_obj_id_row(63122, "Квартал Депо", "Брусника", 56.8505, 60.5985),
_obj_id_row(63282, "Квартал Депо", "Брусника", 56.8506, 60.5986),
# Траектория (Брусника) — 1 obj_id
_obj_id_row(68027, "ЖК «Траектория»", "Брусника", 56.8495, 60.6015),
# 7 ключей (Эфес) — именованная 2 очередь
_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI),
# 3 безымянных Эфеса — гео-кучка ~6 м, ~600 м от «7 ключей»
_obj_id_row(55320, "", "Эфес", *_EFES1),
_obj_id_row(55321, "", "Эфес", *_EFES2),
_obj_id_row(55322, "", "Эфес", *_EFES3),
]
def test_group_radius_objects_prod_case_groups() -> None:
"""Прод-кейс: 12 obj_id → 5 именованных комплексов + 1 безымянный кластер (#2177).
Issue-заголовок «5 реальных комплексов» перечисляет ИМЕНОВАННЫЕ проекты
(Симфония, Квартет, Квартал Депо, Траектория, 7 ключей). 3 безымянных Эфеса
в ~600 м от «7 ключей» issue ЯВНО требует держать ОТДЕЛЬНОЙ группой («честно
неизвестный проект», НЕ приклеивать дальше 300 м). Итог — 6 групп; ключевой
инвариант: «7 ключей» и безымянные Эфесы НЕ слиты.
"""
groups = group_radius_objects(_prod_radius_rows())
# состав групп: множество frozenset(obj_ids) должно совпасть
got = {frozenset(g.obj_ids) for g in groups}
expected = {
frozenset({3346, 43063, 43064}), # Симфония (snapshot-дубли схлопнуты)
frozenset({33955, 21801}), # Квартет (повтор имени схлопнут)
frozenset({63122, 63282}), # Квартал Депо («квартал» сохранён в ядре)
frozenset({68027}), # Траектория
frozenset({15731}), # 7 ключей (именованная 2 очередь)
frozenset({55320, 55321, 55322}), # безымянные Эфесы — свой кластер
}
assert len(groups) == 6, f"ожидалось 6 групп, получено {len(groups)}: {got}"
# load-bearing инвариант: 7 ключей и безымянные Эфесы разделены >300 м
assert frozenset({15731}) in got, "7 ключей должна быть отдельной группой"
assert (
frozenset({55320, 55321, 55322}) in got
), "3 безымянных Эфеса — один кластер, отдельный от 7 ключей"
assert got == expected
def test_group_radius_objects_symphony_collapses_snapshot_dupes() -> None:
"""Симфония: 3 obj_id с одинаковым comm_name+dev_name → одна группа."""
rows = [
_obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1),
_obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2),
_obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3),
]
groups = group_radius_objects(rows)
assert len(groups) == 1
assert sorted(groups[0].obj_ids) == [3346, 43063, 43064]
def test_group_nameless_efes_two_clusters_do_not_merge() -> None:
"""Два разнесённых (>300 м) безымянных кластера одного застройщика НЕ сливаются."""
# кластер A ~6 м; кластер B в ~600 м от A; оба Эфес, comm_name пустой
rows = [
_obj_id_row(55320, "", "Эфес", *_EFES1),
_obj_id_row(55321, "", "Эфес", *_EFES2),
_obj_id_row(55322, "", "Эфес", *_EFES3),
# второй кластер — рядом с «7 ключей» (600 м от первого)
_obj_id_row(90001, "", "Эфес", 56.85, 60.593429),
_obj_id_row(90002, "", "Эфес", 56.850009, 60.5934),
]
groups = group_radius_objects(rows)
assert len(groups) == 2, f"ожидалось 2 кластера, получено {len(groups)}"
clusters = {frozenset(g.obj_ids) for g in groups}
assert frozenset({55320, 55321, 55322}) in clusters
assert frozenset({90001, 90002}) in clusters
def test_group_nameless_different_dev_never_merge() -> None:
"""Безымянные точки разных застройщиков в 1 м — всё равно разные группы."""
rows = [
_obj_id_row(1, "", "Эфес", 56.85, 60.60),
_obj_id_row(2, "", "Брусника", 56.850001, 60.60),
]
groups = group_radius_objects(rows)
assert len(groups) == 2
def test_group_nameless_without_coords_each_own_group() -> None:
"""Безымянный obj_id без координат — своя группа (не с чем кластеризовать)."""
rows = [
_obj_id_row(1, "", "Эфес", None, None),
_obj_id_row(2, "", "Эфес", None, None),
]
groups = group_radius_objects(rows)
assert len(groups) == 2
# ── Нормализация имён ─────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"raw, expected",
[
# ёлочки: ядро из «N очередь жилого комплекса «X»»
("2 очередь жилого комплекса «7 ключей»", "7 ключей"),
("1-я очередь ЖК «Символ»", "символ"),
# повторы ядра
("Квартет Квартет Квартет", "квартет"),
("Квартет", "квартет"),
# generic-префиксы
("ЖК «Траектория»", "траектория"),
("ЖК Траектория", "траектория"),
("Жилой комплекс Символ", "символ"),
("микрорайон Академический", "академический"),
# «квартал» без кавычек — часть имени, НЕ префикс
("Квартал Депо", "квартал депо"),
("квартал депо", "квартал депо"),
# пустое / None
("", ""),
(None, ""),
(" ", ""),
# регистр + пробелы
(" Симфония ", "симфония"),
("ЖК «Символ»", "символ"),
],
)
def test_normalize_complex_name(raw: str | None, expected: str) -> None:
assert normalize_complex_name(raw) == expected
def test_normalize_kvartal_depo_stays_intact() -> None:
"""«Квартал Депо» не должен потерять слово «квартал» (regression guard)."""
assert normalize_complex_name("Квартал Депо") == "квартал депо"
# два obj_id «Квартал Депо» → одна группа (ядро совпадает)
rows = [
_obj_id_row(63122, "Квартал Депо", "Брусника", 56.8505, 60.5985),
_obj_id_row(63282, "Квартал Депо", "Брусника", 56.8506, 60.5986),
]
groups = group_radius_objects(rows)
assert len(groups) == 1
assert sorted(groups[0].obj_ids) == [63122, 63282]
# ── Coverage по группам ───────────────────────────────────────────────────────
def test_coverage_counts_groups_not_raw_obj_ids() -> None:
"""Прод-кейс coverage: velocity у 2 групп → «2 из 6 комплексов», raw=12.
Из 6 групп velocity-данные (obj_ids_with_data) есть у Симфонии (obj 3346) и
Квартета (obj 33955). Группа считается покрытой, если ХОТЯ БЫ ОДИН её obj_id
в velocity-выборке. Знаменатель = группы (6), НЕ сырые obj_id (12).
"""
id_rows = _prod_radius_rows()
# velocity только у Симфонии (через obj 3346) и Квартета (через obj 33955)
vel_rows = [
_vel_row("2", deals_window=48.0, obj_ids=[3346]),
_vel_row("1", deals_window=24.0, obj_ids=[33955]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
dq = resp.data_quality
assert dq.raw_objects_total == 12, "сырой счёт obj_id должен быть 12"
assert dq.objects_total_in_radius == 6, "знаменатель = комплексы (6), не obj_id"
assert dq.objects_with_velocity_data == 2, "покрыты Симфония + Квартет"
# 2/6 = 33.3% (не 2/12 = 16.7%)
assert dq.velocity_coverage_pct == pytest.approx(33.3, rel=1e-2)
def test_coverage_group_covered_by_any_member_obj_id() -> None:
"""Группа «с данными», если velocity есть хотя бы у одного её obj_id.
Симфония = {3346, 43063, 43064}; velocity только у 43064 (не у 3346) →
группа всё равно покрыта.
"""
id_rows = [
_obj_id_row(3346, "ЖК «Симфония»", "Желдорипотека", *_SYM1),
_obj_id_row(43063, "ЖК «Симфония»", "Желдорипотека", *_SYM2),
_obj_id_row(43064, "ЖК «Симфония»", "Желдорипотека", *_SYM3),
]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[43064])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
dq = resp.data_quality
assert dq.objects_total_in_radius == 1
assert dq.objects_with_velocity_data == 1
assert dq.raw_objects_total == 3
assert dq.velocity_coverage_pct == pytest.approx(100.0, rel=1e-3)
def test_nameless_cluster_constant_reasonable() -> None:
"""NAMELESS_CLUSTER_M экспортируется и в разумных пределах (guard)."""
assert 100.0 <= NAMELESS_CLUSTER_M <= 500.0
# ── Issue #2177 шаг 2b: core-attribution velocity по ядру комплекса ──────────
def test_match_groups_to_projects_unique_core() -> None:
"""Группа с уникальным ядром матчится на project_name (dev-сигнал совпадает)."""
rows = [_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI)]
groups = group_radius_objects(rows)
matched = match_groups_to_projects(
groups,
project_names=["7 Ключей", "Другой ЖК"],
developers_by_project={"7 Ключей": ["СЗ Эфес"]},
)
assert list(matched.values()) == ["7 Ключей"]
def test_match_groups_ambiguous_core_not_matched() -> None:
""">1 objective-проекта с тем же ядром → группа НЕ матчится (ambiguous, честно)."""
rows = [_obj_id_row(1, "ЖК «Символ»", "Девелопер", 56.85, 60.60)]
groups = group_radius_objects(rows)
matched = match_groups_to_projects(
groups,
# два разных project_name делят ядро «символ»
project_names=["Символ", "ЖК Символ"],
developers_by_project={},
)
assert matched == {}
def test_match_groups_dev_conflict_not_matched() -> None:
"""Одноимённое ядро, но застройщик группы не подтверждает проект → НЕ матчим."""
rows = [_obj_id_row(1, "ЖК «Символ»", "Брусника", 56.85, 60.60)]
groups = group_radius_objects(rows)
matched = match_groups_to_projects(
groups,
project_names=["Символ"],
# у проекта другой застройщик — dev-confirm провалится (сигнал есть с обеих сторон)
developers_by_project={"Символ": ["СЗ Эфес"]},
)
assert matched == {}
def test_merged_project_name_pool_priority_and_ambiguity() -> None:
"""Пул имён: corpus приоритетен по ядру; lots добавляет только НОВЫЕ ядра.
Прод-кейс 2026-07-03: «ЖК Основа» (лоты без сделок) не попадал в кандидаты →
supply-only всегда пуст. При этом lots-вариант ядра corpus-имени НЕ должен
создавать ambiguous-пару (сломал бы velocity-матч), а два разных lots-проекта
с одним ядром должны остаться оба (честный ambiguous для матчера).
"""
from app.services.site_finder.best_layouts import _merged_project_name_pool
pool = _merged_project_name_pool(
corpus_names=["7 Ключей", "Аксиома"],
lots_names=[
"ЖК 7 ключей", # ядро занято corpus → отбрасывается (тот же проект)
"Основа", # новое ядро → добавляется (supply-only кандидат)
"Меридиан", # два разных lots-проекта с одним ядром —
"Бутик-квартал Меридиан (2)", # (разные строки, ядро может совпасть)
],
)
assert "7 Ключей" in pool and "Аксиома" in pool
assert "ЖК 7 ключей" not in pool # corpus-ядро приоритетно
assert "Основа" in pool
def test_match_groups_no_dev_signal_matches_on_core() -> None:
"""Нет dev-сигнала (у проекта пустые developers) → матч по уникальному ядру достаточен."""
rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)]
groups = group_radius_objects(rows)
matched = match_groups_to_projects(
groups,
project_names=["Траектория"],
developers_by_project={}, # developers неизвестны → dev-confirm пропускается
)
assert list(matched.values()) == ["Траектория"]
def test_seven_keys_core_match_covered_and_deals_in_aggregates() -> None:
"""Реальный кейс #2177: 2-я очередь «7 Ключей» в радиусе матчится по ЯДРУ на
project '7 Ключей' → группа ПОКРЫТА, её сделки входят в агрегаты планировок.
Радиус содержит ТОЛЬКО obj 15731 (2-я очередь), которого нет в mapping. Раньше
(mapping-only) группа была непокрыта и 162 корпус-месяца сделок не входили.
Теперь velocity-строка приходит с matched_project_names=['7 Ключей'] → coverage
по project_name + deals реально в top_layouts.
"""
id_rows = [_obj_id_row(15731, "2 очередь жилого комплекса «7 ключей»", "Эфес", *_KLYUCHI)]
# velocity-строка: НЕТ mapping-obj_id (competitor_obj_ids=[]), покрытие по имени.
vel_rows = [
_vel_row(
"2",
deals_window=162.0,
obj_ids=[],
project_names=["7 Ключей"],
)
]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["7 Ключей", "Симфония"],
developers_by_project={"7 Ключей": ["СЗ Эфес"]},
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
dq = resp.data_quality
# одна группа в радиусе, покрыта по ядру (не через obj_id mapping)
assert dq.objects_total_in_radius == 1
assert dq.objects_with_velocity_data == 1
assert dq.groups_matched_by_core == 1, "покрытие должно идти по core-attribution"
assert dq.velocity_coverage_pct == pytest.approx(100.0, rel=1e-3)
# сделки «7 Ключей» реально вошли в расчёт планировок
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].room_bucket == "2"
assert resp.top_layouts[0].total_sold_in_window == 162
def test_matched_project_names_passed_to_velocity_bind() -> None:
"""Сматченные по ядру project_name'ы передаются bind-параметром в velocity SQL."""
from unittest.mock import ANY
id_rows = [_obj_id_row(15731, "ЖК «Траектория»", "Брусника", *_KLYUCHI)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория"],
developers_by_project={},
)
req = _request(time_window="last_quarter")
get_best_layouts(db, CAD_NUM, req)
# найти вызов velocity SQL и проверить bind matched_project_names
vel_calls = [
c for c in db.execute.call_args_list if c.args and c.args[0] is _INLINE_VELOCITY_SQL
]
assert len(vel_calls) == 1
params = vel_calls[0].args[1] if len(vel_calls[0].args) > 1 else vel_calls[0].kwargs
assert params["matched_project_names"] == ["Траектория"]
assert params["competitor_obj_ids"] == [15731]
# sanity: ANY использован только чтобы не падать на прочих полях
assert params.get("window_interval", ANY) is not None
# ── Issue #2177 шаг 3: fallback-блок «по предложению» (supply-only) ───────────
def test_supply_only_group_without_velocity_appears_in_block() -> None:
"""Группа сматчена по ядру, БЕЗ velocity, но с лотами → в supply_only_layouts + счётчик.
Радиус: две группы. «Траектория» имеет velocity (сделки в окне) → top_layouts.
«Символ» сматчен по ядру, но velocity нет (не в vel_rows) → лоты в продаже
из objective_lots попадают в supply_only_layouts, счётчик = 1.
"""
id_rows = [
_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60),
_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601),
]
# velocity ТОЛЬКО у Траектории (обе группы сматчены по ядру ниже).
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
supply_only_rows = [
_supply_only_row("Символ", room_bucket="1", area_bin="25-40", lots_count=18),
_supply_only_row(
"Символ", room_bucket="studio", area_bin="<25", lots_count=7, median_area_m2=22.0
),
]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория", "Символ"],
developers_by_project={},
supply_only_rows=supply_only_rows,
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# supply-блок содержит ТОЛЬКО «Символ» (без velocity), 2 строки
assert len(resp.supply_only_layouts) == 2
assert {r.project_name for r in resp.supply_only_layouts} == {"Символ"}
first = resp.supply_only_layouts[0]
assert first.lots_count == 18
assert first.median_price_m2 == 145000
assert first.source == "objective_lots"
# счётчик групп в supply-блоке
assert resp.data_quality.groups_with_supply_only == 1
def test_supply_only_velocity_group_not_duplicated() -> None:
"""Группа С velocity НЕ попадает в supply-блок (её темп продаж уже в top_layouts).
Контракт: сервис НЕ передаёт velocity-проект в _SUPPLY_ONLY_LOTS_SQL (mock
фильтрует по :names). Даже если бы для «Траектории» существовали строки лотов,
их не должно быть в ответе — проект исключён как velocity-covered.
"""
id_rows = [_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
# Ловушка: строки лотов для velocity-проекта существуют в БД…
supply_only_rows = [_supply_only_row("Траектория", lots_count=99)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория"],
developers_by_project={},
supply_only_rows=supply_only_rows,
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# velocity-группа в top_layouts, НЕ в supply-блоке
assert len(resp.top_layouts) == 1
assert resp.supply_only_layouts == []
assert resp.data_quality.groups_with_supply_only == 0
# контракт: velocity-проект НЕ уходит в supply-only SQL. Когда supply-only
# набор пуст, сервис короткозамыкает БЕЗ запроса (эффективность) — но если
# запрос всё же сделан, :names НЕ должен содержать velocity-проект.
supply_calls = [
c for c in db.execute.call_args_list if c.args and c.args[0] is _SUPPLY_ONLY_LOTS_SQL
]
for call in supply_calls:
params = call.args[1] if len(call.args) > 1 else call.kwargs
assert "Траектория" not in (
params.get("names") or []
), "velocity-проект не должен уходить в supply-only запрос"
def test_supply_only_empty_snapshot_yields_empty_block() -> None:
"""Пустой снапшот лотов (нет строк) → supply_only_layouts пуст, счётчик 0.
«Символ» сматчен по ядру и без velocity, но в objective_lots для него лотов нет
(пустой снапшот) → блок пустой, счётчик 0 (аддитивная семантика, без ошибок).
"""
id_rows = [_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория", "Символ"],
developers_by_project={},
supply_only_rows=[], # пустой снапшот
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.supply_only_layouts == []
assert resp.data_quality.groups_with_supply_only == 0
def test_supply_only_median_price_none_preserved() -> None:
"""Лот без валидной цены (median_price_m2 NULL) → count есть, цена None (не 0)."""
id_rows = [_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601)]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
supply_only_rows = [
_supply_only_row("Символ", lots_count=5, median_price_m2=None, median_area_m2=None),
]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория", "Символ"],
developers_by_project={},
supply_only_rows=supply_only_rows,
)
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.supply_only_layouts) == 1
row = resp.supply_only_layouts[0]
assert row.lots_count == 5
assert row.median_price_m2 is None
assert row.median_area_m2 is None
def test_supply_only_sql_uses_price_sanity_and_on_sale_filter() -> None:
"""Regression guard: SQL фильтрует «в продаже» + price-sanity + дедуп физлота.
Инварианты контракта (#2177 шаг 3):
• is_sold IS NOT TRUE + status<>'продан' — фильтр «в продаже»;
• price BETWEEN price_min/price_max — вменяемость цены;
• DISTINCT ON (physflat) — дедуп ре-листинг-инфляции objective_lots;
• room_bucket в вокабуляре velocity {studio,4+}, НЕ velocity (без деления на мес).
"""
sql_text = str(_SUPPLY_ONLY_LOTS_SQL.text)
assert "is_sold IS NOT TRUE" in sql_text
assert "<> 'продан'" in sql_text
assert "price_min" in sql_text and "price_max" in sql_text
assert "DISTINCT ON (" in sql_text, "нет физлот-дедупа → ре-листинг-инфляция"
assert "'studio'" in sql_text and "'4+'" in sql_text
# psycopg3: CAST(:x AS type), НЕ :x::type
assert "::text[]" not in sql_text.replace("CAST(:names AS text[])", "")
# ── SAVEPOINT regression (#2464 cluster A) ────────────────────────────────────
#
# Step 5 (_SUPPLY_BATCH_SQL) db shared с caller (GET .../best-layouts,
# Depends(get_db); sync get_best_layouts мостится через run_in_threadpool). Раньше
# db.execute-сбой ловился в bare ``except Exception`` без SAVEPOINT/rollback — на
# реальном Postgres это отравляет db shared с caller: следующий db.execute на этой
# же сессии (напр. Step 9b _fetch_supply_only_layouts) падает "current transaction
# is aborted". Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
# supply=0 fallback при сбое, (2) что begin_nested() реально вызван вокруг именно
# этого db.execute, (3) что db остаётся usable для последующих вызовов на этой же
# mock-сессии (get_best_layouts не крашит, ранжирование по-прежнему строится).
def test_supply_query_failure_degrades_to_zero_supply() -> None:
"""Сбой Step 5 (_SUPPLY_BATCH_SQL) → supply_units_in_radius=0 для всех строк,
остальной pipeline (velocity/ranking) не ломается."""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
real_dispatch = db.execute.side_effect
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
if stmt is _SUPPLY_BATCH_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _execute_raise_on_supply
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].supply_units_in_radius == 0
assert resp.top_layouts[0].sold_pct_of_supply is None
assert resp.top_layouts[0].is_oversold is False
# velocity per-month всё ещё честно посчитан (сбой изолирован в supply-блоке).
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
def test_supply_query_uses_begin_nested_savepoint() -> None:
"""Фикс: Step 5 запрос обёрнут в db.begin_nested(), не голый db.execute."""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 80)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
req = _request(time_window="last_quarter")
get_best_layouts(db, CAD_NUM, req)
assert db.begin_nested.call_count >= 1
def test_supply_query_failure_session_usable_for_supply_only_step() -> None:
"""db остаётся usable после Step 5-сбоя: Step 9b (_fetch_supply_only_layouts,
отдельный db.execute на той же сессии) по-прежнему выполняется без исключения.
Сетап зеркалит test_supply_only_group_without_velocity_appears_in_block: две
группы, «Траектория» с velocity → top_layouts, «Символ» без velocity → должна
дойти до Step 9b (_fetch_supply_only_layouts) на той же db, что и Step 5.
"""
id_rows = [
_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60),
_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601),
]
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
supply_only_rows = [_supply_only_row("Символ", room_bucket="1", area_bin="25-40")]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
project_names=["Траектория", "Символ"],
developers_by_project={},
supply_only_rows=supply_only_rows,
)
real_dispatch = db.execute.side_effect
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
if stmt is _SUPPLY_BATCH_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _execute_raise_on_supply
req = _request(time_window="last_quarter")
# Не должно бросить InFailedSqlTransaction-подобное исключение — Step 9b
# выполняется на той же db без проблем (в mock — просто control-flow guard).
resp = get_best_layouts(db, CAD_NUM, req)
assert resp.top_layouts[0].supply_units_in_radius == 0
# Step 9b реально выполнился на этой же db — supply-only блок не пуст.
assert len(resp.supply_only_layouts) == 1
assert resp.supply_only_layouts[0].project_name == "Символ"
# ── SAVEPOINT regression: ex-bare-rollback sites (#2464 cluster A) ──────────────
#
# ДВА места в best_layouts.py раньше делали ГОЛЫЙ db.rollback() на SHARED сессии в
# except после сбоя db.execute — это ХУЖЕ простого swallow: bare rollback на общей
# сессии откатывает ВСЮ outer-транзакцию /analyze-запроса (всю несохранённую работу
# ранее в этом же запросе) и orphan'ит outer SessionTransaction (velocity.py:170).
# Фикс — savepoint: db.execute обёрнут в ``with db.begin_nested():``, а bare
# db.rollback() УДАЛЁН (auto-rollback savepoint'а откатывает только сбойный statement,
# не весь запрос). Тесты проверяют: (a) документированный fallback сохранён, (b)
# begin_nested() использован И db.rollback() НЕ вызван (весь смысл — outer tx выживает),
# (c) follow-up db.execute на той же сессии проходит.
class TestFetchSupplyOnlyLayoutsSavepoint:
"""Ex-rollback site #1: _fetch_supply_only_layouts (Step 9b)."""
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = []
_fetch_supply_only_layouts(db, ["ЖК Резерв"])
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_and_no_bare_rollback(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
out = _fetch_supply_only_layouts(db, ["ЖК Резерв"])
assert out == [] # документированный fallback
assert db.begin_nested.call_count == 1 # savepoint использован
db.rollback.assert_not_called() # bare rollback удалён — outer tx выживает
def test_empty_project_names_skips_db_entirely(self) -> None:
# Пустой вход → SQL не выполняется, savepoint не открывается (штатный ранний []).
db = MagicMock()
assert _fetch_supply_only_layouts(db, []) == []
db.execute.assert_not_called()
db.begin_nested.assert_not_called()
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _fetch_supply_only_layouts(db, ["ЖК Резерв"]) == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_core_attribution_failure_uses_savepoint_no_bare_rollback() -> None:
"""Ex-rollback site #2: Step 2b core-attribution db.execute сбоит → SAVEPOINT
(не bare rollback) → get_best_layouts деградирует в mapping-only.
Раньше bare db.rollback() здесь откатил бы ВСЮ outer tx /analyze-запроса. Теперь
begin_nested откатывает только сбойный savepoint, а velocity через obj_id (mapping)
по-прежнему считается на той же сессии → top_layouts не пуст, outer tx выживает.
"""
id_rows = [_obj_id_row(1)]
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
supply_rows = [_supply_row("2", "40-60", 80)]
db = _make_db(
coord=_coord_row(),
id_rows=id_rows,
vel_rows=vel_rows,
supply_rows=supply_rows,
project_names=["Символ"],
developers_by_project={},
)
real_dispatch = db.execute.side_effect
def _raise_on_project_names(stmt, params=None, *args, **kwargs):
# Первый из трёх Step 2b запросов — сбой на нём отравил бы сессию без savepoint.
if stmt is _OBJECTIVE_PROJECT_NAMES_SQL:
raise RuntimeError("simulated DB failure")
return real_dispatch(stmt, params, *args, **kwargs)
db.execute.side_effect = _raise_on_project_names
req = _request(time_window="last_quarter")
resp = get_best_layouts(db, CAD_NUM, req)
# savepoint использован, bare rollback НЕ вызван (outer tx /analyze выживает).
assert db.begin_nested.call_count >= 1
db.rollback.assert_not_called()
# fallback на mapping-only: velocity через obj_id всё равно посчитан на той же
# сессии (Step 3 velocity execute не заблокирован aborted-транзакцией).
assert len(resp.top_layouts) == 1
assert resp.top_layouts[0].room_bucket == "2"
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
# core-match не дал имён (сбой) → matched-only supply-блок пуст, но ответ валиден.
assert resp.data_quality.groups_matched_by_core == 0