feat(tradein/zhkh): ЖКХ-приоритет знаменателя «квартир в доме» + loader #2077
4 changed files with 722 additions and 0 deletions
367
tradein-mvp/backend/app/services/zhkh_flats_loader.py
Normal file
367
tradein-mvp/backend/app/services/zhkh_flats_loader.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"""ГИС ЖКХ loader: «квартир на дом» (знаменатель «доли квартир дома в продаже»).
|
||||
|
||||
Тянет точный счёт квартир (residentialPremiseCount) из публичного API ГИС ЖКХ
|
||||
(dom.gosuslugi.ru) и проставляет `houses.zhkh_*` (мигр. 146/149). В отличие от ГАР-loader'а
|
||||
(стриминговый парс многогигабайтного XML + fuzzy canon-match адреса) здесь матч ТРИВИАЛЬНЫЙ:
|
||||
`houses.gar_house_guid` — это И ЕСТЬ ЖКХ `houseGuid` (проверено), поэтому ищем дом строго по
|
||||
этому guid, без нормализации/канона адреса.
|
||||
|
||||
ГИС ЖКХ — точнее ГАР (ГАР дико недосчитывает квартиры в МКД, см. мигр. 149): резолвит ~90%
|
||||
вторички и даёт год ввода (operationYear) + этажность (maxFloorCount) как бонус.
|
||||
|
||||
API-рецепт (verified, прод):
|
||||
POST searchByAddress?pageIndex=1&elementsPerPage=100 — БЕЗ auth/cookies.
|
||||
· pageIndex — 1-based: pageIndex=0 молча отдаёт пусто (200). Всегда 1.
|
||||
· elementsPerPage cap = 100 (≥200 → 403) → батч ≤100 guid на запрос, одна страница.
|
||||
· Тело — ПОЛНЫЙ DTO (все поля, неиспользуемые = None); fiasHouseCodeList — батч guid.
|
||||
· Ответ {"total":N,"items":[...]}; per item residentialPremiseCount (строка) = счёт квартир,
|
||||
operationYear/buildingYear = год, maxFloorCount (строка-диапазон "10-30") = этажность,
|
||||
address.house.houseGuid = входной guid (ключ для обратного маппинга).
|
||||
|
||||
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Константы API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# pageIndex/elementsPerPage — query-параметры (НЕ в теле). pageIndex 1-based (=0 → пусто),
|
||||
# elementsPerPage cap=100 (≥200 → 403).
|
||||
ENDPOINT = (
|
||||
"https://dom.gosuslugi.ru/homemanagement/api/rest/services/houses/public/"
|
||||
"searchByAddress?pageIndex=1&elementsPerPage=100"
|
||||
)
|
||||
# FIAS GUID Свердловской обл. (region 66) — обязателен в теле DTO.
|
||||
REGION_GUID = "92b30014-4d52-4e2e-892d-928142b924bf"
|
||||
# Batch = elementsPerPage cap: ≤100 guid/запрос, одна страница (pageIndex=1).
|
||||
BATCH_SIZE = 100
|
||||
# Группировка UPDATE в один SAVEPOINT: сбойный ЧАНК (≤200 строк) откатывается изолированно,
|
||||
# остальные чанки доезжают; повторный прогон идемпотентен (IS DISTINCT FROM gate).
|
||||
SAVEPOINT_CHUNK = 200
|
||||
DEFAULT_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# NB: maxFloorCount хранится СЫРЫМ ("10-30"/"17,23") в zhkh_floors_raw — числовой парс
|
||||
# этажности отложен до PR фильтра новостроек (#2), который и решит, как трактовать диапазон.
|
||||
def parse_int(raw: object) -> int | None:
|
||||
"""Устойчивый str/int → int|None.
|
||||
|
||||
None/«»/«0»/нечисло → None (0 квартир — не пригодный знаменатель; ≤0 тоже отбрасываем).
|
||||
"""
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, int):
|
||||
return raw if raw > 0 else None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
n = int(s)
|
||||
except ValueError:
|
||||
return None
|
||||
return n if n > 0 else None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ZhkhHouse:
|
||||
"""Распарсенная ЖКХ-строка дома (ключ обратного маппинга — house_guid)."""
|
||||
|
||||
house_guid: str
|
||||
flat_count: int | None
|
||||
year: int | None
|
||||
floors_raw: str | None
|
||||
|
||||
|
||||
def _parse_item(item: dict[str, Any]) -> ZhkhHouse | None:
|
||||
"""Извлечь guid + поля из item ответа ГИС ЖКХ. None, если нет houseGuid.
|
||||
|
||||
year = operationYear (предпочтительно) иначе parse_int(buildingYear);
|
||||
flat_count = parse_int(residentialPremiseCount); floors_raw = сырая maxFloorCount.
|
||||
"""
|
||||
address = item.get("address") or {}
|
||||
house = address.get("house") or {}
|
||||
guid = house.get("houseGuid")
|
||||
if not guid:
|
||||
return None
|
||||
|
||||
year = parse_int(item.get("operationYear"))
|
||||
if year is None:
|
||||
year = parse_int(item.get("buildingYear"))
|
||||
|
||||
floors_value = item.get("maxFloorCount")
|
||||
floors_raw = None if floors_value is None else str(floors_value)
|
||||
|
||||
return ZhkhHouse(
|
||||
house_guid=str(guid),
|
||||
flat_count=parse_int(item.get("residentialPremiseCount")),
|
||||
year=year,
|
||||
floors_raw=floors_raw,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HTTP: заголовки + тело + fetch
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _headers() -> dict[str, str]:
|
||||
"""Заголовки запроса. request-guid/session-guid — свежие random uuid4 (random OK)."""
|
||||
return {
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"accept": "application/json; charset=utf-8",
|
||||
"state-guid": "/houses",
|
||||
"request-guid": str(uuid.uuid4()),
|
||||
"session-guid": str(uuid.uuid4()),
|
||||
"user-agent": DEFAULT_UA,
|
||||
"referer": "https://dom.gosuslugi.ru/",
|
||||
"origin": "https://dom.gosuslugi.ru",
|
||||
}
|
||||
|
||||
|
||||
def _build_body(batch: list[str]) -> dict[str, Any]:
|
||||
"""ПОЛНЫЙ DTO searchByAddress (15 полей; неиспользуемые = None). batch → fiasHouseCodeList."""
|
||||
return {
|
||||
"regionCode": REGION_GUID,
|
||||
"fiasHouseCodeList": batch,
|
||||
"estStatus": None,
|
||||
"strStatus": None,
|
||||
"calcCount": True,
|
||||
"houseConditionRefList": None,
|
||||
"houseTypeRefList": None,
|
||||
"houseManagementTypeRefList": None,
|
||||
"cadastreNumber": None,
|
||||
"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]
|
||||
|
||||
|
||||
def _fetch_batch(
|
||||
batch: list[str], *, client: httpx.Client, max_retries: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""POST одного батча guid (pageIndex=1) → items. Ретрай на 403/5xx/httpx-ошибках.
|
||||
|
||||
Backoff 1,2,4с между попытками. На финальном провале — warning + пустой список (батч
|
||||
пропускается, весь прогон не срывается). Прочие 4xx (битый запрос) — не ретраим.
|
||||
"""
|
||||
body = _build_body(batch)
|
||||
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")
|
||||
return items if isinstance(items, list) else []
|
||||
last_err = f"HTTP {resp.status_code}"
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"zhkh fetch: батч %d guid — невосстановимый %s (пропущен)", len(batch), exc
|
||||
)
|
||||
return []
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
last_err = repr(exc)
|
||||
if attempt < max_retries:
|
||||
backoff = 2 ** (attempt - 1)
|
||||
logger.warning(
|
||||
"zhkh fetch: батч %d guid сбой (%s), retry %d/%d через %dс",
|
||||
len(batch),
|
||||
last_err,
|
||||
attempt,
|
||||
max_retries,
|
||||
backoff,
|
||||
)
|
||||
time.sleep(backoff)
|
||||
logger.warning(
|
||||
"zhkh fetch: батч %d guid сбойнул после %d попыток (%s), пропущен",
|
||||
len(batch),
|
||||
max_retries,
|
||||
last_err,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def fetch_zhkh(
|
||||
guids: list[str],
|
||||
*,
|
||||
client: httpx.Client,
|
||||
sleep_sec: float = 0.4,
|
||||
max_retries: int = 3,
|
||||
) -> dict[str, ZhkhHouse]:
|
||||
"""Бьёт guid на батчи по BATCH_SIZE, POST'ит каждый, парсит items → dict по house_guid.
|
||||
|
||||
Между батчами — пауза sleep_sec (вежливость). Прогресс логируется каждые 10 батчей.
|
||||
Сбойный батч пропускается (см. _fetch_batch), а не срывает весь прогон.
|
||||
"""
|
||||
result: dict[str, ZhkhHouse] = {}
|
||||
batches = list(_chunk_guids(guids, BATCH_SIZE))
|
||||
total = len(batches)
|
||||
for idx, batch in enumerate(batches, start=1):
|
||||
for item in _fetch_batch(batch, client=client, max_retries=max_retries):
|
||||
parsed = _parse_item(item)
|
||||
if parsed is not None:
|
||||
result[parsed.house_guid] = parsed
|
||||
if idx % 10 == 0 or idx == total:
|
||||
logger.info("zhkh fetch: батч %d/%d, резолвлено домов=%d", idx, total, len(result))
|
||||
if idx < total and sleep_sec > 0:
|
||||
time.sleep(sleep_sec)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# UPDATE houses по gar_house_guid (== ЖКХ houseGuid)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Идемпотентный UPDATE: gate `IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие
|
||||
# строки. CAST(:x AS type) — psycopg v3 discipline (никогда :x::type).
|
||||
_UPDATE_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)
|
||||
WHERE gar_house_guid = CAST(:g AS text)
|
||||
AND (
|
||||
zhkh_flat_count IS DISTINCT FROM CAST(:fc AS int)
|
||||
OR zhkh_year IS DISTINCT FROM CAST(:yr AS smallint)
|
||||
OR zhkh_floors_raw IS DISTINCT FROM CAST(:fr AS text)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_SELECT_GUIDS_SQL = text(
|
||||
"""
|
||||
SELECT DISTINCT gar_house_guid
|
||||
FROM houses
|
||||
WHERE gar_house_guid IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Тот же запрос с LIMIT для сэмплинга (--limit). Отдельный text() — psycopg v3 не любит
|
||||
# условную инъекцию LIMIT в строку.
|
||||
_SELECT_GUIDS_LIMIT_SQL = text(
|
||||
"""
|
||||
SELECT DISTINCT gar_house_guid
|
||||
FROM houses
|
||||
WHERE gar_house_guid IS NOT NULL
|
||||
LIMIT CAST(:limit AS int)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _chunk_houses(items: list[ZhkhHouse], size: int) -> Iterator[list[ZhkhHouse]]:
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
|
||||
|
||||
def match_houses_to_zhkh(
|
||||
db: Session,
|
||||
*,
|
||||
region_code: str = "66",
|
||||
limit: int | None = None,
|
||||
dry_run: bool = False,
|
||||
sleep_sec: float = 0.4,
|
||||
) -> dict[str, int]:
|
||||
"""ГИС ЖКХ → houses.zhkh_* по gar_house_guid (== ЖКХ houseGuid). НЕ коммитит (caller).
|
||||
|
||||
region_code — информационно/в лог (все наши guid — region 66; параметр для паритета с
|
||||
ГАР-CLI). limit (если задан) ограничивает выборку домов (сэмпл). dry_run — НОЛЬ записей,
|
||||
только подсчёт. Возвращает {houses_seen, zhkh_resolved, zhkh_with_flatcount, houses_updated}.
|
||||
"""
|
||||
if limit is not None:
|
||||
rows = db.execute(_SELECT_GUIDS_LIMIT_SQL, {"limit": limit}).scalars().all()
|
||||
else:
|
||||
rows = db.execute(_SELECT_GUIDS_SQL).scalars().all()
|
||||
guids = [str(g) for g in rows if g]
|
||||
houses_seen = len(guids)
|
||||
logger.info(
|
||||
"zhkh match: домов с gar_house_guid=%d (region=%s, limit=%s, dry_run=%s)",
|
||||
houses_seen,
|
||||
region_code,
|
||||
limit,
|
||||
dry_run,
|
||||
)
|
||||
|
||||
with httpx.Client(timeout=40) as client:
|
||||
zhkh_by_guid = fetch_zhkh(guids, client=client, sleep_sec=sleep_sec)
|
||||
|
||||
zhkh_resolved = len(zhkh_by_guid)
|
||||
with_flatcount = [
|
||||
z for z in zhkh_by_guid.values() if z.flat_count is not None and z.flat_count > 0
|
||||
]
|
||||
zhkh_with_flatcount = len(with_flatcount)
|
||||
|
||||
houses_updated = 0
|
||||
if dry_run:
|
||||
logger.info(
|
||||
"zhkh match DRY-RUN: резолвлено=%d с flat_count=%d (записей не делаем)",
|
||||
zhkh_resolved,
|
||||
zhkh_with_flatcount,
|
||||
)
|
||||
else:
|
||||
for chunk in _chunk_houses(with_flatcount, SAVEPOINT_CHUNK):
|
||||
chunk_updated = 0
|
||||
try:
|
||||
with db.begin_nested():
|
||||
for z in chunk:
|
||||
res = db.execute(
|
||||
_UPDATE_SQL,
|
||||
{
|
||||
"fc": z.flat_count,
|
||||
"g": z.house_guid,
|
||||
"yr": z.year,
|
||||
"fr": z.floors_raw,
|
||||
},
|
||||
)
|
||||
chunk_updated += res.rowcount
|
||||
houses_updated += chunk_updated
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"zhkh match: чанк из %d UPDATE сбойнул (откат savepoint)",
|
||||
len(chunk),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
result = {
|
||||
"houses_seen": houses_seen,
|
||||
"zhkh_resolved": zhkh_resolved,
|
||||
"zhkh_with_flatcount": zhkh_with_flatcount,
|
||||
"houses_updated": houses_updated,
|
||||
}
|
||||
logger.info(
|
||||
"zhkh match DONE: seen=%d resolved=%d with_flatcount=%d updated=%d (dry_run=%s)",
|
||||
houses_seen,
|
||||
zhkh_resolved,
|
||||
zhkh_with_flatcount,
|
||||
houses_updated,
|
||||
dry_run,
|
||||
)
|
||||
return result
|
||||
83
tradein-mvp/backend/app/tasks/zhkh_flats_load.py
Normal file
83
tradein-mvp/backend/app/tasks/zhkh_flats_load.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""CLI: загрузка ЖКХ-знаменателя «квартир на дом» → houses.zhkh_* (мигр. 146/149).
|
||||
|
||||
Тянет точный счёт квартир из публичного API ГИС ЖКХ (dom.gosuslugi.ru) и проставляет
|
||||
`houses.zhkh_flat_count` / `zhkh_house_guid` / `zhkh_matched_at` / `zhkh_year` /
|
||||
`zhkh_floors_raw`. Матч ТРИВИАЛЬНЫЙ: `houses.gar_house_guid` — это И ЕСТЬ ЖКХ `houseGuid`
|
||||
(проверено), поэтому ищем дом по нему, без нормализации/канона адреса.
|
||||
|
||||
Запуск из контейнера tradein-backend (у прода есть интернет к dom.gosuslugi.ru):
|
||||
|
||||
python -m app.tasks.zhkh_flats_load # полный прогон
|
||||
python -m app.tasks.zhkh_flats_load --limit 300 --dry-run # сэмпл, без записи
|
||||
|
||||
В режиме --dry-run выборка/фетч происходят, но НИ ОДНОЙ записи в БД не делается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.zhkh_flats_loader import match_houses_to_zhkh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ГИС ЖКХ loader: квартир на дом → houses.zhkh_* по gar_house_guid"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--region", default="66", help="код региона (информационно, по умолчанию 66)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=None, help="ограничить число домов (сэмпл); по умолчанию все"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="без записи в БД — только фетч и подсчёт"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep", type=float, default=0.4, help="пауза между батчами, сек (по умолчанию 0.4)"
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
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()
|
||||
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()
|
||||
130
tradein-mvp/backend/data/sql/149_zhkh_priority_denominator.sql
Normal file
130
tradein-mvp/backend/data/sql/149_zhkh_priority_denominator.sql
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
-- 149_zhkh_priority_denominator.sql
|
||||
--
|
||||
-- CONTEXT: страница «доля квартир дома в продаже» (/trade-in/sale-share). Знаменатель
|
||||
-- «квартир в доме» = flat_count_effective из view v_building_sale_share.
|
||||
-- Было (мигр. 146/148): приоритет COALESCE = ГАР первый
|
||||
-- COALESCE(gar_flat_count, zhkh_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0)).
|
||||
-- Проблема: ГАР ДИКО недосчитывает квартиры в МКД (прод-замер):
|
||||
-- · ул. Крауля 89А — ГАР=11 vs ЖКХ=429;
|
||||
-- · 27 из 347 домов — ГАР ≥2× недосчёт (gar=7→zhkh=639, gar=2→zhkh=376).
|
||||
-- ГИС ЖКХ (houses.zhkh_flat_count) точнее — заполняется отдельным loader'ом
|
||||
-- (app/tasks/zhkh_flats_load.py), резолвит ~90% вторички.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. houses — две ЖКХ-провенанс-колонки: zhkh_year (smallint), zhkh_floors_raw (text)
|
||||
-- — заполняются тем же loader'ом, schema-slot здесь.
|
||||
-- 2. v_building_sale_share — ИНВЕРТИРУЕМ приоритет знаменателя: ЖКХ ПЕРВЫЙ
|
||||
-- COALESCE(zhkh_flat_count, gar_flat_count, NULLIF(total_units,0), NULLIF(flat_count,0))
|
||||
-- во ВСЕХ 7 местах (flat_count_effective + оба плаузибилити-гейта + оба деления +
|
||||
-- оба numerator-guard). Плюс две новые transparency-колонки в конце SELECT:
|
||||
-- zhkh_flat_count (сырой ЖКХ-счёт) + flat_count_source ('zhkh'|'gar'|'total_units'|
|
||||
-- 'flat_count'|NULL — какой источник реально дал знаменатель). CREATE OR REPLACE
|
||||
-- VIEW допускает только ДОБАВЛЕНИЕ колонок в конец → существующие позиции/типы/
|
||||
-- имена не трогаем (включая h.gar_flat_count, h.gar_match_method).
|
||||
--
|
||||
-- DEPENDENCIES: 143 (view + houses.gar_*), 144 (canon match → gar_flat_count),
|
||||
-- 145 (плаузибилити-гейт знаменателя), 146 (listings_45d + sale_share_pct_45d + zhkh
|
||||
-- в COALESCE + houses.zhkh_flat_count), 147 (canon strip geo-prefixes),
|
||||
-- 148 (дедуп кросс-площадочных дублей — числители count(DISTINCT (rooms,round(area_m2),
|
||||
-- floor))). Базируется на текущем (148) определении view — меняем ТОЛЬКО приоритет
|
||||
-- COALESCE + добавляем 2 колонки в конец. houses.zhkh_flat_count — из мигр. 146.
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: только ADD COLUMN IF NOT EXISTS + CREATE OR REPLACE VIEW
|
||||
-- (колонки только добавляются в конец → структура совместима) + COMMENT. Повторный
|
||||
-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ
|
||||
-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 146/148.
|
||||
--
|
||||
-- NB по нумерации: последний занятый = 148; следующий свободный sequential = 149
|
||||
-- (проверено `ls tradein-mvp/backend/data/sql | grep '^14'` + открытые PR — дубля basename нет).
|
||||
--
|
||||
-- Deploy order: после 148_dedup_apartments_in_sale_share.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_year smallint;
|
||||
ALTER TABLE houses ADD COLUMN IF NOT EXISTS zhkh_floors_raw text;
|
||||
|
||||
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) 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')
|
||||
) 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) 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) 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) AS avg_days_on_market
|
||||
FROM listings l
|
||||
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), 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), 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). 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), иначе NULL. Частично-NULL сигнатуры считаются раздельно '
|
||||
'(count(DISTINCT ROW) игнорит лишь полностью-NULL строку). Фильтр: geom NOT NULL AND '
|
||||
'(active_secondary>0 OR listings_45d>0) — churn-only дома (0 активных, но есть листинги за '
|
||||
'45д) тоже видны.';
|
||||
|
||||
COMMIT;
|
||||
142
tradein-mvp/backend/tests/test_zhkh_flats_loader.py
Normal file
142
tradein-mvp/backend/tests/test_zhkh_flats_loader.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Тесты ЖКХ-loader'а знаменателя (app/services/zhkh_flats_loader.py, мигр. 146/149).
|
||||
|
||||
Только ЧИСТЫЕ хелперы — БЕЗ сети и БД:
|
||||
- parse_int — числовой парс полей ГИС ЖКХ.
|
||||
- _build_body — полный DTO searchByAddress (15 ключей; pageIndex — query-param, НЕ в теле).
|
||||
- _parse_item — извлечение guid + полей из реалистичного item ответа.
|
||||
- статические asserts по _UPDATE_SQL (psycopg v3 CAST, IS DISTINCT FROM gate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
|
||||
# sibling-тестах задаём заглушку (ядро статично/без БД).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import zhkh_flats_loader as zfl
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_int
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_int_robust() -> None:
|
||||
assert zfl.parse_int("429") == 429
|
||||
assert zfl.parse_int("0") is None # 0 квартир — не пригодный знаменатель
|
||||
assert zfl.parse_int("") is None
|
||||
assert zfl.parse_int(None) is None
|
||||
assert zfl.parse_int(162) == 162
|
||||
assert zfl.parse_int("x") is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# _build_body — полный DTO
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_build_body_has_all_dto_keys_and_batch() -> None:
|
||||
batch = ["g1", "g2", "g3"]
|
||||
body = zfl._build_body(batch)
|
||||
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
|
||||
assert body["fiasHouseCodeList"] == batch
|
||||
assert body["statuses"] == ["APPROVED"]
|
||||
assert body["regionCode"] == zfl.REGION_GUID
|
||||
assert body["calcCount"] is True
|
||||
assert body["useReadOnlyDataSource"] is True
|
||||
# pageIndex — query-параметр в ENDPOINT, его НЕТ в теле.
|
||||
assert "pageIndex" not in body
|
||||
assert "pageIndex=1" in zfl.ENDPOINT
|
||||
assert "elementsPerPage=100" in zfl.ENDPOINT
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# _parse_item
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_item_realistic() -> None:
|
||||
item = {
|
||||
"residentialPremiseCount": "429",
|
||||
"operationYear": 2021,
|
||||
"buildingYear": "2019",
|
||||
"maxFloorCount": "10-30",
|
||||
"address": {"house": {"houseGuid": "abc-guid-123"}},
|
||||
"houseType": {"houseTypeName": "Многоквартирный"},
|
||||
}
|
||||
parsed = zfl._parse_item(item)
|
||||
assert parsed is not None
|
||||
assert parsed.house_guid == "abc-guid-123"
|
||||
assert parsed.flat_count == 429
|
||||
assert parsed.year == 2021 # operationYear предпочтительнее buildingYear
|
||||
assert parsed.floors_raw == "10-30" # сырая строка, не распарсенный максимум
|
||||
|
||||
|
||||
def test_parse_item_falls_back_to_building_year() -> None:
|
||||
item = {
|
||||
"residentialPremiseCount": "100",
|
||||
"operationYear": None,
|
||||
"buildingYear": "1998",
|
||||
"maxFloorCount": "9",
|
||||
"address": {"house": {"houseGuid": "g-fallback"}},
|
||||
}
|
||||
parsed = zfl._parse_item(item)
|
||||
assert parsed is not None
|
||||
assert parsed.year == 1998
|
||||
assert parsed.flat_count == 100
|
||||
|
||||
|
||||
def test_parse_item_no_guid_returns_none() -> None:
|
||||
assert zfl._parse_item({"residentialPremiseCount": "100"}) is None
|
||||
assert zfl._parse_item({"address": {"house": {}}}) is None
|
||||
assert zfl._parse_item({"address": {}}) is None
|
||||
|
||||
|
||||
def test_parse_item_zero_flatcount_is_none() -> None:
|
||||
item = {
|
||||
"residentialPremiseCount": "0",
|
||||
"maxFloorCount": None,
|
||||
"address": {"house": {"houseGuid": "g-zero"}},
|
||||
}
|
||||
parsed = zfl._parse_item(item)
|
||||
assert parsed is not None
|
||||
assert parsed.flat_count is None # «0» → None (не пригодный знаменатель)
|
||||
assert parsed.floors_raw is None
|
||||
assert parsed.year is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts по _UPDATE_SQL (psycopg v3, идемпотентность)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_update_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
|
||||
sql = str(zfl._UPDATE_SQL.text)
|
||||
# Никаких :name::type — только CAST(:name AS type).
|
||||
assert not re.search(r":\w+::", sql)
|
||||
assert "CAST(:fc AS int)" in sql
|
||||
assert "CAST(:g AS text)" in sql
|
||||
assert "CAST(:yr AS smallint)" in sql
|
||||
assert "CAST(:fr AS text)" in sql
|
||||
|
||||
|
||||
def test_update_sql_is_idempotent_gate() -> None:
|
||||
flat = re.sub(r"\s+", " ", str(zfl._UPDATE_SQL.text))
|
||||
assert "UPDATE houses" in flat
|
||||
assert "WHERE gar_house_guid = CAST(:g AS text)" in flat
|
||||
# Повторный прогон не трогает уже совпавшие строки (IS DISTINCT FROM gate).
|
||||
assert "IS DISTINCT FROM CAST(:fc AS int)" in flat
|
||||
assert "zhkh_matched_at = now()" in flat
|
||||
Loading…
Add table
Reference in a new issue