Merge pull request 'refactor(tradein): консолидировать corridor/street-deals city-scope на _resolve_target_city (п.3)' (#2501) from fix/tradein-corridor-dedup-scope into main
Some checks failed
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-12 20:31:05 +00:00
commit e7c388dde7
2 changed files with 23 additions and 20 deletions

View file

@ -1638,7 +1638,12 @@ def get_street_deals(
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh). в 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) now = datetime.now(tz=UTC)
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
@ -1673,21 +1678,25 @@ def get_street_deals(
area_min = area_m2 * (1.0 - area_tolerance) area_min = area_m2 * (1.0 - area_tolerance)
area_max = 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 = ( rows = (
db.execute( db.execute(
text( text(
""" f"""
SELECT address, area_m2, rooms, floor, total_floors, SELECT address, area_m2, rooms, floor, total_floors,
price_rub, price_per_m2, deal_date, source price_rub, price_per_m2, deal_date, source
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND address ILIKE :street_pattern AND address ILIKE :street_pattern
AND address ~* :street_regex AND address ~* :street_regex
-- #C1 city-scope (см. estimator._fetch_dkp_corridor): сделка в городе {city_filter}
-- целевого адреса одноимённые улицы др. городов обл.66 (миграция 177)
-- не контаминируют витрину. Матчим канонический city как подстроку адреса.
AND city IS NOT NULL
AND :address ILIKE '%' || city || '%'
AND rooms = CAST(:rooms AS integer) AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
@ -1698,7 +1707,7 @@ def get_street_deals(
{ {
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"address": address, "target_city": target_city.lower() if target_city else None,
"rooms": rooms, "rooms": rooms,
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,

View file

@ -1363,9 +1363,12 @@ def _fetch_dkp_corridor(
return None return None
area_min = area * (1.0 - area_tolerance) area_min = area * (1.0 - area_tolerance)
area_max = area * (1.0 + area_tolerance) area_max = area * (1.0 + area_tolerance)
# (oblast C2): city приходит уже lower-cased (_resolve_target_city) — deals.city в БД # #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана
# хранится с исходным регистром («Нижний Тагил»), поэтому сравниваем через # быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case,
# LOWER(d.city), а не byte-equal. # словарь ~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 "" city_filter_sql = "AND LOWER(d.city) = CAST(:target_city AS text)" if city else ""
try: try:
rows = ( rows = (
@ -1378,14 +1381,6 @@ def _fetch_dkp_corridor(
WHERE d.source = 'rosreestr' WHERE d.source = 'rosreestr'
AND d.address ILIKE :street_pattern AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex 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.rooms = CAST(:rooms AS integer)
AND d.area_m2 BETWEEN :area_min AND :area_max AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW() AND d.deal_date > NOW()
@ -1404,7 +1399,6 @@ def _fetch_dkp_corridor(
{ {
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"address": address,
"rooms": rooms, "rooms": rooms,
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,