From fbe85fcc7527e5dc165b74710c42132ec35774cc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 8 Sep 2026 23:10:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/estimator):=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD-=D1=81=D0=BA=D0=BE=D1=83=D0=BF=20=D0=94?= =?UTF-8?q?=D0=9A=D0=9F-=D0=BA=D0=BE=D1=80=D0=B8=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BF?= =?UTF-8?q?=D0=BE=20d.region=5Fcode=20(#3051=20PR-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/). --- tradein-mvp/backend/app/api/v1/trade_in.py | 17 +++++++++++- tradein-mvp/backend/app/services/estimator.py | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index e5542b73..bc05717c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 0a2da10a..f299925b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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) ──