Merge pull request 'feat(tradein/zhkh): ЖКХ-приоритет знаменателя «квартир в доме» + loader' (#2077) from feat/tradein-zhkh-denominator into main
Some checks failed
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) Successful in 1m27s
Deploy Trade-In / build-backend (push) Has been cancelled

This commit is contained in:
lekss361 2026-06-28 18:56:34 +00:00
commit ec03a43283
4 changed files with 722 additions and 0 deletions

View 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

View 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()

View 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;

View 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