From 45ee0a29454118d0ecd3238ce29a2f10d6db658e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 13:14:59 +0000 Subject: [PATCH] fix(tradein-estimator): extend cohort filter to Tier S + Tier H, fix late_soviet overlap (#522) --- tradein-mvp/backend/app/services/estimator.py | 17 +++++++- .../backend/tests/test_estimator_cohort.py | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_cohort.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5aa46bdb..da4c104f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -71,7 +71,7 @@ COHORTS = ( # (cohort_name, year_min_inclusive, year_max_inclusive) ('khrushchev', 1955, 1969), # Хрущёвки 5-эт ('brezhnev', 1970, 1989), # Брежневка кирпич/панель 9–12-эт - ('late_soviet', 1985, 1999), # Поздний СССР (overlap с brezhnev intentional) + ('late_soviet', 1990, 1999), # Поздний СССР (no overlap; first-match would never pick old range) ('2000s', 2000, 2010), # Ранние новостройки ('modern', 2011, 2100), # Современные ЖК ) @@ -1023,7 +1023,20 @@ _COMMON_WHERE = """ AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval AND price_rub > 0 + -- Когортный фильтр (PR 10): применяется к Tier S и Tier H через _COMMON_WHERE. + -- Tier W имеет свою копию этого блока в inline SQL. Если cohort_year_min IS NULL — + -- фильтр прозрачен. CAST обязателен — psycopg3 prepared statement не выводит + -- тип $N при IS NULL в predicate (см. PR #518 fix). + AND ( + CAST(:cohort_year_min AS integer) IS NULL + OR year_built IS NULL + OR year_built BETWEEN CAST(:cohort_year_min AS integer) + AND CAST(:cohort_year_max AS integer) + ) """ +# Note: Tier W has its own inline copy of the cohort clause (PR #519 line +# ~1280). Не удалять — Tier W не использует _COMMON_WHERE из-за inline +# relevance_score CASE expressions. Both code paths must stay in sync. def _fetch_analogs( @@ -1073,6 +1086,8 @@ def _fetch_analogs( "area_max": area_max, "fresh_days": LISTINGS_FRESH_DAYS, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, + "cohort_year_min": cohort_year_min, + "cohort_year_max": cohort_year_max, } # ── Tier S: same building ───────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_estimator_cohort.py b/tradein-mvp/backend/tests/test_estimator_cohort.py new file mode 100644 index 00000000..b6c572fb --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_cohort.py @@ -0,0 +1,43 @@ +"""Unit tests for cohort filter helpers in estimator.py. + +PR 10 (2026-05-24) — covers _target_cohort_range edge cases: +None, out-of-range, exact boundaries, first-match semantics. +""" +import os + +# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +import pytest + +from app.services.estimator import _target_cohort_range + + +@pytest.mark.parametrize( + ("year", "expected"), + [ + (None, None), # no year → no cohort + (1900, None), # out-of-range below → no cohort + (1954, None), # below khrushchev floor + (1955, (1955, 1969)), # khrushchev floor + (1960, (1955, 1969)), # khrushchev mid + (1969, (1955, 1969)), # khrushchev ceiling + (1970, (1970, 1989)), # brezhnev floor + (1978, (1970, 1989)), # brezhnev (target audit case) + (1988, (1970, 1989)), # brezhnev pre-overlap fix held + (1989, (1970, 1989)), # brezhnev ceiling + (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix) + (1995, (1990, 1999)), # late_soviet mid + (1999, (1990, 1999)), # late_soviet ceiling + (2000, (2000, 2010)), # 2000s floor + (2010, (2000, 2010)), # 2000s ceiling + (2011, (2011, 2100)), # modern floor + (2024, (2011, 2100)), # modern current + (2100, (2011, 2100)), # modern ceiling + (2101, None), # above modern ceiling → no cohort + (2200, None), # far future → no cohort + ], +) +def test_target_cohort_range(year, expected): + """First-match cohort mapping; out-of-range and None → None.""" + assert _target_cohort_range(year) == expected