From a4e97c4b708e349c21727ffcd409d668deee37c3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:33:00 +0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(tradein/estimate):=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=20=D1=80=D0=B0=D0=B7=D0=B2=D1=91=D1=80=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D1=81=D0=BA=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D0=BE=D1=80=D0=B0=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20(#2583=20H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам (Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП собирался по одной улице во всей области, где «Ленина» в основном екатеринбургская, и Реж получал цену Екатеринбурга. Резолвер теперь знает и города развёрток (кроме городов других регионов). Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома. Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках ЕКБ ни одного адреса с этими именами отдельным словом. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/estimator.py | 33 +++-- tradein-mvp/backend/app/services/geocoder.py | 7 +- .../test_estimator_target_city_sweep_2583.py | 115 ++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8beb6cd1..da9bdb96 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -34,6 +34,7 @@ from typing import Any, Literal from uuid import uuid4 from fastapi import HTTPException +from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError, IMVAuthError, @@ -2115,15 +2116,29 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float: return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor)) -# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов -# региона 66) — resolve target-города для city-scoping ДКП-коридора (см. -# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc), -# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно -# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но -# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE). +# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут +# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с +# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей +# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в +# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities +# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS +# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а +# deals.city хранит е-формы («Артемовский»). +_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset( + name.lower().replace("ё", "е") + for name in CITY_DISPLAY_NAMES.values() + if (region := regions_mod.region_by_city(name)) is None or region.code == 66 +) + +# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для +# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких +# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил» +# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ +# именам городов, но сохраняем ту же дисциплину сортировки, что и +# geocoder._OBLAST_CITY_RE). _TARGET_CITY_RE = re.compile( r"\b(?:" - + "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True)) + + "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True)) + r")\b" ) @@ -2131,8 +2146,8 @@ _TARGET_CITY_RE = re.compile( def _resolve_target_city(address_text: str | None) -> str | None: """Извлекает город региона 66 из геокодированного адреса (best-effort). - Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по - границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. + Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города + развёрток) по границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key). diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 8f7412c9..6f38125f 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None: Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город, отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый - (`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не - резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и - гейт хинта не создаёт новой связности. + (`_names_non_ekb_city` его не увидит) — т.е. новый город области в любом + случае добавляется СЮДА, и гейт хинта не создаёт новой связности. + (`estimator._resolve_target_city` дополнительно знает города развёрток, + #2583 H1 — ему город нужен для фильтра сделок, а не для гейта.) Цена решения (осознанная): легитимный, но не перечисленный населённый пункт («Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по diff --git a/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py b/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py new file mode 100644 index 00000000..fcd0ab3d --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py @@ -0,0 +1,115 @@ +"""#2583 H1 — город развёртки обязан попадать в городской фильтр ДКП-коридора. + +Словарь региона 66 (24 имени) не знал 23 города, по которым уже идут развёртки +(Реж, Карпинск, Кушва, Лесной, Тавда…). `_resolve_target_city` возвращал None, +`_fetch_dkp_corridor` собирал сделки по одной улице во всей области — «Ленина» там +в основном екатеринбургская, и коридор Режа получался ценой Екатеринбурга. + +Замер прода 2026-09-17 (tradein-postgres, deals source='rosreestr', region 66): по +всем 23 городам сделки есть под теми же е-формами (Реж 745, из них 189 за 12 мес.; +Карпинск 521/161; Лесной 1215/402), а среди сделок Екатеринбурга ни один адрес не +содержит этих имён отдельным словом — ложного городского фильтра на ЕКБ-адресах нет. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES + +from app.services.estimator import _fetch_dkp_corridor, _resolve_target_city + +_REGION_66_SWEEP_CITIES = sorted( + name for slug, name in CITY_DISPLAY_NAMES.items() if slug != "moskva" +) + + +@pytest.mark.parametrize( + ("address", "city"), + [ + ("Реж, Ленина, 12", "реж"), + ("Карпинск, Ленина, 5", "карпинск"), + ("Россия, Свердловская область, Артёмовский, улица Ленина, 5", "артемовский"), + ("Свердловская обл., Нижнетуринский г.о., Нижняя Тура, ул. Ленина, 3", "нижняя тура"), + ], +) +def test_sweep_city_from_the_audit_is_resolved(address: str, city: str) -> None: + assert _resolve_target_city(address) == city + + +@pytest.mark.parametrize("name", _REGION_66_SWEEP_CITIES) +def test_every_region_66_sweep_city_is_resolved(name: str) -> None: + """Новый город развёртки не должен снова выпасть в «коридор по всей области».""" + assert _resolve_target_city(f"{name}, ул. Ленина, 12") == name.lower().replace("ё", "е") + + +def test_moscow_is_not_pulled_into_the_oblast_resolver() -> None: + """Москва — город развёртки, но другого региона: для неё прежний None.""" + assert _resolve_target_city("Москва, ул. Тверская, 1") is None + + +def test_ekb_district_named_like_a_city_still_not_a_city() -> None: + """Прежний гард «мкр Заречный» не сломан расширением словаря.""" + assert _resolve_target_city("р-н Верх-Исетский, мкр. Заречный, улица Опалихинская, 20А") is None + + +# ── коридор: значение, а не параметр ───────────────────────────────────────── + +_EKB_PPM2 = 150_000 +_REZH_PPM2 = 55_000 + + +def _deals_db() -> Any: + """Двойник сессии, исполняющий городской фильтр так же, как SQL коридора. + + Улица «Ленина» есть и в Екатеринбурге (10 сделок), и в Реже (4 сделки). Без + `LOWER(d.city) = :target_city` запрос отдаёт обе, с ним — только свой город. + """ + rows = [{"city": "екатеринбург", "ppm2": _EKB_PPM2}] * 10 + [ + {"city": "реж", "ppm2": _REZH_PPM2} + ] * 4 + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + sql = str(query) + picked: list[dict[str, Any]] = [] + if "FROM deals d" in sql: + city = (params or {}).get("target_city") + filtered = "LOWER(d.city) = CAST(:target_city AS text)" in sql + picked = [ + {"price_per_m2": r["ppm2"], "deal_date": date(2026, 1, 1)} + for r in rows + if not filtered or r["city"] == city + ] + result.mappings.return_value.all.return_value = picked + return result + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def test_rezh_corridor_is_built_from_rezh_deals_not_the_whole_oblast() -> None: + """«Реж, Ленина, 12»: медиана коридора — цена Режа, а не Екатеринбурга. + + На main город не распознан → фильтра нет → 10 екатеринбургских сделок из 14 + и медиана 150 000 ₽/м². + """ + address = "Реж, Ленина, 12" + raw = _fetch_dkp_corridor( + _deals_db(), + address=address, + rooms=2, + area=45.0, + city=_resolve_target_city(address), + ) + assert raw is not None + assert raw["count"] == 4 + assert raw["median_ppm2"] == _REZH_PPM2 -- 2.45.3 From b9c89641d187926e12522857b7d7140af2cb3278 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:35:23 +0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(tradein/gar):=20=D0=B4=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0=D0=B5=D1=82=20=D0=93=D0=90?= =?UTF-8?q?=D0=A0-guid=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D0=B5=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B3=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20(#2583=20H5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50, 340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома. Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого региона (дом без региона не трогаем) — иначе дом без пары в своём регионе так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only. Co-Authored-By: Claude Opus 5 --- .../backend/app/services/gar_flats_loader.py | 44 ++++++-- tradein-mvp/backend/tests/skip_allowlist.txt | 1 + .../backend/tests/test_gar_flats_loader.py | 101 +++++++++++++++++- tradein-mvp/docs/gar-flats-runbook.md | 8 +- 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/tradein-mvp/backend/app/services/gar_flats_loader.py b/tradein-mvp/backend/app/services/gar_flats_loader.py index b7ae7ed9..059e5d73 100644 --- a/tradein-mvp/backend/app/services/gar_flats_loader.py +++ b/tradein-mvp/backend/app/services/gar_flats_loader.py @@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None: # НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация — # пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` → # повторный прогон не трогает уже совпавшие строки. +# +# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону +# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил +# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов +# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с +# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона. _MATCH_SQL = text( """ WITH gar_scope AS ( SELECT - tradein_canon_addr(norm_address) AS canon, house_guid, flat_count + tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code FROM gar_house_flats WHERE flat_count > 0 AND tradein_canon_addr(norm_address) <> '' @@ -622,15 +628,15 @@ _MATCH_SQL = text( ), gar_ranked AS ( SELECT - canon, house_guid, flat_count, + canon, house_guid, flat_count, region_code, ROW_NUMBER() OVER ( - PARTITION BY canon ORDER BY flat_count DESC, house_guid + PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid ) AS rn, - COUNT(*) OVER (PARTITION BY canon) AS canon_hits + COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits FROM gar_scope ), gar_pick AS ( - SELECT canon, house_guid, flat_count + SELECT canon, house_guid, flat_count, region_code FROM gar_ranked WHERE rn = 1 AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1) @@ -644,10 +650,33 @@ _MATCH_SQL = text( WHERE tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon + AND CAST(h.region_code AS text) = gp.region_code AND h.gar_flat_count IS DISTINCT FROM gp.flat_count """ ) +# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам +# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid +# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и +# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем. +_CLEAR_FOREIGN_REGION_SQL = text( + """ + UPDATE houses h + SET gar_house_guid = NULL, + gar_flat_count = NULL, + gar_matched_at = NULL, + gar_match_method = NULL + FROM gar_house_flats g + WHERE g.house_guid = h.gar_house_guid + AND h.gar_match_method = 'canon_addr' + AND CAST(h.region_code AS text) <> g.region_code + AND ( + CAST(:region AS text) IS NULL + OR CAST(h.region_code AS text) = CAST(:region AS text) + ) + """ +) + def match_houses_to_gar( db: Session, @@ -666,11 +695,14 @@ def match_houses_to_gar( """ if isinstance(city_filter, CityFilterAutoType): city_filter = default_city_filter_for_region(region_code) + cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter}) matched = result.rowcount logger.info( - "gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)", + "gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d" + " (region=%s, city=%s)", matched, + cleared, region_code, city_filter, ) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 69c61d4b..a75832d6 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -33,6 +33,7 @@ tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_ tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched +tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index dbeb700d..381996b9 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None: # Канонический ключ (мигр. 144), а не точное равенство norm_address. assert "tradein_canon_addr(norm_address) AS canon" in flat # Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid. - assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat + # #2583 H5: ранжирование внутри региона — канон «ленина5» 66-го и 50-го не соревнуются. + assert "PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid" in flat # houses-сторона: тот же канон поверх COALESCE(short/full/address). houses_side = ( "tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon" @@ -512,6 +513,7 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: text( "CREATE TEMP TABLE houses (" " id serial PRIMARY KEY, short_address text, full_address text, address text," + " region_code smallint," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_match_method text" ") ON COMMIT DROP" @@ -522,8 +524,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: # (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже). db.execute( text( - "INSERT INTO houses (short_address) VALUES " - "('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')" + "INSERT INTO houses (short_address, region_code) VALUES " + "('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)" ) ) @@ -613,6 +615,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: text( "CREATE TEMP TABLE houses (" " id serial PRIMARY KEY, short_address text, full_address text, address text," + " region_code smallint," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_match_method text" ") ON COMMIT DROP" @@ -633,7 +636,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: ) ) db.execute( - text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')") + text( + "INSERT INTO houses (short_address, region_code) VALUES " + "('ул. Ленина,5', 50), ('ул. Мира,1', 50)" + ) ) matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None) @@ -652,3 +658,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: finally: db.rollback() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") +def test_house_gets_gar_guid_only_from_its_own_region() -> None: + """#2583 H5: UPDATE houses ограничен регионом дома, чужие guid снимаются. + + Прод 2026-09-17, после матча регионов 77/50 (#3523): 578 домов региона 66 несли guid + региона 50 и 340 — региона 77. Канон не несёт ни город, ни регион, поэтому + екатеринбургская «Ленина, 5» и подмосковная «Ленина, 5» — один ключ. + """ + from sqlalchemy import text + + db = _live_session() + assert db is not None + try: + raw = db.connection() + raw.exec_driver_sql(_NORMALIZER_FN) + raw.exec_driver_sql(_CANON_FN) + db.execute( + text( + "CREATE TEMP TABLE gar_house_flats (" + " house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL," + " flat_count int, full_address text, norm_address text, street_name text," + " house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text" + ") ON COMMIT DROP" + ) + ) + db.execute( + text( + "CREATE TEMP TABLE houses (" + " id serial PRIMARY KEY, short_address text, full_address text, address text," + " region_code smallint," + " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," + " gar_match_method text" + ") ON COMMIT DROP" + ) + ) + db.execute( + text( + "INSERT INTO gar_house_flats " + "(house_guid, region_code, flat_count, full_address, norm_address) VALUES " + "('g66-lenina', '66', 60, 'обл Свердловская, г Екатеринбург, ул Ленина, 5', " + "'ул Ленина, 5'), " + "('g50-lenina', '50', 400, 'обл Московская, г Химки, ул Ленина, 5', " + "'ул Ленина, 5'), " + "('g50-mira', '50', 250, 'обл Московская, г Химки, ул Мира, 7', 'ул Мира, 7')" + ) + ) + # ЕКБ «Ленина, 5» и «Мира, 7» уже несут подмосковные guid (как на проде после + # прогона региона 50); подмосковная «Ленина, 5» и дом без региона — не матчены. + db.execute( + text( + "INSERT INTO houses (short_address, region_code, gar_house_guid, gar_flat_count, " + "gar_match_method) VALUES " + "('ул. Ленина,5', 66, 'g50-lenina', 400, 'canon_addr'), " + "('ул. Мира,7', 66, 'g50-mira', 250, 'canon_addr'), " + "('ул. Ленина,5', 50, NULL, NULL, NULL), " + "('ул. Мира,7', NULL, 'g50-mira', 250, 'canon_addr')" + ) + ) + + def _state() -> set[tuple[Any, ...]]: + rows = db.execute( + text( + "SELECT short_address, region_code, gar_house_guid, gar_flat_count FROM houses" + ) + ).all() + return {tuple(r) for r in rows} + + assert gfl.match_houses_to_gar(db, region_code="66") == 1 + assert _state() == { + ("ул. Ленина,5", 66, "g66-lenina", 60), # свой ЕКБ-дом вместо подмосковного + ("ул. Мира,7", 66, None, None), # пары в 66 нет — чужой guid снят, не оставлен + ("ул. Ленина,5", 50, None, None), # прогон 66 не трогает дома 50 + ("ул. Мира,7", None, "g50-mira", 250), # регион дома неизвестен — не доказуемо + } + + assert gfl.match_houses_to_gar(db, region_code="50", city_filter=None) == 1 + assert _state() == { + ("ул. Ленина,5", 66, "g66-lenina", 60), # прогон 50 не перетирает ЕКБ + ("ул. Мира,7", 66, None, None), + ("ул. Ленина,5", 50, "g50-lenina", 400), + ("ул. Мира,7", None, "g50-mira", 250), + } + finally: + db.rollback() + db.close() diff --git a/tradein-mvp/docs/gar-flats-runbook.md b/tradein-mvp/docs/gar-flats-runbook.md index c7c97af4..06202b80 100644 --- a/tradein-mvp/docs/gar-flats-runbook.md +++ b/tradein-mvp/docs/gar-flats-runbook.md @@ -178,10 +178,14 @@ LIMIT 20; одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже закрыта сужением по городу, поведение byte-for-byte прежнее. +- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона + (`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже + проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог + пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50. - **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве — - лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER - BY flat_count DESC, house_guid)`. + лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon + ORDER BY flat_count DESC, house_guid)`. - **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`). Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс `gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера. -- 2.45.3 From aefbd993bb29134f53c717f237baeb71076a925c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:39:10 +0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(tradein/ui):=20=C2=AB=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=8D=D1=82=D0=BE=D1=82=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=C2=BB=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D1=8F=D0=BB=D0=B0=D1=81=D1=8C=20=D1=81=D0=BE=D1=81=D0=B5=D0=B4?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8=20(#2583=20M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /house-analytics при <8 архивных лотах добирает до 30 соседних домов в радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала «История цен в этом доме · только этот дом», а рядом на том же экране стояло «в радиусе 300 м». Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый PriceHistoryChart). Co-Authored-By: Claude Opus 5 --- .../trade-in/HouseAnalyticsSection.tsx | 2 +- .../components/trade-in/PriceHistoryChart.tsx | 12 ++- .../components/trade-in/v2/AnalyticsView.tsx | 6 +- .../v2/__tests__/priceHistoryScope.test.tsx | 75 +++++++++++++++++++ .../src/components/trade-in/v2/mappers.ts | 12 ++- .../src/components/trade-in/v2/types.ts | 2 + 6 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx b/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx index decb019d..168f870c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HouseAnalyticsSection.tsx @@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) { {sellTime.data && } {data.price_history.length >= 2 && ( - + )} {data.recent_sold.length > 0 && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx b/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx index 420125c9..6bbb51d6 100644 --- a/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/PriceHistoryChart.tsx @@ -13,7 +13,9 @@ import { import type { PriceHistoryYearPoint } from "@/types/trade-in"; import { sourcePublicLabel } from "@/lib/source-registry"; -type Props = { points: PriceHistoryYearPoint[] }; +// radiusM — radius_m из /house-analytics: 0 = выборка ровно по дому, >0 = дом и соседние +// в этом радиусе (M2 audit #2583). +type Props = { points: PriceHistoryYearPoint[]; radiusM: number }; const AVITO_LABEL = sourcePublicLabel("avito_imv"); const YANDEX_LABEL = sourcePublicLabel("yandex_valuation"); @@ -28,7 +30,7 @@ interface PivotRow { n_yandex?: number; } -export function PriceHistoryChart({ points }: Props) { +export function PriceHistoryChart({ points, radiusM }: Props) { const pivoted = useMemo(() => { const byYear: Record = {}; for (const p of points) { @@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {

- История цен в этом доме + {radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}

- Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов) + Медиана ₽/м² по годам ·{" "} + {radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "} + {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx index 082b1101..02602fc6 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -357,7 +357,7 @@ export default function AnalyticsView({ >
- История цен в этом доме + {data.priceHistory.title}
{historyNote} @@ -401,9 +401,9 @@ export default function AnalyticsView({ style={{ width: "100%", height: 260, marginTop: 12 }} preserveAspectRatio="none" role="img" - aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`} + aria-label={`${data.priceHistory.title}: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`} > - История цен в этом доме — медиана ₽/м² по годам + {`${data.priceHistory.title} — медиана ₽/м² по годам`} {PH_GRID_Y.map((y) => ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx new file mode 100644 index 00000000..fab3e36a --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/priceHistoryScope.test.tsx @@ -0,0 +1,75 @@ +// M2 audit #2583 — подпись «История цен в этом доме · только этот дом» была +// безусловной, хотя /house-analytics при <8 архивных лотах расширяет выборку до +// соседних домов в радиусе 300 м (radius_m в ответе). На том же экране рядом +// стояло «в радиусе 300 м» — две взаимоисключающие подписи. + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { PriceHistoryChart } from "../../PriceHistoryChart"; +import AnalyticsView from "../AnalyticsView"; +import { mapAnalytics } from "../mappers"; +import type { + HouseAnalyticsResponse, + PriceHistoryYearPoint, +} from "@/types/trade-in"; + +const points: PriceHistoryYearPoint[] = [ + { year: 2024, source: "avito_imv", median_price_per_m2: 120_000, n_lots: 3, median_price_rub: 6_000_000 }, + { year: 2025, source: "avito_imv", median_price_per_m2: 130_000, n_lots: 4, median_price_rub: 6_500_000 }, +]; + +function analytics(radius_m: number): HouseAnalyticsResponse { + return { + house_ids: [1, 2, 3], + radius_m, + price_history: points, + recent_sold: [], + kpi: { + total_lots: 7, + sold_count: 0, + sold_rate_pct: 0, + median_exposure_days: null, + median_bargain_pct: null, + }, + }; +} + +describe("v2 история цен: подпись следует radius_m", () => { + it("выборка расширена соседями — не «только этот дом»", () => { + const { priceHistory } = mapAnalytics(analytics(300), null, null); + expect(priceHistory.title).toBe("История цен: дом и соседние"); + expect(priceHistory.note).toContain("дом и соседние в радиусе 300 м"); + expect(priceHistory.note).not.toContain("только этот дом"); + }); + + it("экран v2 печатает подпись из данных, а не константу", () => { + render(); + expect(screen.getByText("История цен: дом и соседние")).toBeTruthy(); + expect(screen.queryByText(/в этом доме/)).toBeNull(); + }); + + it("выборка ровно по дому — прежняя подпись", () => { + const { priceHistory } = mapAnalytics(analytics(0), null, null); + expect(priceHistory.title).toBe("История цен в этом доме"); + expect(priceHistory.note).toContain("только этот дом"); + }); +}); + +describe("старый экран: PriceHistoryChart", () => { + it("radiusM>0 — заголовок и подпись про соседей", () => { + // recharts ResponsiveContainer требует ResizeObserver, которого нет в jsdom. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + render(); + expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние"); + expect(screen.queryByText(/только этот дом/)).toBeNull(); + expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy(); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 5f33f713..c293fc51 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1820,7 +1820,16 @@ export function phYearX(i: number, n: number): number { } function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { + // M2 audit #2583: при <8 архивных лотах /house-analytics подмешивает до 30 соседних + // домов в радиусе radius_m — «только этот дом» тогда неправда (рядом на экране + // стоит «в радиусе 300 м»). radius_m=0 — выборка ровно по дому. + const radiusM = a?.radius_m ?? 0; + const title = + radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"; + const scope = + radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"; const empty: PriceHistory = { + title, note: "Нет данных по истории цен", years: [], avito: "", @@ -1879,7 +1888,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { .join(" "); const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0); - const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu( + const note = `Медиана ₽/м² по годам · ${scope} · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu( totalLots, ["лот", "лота", "лотов"], )})`; @@ -1890,6 +1899,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { }); return { + title, note, years: yearsNum.map(String), avito: series("avito_imv"), diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 61e4b8af..961c86d5 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -264,6 +264,8 @@ export interface SellTimeTier { } export interface PriceHistory { + /** Заголовок карточки: «в этом доме» только когда выборка не расширялась соседями. */ + title: string; note: string; years: string[]; /** SVG polyline points string (viewBox 0 0 900 220). */ -- 2.45.3