fix(tradein/matching): снять слияние по ГАР-GUID, починить приёмник кадастра и keeper (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
Ревью PR #2688 нашло, что расширение ключа дедупа было неверным. Снимаю его полностью и добавляю три правки, которых не хватало. СНЯТО: слияние 781 дома по COALESCE(house_fias_id, gar_house_guid). Аргумент «общий UUID здания есть независимая идентичность» оказался круговым. gar_flats_loader проставляет gar_house_guid предикатом WHERE tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)) = gp.canon — левая часть побайтово равна ключу канон-прохода, то есть guid является детерминированной функцией канон-адреса, а не вторым наблюдением. Проход шёл с выключенным гео-стражем, значит #2187 обходился боковой дверью: канон-проход отказывается слить два дома в 6 км, а этот сливал их же за «общий UUID», выданный за тот же адрес. Плюс gar_pick берёт DISTINCT ON (canon) — одна ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий, и ЕКБ-фильтр стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ. Нужен ключ, независимый от канона, либо включённый гео-страж — это другая задача. Приёмник кадастра сужен до кадастра ЗДАНИЯ. Параметр cadastral_number (кадастр КВАРТИРЫ) убран из match_or_create_house, Protocol HouseMatcher, RealMatcherAdapter и обоих вызывающих; `cad` больше не падает на него фолбэком. Мина была отложенной: начни Циан отдавать offer["cadastralNumber"], который парсер уже читает, — у каждой квартиры свой номер, Tier 0 не сматчил бы никогда, New-house INSERT записал бы номер квартиры в houses.cadastral_number и попутно снял P1-страж «безномерный адрес без кадастра не создаём». Две квартиры одного дома дали бы два дома — то самое дробление. В listings оба поля пишутся как раньше. Keeper: listing_cnt DESC NULLS LAST. Счётчик приходит из LEFT JOIN, у дома без объявлений он NULL, а DESC в Postgres — NULLS FIRST, поэтому пустая запись обгоняла запись со 192 объявлениями вопреки задокументированному правилу. Дефект предсуществующий и живой для канон-прохода. Сторож границы вызова для живого ФИАС-тира. Прежние проверки были структурными — видели имя параметра в сигнатуре. Уберут аргумент на настоящей границе (estimator.estimate_quality -> match_house_readonly) — сигнатура цела, тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов. Заявление «тест ловит неуловимый класс» из прошлого описания снято как преувеличение: структурная проверка ловит подслучай, и building_cadastral_number её проходит при нуле срабатываний из 49 502. Остаётся из первого захода: снятый фильтр поиска has_kadastr, разделение ФИАС-тира (удалён в пути создания, оставлен в read-only), поправка ложного утверждения в шапке cadastral_geo_match.py. Refs #2674
This commit is contained in:
parent
3fd6550a16
commit
1a577fe748
9 changed files with 108 additions and 134 deletions
|
|
@ -35,13 +35,11 @@ WHAT this is:
|
|||
TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry-identity→delete
|
||||
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
|
||||
(`_mapping_sql`) — no 300-line copy-paste:
|
||||
1. FIAS pass — clusters by lower(COALESCE(house_fias_id, gar_house_guid)) — the ФИАС/ГАР
|
||||
building UUID, whichever source filled it (DaData estimate-путь / ГАР-лоадер; #2674
|
||||
расширил ключ со второго слагаемого, детали у _FIAS_KEY_EXPR). Only non-null UUIDs
|
||||
shared by >1 house_id cluster. Catches duplicates the canon MISSES: slash-collapse
|
||||
(«Сулимова, 32» vs «Сулимова, 3/2» → same canon «сулимова32»), посёлок truncation
|
||||
(Кедровка/Шувакиш colliding with a same-named ЕКБ street) и — основная масса — те, что
|
||||
канон-проход УЗНАЁТ, но не сливает из-за гео-стража (битая/пустая координата).
|
||||
1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID,
|
||||
DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id
|
||||
cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs
|
||||
«Сулимова, 3/2» → same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш
|
||||
colliding with a same-named ЕКБ street).
|
||||
2. CANON pass — the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a
|
||||
canon cluster a loser is NOT merged when it AND the keeper both carry a non-null but
|
||||
DIFFERENT house_fias_id — provably different buildings the canon collapsed (the
|
||||
|
|
@ -134,9 +132,16 @@ _COMPLETENESS_EXPR = """
|
|||
|
||||
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
|
||||
# agree row-for-row. Priority: geom present → most linked listings → most-populated → min id.
|
||||
#
|
||||
# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома
|
||||
# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём
|
||||
# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному
|
||||
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
|
||||
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
|
||||
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
|
||||
_KEEPER_ORDER = f"""
|
||||
(h.geom IS NOT NULL) DESC,
|
||||
listing_cnt DESC,
|
||||
listing_cnt DESC NULLS LAST,
|
||||
({_COMPLETENESS_EXPR}) DESC,
|
||||
h.id ASC
|
||||
"""
|
||||
|
|
@ -147,25 +152,10 @@ _KEEPER_ORDER = f"""
|
|||
#
|
||||
# FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches
|
||||
# duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок
|
||||
# truncation (Кедровка/Шувакиш). Only non-null UUIDs cluster.
|
||||
#
|
||||
# #2674: ключ расширен с `house_fias_id` до COALESCE(house_fias_id, gar_house_guid). Это ОДИН И
|
||||
# ТОТ ЖЕ UUID здания в ГАР — на проде 3 666 из 3 667 домов, где заполнены оба поля, несут
|
||||
# идентичное значение (0.03% расхождения), и domrf_kapremont_loader уже читает их одним
|
||||
# COALESCE. Разошлись они только по происхождению: house_fias_id заполняет DaData из
|
||||
# estimate-пути (3 678 домов), gar_house_guid — ГАР-лоадер (5 551 дом), и вторая половина в
|
||||
# ключ не входила. Замер на проде 2026-08-05: 653 кластера / 1 434 дома / 781 лишняя строка
|
||||
# (8.3% таблицы houses) и 6 389 объявлений на них — один физический дом, размазанный по
|
||||
# нескольким записям (риск #1772). Ни один существующий проход их не берёт: canon-проход видит
|
||||
# у 900 из 919 пар ОДИН И ТОТ ЖЕ канон-адрес, но блокирует слияние гео-стражем — 356 пар имеют
|
||||
# NULL geom с одной стороны, 457 разъехались дальше 250 м (максимум — 5 065 км, битый геокод).
|
||||
# Это ровно аргумент #2187: общий UUID здания ЕСТЬ идентичность и старше близости, поэтому
|
||||
# проход и идёт с apply_geo_guard=False (keeper'ом станет строка с geom → сломанная координата
|
||||
# заодно чинится).
|
||||
# truncation (Кедровка/Шувакиш). Only non-null fias clusters.
|
||||
_FIAS_KEY_EXPR = """
|
||||
CASE WHEN COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, '')) IS NOT NULL
|
||||
THEN 'fias:'
|
||||
|| lower(COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, '')))
|
||||
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
|
||||
THEN 'fias:' || lower(house_fias_id)
|
||||
END
|
||||
"""
|
||||
|
||||
|
|
@ -187,9 +177,8 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
|||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies. С расширенным ключом (#2674) он перестал быть
|
||||
no-op'ом для fias-прохода: кластер, собранный по общему gar_house_guid, может содержать строки
|
||||
с РАЗНЫМ непустым house_fias_id — такую пару guard блокирует (консервативно, 1 случай на проде).
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@
|
|||
обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр
|
||||
не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы
|
||||
over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка
|
||||
начнёт отдавать настоящий кадастр (парсер Циана уже пишет оба поля).
|
||||
начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр
|
||||
`cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих.
|
||||
Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда,
|
||||
падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и
|
||||
попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на
|
||||
квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное.
|
||||
|
||||
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
||||
"""
|
||||
|
|
@ -70,7 +75,6 @@ def match_or_create_house(
|
|||
*,
|
||||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""Match existing house or create new canonical record.
|
||||
|
|
@ -107,7 +111,16 @@ def match_or_create_house(
|
|||
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
||||
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
||||
"""
|
||||
cad = building_cadastral_number or cadastral_number
|
||||
# ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`,
|
||||
# где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из
|
||||
# сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка,
|
||||
# ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]`
|
||||
# (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы
|
||||
# никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ
|
||||
# проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без
|
||||
# кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы
|
||||
# два дома — то самое дробление, против которого Tier 0 и заведён.
|
||||
cad = building_cadastral_number
|
||||
|
||||
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
|
||||
fp = address_fingerprint(address, lat, lon)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ class RealMatcherAdapter:
|
|||
*,
|
||||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
# house_id is None when the matcher refuses a numberless address without a
|
||||
|
|
@ -80,7 +79,6 @@ class RealMatcherAdapter:
|
|||
lon,
|
||||
year_built=year_built,
|
||||
building_cadastral_number=building_cadastral_number,
|
||||
cadastral_number=cadastral_number,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -291,8 +291,8 @@ def _link_listing_to_house(
|
|||
lat=row.lat,
|
||||
lon=row.lon,
|
||||
year_built=row.year_built,
|
||||
# Кадастр КВАРТИРЫ (row.cadastral_number) в ключ дома не идёт (#2674).
|
||||
building_cadastral_number=row.building_cadastral_number,
|
||||
cadastral_number=row.cadastral_number,
|
||||
source_url=row.house_url or row.source_url,
|
||||
)
|
||||
house_resolved = house_id is not None
|
||||
|
|
|
|||
|
|
@ -106,6 +106,24 @@ def test_keeper_rule_priority_order() -> None:
|
|||
assert col in hdm._COMPLETENESS_EXPR
|
||||
|
||||
|
||||
def test_keeper_listing_count_puts_nulls_last() -> None:
|
||||
"""«Больше объявлений» обязано означать больше, а не меньше (#2674).
|
||||
|
||||
listing_cnt приходит из LEFT JOIN listing_counts → у дома БЕЗ объявлений он NULL, а `DESC`
|
||||
в Postgres по умолчанию NULLS FIRST. Без явного NULLS LAST правило инвертировано: пустая
|
||||
запись обгоняет запись со 192 объявлениями и забирает роль keeper'а, после чего объявления
|
||||
проигравшего переезжают на дом, на который корпус никогда не ссылался, а неполный
|
||||
COALESCE-перенос (год постройки / тип / этажность / застройщик не переносятся) удаляет
|
||||
данные более богатой строки. RED до фикса: был голый `listing_cnt DESC`.
|
||||
"""
|
||||
order = _flat(hdm._KEEPER_ORDER)
|
||||
assert "listing_cnt DESC NULLS LAST" in order
|
||||
# Оба места, где применяется порядок (ROW_NUMBER-ранг и first_value-выбор keeper'а),
|
||||
# берут одну и ту же константу — иначе ранг и keeper разъедутся построчно.
|
||||
mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR, apply_geo_guard=False))
|
||||
assert mapping.count("listing_cnt DESC NULLS LAST") >= 2
|
||||
|
||||
|
||||
def test_mapping_selects_losers_only() -> None:
|
||||
"""The mapping is loser_id→keeper_id; the keeper itself is excluded (rn>1, id<>keeper)."""
|
||||
flat = _flat(_MAPPING_SQL)
|
||||
|
|
@ -240,27 +258,18 @@ def test_no_fstring_or_format_in_parametrised_sql() -> None:
|
|||
|
||||
|
||||
def test_fias_pass_clusters_by_house_fias_id() -> None:
|
||||
"""A second cluster key: the ФИАС/ГАР building UUID from EITHER column (#2674).
|
||||
"""A second cluster key: lower(NULLIF(house_fias_id,'')) — the ФИАС/ГАР building UUID.
|
||||
|
||||
The fias pass catches duplicates the address canon misses (slash-collapse «Сулимова, 32» vs
|
||||
«Сулимова, 3/2»; посёлок truncation). Only non-null UUIDs shared by >1 house_id cluster.
|
||||
|
||||
#2674: ключ — COALESCE(house_fias_id, gar_house_guid). Это один и тот же UUID здания
|
||||
(на проде 3 666/3 667 совпадений там, где заполнены оба), но заполняют его разные
|
||||
источники: DaData из estimate-пути и ГАР-лоадер. Пока ключом был только house_fias_id,
|
||||
5 551 дом с gar_house_guid в проход не попадал — 781 дублирующая строка (8.3% houses)
|
||||
и 6 389 объявлений на них оставались раздробленными (риск #1772).
|
||||
«Сулимова, 3/2»; посёлок truncation). Only non-null fias shared by >1 house_id clusters.
|
||||
"""
|
||||
flat = _flat(_FIAS_MAPPING_SQL)
|
||||
coalesce = "COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, ''))"
|
||||
assert f"'fias:' || lower({coalesce})" in flat
|
||||
assert f"{coalesce} IS NOT NULL" in flat
|
||||
# ГАР-GUID обязан быть частью ключа — иначе половина заполненных идентичностей вне прохода.
|
||||
assert "gar_house_guid" in flat
|
||||
assert "'fias:' || lower(house_fias_id)" in flat
|
||||
assert "NULLIF(house_fias_id, '') IS NOT NULL" in flat
|
||||
# same collision-safe pipeline (same temp table + >1 grouping), NOT the canon cluster key.
|
||||
assert "CREATE TEMP TABLE _1772_dup_mapping" in flat
|
||||
assert "HAVING count(*) > 1" in flat
|
||||
# the fias pass keys on the building UUID, never on the 'addr:' canon key.
|
||||
# the fias pass keys on the fias UUID, never on the 'addr:' canon key.
|
||||
assert "'addr:'" not in flat
|
||||
|
||||
|
||||
|
|
@ -1022,84 +1031,3 @@ def test_real_fias_pass_ignores_geo_guard() -> None:
|
|||
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_identity_pass_clusters_on_gar_house_guid() -> None:
|
||||
"""End-to-end на живой БД для #2674 — идентичность берётся из ОБЕИХ UUID-колонок:
|
||||
|
||||
A. одинаковый gar_house_guid, house_fias_id пуст у обоих, разные каноны и >250 м
|
||||
→ СЛИТЫ (раньше проход их не видел: ключом был только house_fias_id, а гео-страж
|
||||
канон-прохода такую пару блокирует).
|
||||
B. gar_house_guid у одного, тот же UUID в house_fias_id у другого (ровно смешанный
|
||||
случай прода: DaData заполнила одну строку, ГАР-лоадер — другую) → СЛИТЫ.
|
||||
C. РАЗНЫЕ gar_house_guid, одинаковый канон, >250 м → НЕ слиты (канон-страж на месте,
|
||||
расширение ключа не расширило слияние).
|
||||
|
||||
Улицы «*2674» синтетические, с прод-адресами/алиасами не пересекаются.
|
||||
Дельта широты 0.045° на ~56.84° ≈ 5 км (>250 м).
|
||||
"""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
try:
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO houses "
|
||||
"(id, source, ext_house_id, address, lat, lon, house_fias_id, gar_house_guid) "
|
||||
"VALUES "
|
||||
# A — общий ГАР-GUID, fias пуст, разные каноны, ~5 км → слияние по идентичности
|
||||
"(900040,'avito','EXT-2674-A-K','ГарОдин2674, 1', 56.84000,60.60000,"
|
||||
" NULL,'G-A-2674'),"
|
||||
"(900041,'cian', 'EXT-2674-A-L','ГарДругой2674, 2',56.88500,60.60000,"
|
||||
" NULL,'G-A-2674'),"
|
||||
# B — один и тот же UUID, но в РАЗНЫХ колонках → всё равно один кластер
|
||||
"(900042,'avito','EXT-2674-B-K','ГарМикс2674, 3', 56.84000,60.60000,"
|
||||
" NULL,'G-B-2674'),"
|
||||
"(900043,'cian', 'EXT-2674-B-L','ГарМиксДва2674, 4',56.88500,60.60000,"
|
||||
" 'G-B-2674',NULL),"
|
||||
# C — РАЗНЫЕ ГАР-GUID, общий канон, ~5 км → канон-страж по-прежнему блокирует
|
||||
"(900044,'avito','EXT-2674-C-1','ГарКанон2674, 5', 56.84000,60.60000,"
|
||||
" NULL,'G-C1-2674'),"
|
||||
"(900045,'cian', 'EXT-2674-C-2','ГарКанон2674, 5', 56.88500,60.60000,"
|
||||
" NULL,'G-C2-2674')"
|
||||
)
|
||||
)
|
||||
# Объявление на проигравшем A — доказываем, что переуказание отработало.
|
||||
db.execute(
|
||||
_t(
|
||||
"INSERT INTO listings "
|
||||
"(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES "
|
||||
"(910041,'cian','http://t/2674/al','L-2674-AL','dh-2674-al',6000000,900041)"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
out = hdm.merge_duplicate_houses(db, dry_run=False)
|
||||
assert out["losers_deleted"] >= 2 # A + B
|
||||
|
||||
ids = [
|
||||
r.id
|
||||
for r in db.execute(
|
||||
_t("SELECT id FROM houses WHERE id BETWEEN 900040 AND 900045 ORDER BY id")
|
||||
).all()
|
||||
]
|
||||
# A: общий ГАР-GUID при пустом fias — раньше НЕ сливалось (ключ не видел колонку).
|
||||
assert 900040 in ids and 900041 not in ids, "общий gar_house_guid обязан склеивать дома"
|
||||
# B: один UUID в разных колонках — COALESCE обязан свести их в один кластер.
|
||||
assert 900042 in ids and 900043 not in ids, "fias и gar с одним UUID — один кластер"
|
||||
# C: разные UUID + общий канон + >250 м — слияния быть не должно.
|
||||
assert 900044 in ids and 900045 in ids, "разные ГАР-GUID не должны сливаться"
|
||||
|
||||
# A: объявление проигравшего переуказано на выжившего.
|
||||
repointed = db.execute(_t("SELECT house_id_fk FROM listings WHERE id = 910041")).scalar()
|
||||
assert repointed == 900040
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM listings WHERE id = 910041"))
|
||||
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900040 AND 900045"))
|
||||
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900040 AND 900045"))
|
||||
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900040 AND 900045"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -276,7 +276,8 @@ def test_match_house_tier0_cadastr():
|
|||
address="ул Ленина 5",
|
||||
lat=56.8,
|
||||
lon=60.5,
|
||||
cadastral_number="66:41:0000000:999",
|
||||
# Кадастр ЗДАНИЯ — единственный, что принимает ключ дома (#2674).
|
||||
building_cadastral_number="66:41:0000000:999",
|
||||
)
|
||||
assert house_id == 42
|
||||
assert conf == 1.0
|
||||
|
|
@ -605,7 +606,9 @@ def test_numberless_address_with_cadastral_creates_house():
|
|||
"cian",
|
||||
"ext-cad",
|
||||
address="екатеринбург улица мамина сибиряка",
|
||||
cadastral_number="66:41:0000000:12345",
|
||||
# Именно кадастр ЗДАНИЯ снимает P1-страж: он идентифицирует дом. Кадастр КВАРТИРЫ этого
|
||||
# права лишён (#2674) — иначе номер квартиры открывал бы создание мега-бакетов.
|
||||
building_cadastral_number="66:41:0000000:12345",
|
||||
)
|
||||
assert (house_id, conf, method) == (321, 1.0, "new")
|
||||
assert any(
|
||||
|
|
|
|||
|
|
@ -69,3 +69,41 @@ def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None:
|
|||
match_or_create_house
|
||||
), "мёртвая fias-ветка вернулась в путь создания домов"
|
||||
assert '"fias_exact"' in inspect.getsource(match_house_readonly)
|
||||
|
||||
|
||||
def test_readonly_fias_tier_is_actually_fed_by_its_caller() -> None:
|
||||
"""Единственный живой ФИАС-тир должен ПОЛУЧАТЬ аргумент, а не только объявлять параметр.
|
||||
|
||||
Проверки выше — структурные: они видят имя в сигнатуре. Этого мало. Уберут аргумент на
|
||||
настоящей границе вызова (`estimator.estimate_quality` → `match_house_readonly`) — сигнатура
|
||||
останется прежней, оба теста выше зелёные, а тир снова мёртв, и мы снова здесь.
|
||||
|
||||
Доказательство, что структурной проверки недостаточно, лежит рядом: `building_cadastral_number`
|
||||
её проходит и при этом сработал 0 раз из 49 502.
|
||||
"""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
src = inspect.getsource(estimate_quality)
|
||||
assert "match_house_readonly" in src, "estimate_quality больше не зовёт read-only матчер"
|
||||
assert "house_fias_id=" in src, (
|
||||
"estimate_quality перестал передавать house_fias_id — единственный живой fias-тир "
|
||||
"снова недостижим (это и есть класс ошибки #2674)"
|
||||
)
|
||||
|
||||
|
||||
def test_house_key_never_accepts_flat_cadastre() -> None:
|
||||
"""Ключ ДОМА строится только на кадастре здания — не на кадастре квартиры (#2674).
|
||||
|
||||
Ловушка «оставленного рабочего приёмника»: у квартиры свой кадастр, поэтому Tier 0 по нему
|
||||
не сматчит НИКОГДА, упадёт в New-house INSERT и проштампует номер квартиры в
|
||||
houses.cadastral_number; плюс `cad` снимает P1-страж «безномерный адрес без кадастра не
|
||||
создаём». Две квартиры одного дома → два дома. RED до фикса: параметр был в сигнатуре, а
|
||||
`cad = building_cadastral_number or cadastral_number`.
|
||||
"""
|
||||
assert "cadastral_number" not in _params(match_or_create_house)
|
||||
assert "building_cadastral_number" in _params(match_or_create_house)
|
||||
|
||||
src = inspect.getsource(match_or_create_house)
|
||||
assert (
|
||||
"cad = building_cadastral_number\n" in src
|
||||
), "в ключ дома вернулся фолбэк на кадастр квартиры"
|
||||
|
|
|
|||
|
|
@ -908,8 +908,10 @@ def _link_listing_to_house(
|
|||
lat=lot.lat,
|
||||
lon=lot.lon,
|
||||
year_built=lot.year_built,
|
||||
# Кадастр КВАРТИРЫ (lot.cadastral_number / lot.kadastr_num) сюда БОЛЬШЕ НЕ идёт
|
||||
# (#2674) — ключ дома строится только на кадастре здания. В listings оба поля
|
||||
# по-прежнему пишутся save_listings'ом, теряется только ложная идентичность.
|
||||
building_cadastral_number=lot.building_cadastral_number,
|
||||
cadastral_number=lot.cadastral_number or lot.kadastr_num,
|
||||
source_url=lot.house_url or lot.source_url,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,11 +65,14 @@ class HouseMatcher(Protocol):
|
|||
*,
|
||||
year_built: int | None = ...,
|
||||
building_cadastral_number: str | None = ...,
|
||||
cadastral_number: str | None = ...,
|
||||
source_url: str | None = ...,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""Найти или создать канонический дом.
|
||||
|
||||
NB (#2674): `cadastral_number` (кадастр КВАРТИРЫ) из контракта УБРАН. Ключ дома —
|
||||
только `building_cadastral_number`; квартирный номер в нём породил бы по дому на
|
||||
квартиру, см. комментарий у `cad` в matching/houses.py.
|
||||
|
||||
Returns:
|
||||
(house_id, confidence ∈ [0.0, 1.0], method), где method ∈ {
|
||||
'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue