МЕРА, область: коридор сделок узнаёт города развёрток, ГАР не раздаёт домам чужой регион, история цен не врёт про «только этот дом» #3575
13 changed files with 385 additions and 33 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 — ему город нужен для фильтра сделок, а не для гейта.)
|
||||
|
||||
Цена решения (осознанная): легитимный, но не перечисленный населённый пункт
|
||||
(«Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 матчера.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function HouseAnalyticsSection({ estimateId }: Props) {
|
|||
<HouseAnalyticsKpiRow kpi={data.kpi} />
|
||||
{sellTime.data && <SellTimeSensitivity data={sellTime.data} />}
|
||||
{data.price_history.length >= 2 && (
|
||||
<PriceHistoryChart points={data.price_history} />
|
||||
<PriceHistoryChart points={data.price_history} radiusM={data.radius_m} />
|
||||
)}
|
||||
{data.recent_sold.length > 0 && (
|
||||
<RecentSoldList items={data.recent_sold} />
|
||||
|
|
|
|||
|
|
@ -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<PivotRow[]>(() => {
|
||||
const byYear: Record<number, PivotRow> = {};
|
||||
for (const p of points) {
|
||||
|
|
@ -50,10 +52,12 @@ export function PriceHistoryChart({ points }: Props) {
|
|||
<article className="card" style={{ marginTop: 12, padding: 16 }}>
|
||||
<header style={{ marginBottom: 8 }}>
|
||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
История цен в этом доме
|
||||
{radiusM > 0 ? "История цен: дом и соседние" : "История цен в этом доме"}
|
||||
</h4>
|
||||
<small style={{ color: "var(--muted, #6b7280)" }}>
|
||||
Медиана ₽/м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
Медиана ₽/м² по годам ·{" "}
|
||||
{radiusM > 0 ? `дом и соседние в радиусе ${radiusM} м` : "только этот дом"} ·{" "}
|
||||
{AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
|
||||
</small>
|
||||
</header>
|
||||
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ export default function AnalyticsView({
|
|||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
История цен в этом доме
|
||||
{data.priceHistory.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{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}`}
|
||||
>
|
||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
||||
<title>{`${data.priceHistory.title} — медиана ₽/м² по годам`}</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
{PH_GRID_Y.map((y) => (
|
||||
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
||||
|
|
|
|||
|
|
@ -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(<AnalyticsView data={mapAnalytics(analytics(300), null, null)} />);
|
||||
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(<PriceHistoryChart points={points} radiusM={300} />);
|
||||
expect(screen.getByRole("heading").textContent).toBe("История цен: дом и соседние");
|
||||
expect(screen.queryByText(/только этот дом/)).toBeNull();
|
||||
expect(screen.getByText(/дом и соседние в радиусе 300 м/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue