From 77c01b6d3b129b0c3e7a84fe6f5e8bea06692929 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 21:55:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/zhkh):=20=D0=96=D0=9A=D0=A5-=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=C2=AB?= =?UTF-8?q?=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B8=D1=80=20=D0=B2=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=C2=BB=20+=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ГАР дико недосчитывает квартиры в МКД (прод-замер: Крауля 89А ГАР=11 vs ЖКХ=429; 27 из 347 домов ГАР ≥2× недосчёт). ГИС ЖКХ точнее — резолвит ~90% вторички с gar_house_guid. - мигр.149: COALESCE знаменателя → ЖКХ ПЕРВЫЙ (zhkh_flat_count, gar_flat_count, total_units, flat_count) во всех 7 местах view v_building_sale_share; +houses.zhkh_year/zhkh_floors_raw; +transparency- колонки zhkh_flat_count/flat_count_source. - app/services/zhkh_flats_loader.py + app/tasks/zhkh_flats_load.py: фетч ГИС ЖКХ searchByAddress (батч ≤100 по gar_house_guid == ЖКХ houseGuid, pageIndex=1, cookieless), идемпотентный UPDATE houses.zhkh_* (CAST(:x AS type) + IS DISTINCT FROM gate, SAVEPOINT-чанки, dry-run). Безопасно к мержу: до ручного прогона loader'а zhkh_flat_count=NULL → COALESCE падает на ГАР → поведение идентично до-149. ruff clean, pytest 8 green. --- .../backend/app/services/zhkh_flats_loader.py | 367 ++++++++++++++++++ .../backend/app/tasks/zhkh_flats_load.py | 83 ++++ .../sql/149_zhkh_priority_denominator.sql | 130 +++++++ .../backend/tests/test_zhkh_flats_loader.py | 142 +++++++ 4 files changed, 722 insertions(+) create mode 100644 tradein-mvp/backend/app/services/zhkh_flats_loader.py create mode 100644 tradein-mvp/backend/app/tasks/zhkh_flats_load.py create mode 100644 tradein-mvp/backend/data/sql/149_zhkh_priority_denominator.sql create mode 100644 tradein-mvp/backend/tests/test_zhkh_flats_loader.py diff --git a/tradein-mvp/backend/app/services/zhkh_flats_loader.py b/tradein-mvp/backend/app/services/zhkh_flats_loader.py new file mode 100644 index 00000000..bc8d7380 --- /dev/null +++ b/tradein-mvp/backend/app/services/zhkh_flats_loader.py @@ -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 diff --git a/tradein-mvp/backend/app/tasks/zhkh_flats_load.py b/tradein-mvp/backend/app/tasks/zhkh_flats_load.py new file mode 100644 index 00000000..3cf0d95f --- /dev/null +++ b/tradein-mvp/backend/app/tasks/zhkh_flats_load.py @@ -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() diff --git a/tradein-mvp/backend/data/sql/149_zhkh_priority_denominator.sql b/tradein-mvp/backend/data/sql/149_zhkh_priority_denominator.sql new file mode 100644 index 00000000..fc129e09 --- /dev/null +++ b/tradein-mvp/backend/data/sql/149_zhkh_priority_denominator.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_zhkh_flats_loader.py b/tradein-mvp/backend/tests/test_zhkh_flats_loader.py new file mode 100644 index 00000000..e0daa11a --- /dev/null +++ b/tradein-mvp/backend/tests/test_zhkh_flats_loader.py @@ -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 -- 2.45.3