feat(tradein/estimator): регион-скоуп ДКП-коридора — фильтр по d.region_code (#3051 PR-A)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s

target_city резолвится ТОЛЬКО для городов Свердловской области
(_resolve_target_city матчит SVERDLOVSK_OBLAST_CITIES) — для Москвы/любого
нового региона city=None, и street ILIKE оставался единственным скоупом
сделки: одноимённая улица чужого региона утекала в коридор. Добавлен
d.region_code = CAST(:region_code AS int) в оба ДКП-запроса
(_fetch_dkp_corridor) + region_code передаётся из обоих вызывающих (POST
/estimate через geo.lat/lon, GET-rehydrate через row.lat/lon) с гарантией
«не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL-параметре
обнулил бы фильтр целиком). Дефолт региона и джойн deal_city_price_bands не
трогаются — следующие PR (B, F).

Regression: 5622 passed, 37 skipped (полный прогон tests/).
This commit is contained in:
bot-backend 2026-09-08 23:10:20 +03:00
parent 541c4ff28d
commit fbe85fcc75
2 changed files with 42 additions and 1 deletions

View file

@ -50,6 +50,7 @@ from app.schemas.trade_in import (
TradeInEstimateInput,
)
from app.services import account_quota
from app.services import regions as regions_mod
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
@ -763,8 +764,22 @@ def load_estimate(
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
# #3051 PR-A: тот же region_code-скоуп, что и POST /estimate — гарантирует
# «регион не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL
# обнулил бы фильтр). row.lat/row.lon персистятся с estimate-time.
target_region = (
regions_mod.region_for_point(row.lat, row.lon)
if row.lat is not None and row.lon is not None
else None
)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = _fetch_dkp_corridor(
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
db,
address=row.address,
rooms=row.rooms,
area=area_f,
city=target_city,
region_code=target_region_code,
)
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None

View file

@ -66,6 +66,7 @@ from app.schemas.trade_in import (
PriceTrendPoint,
TradeInEstimateInput,
)
from app.services import regions as regions_mod
from app.services.dadata import DadataAddressResult
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
@ -1832,6 +1833,7 @@ def _fetch_dkp_corridor(
rooms: int | None,
area: float | None,
city: str | None = None,
region_code: int = regions_mod.DEFAULT_REGION_CODE,
period_months: int = DEALS_PERIOD_MONTHS,
area_tolerance: float = AREA_TOLERANCE,
) -> dict[str, Any] | None:
@ -1851,6 +1853,16 @@ def _fetch_dkp_corridor(
Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) коридор превращался
в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился)
фильтр не применяется, прежнее (pre-oblast) поведение сохраняется как есть.
region_code: (#3051 «Москва», PR-A) — регион целевого запроса, ОБЯЗАТЕЛЬНЫЙ
фильтр (не advisory, в отличие от city) в обоих ДКП-запросах. `city`
распознаётся только для Свердловской области (_resolve_target_city матчит
исключительно SVERDLOVSK_OBLAST_CITIES) для Москвы/любого нового региона
city=None и street-ILIKE фильтр остаётся ЕДИНСТВЕННЫМ скоупом сделки; без
region_code одноимённая улица чужого региона (или сделка без узнанного
города вовсе) утекает в коридор через тот же street ILIKE. Дефолт
regions_mod.DEFAULT_REGION_CODE (66) байт-в-байт прежнее поведение для
всех сегодняшних вызовов (все сделки в БД сейчас region_code=66).
"""
if not address or rooms is None or not area:
return None
@ -1875,6 +1887,7 @@ def _fetch_dkp_corridor(
FROM deals d
LEFT JOIN deal_city_price_bands b ON b.city = d.city
WHERE d.source = 'rosreestr'
AND d.region_code = CAST(:region_code AS int)
AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex
AND d.rooms = CAST(:rooms AS integer)
@ -1902,6 +1915,7 @@ def _fetch_dkp_corridor(
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"target_city": city.lower() if city else None,
"region_code": region_code,
},
)
.mappings()
@ -1956,6 +1970,7 @@ def _fetch_dkp_corridor(
FROM deals d
LEFT JOIN deal_city_price_bands b ON b.city = d.city
WHERE d.source = 'rosreestr'
AND d.region_code = CAST(:region_code AS int)
AND d.city IS NOT NULL
AND LOWER(d.city) = CAST(:target_city AS text)
AND d.rooms = CAST(:rooms AS integer)
@ -1975,6 +1990,7 @@ def _fetch_dkp_corridor(
"period_months": period_months,
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"region_code": region_code,
},
)
.mappings()
@ -4555,6 +4571,15 @@ async def estimate_quality(
target_city = _resolve_target_city(payload.address) or _resolve_target_city(
(dadata.canonical_address if dadata else None) or geo.full_address
)
# #3051 PR-A: региональный скоуп ДКП-коридора — target_city выше распознаёт
# ТОЛЬКО города Свердловской области (_resolve_target_city матчит
# SVERDLOVSK_OBLAST_CITIES), т.е. для Москвы/любого нового региона city=None
# и без region_code street ILIKE ловил бы одноимённую улицу ЧУЖОГО региона.
# region_for_point(None) (точка вне охвата ни одного региона) → дефолт 66,
# НЕ None — NULL в SQL-параметре обнулил бы фильтр (`= NULL` не матчит
# ничего), т.е. держим гарантию «не резолвится → 66», не пусто.
target_region = regions_mod.region_for_point(geo.lat, geo.lon)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = await asyncio.to_thread(
_fetch_dkp_corridor,
db,
@ -4562,6 +4587,7 @@ async def estimate_quality(
rooms=payload.rooms,
area=payload.area_m2,
city=target_city,
region_code=target_region_code,
)
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──