Merge pull request 'fix(tradein): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard' (#2090) from feat/tradein-zhkh-cadastre-plus into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled

This commit is contained in:
lekss361 2026-06-28 22:34:15 +00:00
commit cbe56cab0e
6 changed files with 516 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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