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
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:
commit
cbe56cab0e
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue