diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 094c5ca4..c463f520 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1638,7 +1638,12 @@ def get_street_deals( После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована в import-rosreestr.sh). """ - from app.services.estimator import _deal_to_analog, _percentile, extract_street_name + from app.services.estimator import ( + _deal_to_analog, + _percentile, + _resolve_target_city, + extract_street_name, + ) now = datetime.now(tz=UTC) # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который @@ -1673,21 +1678,25 @@ def get_street_deals( area_min = area_m2 * (1.0 - area_tolerance) area_max = area_m2 * (1.0 + area_tolerance) + # #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим + # город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66 + # вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы + # др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не + # применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром. + target_city = _resolve_target_city(address) + city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else "" + rows = ( db.execute( text( - """ + f""" SELECT address, area_m2, rooms, floor, total_floors, price_rub, price_per_m2, deal_date, source FROM deals WHERE source = 'rosreestr' AND address ILIKE :street_pattern AND address ~* :street_regex - -- #C1 city-scope (см. estimator._fetch_dkp_corridor): сделка в городе - -- целевого адреса — одноимённые улицы др. городов обл.66 (миграция 177) - -- не контаминируют витрину. Матчим канонический city как подстроку адреса. - AND city IS NOT NULL - AND :address ILIKE '%' || city || '%' + {city_filter} AND rooms = CAST(:rooms AS integer) AND area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval @@ -1698,7 +1707,7 @@ def get_street_deals( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", - "address": address, + "target_city": target_city.lower() if target_city else None, "rooms": rooms, "area_min": area_min, "area_max": area_max, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5fc8ff0c..290305fd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1363,9 +1363,12 @@ def _fetch_dkp_corridor( return None area_min = area * (1.0 - area_tolerance) area_max = area * (1.0 + area_tolerance) - # (oblast C2): city приходит уже lower-cased (_resolve_target_city) — deals.city в БД - # хранится с исходным регистром («Нижний Тагил»), поэтому сравниваем через - # LOWER(d.city), а не byte-equal. + # #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана + # быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case, + # словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД + # с исходным регистром → сравниваем через LOWER(d.city). city=None (адрес вне + # словаря, редкий мелкий н.п.) → фильтр не применяется (прежнее #2488-поведение). + # Прежний #2489 substring-фильтр УБРАН — был двойным с этим (benign, но ё/е edge). city_filter_sql = "AND LOWER(d.city) = CAST(:target_city AS text)" if city else "" try: rows = ( @@ -1378,14 +1381,6 @@ def _fetch_dkp_corridor( WHERE d.source = 'rosreestr' AND d.address ILIKE :street_pattern AND d.address ~* :street_regex - -- #C1 city-scope: сделка должна быть в ГОРОДЕ целевого адреса. - -- Миграция 177 залила ДКП по всей обл.66 (deals.city, 368 городов) — - -- без этого одноимённые улицы др. городов (Ленина/Мира/…) - -- контаминируют коридор (median занижался ~−46% для ЕКБ). Матчим - -- канонический deals.city как подстроку целевого адреса (адрес - -- всегда содержит город: «…г. Екатеринбург…»). self-normalizing. - AND d.city IS NOT NULL - AND :address ILIKE '%' || d.city || '%' AND d.rooms = CAST(:rooms AS integer) AND d.area_m2 BETWEEN :area_min AND :area_max AND d.deal_date > NOW() @@ -1404,7 +1399,6 @@ def _fetch_dkp_corridor( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", - "address": address, "rooms": rooms, "area_min": area_min, "area_max": area_max,