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

Ревью 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:
bot-backend 2026-08-06 05:11:47 +05:00
parent 3fd6550a16
commit 1a577fe748
9 changed files with 108 additions and 134 deletions

View file

@ -35,13 +35,11 @@ WHAT this is:
TWO PASSES (2026-07-02 follow-up): the SAME clusterkeeperre-pointcarry-identitydelete
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

View file

@ -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)

View file

@ -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,
)

View file

@ -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

View file

@ -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()

View file

@ -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(

View file

@ -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
), "в ключ дома вернулся фолбэк на кадастр квартиры"

View file

@ -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,
)

View file

@ -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',