Merge pull request 'fix(estimator): #1774 Tier A admits same-building novostroyki resales (secondary-dominant gate)' (#1776) from fix/1774-tier-a-same-building-primary into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m21s
Deploy Trade-In / build-backend (push) Successful in 51s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m4s
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m21s
Deploy Trade-In / build-backend (push) Successful in 51s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m4s
Reviewed-on: #1776
This commit is contained in:
commit
ee2703419b
4 changed files with 367 additions and 9 deletions
|
|
@ -96,6 +96,12 @@ class Settings(BaseSettings):
|
||||||
estimate_sb_floor_sigma: float = 0.25
|
estimate_sb_floor_sigma: float = 0.25
|
||||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||||
|
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||||
|
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||||
|
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||||
|
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||||
|
# ratio — не затрагиваются.
|
||||||
|
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
||||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1469,14 +1469,13 @@ def _fetch_anchor_comps(
|
||||||
"""
|
"""
|
||||||
SELECT price_per_m2, area_m2, rooms, floor, total_floors,
|
SELECT price_per_m2, area_m2, rooms, floor, total_floors,
|
||||||
address, source, source_url, price_rub, listing_date,
|
address, source, source_url, price_rub, listing_date,
|
||||||
days_on_market, photo_urls, lat, lon
|
days_on_market, photo_urls, lat, lon,
|
||||||
|
listing_segment, source_id
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
AND price_per_m2 > 0
|
AND price_per_m2 > 0
|
||||||
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
||||||
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
|
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
|
||||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
@ -1494,14 +1493,60 @@ def _fetch_anchor_comps(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
rows = []
|
rows = []
|
||||||
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
|
# #1774: gated relaxation of the #1186 guard, Tier A ONLY. В сданном доме
|
||||||
|
# cian тегирует переуступки/перепродажи собственниками как 'novostroyki';
|
||||||
|
# впускаем novostroyki только если вторичных (vtorichka/NULL) НЕ МЕНЬШЕ, чем
|
||||||
|
# первичных — иначе MAD-clip может выкинуть редкую вторичку и заякорить на
|
||||||
|
# ценах застройщика. Чисто-первичный / primary-dominated дом → гард #1186.
|
||||||
|
raw_rows = list(rows)
|
||||||
|
secondary_count = sum(
|
||||||
|
1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")
|
||||||
|
)
|
||||||
|
primary_count = len(raw_rows) - secondary_count
|
||||||
|
include_primary = (
|
||||||
|
settings.estimate_sb_tier_a_allow_primary_if_secondary_present
|
||||||
|
and secondary_count >= 1
|
||||||
|
and secondary_count >= primary_count
|
||||||
|
)
|
||||||
|
if include_primary:
|
||||||
|
kept = raw_rows # mixed/delivered house: include novostroyki resales
|
||||||
|
else:
|
||||||
|
kept = [r for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")]
|
||||||
|
# Dedup дублей одного объявления (#1774: source_id=330047129 → 2 active-строки,
|
||||||
|
# одна с house_id_fk, одна NULL). Ключ — (source, source_id) PRIMARY (codebase
|
||||||
|
# canon: скрейперы дедупят на source_id; source_url может отличаться trailing
|
||||||
|
# slash/query-param). Fallback source_url, затем атрибуты. Namespaced-теги
|
||||||
|
# ('id'/'url') защищают от value-collision между id и url.
|
||||||
|
deduped: dict[Any, dict[str, Any]] = {}
|
||||||
|
for r in kept:
|
||||||
|
sid = r.get("source_id")
|
||||||
|
url = r.get("source_url")
|
||||||
|
if sid is not None:
|
||||||
|
key: Any = (r.get("source"), "id", sid)
|
||||||
|
elif url is not None:
|
||||||
|
key = (r.get("source"), "url", url)
|
||||||
|
else:
|
||||||
|
key = (
|
||||||
|
r.get("source"),
|
||||||
|
r.get("address"),
|
||||||
|
r.get("area_m2"),
|
||||||
|
r.get("price_per_m2"),
|
||||||
|
)
|
||||||
|
if key not in deduped:
|
||||||
|
deduped[key] = r
|
||||||
|
deduped_rows = list(deduped.values())
|
||||||
|
comps = [_anchor_comp_from_row(r) for r in deduped_rows if r["price_per_m2"]]
|
||||||
if len(comps) >= min_comps:
|
if len(comps) >= min_comps:
|
||||||
|
primary_n = sum(
|
||||||
|
1 for r in deduped_rows if r.get("listing_segment") == "novostroyki"
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"anchor tier=A street=%r base=%s letter=%s → %d comps",
|
"anchor tier=A street=%r base=%s letter=%s → %d comps (primary=%d)",
|
||||||
street,
|
street,
|
||||||
base_no,
|
base_no,
|
||||||
letter,
|
letter,
|
||||||
len(comps),
|
len(comps),
|
||||||
|
primary_n,
|
||||||
)
|
)
|
||||||
return comps, "A"
|
return comps, "A"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,30 @@ def test_tier_w_inline_has_canonical_guard() -> None:
|
||||||
assert "listing_segment != 'novostroyki'" not in src
|
assert "listing_segment != 'novostroyki'" not in src
|
||||||
|
|
||||||
|
|
||||||
def test_tier_a_anchor_has_canonical_guard() -> None:
|
def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
|
||||||
"""Tier A anchor (same building) — ранее не имел segment-guard (#1186 fix)."""
|
"""#1774: Tier A больше НЕ хардкодит SQL-guard — он делает gated Python-side
|
||||||
|
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
||||||
|
|
||||||
|
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
||||||
|
Python-side gating — источник содержит и флаг настройки, и hardened
|
||||||
|
secondary_count/primary_count gate (#1774, после code-review).
|
||||||
|
"""
|
||||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||||
assert _GUARD_RE.search(src), "_fetch_anchor_comps Tier A SQL lacks novostroyki guard"
|
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
||||||
|
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
||||||
|
# (b) Tier A теперь gated Python-side (#1774).
|
||||||
|
assert "estimate_sb_tier_a_allow_primary_if_secondary_present" in src, (
|
||||||
|
"Tier A no longer references the #1774 gating flag — gated relaxation missing"
|
||||||
|
)
|
||||||
|
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
||||||
|
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
||||||
|
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
||||||
|
|
||||||
|
|
||||||
def test_tier_c_anchor_has_canonical_guard_not_segment_param() -> None:
|
def test_tier_c_anchor_has_canonical_guard_not_segment_param() -> None:
|
||||||
"""Tier C anchor — канон-предикат, не CAST(:segment AS text) (тот вариант отбрасывал NULL)."""
|
"""Tier C anchor — канон-предикат, не CAST(:segment AS text) (тот вариант отбрасывал NULL)."""
|
||||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||||
# Canonical guard present.
|
# Canonical guard present (Tier C; Tier A guard снят в #1774 → gated Python-side).
|
||||||
assert _GUARD_RE.search(src)
|
assert _GUARD_RE.search(src)
|
||||||
# Старый параметрический вариант убран.
|
# Старый параметрический вариант убран.
|
||||||
assert "listing_segment = CAST(:segment AS text)" not in src
|
assert "listing_segment = CAST(:segment AS text)" not in src
|
||||||
|
|
|
||||||
293
tradein-mvp/backend/tests/test_tier_a_primary_1774.py
Normal file
293
tradein-mvp/backend/tests/test_tier_a_primary_1774.py
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
"""#1774 — Tier A (same building) gated relaxation of the #1186 novostroyki guard.
|
||||||
|
|
||||||
|
В СДАННОМ доме cian тегирует переуступки/перепродажи собственниками (sale_type=free)
|
||||||
|
как listing_segment='novostroyki'. Раньше Tier A-гард #1186
|
||||||
|
`(listing_segment IS NULL OR listing_segment='vtorichka')` отбрасывал их, и
|
||||||
|
same-building listing пользователя не учитывался (кейс «Евгения Савкова 29», ЕКБ).
|
||||||
|
|
||||||
|
Дизайн #1774 (Tier A ONLY), gate hardened после code-review:
|
||||||
|
- secondary_count = число vtorichka/NULL rows; primary_count = остальные.
|
||||||
|
- flag ON + secondary_count ≥ 1 И secondary_count ≥ primary_count →
|
||||||
|
впускаем ВСЕ rows (включая novostroyki-переуступки).
|
||||||
|
- flag ON + primary-dominated дом (secondary_count < primary_count) → гард
|
||||||
|
#1186 сохраняется: иначе MAD-clip выкинет редкую вторичку и заякорит на
|
||||||
|
ценах застройщика (~27% overvaluation).
|
||||||
|
- flag OFF → гард #1186 сохраняется всегда (novostroyki исключены).
|
||||||
|
- dedup: PRIMARY ключ (source, source_id) — codebase canon (скрейперы дедупят
|
||||||
|
на source_id; source_url может отличаться trailing slash/query-param).
|
||||||
|
|
||||||
|
Tier C / радиус / ratio — НЕ затрагиваются (см. test_segment_guard_1186.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from app.services import estimator as est_mod
|
||||||
|
from app.services.estimator import _fetch_anchor_comps
|
||||||
|
|
||||||
|
# Адрес сводится к ('евгения савкова', 29, None) — Tier A street+base.
|
||||||
|
_ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29"
|
||||||
|
|
||||||
|
|
||||||
|
def _row(
|
||||||
|
*,
|
||||||
|
source: str = "avito",
|
||||||
|
source_url: str | None = None,
|
||||||
|
source_id: str | None = None,
|
||||||
|
listing_segment: str | None = None,
|
||||||
|
price_per_m2: float = 150_000.0,
|
||||||
|
area_m2: float = 50.0,
|
||||||
|
rooms: int = 2,
|
||||||
|
address: str = _ADDRESS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Mock listings-row (mappings shape) для Tier A: несёт listing_segment + source_id."""
|
||||||
|
if source_url is None:
|
||||||
|
source_url = f"https://{source}.ru/offer/{source_id or '0'}"
|
||||||
|
return {
|
||||||
|
"price_per_m2": price_per_m2,
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"rooms": rooms,
|
||||||
|
"floor": 3,
|
||||||
|
"total_floors": 9,
|
||||||
|
"address": address,
|
||||||
|
"source": source,
|
||||||
|
"source_url": source_url,
|
||||||
|
"price_rub": int(price_per_m2 * area_m2),
|
||||||
|
"listing_date": None,
|
||||||
|
"days_on_market": 15,
|
||||||
|
"photo_urls": [],
|
||||||
|
"lat": 56.838,
|
||||||
|
"lon": 60.595,
|
||||||
|
"listing_segment": listing_segment,
|
||||||
|
"source_id": source_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _db_mock(rows: list[dict[str, Any]]) -> MagicMock:
|
||||||
|
"""Session mock: db.execute().mappings().all() → rows (Tier A path)."""
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip)."""
|
||||||
|
return _fetch_anchor_comps(
|
||||||
|
db,
|
||||||
|
address=_ADDRESS,
|
||||||
|
target_house_id=None,
|
||||||
|
lat=None,
|
||||||
|
lon=None,
|
||||||
|
rooms=2,
|
||||||
|
area=50.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
||||||
|
"""Mixed/сданный дом: 4 vtorichka/NULL + 2 novostroyki → flag ON впускает ВСЕ 6.
|
||||||
|
|
||||||
|
secondary_count=4 ≥ primary_count=2 → hardened gate допускает novostroyki.
|
||||||
|
Зеркало «Евгения Савкова 29»: vtorichka-комплы (avito/yandex) соседствуют с
|
||||||
|
cian-переуступками (novostroyki) — последние тоже должны попасть в anchor.
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||||
|
_row(source="avito", source_id="a2", listing_segment=None),
|
||||||
|
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||||
|
_row(source="yandex", source_id="y2", listing_segment=None),
|
||||||
|
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
assert tier == "A"
|
||||||
|
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||||
|
assert len(comps) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
||||||
|
"""Hardened gate (code-review fix): 1 vtorichka + 5 novostroyki → secondary_count=1
|
||||||
|
< primary_count=5 → novostroyki ИСКЛЮЧЕНЫ (anchor строился бы на ценах застройщика
|
||||||
|
→ ~27% overvaluation после MAD-clip выкидывания одинокой вторички).
|
||||||
|
|
||||||
|
После исключения остаётся 1 vtorichka < min_comps=4 → Tier A не срабатывает
|
||||||
|
(lat/lon None → Tier C skip → tier=None, comps=[]). Это доказывает, что primary
|
||||||
|
rows отброшены: при их допуске было бы 6 comps ≥ min_comps и tier='A'.
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||||
|
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c3", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||||
|
assert tier is None
|
||||||
|
assert comps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
||||||
|
"""Чисто-первичный дом (0 вторички, ВСЕ novostroyki) → гард #1186 сохраняется,
|
||||||
|
novostroyki исключаются → comps < min_comps → Tier A не срабатывает (tier=None).
|
||||||
|
|
||||||
|
Tier-D-skip path намеренный: lat/lon=None → Tier C не запускается, поэтому
|
||||||
|
после отбраковки всех 4 novostroyki Tier A не набирает min_comps и возвращает
|
||||||
|
(tier=None, comps=[]). Assert именно на этом нулевом результате — фиксирует,
|
||||||
|
что чисто-первичный дом НЕ заякоривается на ценах застройщика."""
|
||||||
|
rows = [
|
||||||
|
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c3", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||||
|
assert tier is None
|
||||||
|
assert comps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
|
||||||
|
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
|
||||||
|
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
|
||||||
|
rows = [
|
||||||
|
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||||
|
_row(source="avito", source_id="a2", listing_segment=None),
|
||||||
|
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||||
|
_row(source="yandex", source_id="y2", listing_segment=None),
|
||||||
|
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||||
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
assert tier == "A"
|
||||||
|
# novostroyki отброшены → только 4 вторички.
|
||||||
|
assert len(comps) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||||
|
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
||||||
|
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
||||||
|
(source, source_id).
|
||||||
|
|
||||||
|
5 уникальных vtorichka + дубль одного source_id → 5 comps (не 6 — дубль убран).
|
||||||
|
"""
|
||||||
|
dup_url = "https://cian.ru/sale/flat/330047129/"
|
||||||
|
rows = [
|
||||||
|
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||||
|
_row(source="avito", source_id="a2", listing_segment="vtorichka"),
|
||||||
|
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||||
|
_row(source="yandex", source_id="y2", listing_segment="vtorichka"),
|
||||||
|
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment="vtorichka"),
|
||||||
|
# Дубль: тот же (source, source_id), но другой row (house_id_fk NULL-вариант).
|
||||||
|
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
assert tier == "A"
|
||||||
|
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||||
|
assert len(comps) == 5
|
||||||
|
cian = [c for c in comps if c["source"] == "cian"]
|
||||||
|
assert len(cian) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
||||||
|
"""Code-review Fix 2: те же source_id, но РАЗНЫЙ source_url (trailing slash /
|
||||||
|
query-param). PRIMARY ключ (source, source_id) всё равно схлопывает в 1 comp —
|
||||||
|
старый url-primary ключ этот дубль ПРОПУСКАЛ (это и есть баг #1774 Fix 2).
|
||||||
|
|
||||||
|
5 уникальных vtorichka + дубль того же source_id с другим url → 5 comps.
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||||
|
_row(source="avito", source_id="a2", listing_segment="vtorichka"),
|
||||||
|
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||||
|
_row(source="yandex", source_id="y2", listing_segment="vtorichka"),
|
||||||
|
_row(
|
||||||
|
source="cian",
|
||||||
|
source_url="https://cian.ru/sale/flat/330047129/",
|
||||||
|
source_id="330047129",
|
||||||
|
listing_segment="vtorichka",
|
||||||
|
),
|
||||||
|
# Дубль того же source_id, но url отличается trailing slash + query-param.
|
||||||
|
_row(
|
||||||
|
source="cian",
|
||||||
|
source_url="https://cian.ru/sale/flat/330047129?utm=x",
|
||||||
|
source_id="330047129",
|
||||||
|
listing_segment=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
assert tier == "A"
|
||||||
|
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||||
|
assert len(comps) == 5
|
||||||
|
cian = [c for c in comps if c["source"] == "cian"]
|
||||||
|
assert len(cian) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
||||||
|
"""source_url=None, но source_id задан → PRIMARY ключ (source, source_id):
|
||||||
|
реально разные лоты НЕ схлопываются (разные source_id)."""
|
||||||
|
rows = [
|
||||||
|
_row(source="avito", source_url=None, source_id="a1", area_m2=50.0),
|
||||||
|
_row(source="avito", source_url=None, source_id="a2", area_m2=60.0),
|
||||||
|
_row(source="avito", source_url=None, source_id="a3", area_m2=70.0),
|
||||||
|
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||||
|
]
|
||||||
|
db = _db_mock(rows)
|
||||||
|
with (
|
||||||
|
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||||
|
patch.object(
|
||||||
|
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
comps, tier = _fetch(db)
|
||||||
|
assert tier == "A"
|
||||||
|
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||||
|
assert len(comps) == 4
|
||||||
Loading…
Add table
Reference in a new issue