Серия sale-share data-quality (по находкам юзера): - house_dedup_merge: cluster_key → tradein_canon_addr(COALESCE(short_address, full_address, address)) — ловит дубли с район-noise в address (Вайнера 66). - zhkh_flats_loader: cadastre-fallback (searchByAddress по cadastreNumber, guid-независимо) — резолвит дома где gar_house_guid ≠ ЖКХ houseGuid (Переходный 6: guid→0, cadastre→165). CLI --cadastre. - мигр.153: плаузибилити-гард по медиане этажности листингов — denom >= GREATEST(total_floors, median(listings.total_floors), 8); отсекает gar-недосчёт башен без ЖКХ (gar=9 vs листинги 19-26 эт → NULL). ruff clean, pytest 36 green.
604 lines
26 KiB
Python
604 lines
26 KiB
Python
"""ГИС ЖКХ 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 re
|
||
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
|
||
|
||
|
||
_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)."""
|
||
|
||
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 _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]
|
||
|
||
|
||
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
|
||
|
||
|
||
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: 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
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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
|