Compare commits

..

No commits in common. "cbe56cab0e73c3c9b7806a9ffc39815a248af155" and "169201bb8c2646af6fee35f831d772f5a6349c12" have entirely different histories.

6 changed files with 34 additions and 516 deletions

View file

@ -12,14 +12,10 @@ 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() over the CLEAN address Cluster key: CANONICAL address via tradein_canon_addr() (cadastral_number is 100% NULL on
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod prod confirmed in migration 040 so address is the real building key). The canon collapses
confirmed in migration 040 so address is the real building key). The clean source matters: spelling/район variants of the SAME building (улулица, strips город/район/«россия»/«м-н»/
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр» «р-н» noise, removes spaces/punct, PRESERVES корпус) so e.g. «ул. Вайнера,66» and
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.
@ -121,13 +117,9 @@ _BUILD_MAPPING_SQL = text(
WITH clustered AS ( WITH clustered AS (
SELECT SELECT
id, id,
CASE WHEN NULLIF( CASE WHEN NULLIF(tradein_canon_addr(address), '') IS NOT NULL
tradein_canon_addr(COALESCE(short_address, full_address, address)), '' AND tradein_canon_addr(address) ~ '[0-9]'
) IS NOT NULL THEN 'addr:' || tradein_canon_addr(address) END AS cluster_key
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 (
@ -153,7 +145,7 @@ _BUILD_MAPPING_SQL = text(
SELECT SELECT
dh.id, dh.id,
dh.cluster_key, dh.cluster_key,
lower(trim(COALESCE(h.short_address, h.full_address, h.address))) AS norm_address, lower(trim(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

View file

@ -24,7 +24,6 @@ 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
@ -83,21 +82,6 @@ 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)."""
@ -173,29 +157,6 @@ 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]
@ -274,47 +235,6 @@ 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)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -445,160 +365,3 @@ 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

View file

@ -7,13 +7,10 @@
Запуск из контейнера tradein-backend (у прода есть интернет к dom.gosuslugi.ru): Запуск из контейнера tradein-backend (у прода есть интернет к dom.gosuslugi.ru):
python -m app.tasks.zhkh_flats_load # полный прогон (по gar_house_guid) python -m app.tasks.zhkh_flats_load # полный прогон
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
@ -22,10 +19,7 @@ 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 ( from app.services.zhkh_flats_loader import match_houses_to_zhkh
match_houses_to_zhkh,
match_houses_to_zhkh_by_cadastre,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -47,12 +41,6 @@ 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
@ -66,48 +54,30 @@ def main() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
if args.cadastre: counts = match_houses_to_zhkh(
cad_counts = match_houses_to_zhkh_by_cadastre( db,
db, region_code=args.region,
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()

View file

@ -1,157 +0,0 @@
-- 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,18 +52,12 @@ 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)
# Cluster on the CLEAN address: COALESCE(short_address, full_address, address) — `address` assert "'addr:' || tradein_canon_addr(address)" in flat
# 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 ( assert "NULLIF(tradein_canon_addr(address), '') IS NOT NULL" in flat
"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(COALESCE(short_address, full_address, address)) ~ '[0-9]'" in flat assert "tradein_canon_addr(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")]

View file

@ -67,50 +67,6 @@ 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
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────