fix(tradein): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard
Серия 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.
This commit is contained in:
parent
31e7816967
commit
078dadc440
6 changed files with 516 additions and 34 deletions
|
|
@ -12,10 +12,14 @@ WHAT this is:
|
||||||
cluster → pick canonical → re-point children (UNIQUE-collision-safe) → delete losers
|
cluster → pick canonical → re-point children (UNIQUE-collision-safe) → delete losers
|
||||||
pipeline, run inside ONE transaction so a crash leaves the table untouched.
|
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
|
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
|
||||||
prod — confirmed in migration 040 — so address is the real building key). The canon collapses
|
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod —
|
||||||
spelling/район variants of the SAME building (ул→улица, strips город/район/«россия»/«м-н»/
|
confirmed in migration 040 — so address is the real building key). The clean source matters:
|
||||||
«р-н» noise, removes spaces/punct, PRESERVES корпус) so e.g. «ул. Вайнера,66» and
|
`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
|
«улица Вайнера, 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.
|
(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 (
|
WITH clustered AS (
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
CASE WHEN NULLIF(tradein_canon_addr(address), '') IS NOT NULL
|
CASE WHEN NULLIF(
|
||||||
AND tradein_canon_addr(address) ~ '[0-9]'
|
tradein_canon_addr(COALESCE(short_address, full_address, address)), ''
|
||||||
THEN 'addr:' || tradein_canon_addr(address) END AS cluster_key
|
) 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
|
FROM houses
|
||||||
),
|
),
|
||||||
clusters_with_count AS (
|
clusters_with_count AS (
|
||||||
|
|
@ -145,7 +153,7 @@ _BUILD_MAPPING_SQL = text(
|
||||||
SELECT
|
SELECT
|
||||||
dh.id,
|
dh.id,
|
||||||
dh.cluster_key,
|
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,
|
h.geom AS loser_geom,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY dh.cluster_key
|
PARTITION BY dh.cluster_key
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`,
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
@ -82,6 +83,21 @@ def parse_int(raw: object) -> int | None:
|
||||||
return n if n > 0 else 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)
|
@dataclass(slots=True)
|
||||||
class ZhkhHouse:
|
class ZhkhHouse:
|
||||||
"""Распарсенная ЖКХ-строка дома (ключ обратного маппинга — house_guid)."""
|
"""Распарсенная ЖКХ-строка дома (ключ обратного маппинга — 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]]:
|
def _chunk_guids(guids: list[str], size: int) -> Iterator[list[str]]:
|
||||||
for i in range(0, len(guids), size):
|
for i in range(0, len(guids), size):
|
||||||
yield guids[i : i + size]
|
yield guids[i : i + size]
|
||||||
|
|
@ -235,6 +274,47 @@ def fetch_zhkh(
|
||||||
return result
|
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)
|
# UPDATE houses по gar_house_guid (== ЖКХ houseGuid)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -365,3 +445,160 @@ def match_houses_to_zhkh(
|
||||||
dry_run,
|
dry_run,
|
||||||
)
|
)
|
||||||
return result
|
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
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@
|
||||||
|
|
||||||
Запуск из контейнера tradein-backend (у прода есть интернет к dom.gosuslugi.ru):
|
Запуск из контейнера 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 --limit 300 --dry-run # сэмпл, без записи
|
||||||
|
python -m app.tasks.zhkh_flats_load --cadastre # fallback по кадастру листингов
|
||||||
|
|
||||||
В режиме --dry-run выборка/фетч происходят, но НИ ОДНОЙ записи в БД не делается.
|
В режиме --dry-run выборка/фетч происходят, но НИ ОДНОЙ записи в БД не делается.
|
||||||
|
В режиме --cadastre резолвим дома, чей gar_house_guid ≠ ЖКХ houseGuid, по
|
||||||
|
listings.building_cadastral_number и UPDATE'им строго по houses.id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -19,7 +22,10 @@ import argparse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -41,6 +47,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--sleep", type=float, default=0.4, help="пауза между батчами, сек (по умолчанию 0.4)"
|
"--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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,30 +66,48 @@ def main() -> None:
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
counts = match_houses_to_zhkh(
|
if args.cadastre:
|
||||||
db,
|
cad_counts = match_houses_to_zhkh_by_cadastre(
|
||||||
region_code=args.region,
|
db,
|
||||||
limit=args.limit,
|
limit=args.limit,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
sleep_sec=args.sleep,
|
sleep_sec=args.sleep,
|
||||||
)
|
)
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -52,12 +52,18 @@ def test_cluster_key_is_canonical_address_not_cadastral() -> None:
|
||||||
share one cluster_key.
|
share one cluster_key.
|
||||||
"""
|
"""
|
||||||
flat = _flat(_MAPPING_SQL)
|
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.
|
# 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
|
# Digit guard: canon must carry a house number — excludes degenerate canons like
|
||||||
# «екатеринбург»/«сооружение» (street-less addresses) from ever forming a cluster.
|
# «екатеринбург»/«сооружение» (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).
|
# cadastral_number must NOT be part of the cluster key (it is 100% NULL).
|
||||||
assert "cadastral_number" not in _flat(
|
assert "cadastral_number" not in _flat(
|
||||||
_MAPPING_SQL[: _MAPPING_SQL.index("ranked")]
|
_MAPPING_SQL[: _MAPPING_SQL.index("ranked")]
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,50 @@ def test_build_body_has_all_dto_keys_and_batch() -> None:
|
||||||
assert "elementsPerPage=100" in zfl.ENDPOINT
|
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
|
# _parse_item
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue