From 078dadc440eb47770509883be47279eb3366fd07 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 29 Jun 2026 01:32:48 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20cadastre=20=D0=96=D0=9A=D0=A5-f?= =?UTF-8?q?allback=20+=20COALESCE=20merge-key=20+=20median-floors=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Серия sale-share data-quality (по находкам юзера): - house_dedup_merge: cluster_key → tradein_canon_addr(COALESCE(short_address, full_address, address)) — ловит дубли с район-noise в address (Вайнера 66). - zhkh_flats_loader: cadastre-fallback (searchByAddress по cadastreNumber, guid-независимо) — резолвит дома где gar_house_guid ≠ ЖКХ houseGuid (Переходный 6: guid→0, cadastre→165). CLI --cadastre. - мигр.153: плаузибилити-гард по медиане этажности листингов — denom >= GREATEST(total_floors, median(listings.total_floors), 8); отсекает gar-недосчёт башен без ЖКХ (gar=9 vs листинги 19-26 эт → NULL). ruff clean, pytest 36 green. --- .../backend/app/services/house_dedup_merge.py | 24 +- .../backend/app/services/zhkh_flats_loader.py | 237 ++++++++++++++++++ .../backend/app/tasks/zhkh_flats_load.py | 76 ++++-- ...ale_share_listings_floors_plausibility.sql | 157 ++++++++++++ .../backend/tests/test_house_dedup_merge.py | 12 +- .../backend/tests/test_zhkh_flats_loader.py | 44 ++++ 6 files changed, 516 insertions(+), 34 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/153_sale_share_listings_floors_plausibility.sql diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index fb734901..dd90c9d1 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -12,10 +12,14 @@ WHAT this is: cluster → pick canonical → re-point children (UNIQUE-collision-safe) → delete losers pipeline, run inside ONE transaction so a crash leaves the table untouched. - Cluster key: CANONICAL address via tradein_canon_addr() (cadastral_number is 100% NULL on - prod — confirmed in migration 040 — so address is the real building key). The canon collapses - spelling/район variants of the SAME building (ул→улица, strips город/район/«россия»/«м-н»/ - «р-н» noise, removes spaces/punct, PRESERVES корпус) so e.g. «ул. Вайнера,66» and + Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address + COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod — + confirmed in migration 040 — so address is the real building key). The clean source matters: + `address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр» + → canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66» + (→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon + collapses spelling/район variants of the SAME building (ул→улица, strips город/район/«россия»/ + «м-н»/«р-н» noise, removes spaces/punct, PRESERVES корпус) so e.g. «ул. Вайнера,66» and «улица Вайнера, 66» share one cluster_key. Rows whose canon is NULL/blank are never clustered (cluster_key NULL → ignored). Only canons shared by >1 house_id form a cluster. @@ -117,9 +121,13 @@ _BUILD_MAPPING_SQL = text( WITH clustered AS ( SELECT id, - CASE WHEN NULLIF(tradein_canon_addr(address), '') IS NOT NULL - AND tradein_canon_addr(address) ~ '[0-9]' - THEN 'addr:' || tradein_canon_addr(address) END AS cluster_key + CASE WHEN NULLIF( + tradein_canon_addr(COALESCE(short_address, full_address, address)), '' + ) IS NOT NULL + AND tradein_canon_addr(COALESCE(short_address, full_address, address)) ~ '[0-9]' + THEN 'addr:' + || tradein_canon_addr(COALESCE(short_address, full_address, address)) + END AS cluster_key FROM houses ), clusters_with_count AS ( @@ -145,7 +153,7 @@ _BUILD_MAPPING_SQL = text( SELECT dh.id, dh.cluster_key, - lower(trim(h.address)) AS norm_address, + lower(trim(COALESCE(h.short_address, h.full_address, h.address))) AS norm_address, h.geom AS loser_geom, ROW_NUMBER() OVER ( PARTITION BY dh.cluster_key diff --git a/tradein-mvp/backend/app/services/zhkh_flats_loader.py b/tradein-mvp/backend/app/services/zhkh_flats_loader.py index bc8d7380..5773cda1 100644 --- a/tradein-mvp/backend/app/services/zhkh_flats_loader.py +++ b/tradein-mvp/backend/app/services/zhkh_flats_loader.py @@ -24,6 +24,7 @@ psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, from __future__ import annotations import logging +import re import time import uuid from collections.abc import Iterator @@ -82,6 +83,21 @@ def parse_int(raw: object) -> int | None: return n if n > 0 else None +_FLOORS_RE = re.compile(r"\d+") + + +def _parse_floors_max(floors_raw: str | None) -> int | None: + """Макс. этаж из сырой maxFloorCount («10-30»/«17,23»/«9») → int|None. + + Зеркалит мигр. 152 (max((m[1])::int) по regexp '[0-9]+' над zhkh_floors_raw): берём все + числовые прогоны и возвращаем максимум; нет цифр/None → None. + """ + if not floors_raw: + return None + nums = [int(m) for m in _FLOORS_RE.findall(floors_raw)] + return max(nums) if nums else None + + @dataclass(slots=True) class ZhkhHouse: """Распарсенная ЖКХ-строка дома (ключ обратного маппинга — house_guid).""" @@ -157,6 +173,29 @@ def _build_body(batch: list[str]) -> dict[str, Any]: } +def _body_by_cadastre(cad: str) -> dict[str, Any]: + """Тот же полный DTO searchByAddress, но поиск по кадастру: fiasHouseCodeList=None, + cadastreNumber=cad (ОДИНОЧНОЕ значение, НЕ список — verified, прод). Остальные 15 ключей + идентичны _build_body.""" + return { + "regionCode": REGION_GUID, + "fiasHouseCodeList": None, + "estStatus": None, + "strStatus": None, + "calcCount": True, + "houseConditionRefList": None, + "houseTypeRefList": None, + "houseManagementTypeRefList": None, + "cadastreNumber": cad, + "oktmo": None, + "statuses": ["APPROVED"], + "regionProperty": None, + "municipalProperty": None, + "hostelTypeCodes": None, + "useReadOnlyDataSource": True, + } + + def _chunk_guids(guids: list[str], size: int) -> Iterator[list[str]]: for i in range(0, len(guids), size): yield guids[i : i + size] @@ -235,6 +274,47 @@ def fetch_zhkh( return result +def fetch_by_cadastre(cad: str, *, client: httpx.Client, max_retries: int = 3) -> ZhkhHouse | None: + """POST searchByAddress по кадастру (одиночный cadastreNumber) → первый item → ZhkhHouse|None. + + Тот же ретрай-паттерн, что и _fetch_batch: 403/5xx/httpx-ошибки → backoff 1,2,4с и повтор; + прочие 4xx (битый запрос) → None без ретрая; пустой ответ / нет items → None. + """ + body = _body_by_cadastre(cad) + last_err = "" + for attempt in range(1, max_retries + 1): + try: + resp = client.post(ENDPOINT, json=body, headers=_headers()) + if resp.status_code != 403 and resp.status_code < 500: + resp.raise_for_status() # прочие 4xx → невосстановимый HTTPStatusError + data = resp.json() + items = data.get("items") + if not isinstance(items, list) or not items: + return None + return _parse_item(items[0]) + last_err = f"HTTP {resp.status_code}" + except httpx.HTTPStatusError as exc: + logger.warning("zhkh cadastre: %s — невосстановимый %s (пропущен)", cad, exc) + return None + except (httpx.HTTPError, ValueError) as exc: + last_err = repr(exc) + if attempt < max_retries: + backoff = 2 ** (attempt - 1) + logger.warning( + "zhkh cadastre: %s сбой (%s), retry %d/%d через %dс", + cad, + last_err, + attempt, + max_retries, + backoff, + ) + time.sleep(backoff) + logger.warning( + "zhkh cadastre: %s сбойнул после %d попыток (%s), пропущен", cad, max_retries, last_err + ) + return None + + # ───────────────────────────────────────────────────────────────────────────── # UPDATE houses по gar_house_guid (== ЖКХ houseGuid) # ───────────────────────────────────────────────────────────────────────────── @@ -365,3 +445,160 @@ def match_houses_to_zhkh( dry_run, ) return result + + +# ───────────────────────────────────────────────────────────────────────────── +# Cadastre-fallback: резолв ЖКХ по listings.building_cadastral_number (UPDATE по houses.id) +# ───────────────────────────────────────────────────────────────────────────── +# Гид-loader промахивается по домам, чей gar_house_guid ≠ ЖКХ houseGuid (напр. Переходный 6: +# наш guid → ЖКХ total 0, но кадастр 66:41:0604007:747 → ЖКХ 165). Здесь матчим по кадастру +# листингов и UPDATE'им строго по houses.id (ЖКХ houseGuid отличается → match по guid невозможен). +# Идемпотентность: gate `zhkh_flat_count IS NULL` → повторный прогон не трогает уже резолвленные. +_UPDATE_BY_ID_SQL = text( + """ + UPDATE houses + SET zhkh_flat_count = CAST(:fc AS int), + zhkh_house_guid = CAST(:g AS text), + zhkh_matched_at = now(), + zhkh_year = CAST(:yr AS smallint), + zhkh_floors_raw = CAST(:fr AS text), + zhkh_floors = CAST(:zf AS smallint) + WHERE id = CAST(:hid AS bigint) + AND zhkh_flat_count IS NULL + """ +) + +# Дома без ЖКХ-счёта, у которых есть кадастр здания в листингах: берём САМЫЙ ЧАСТЫЙ непустой +# building_cadastral_number на дом (DISTINCT ON + ORDER BY count(*) DESC). +_SELECT_CADASTRE_SQL = text( + """ + SELECT DISTINCT ON (l.house_id_fk) + l.house_id_fk, l.building_cadastral_number + FROM listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.zhkh_flat_count IS NULL + AND l.building_cadastral_number IS NOT NULL + GROUP BY l.house_id_fk, l.building_cadastral_number + ORDER BY l.house_id_fk, count(*) DESC + """ +) + +# Тот же запрос с LIMIT (сэмпл): LIMIT применяется ПОСЛЕ DISTINCT ON → ограничивает число домов. +_SELECT_CADASTRE_LIMIT_SQL = text( + """ + SELECT DISTINCT ON (l.house_id_fk) + l.house_id_fk, l.building_cadastral_number + FROM listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.zhkh_flat_count IS NULL + AND l.building_cadastral_number IS NOT NULL + GROUP BY l.house_id_fk, l.building_cadastral_number + ORDER BY l.house_id_fk, count(*) DESC + LIMIT CAST(:limit AS int) + """ +) + + +def _chunk_targets(targets: list[tuple[int, str]], size: int) -> Iterator[list[tuple[int, str]]]: + for i in range(0, len(targets), size): + yield targets[i : i + size] + + +def match_houses_to_zhkh_by_cadastre( + db: Session, + *, + limit: int | None = None, + dry_run: bool = False, + sleep_sec: float = 0.5, + cooldown: int = 150, + chunk: int = 15, +) -> dict[str, int]: + """Cadastre-fallback резолв ЖКХ для домов, чей gar_house_guid ≠ ЖКХ houseGuid. + + Выбирает дома без zhkh_flat_count, у которых есть building_cadastral_number в листингах + (самый частый непустой кадастр на дом), ищет каждый дом в ГИС ЖКХ по cadastreNumber и при + flat_count>0 UPDATE'ит houses ПО id (ЖКХ houseGuid отличается → match по guid невозможен, + см. Переходный 6). + + Throttle-aware: лукапы режутся на чанки по `chunk`, между чанками — time.sleep(cooldown) + (публичное API лимитит ~per-window, как и батч-путь); sleep_sec — пауза между отдельными + запросами. Коммит per-chunk (не dry-run) → прогресс длинного прогона переживает обрыв. + dry_run → НОЛЬ записей (только подсчёт резолвимых). Идемпотентно (gate zhkh_flat_count IS NULL). + Возвращает {houses_seen, resolved, updated}. + """ + if limit is not None: + rows = db.execute(_SELECT_CADASTRE_LIMIT_SQL, {"limit": limit}).all() + else: + rows = db.execute(_SELECT_CADASTRE_SQL).all() + targets: list[tuple[int, str]] = [ + (int(r.house_id_fk), str(r.building_cadastral_number)) for r in rows + ] + houses_seen = len(targets) + logger.info( + "zhkh cadastre match: домов без ЖКХ с кадастром=%d (limit=%s, dry_run=%s, chunk=%d, " + "cooldown=%dс, sleep=%.2fс)", + houses_seen, + limit, + dry_run, + chunk, + cooldown, + sleep_sec, + ) + + resolved = 0 + updated = 0 + batches = list(_chunk_targets(targets, chunk)) + total_chunks = len(batches) + with httpx.Client(timeout=40) as client: + for chunk_idx, batch in enumerate(batches, start=1): + for hid, cad in batch: + zh = fetch_by_cadastre(cad, client=client) + if zh is not None and zh.flat_count is not None and zh.flat_count > 0: + resolved += 1 + if not dry_run: + zf = _parse_floors_max(zh.floors_raw) + try: + with db.begin_nested(): + res = db.execute( + _UPDATE_BY_ID_SQL, + { + "fc": zh.flat_count, + "g": zh.house_guid, + "yr": zh.year, + "fr": zh.floors_raw, + "zf": zf, + "hid": hid, + }, + ) + updated += res.rowcount + except Exception: + logger.warning( + "zhkh cadastre match: UPDATE house_id=%d cad=%s сбойнул " + "(откат savepoint)", + hid, + cad, + exc_info=True, + ) + if sleep_sec > 0: + time.sleep(sleep_sec) + if not dry_run: + db.commit() # фиксируем прогресс чанка до cooldown + logger.info( + "zhkh cadastre match: чанк %d/%d, резолвлено=%d, updated=%d", + chunk_idx, + total_chunks, + resolved, + updated, + ) + if chunk_idx < total_chunks and cooldown > 0: + time.sleep(cooldown) + + result = {"houses_seen": houses_seen, "resolved": resolved, "updated": updated} + logger.info( + "zhkh cadastre match DONE: seen=%d resolved=%d updated=%d (dry_run=%s)", + houses_seen, + resolved, + updated, + dry_run, + ) + return result diff --git a/tradein-mvp/backend/app/tasks/zhkh_flats_load.py b/tradein-mvp/backend/app/tasks/zhkh_flats_load.py index 3cf0d95f..a401c72c 100644 --- a/tradein-mvp/backend/app/tasks/zhkh_flats_load.py +++ b/tradein-mvp/backend/app/tasks/zhkh_flats_load.py @@ -7,10 +7,13 @@ Запуск из контейнера tradein-backend (у прода есть интернет к dom.gosuslugi.ru): - python -m app.tasks.zhkh_flats_load # полный прогон + python -m app.tasks.zhkh_flats_load # полный прогон (по gar_house_guid) python -m app.tasks.zhkh_flats_load --limit 300 --dry-run # сэмпл, без записи + python -m app.tasks.zhkh_flats_load --cadastre # fallback по кадастру листингов В режиме --dry-run выборка/фетч происходят, но НИ ОДНОЙ записи в БД не делается. +В режиме --cadastre резолвим дома, чей gar_house_guid ≠ ЖКХ houseGuid, по +listings.building_cadastral_number и UPDATE'им строго по houses.id. """ from __future__ import annotations @@ -19,7 +22,10 @@ import argparse import logging from app.core.db import SessionLocal -from app.services.zhkh_flats_loader import match_houses_to_zhkh +from app.services.zhkh_flats_loader import ( + match_houses_to_zhkh, + match_houses_to_zhkh_by_cadastre, +) logger = logging.getLogger(__name__) @@ -41,6 +47,12 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--sleep", type=float, default=0.4, help="пауза между батчами, сек (по умолчанию 0.4)" ) + parser.add_argument( + "--cadastre", + action="store_true", + help="cadastre-fallback: резолв ЖКХ по listings.building_cadastral_number для домов, " + "чей gar_house_guid ≠ ЖКХ houseGuid; UPDATE по houses.id", + ) return parser @@ -54,30 +66,48 @@ def main() -> None: db = SessionLocal() try: - counts = match_houses_to_zhkh( - db, - region_code=args.region, - limit=args.limit, - dry_run=args.dry_run, - sleep_sec=args.sleep, - ) - if not args.dry_run: - db.commit() + if args.cadastre: + cad_counts = match_houses_to_zhkh_by_cadastre( + db, + limit=args.limit, + dry_run=args.dry_run, + sleep_sec=args.sleep, + ) + if not args.dry_run: + db.commit() + logger.info( + "zhkh_flats_load (cadastre) DONE: limit=%s dry_run=%s seen=%d resolved=%d " + "updated=%d", + args.limit, + args.dry_run, + cad_counts["houses_seen"], + cad_counts["resolved"], + cad_counts["updated"], + ) + else: + counts = match_houses_to_zhkh( + db, + region_code=args.region, + limit=args.limit, + dry_run=args.dry_run, + sleep_sec=args.sleep, + ) + if not args.dry_run: + db.commit() + logger.info( + "zhkh_flats_load DONE: region=%s limit=%s dry_run=%s seen=%d resolved=%d " + "with_flatcount=%d updated=%d", + args.region, + args.limit, + args.dry_run, + counts["houses_seen"], + counts["zhkh_resolved"], + counts["zhkh_with_flatcount"], + counts["houses_updated"], + ) finally: db.close() - logger.info( - "zhkh_flats_load DONE: region=%s limit=%s dry_run=%s seen=%d resolved=%d " - "with_flatcount=%d updated=%d", - args.region, - args.limit, - args.dry_run, - counts["houses_seen"], - counts["zhkh_resolved"], - counts["zhkh_with_flatcount"], - counts["houses_updated"], - ) - if __name__ == "__main__": main() diff --git a/tradein-mvp/backend/data/sql/153_sale_share_listings_floors_plausibility.sql b/tradein-mvp/backend/data/sql/153_sale_share_listings_floors_plausibility.sql new file mode 100644 index 00000000..789693af --- /dev/null +++ b/tradein-mvp/backend/data/sql/153_sale_share_listings_floors_plausibility.sql @@ -0,0 +1,157 @@ +-- 153_sale_share_listings_floors_plausibility.sql +-- +-- CONTEXT: плаузибилити-гейт знаменателя `denom >= GREATEST(total_floors, 8)` +-- (мигр. 145) пропускает дома, у которых ГАР дико недосчитал квартиры И нет +-- ЖКХ-счёта по guid, А У САМОГО дома total_floors NULL. Прод-кейс — Переходный +-- пер. 6: gar_flat_count=9, house.total_floors NULL → GREATEST(0,8)=8, гейт 9>=8 +-- проходит → ложные 77.8% «доли в продаже». Но ВСЕ листинги дома заявляют +-- total_floors 19-26 (это 19-24-эт. башня, ЖКХ=165 квартир) — здание в N этажей +-- физически не может иметь ~9 квартир. Сигнал недосчёта знаменателя — этажность, +-- о которой говорят сами листинги дома, а не (отсутствующая) house.total_floors. +-- +-- WHAT: +-- v_building_sale_share — усиливаем нижнюю границу плаузибилити-гейта знаменателя +-- медианной этажностью оставленных (KEPT) активных листингов дома: +-- 1) В CTE listing_agg добавлен ОДИН внутренний агрегат listings_med_floors = +-- percentile_cont(0.5) WITHIN GROUP (ORDER BY l.total_floors) под ТЕМИ ЖЕ +-- гео(≤300м)+floors(±3)-фильтрами, что и active_secondary (мигр. 150/152) — +-- чтобы чужие/дальние/height-mismatched листинги не задирали медиану. +-- Медиана (а не max) устойчива к одному чужому высокому листингу. +-- 2) Обе плаузибилити-CASE (sale_share_pct, sale_share_pct_45d) расширяют нижнюю +-- границу этажа с `GREATEST(COALESCE(h.total_floors,0), 8)` до +-- `GREATEST(COALESCE(h.total_floors,0), COALESCE(la.listings_med_floors,0)::int, 8)`: +-- знаменатель должен быть НЕ МЕНЬШЕ собственной этажности дома ЛИБО медианной +-- этажности его листингов ЛИБО 8. Часть `AND числитель <= denom` и само деление +-- НЕ изменены; COALESCE-цепочка знаменателя (ЖКХ-приоритет) НЕ изменена. +-- listings_med_floors — ВНУТРЕННИЙ агрегат CTE, в top-level SELECT НЕ выводится. +-- Остальные 4 FILTER-агрегата (active_secondary, listings_45d, median_price_rub, +-- median_price_per_m2, avg_days_on_market — 5 шт.) с их гео+floors-гардами, весь +-- top-level SELECT / WHERE / appended-колонки (zhkh_flat_count, flat_count_source) +-- — БАЙТ-В-БАЙТ как в мигр. 152, дельта только: +1 агрегат в listing_agg и +1 терм +-- в двух GREATEST. +-- +-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count), +-- 145 (плаузибилити-гейт знаменателя GREATEST(total_floors,8)), 146 (listings_45d + +-- sale_share_pct_45d + zhkh в COALESCE + houses.zhkh_flat_count), 147 (canon strip +-- geo-prefixes), 148 (дедуп кросс-площадочных дублей), 149 (ЖКХ-приоритет знаменателя +-- + zhkh_flat_count/flat_count_source колонки + houses.zhkh_floors_raw text), 150 +-- (гео-фильтр числителя ≤300м в CTE), 151 (clean bare-street aliases — view НЕ +-- трогала), 152 (houses.zhkh_floors smallint + floors-guard ±3 во всех 5 FILTER). +-- Базируется на текущем (152) определении view — меняем ТОЛЬКО CTE listing_agg +-- (+1 агрегат) + два GREATEST в CASE. Использует listings.total_floors (int), +-- houses.total_floors (int), houses.zhkh_floors (smallint, мигр. 152). +-- +-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE VIEW ONLY (структура top-level колонок не +-- меняется — те же позиции/типы/имена, что в 152; listings_med_floors внутренний) + +-- COMMENT. Никакого DDL над таблицами (zhkh_floors уже добавлен мигр. 152). Повторный +-- прогон — no-op (REPLACE на идентичное определение). Деплой-раннер гонит файл через +-- psql -v ON_ERROR_STOP=on БЕЗ --single-transaction → транзакцию открывает САМ файл +-- (BEGIN/COMMIT ниже), как 146/148/149/150/152. +-- +-- NB по нумерации: последний занятый = 152; следующий свободный sequential = 153 +-- (проверено `ls tradein-mvp/backend/data/sql | grep '^15'` → 150, 151, 152; коллизий нет). +-- +-- Deploy order: после 152_sale_share_floors_guard.sql. + +BEGIN; + +CREATE OR REPLACE VIEW v_building_sale_share AS + WITH listing_agg AS ( + SELECT l.house_id_fk AS house_id, + count(DISTINCT (l.rooms, round(l.area_m2), l.floor)) FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS active_secondary, + count(DISTINCT (l.rooms, round(l.area_m2), l.floor)) FILTER ( + WHERE l.listing_segment = 'vtorichka'::text + AND l.last_seen_at >= (now() - interval '45 days') + AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) + AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3) + ) AS listings_45d, + percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_rub::double precision)) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_rub IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_rub, + percentile_cont(0.5::double precision) WITHIN GROUP (ORDER BY (l.price_per_m2::double precision)) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.price_per_m2 IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS median_price_per_m2, + avg(l.days_on_market) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND l.days_on_market IS NOT NULL AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS avg_days_on_market, + percentile_cont(0.5) WITHIN GROUP (ORDER BY l.total_floors) + FILTER (WHERE l.is_active AND l.listing_segment = 'vtorichka'::text AND (l.geom IS NULL OR hg.geom IS NULL OR ST_DistanceSphere(l.geom, hg.geom) <= 300) AND (l.total_floors IS NULL OR COALESCE(hg.zhkh_floors, hg.total_floors) IS NULL OR abs(l.total_floors - COALESCE(hg.zhkh_floors, hg.total_floors)) <= 3)) AS listings_med_floors + FROM listings l + JOIN houses hg ON hg.id = l.house_id_fk + WHERE l.house_id_fk IS NOT NULL + GROUP BY l.house_id_fk + ) + SELECT h.id AS house_id, + h.short_address, + h.full_address, + h.address, + h.lat, + h.lon, + h.year_built, + h.house_type, + h.total_floors, + h.series_name, + h.is_emergency, + COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) AS flat_count_effective, + h.gar_flat_count, + h.gar_match_method, + la.active_secondary, + la.median_price_rub, + la.median_price_per_m2, + la.avg_days_on_market, + CASE + WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + >= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8) + AND la.active_secondary <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + THEN round(100.0 * la.active_secondary::numeric + / COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1) + ELSE NULL::numeric + END AS sale_share_pct, + la.listings_45d, + CASE + WHEN COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + >= GREATEST(COALESCE(h.total_floors, 0), COALESCE(la.listings_med_floors, 0)::int, 8) + AND la.listings_45d <= COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0)) + THEN round(100.0 * la.listings_45d::numeric + / COALESCE(h.zhkh_flat_count, h.gar_flat_count, NULLIF(h.total_units, 0), NULLIF(h.flat_count, 0))::numeric, 1) + ELSE NULL::numeric + END AS sale_share_pct_45d, + h.zhkh_flat_count, + CASE + WHEN h.zhkh_flat_count IS NOT NULL THEN 'zhkh' + WHEN h.gar_flat_count IS NOT NULL THEN 'gar' + WHEN NULLIF(h.total_units, 0) IS NOT NULL THEN 'total_units' + WHEN NULLIF(h.flat_count, 0) IS NOT NULL THEN 'flat_count' + ELSE NULL::text + END AS flat_count_source + FROM houses h + JOIN listing_agg la ON la.house_id = h.id + WHERE h.geom IS NOT NULL AND (la.active_secondary > 0 OR la.listings_45d > 0); + +COMMENT ON VIEW v_building_sale_share IS + 'Per-building rollup вторички для «доли квартир дома в продаже» (мигр. 143; знаменатель — ' + 'ГАР canon-match мигр. 144; 2-й источник ЖКХ + окно 45д мигр. 146; дедуп кросс-площадочных ' + 'дублей мигр. 148; ЖКХ-приоритет знаменателя мигр. 149; гео-фильтр числителя ≤300м мигр. 150). ' + 'flat_count_effective = ' + 'COALESCE(zhkh_flat_count, gar_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)) — ' + 'ЖКХ ПРИОРИТЕТ (ГИС ЖКХ точнее ГАР, который дико недосчитывает квартиры в МКД; мигр. 149). ' + 'Колонки zhkh_flat_count (сырой ЖКХ-счёт) + flat_count_source (zhkh|gar|total_units|flat_count|' + 'NULL — какой источник реально дал знаменатель) добавлены для прозрачности. Оба числителя ' + 'считают УНИКАЛЬНЫЕ КВАРТИРЫ по сигнатуре count(DISTINCT (rooms, round(area_m2), floor)), а не ' + 'объявления: одна квартира на avito+cian+domclick = разные dedup_hash, но одна сигнатура. ' + 'active_secondary = FILTER (is_active AND vtorichka); listings_45d = FILTER (vtorichka AND ' + 'last_seen_at>=now()-45d). sale_share_pct = active_secondary/denom; sale_share_pct_45d = ' + 'listings_45d/denom. Оба под плаузибилити-гейтом (denom>=GREATEST(total_floors, ' + 'листинговая-медианная-этажность, 8) AND числитель<=denom; мигр. 145 + 153), иначе NULL. ' + 'Нижняя граница знаменателя включает медианную этажность листингов дома ' + '(percentile_cont(0.5) total_floors под тем же гео+floors-фильтром; мигр. 153) — режет ложные ' + 'высокие доли, когда ГАР дико недосчитал квартиры, ЖКХ-счёта нет, а house.total_floors NULL ' + '(прод-кейс Переходный пер. 6: gar=9 vs 19-24-эт. башня). Частично-NULL сигнатуры считаются ' + 'раздельно (count(DISTINCT ROW) игнорит лишь полностью-NULL строку). Фильтр: geom NOT NULL AND ' + '(active_secondary>0 OR listings_45d>0) — churn-only дома (0 активных, но есть листинги за ' + '45д) тоже видны. active_secondary/listings_45d/медианы цены и срока теперь считают ТОЛЬКО ' + 'листинги ≤300м от geom своего дома (JOIN houses hg + ST_DistanceSphere<=300 в каждой FILTER; ' + 'анти-mis-bucketing раздутого числителя из-за бага house_id_fk; мигр. 150). Листинги/дома без ' + 'geom — кепим. Знаменатель НЕ изменён. Поверх гео — floors-guard (мигр. 152): листинг входит в ' + 'числитель/медианы, только если его total_floors неизвестен, либо этажность дома ' + 'COALESCE(zhkh_floors, total_floors) неизвестна, либо |разница| <= 3 этажа — режет height-' + 'mismatched mis-bucketing (5-эт. дом vs 19-эт. башни <300м), который гео-фильтр пропускает.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index e7fcb878..862ce43a 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -52,12 +52,18 @@ def test_cluster_key_is_canonical_address_not_cadastral() -> None: share one cluster_key. """ flat = _flat(_MAPPING_SQL) - assert "'addr:' || tradein_canon_addr(address)" in flat + # Cluster on the CLEAN address: COALESCE(short_address, full_address, address) — `address` + # may carry район-noise the canon does not strip (e.g. «… · р-н Центр»), while short_address + # is clean, so preferring the clean field lets Вайнера 66 cluster with its twin. + assert "'addr:' || tradein_canon_addr(COALESCE(short_address, full_address, address))" in flat # The empty-canon guard still keeps blank-canon houses out of any cluster. - assert "NULLIF(tradein_canon_addr(address), '') IS NOT NULL" in flat + assert ( + "NULLIF( tradein_canon_addr(COALESCE(short_address, full_address, address)), '' )" + " IS NOT NULL" in flat + ) # Digit guard: canon must carry a house number — excludes degenerate canons like # «екатеринбург»/«сооружение» (street-less addresses) from ever forming a cluster. - assert "tradein_canon_addr(address) ~ '[0-9]'" in flat + assert "tradein_canon_addr(COALESCE(short_address, full_address, address)) ~ '[0-9]'" in flat # cadastral_number must NOT be part of the cluster key (it is 100% NULL). assert "cadastral_number" not in _flat( _MAPPING_SQL[: _MAPPING_SQL.index("ranked")] diff --git a/tradein-mvp/backend/tests/test_zhkh_flats_loader.py b/tradein-mvp/backend/tests/test_zhkh_flats_loader.py index e0daa11a..7be4a247 100644 --- a/tradein-mvp/backend/tests/test_zhkh_flats_loader.py +++ b/tradein-mvp/backend/tests/test_zhkh_flats_loader.py @@ -67,6 +67,50 @@ def test_build_body_has_all_dto_keys_and_batch() -> None: assert "elementsPerPage=100" in zfl.ENDPOINT +# ───────────────────────────────────────────────────────────────────────────── +# _body_by_cadastre — тот же DTO, но поиск по кадастру +# ───────────────────────────────────────────────────────────────────────────── +def test_body_by_cadastre_sets_cadastre_and_nulls_fias() -> None: + body = zfl._body_by_cadastre("66:41:0604007:747") + expected_keys = { + "regionCode", + "fiasHouseCodeList", + "estStatus", + "strStatus", + "calcCount", + "houseConditionRefList", + "houseTypeRefList", + "houseManagementTypeRefList", + "cadastreNumber", + "oktmo", + "statuses", + "regionProperty", + "municipalProperty", + "hostelTypeCodes", + "useReadOnlyDataSource", + } + assert len(expected_keys) == 15 + assert set(body.keys()) == expected_keys + # cadastreNumber — ОДИНОЧНОЕ значение (НЕ список); fiasHouseCodeList = None. + assert body["cadastreNumber"] == "66:41:0604007:747" + assert body["fiasHouseCodeList"] is None + assert body["regionCode"] == zfl.REGION_GUID + assert body["statuses"] == ["APPROVED"] + assert body["useReadOnlyDataSource"] is True + + +# ───────────────────────────────────────────────────────────────────────────── +# _parse_floors_max — макс. этаж из сырой maxFloorCount +# ───────────────────────────────────────────────────────────────────────────── +def test_parse_floors_max() -> None: + assert zfl._parse_floors_max("10-30") == 30 + assert zfl._parse_floors_max("17,23") == 23 + assert zfl._parse_floors_max("9") == 9 + assert zfl._parse_floors_max(None) is None + assert zfl._parse_floors_max("") is None + assert zfl._parse_floors_max("нет") is None + + # ───────────────────────────────────────────────────────────────────────────── # _parse_item # ───────────────────────────────────────────────────────────────────────────── -- 2.45.3