From cc231946c2c72a60ba063c075a6d34e6635ae330 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 13:12:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20fill=20houses.year=5Fbuilt/mat?= =?UTF-8?q?erial=5Fwalls=20from=20=D0=94=D0=9E=D0=9C.=D0=A0=D0=A4=20=D0=9A?= =?UTF-8?q?=D0=A01.1/1.2=20+=20zhkh=5Fyear,=20propagate=20to=20listings=20?= =?UTF-8?q?(#2013)=20(#2476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/domrf_kapremont_loader.py | 479 ++++++++++++++++++ .../backend/app/tasks/domrf_kapremont_load.py | 124 +++++ .../backend/data/sql/176_domrf_kapremont.sql | 67 +++ .../tests/test_domrf_kapremont_loader.py | 389 ++++++++++++++ 4 files changed, 1059 insertions(+) create mode 100644 tradein-mvp/backend/app/services/domrf_kapremont_loader.py create mode 100644 tradein-mvp/backend/app/tasks/domrf_kapremont_load.py create mode 100644 tradein-mvp/backend/data/sql/176_domrf_kapremont.sql create mode 100644 tradein-mvp/backend/tests/test_domrf_kapremont_loader.py diff --git a/tradein-mvp/backend/app/services/domrf_kapremont_loader.py b/tradein-mvp/backend/app/services/domrf_kapremont_loader.py new file mode 100644 index 00000000..4678ed4d --- /dev/null +++ b/tradein-mvp/backend/app/services/domrf_kapremont_loader.py @@ -0,0 +1,479 @@ +"""ДОМ.РФ капремонт open data loader: houses.year_built/material_walls (issue #2013). + +CONTEXT: houses.year_built заполнен только на 38%, houses.material_walls — на 0%. +Существующий ГИС-ЖКХ loader (zhkh_flats_loader.py, мигр. 146/149) заполняет +houses.zhkh_year (70%) и houses.zhkh_floors (70%), НО никогда не копирует их в +houses.year_built/total_floors — это основной пробел. material_walls вообще ни разу +не заполнялся никаким источником. Estimator (app/services/estimator.py) фильтрует +когорту по `year_built BETWEEN ...` — реальные годы напрямую двигают точность оценки. + +ИСТОЧНИК: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.): + КР1.1 house registry (export/190) — zip → CSV, delimiter ';', UTF-8 BOM. Колонки: + mkd_code, houseguid (ФИАС GUID дома), address, commission_year (год ввода, int), + total_sq (decimal, запятая — «909,80»), number_floors_max (int), + служебные. + КР1.2 constructive elements (export/275) — zip → CSV, тот же delimiter/encoding. + LONG FORMAT: одна строка на конструктивный элемент на mkd_code; wall_material + заполнен ТОЛЬКО на строке construction_element_type='фасад'. Чтобы получить + материал стен на дом — группируем по mkd_code, берём первую строку с непустым + wall_material. + +TLS: домен домрф.рф отдаёт RU-сертификат, который httpx с default trust store не +верифицирует ("unable to get local issuer certificate") — та же ситуация, что и у +sber_index.py. Открытые неавторизованные данные, поэтому используем verify=False, +как остальные RU-gov loader'ы в этом репо. + +Матч staging→houses: domrf_kapremont.houseguid = COALESCE(houses.gar_house_guid, +houses.house_fias_id, houses.zhkh_house_guid) — простой приоритетный COALESCE-джойн +(без multi-attempt fallback-если-нет-матча — see PR discussion, "keep it simple/safe"). + +psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`. +""" + +from __future__ import annotations + +import csv +import io +import logging +import tempfile +import zipfile +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import httpx +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# ───────────────────────────────────────────────────────────────────────────── +# Константы +# ───────────────────────────────────────────────────────────────────────────── +# export/190 (КР1.1) и export/275 (КР1.2) без доп. параметров отдают Свердловскую +# обл. (region 66) — подтверждено инспекцией скачанных файлов. Для других регионов +# понадобится region-gid параметр (не реализовано — вне скоупа #2013). +KR11_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/190" +KR12_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/275" + +DOWNLOAD_TIMEOUT_SEC = 180 + +# UPSERT в staging чанками (в одном SAVEPOINT), как zhkh_flats_loader.SAVEPOINT_CHUNK — +# сбойный чанк откатывается изолированно, остальные доезжают. +UPSERT_CHUNK_SIZE = 500 + + +# ───────────────────────────────────────────────────────────────────────────── +# Чистые хелперы парсинга (юнит-тестируются без сети/БД) +# ───────────────────────────────────────────────────────────────────────────── +def parse_decimal_comma(raw: str | None) -> float | None: + """«909,80» / «909.80» / «» / None → float|None. Запятая — decimal separator ДОМ.РФ CSV.""" + if raw is None: + return None + s = raw.strip() + if not s: + return None + try: + return float(s.replace(",", ".")) + except ValueError: + return None + + +def parse_int_field(raw: str | None) -> int | None: + """Устойчивый str → int|None (commission_year / number_floors_max). + + Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек — + ДОМ.РФ CSV в принципе может так отдать целочисленные поля. + """ + if raw is None: + return None + s = raw.strip() + if not s: + return None + try: + return int(s) + except ValueError: + pass + try: + return int(float(s.replace(",", "."))) + except ValueError: + return None + + +@dataclass(slots=True) +class Kr11Row: + """Одна строка КР1.1 house registry (house-per-row).""" + + mkd_code: str + houseguid: str | None + address: str | None + commission_year: int | None + number_floors_max: int | None + total_sq: float | None + + +@dataclass(slots=True) +class DomrfHouse: + """Объединённая КР1.1 + КР1.2(wall_material) строка — готова к UPSERT в staging.""" + + mkd_code: str + houseguid: str | None + address: str | None + commission_year: int | None + number_floors_max: int | None + total_sq: float | None + wall_material: str | None + + +def _open_csv(path: str | Path) -> Iterator[dict[str, str]]: + """csv.DictReader по ДОМ.РФ CSV: delimiter ';', UTF-8 BOM (utf-8-sig).""" + with open(path, encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f, delimiter=";") + yield from reader + + +def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]: + """КР1.1 → dict по mkd_code. Строки без mkd_code пропускаются. + + Дубликат mkd_code (не ожидается — mkd_code PK в реестре ДОМ.РФ) — последняя + строка побеждает (совпадает с семантикой ON CONFLICT DO UPDATE ниже). + """ + rows: dict[str, Kr11Row] = {} + for raw in _open_csv(path): + mkd_code = (raw.get("mkd_code") or "").strip() + if not mkd_code: + continue + rows[mkd_code] = Kr11Row( + mkd_code=mkd_code, + houseguid=(raw.get("houseguid") or "").strip() or None, + address=(raw.get("address") or "").strip() or None, + commission_year=parse_int_field(raw.get("commission_year")), + number_floors_max=parse_int_field(raw.get("number_floors_max")), + total_sq=parse_decimal_comma(raw.get("total_sq")), + ) + return rows + + +def parse_kr12_wall_materials(path: str | Path) -> dict[str, str]: + """КР1.2 (long-format) → dict mkd_code → wall_material. + + wall_material непустой ТОЛЬКО на строке construction_element_type='фасад' — + группировка не по этому полю, а просто "первая непустая wall_material на + mkd_code" (устойчиво даже если разметка типа элемента когда-то изменится). + """ + materials: dict[str, str] = {} + for raw in _open_csv(path): + mkd_code = (raw.get("mkd_code") or "").strip() + wall = (raw.get("wall_material") or "").strip() + if mkd_code and wall and mkd_code not in materials: + materials[mkd_code] = wall + return materials + + +def build_domrf_houses( + kr11: dict[str, Kr11Row], wall_materials: dict[str, str] +) -> list[DomrfHouse]: + """КР1.1 rows + КР1.2 wall_material lookup → список DomrfHouse (для UPSERT).""" + return [ + DomrfHouse( + mkd_code=row.mkd_code, + houseguid=row.houseguid, + address=row.address, + commission_year=row.commission_year, + number_floors_max=row.number_floors_max, + total_sq=row.total_sq, + wall_material=wall_materials.get(row.mkd_code), + ) + for row in kr11.values() + ] + + +# ───────────────────────────────────────────────────────────────────────────── +# HTTP: скачивание zip + извлечение CSV +# ───────────────────────────────────────────────────────────────────────────── +def _download_zip(url: str, *, client: httpx.Client) -> bytes: + resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC) + resp.raise_for_status() + return resp.content + + +def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path: + """Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь.""" + with zipfile.ZipFile(io.BytesIO(data)) as zf: + csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")] + if not csv_names: + raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}") + extracted = zf.extract(csv_names[0], dest_dir) + return Path(extracted) + + +def fetch_domrf_csvs(dest_dir: Path, *, client: httpx.Client) -> tuple[Path, Path]: + """Скачивает КР1.1 + КР1.2 zip'ы, распаковывает в dest_dir. Возвращает (kr11, kr12).""" + kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest_dir) + kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest_dir) + return kr11_path, kr12_path + + +# ───────────────────────────────────────────────────────────────────────────── +# UPSERT staging (domrf_kapremont, мигр. 176) +# ───────────────────────────────────────────────────────────────────────────── +_UPSERT_SQL = text( + """ + INSERT INTO domrf_kapremont ( + mkd_code, houseguid, address, commission_year, + number_floors_max, total_sq, wall_material, loaded_at + ) + VALUES ( + CAST(:mkd_code AS text), CAST(:houseguid AS text), CAST(:address AS text), + CAST(:commission_year AS smallint), CAST(:number_floors_max AS int), + CAST(:total_sq AS numeric), CAST(:wall_material AS text), now() + ) + ON CONFLICT (mkd_code) DO UPDATE SET + houseguid = EXCLUDED.houseguid, + address = EXCLUDED.address, + commission_year = EXCLUDED.commission_year, + number_floors_max = EXCLUDED.number_floors_max, + total_sq = EXCLUDED.total_sq, + wall_material = EXCLUDED.wall_material, + loaded_at = now() + WHERE domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid + OR domrf_kapremont.address IS DISTINCT FROM EXCLUDED.address + OR domrf_kapremont.commission_year IS DISTINCT FROM EXCLUDED.commission_year + OR domrf_kapremont.number_floors_max IS DISTINCT FROM EXCLUDED.number_floors_max + OR domrf_kapremont.total_sq IS DISTINCT FROM EXCLUDED.total_sq + OR domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material + """ +) + + +def _chunk_houses(items: list[DomrfHouse], size: int) -> Iterator[list[DomrfHouse]]: + for i in range(0, len(items), size): + yield items[i : i + size] + + +def upsert_domrf_kapremont( + db: Session, houses: list[DomrfHouse], *, chunk_size: int = UPSERT_CHUNK_SIZE +) -> int: + """UPSERT списка DomrfHouse в domrf_kapremont, чанками по SAVEPOINT. Не коммитит (caller). + + Идемпотентно (IS DISTINCT FROM gate в _UPSERT_SQL — повторный прогон с теми же + данными не трогает уже актуальные строки). Сбойный чанк откатывается изолированно + (та же схема, что zhkh_flats_loader.match_houses_to_zhkh). + """ + upserted = 0 + for chunk in _chunk_houses(houses, chunk_size): + try: + with db.begin_nested(): + for h in chunk: + res = db.execute( + _UPSERT_SQL, + { + "mkd_code": h.mkd_code, + "houseguid": h.houseguid, + "address": h.address, + "commission_year": h.commission_year, + "number_floors_max": h.number_floors_max, + "total_sq": h.total_sq, + "wall_material": h.wall_material, + }, + ) + upserted += res.rowcount + except Exception: + logger.warning( + "domrf_kapremont upsert: чанк из %d строк сбойнул (откат savepoint)", + len(chunk), + exc_info=True, + ) + return upserted + + +def load_domrf_kapremont( + db: Session, + *, + kr11_path: str | Path | None = None, + kr12_path: str | Path | None = None, + work_dir: str | Path | None = None, + chunk_size: int = UPSERT_CHUNK_SIZE, + dry_run: bool = False, +) -> dict[str, int]: + """Скачивает (если пути не заданы) КР1.1+КР1.2, парсит, UPSERT в domrf_kapremont. + + kr11_path/kr12_path — локальные CSV (пропустить скачивание; используется тестами + и ops-дебагом). work_dir — куда распаковывать скачанные zip (по умолчанию — + временный каталог, удаляется после загрузки). dry_run — парс происходит, но НИ + ОДНОЙ записи в БД не делается. Не коммитит (caller). + """ + tmp_ctx: tempfile.TemporaryDirectory[str] | None = None + if kr11_path is None or kr12_path is None: + if work_dir is not None: + dest = Path(work_dir) + dest.mkdir(parents=True, exist_ok=True) + else: + tmp_ctx = tempfile.TemporaryDirectory() + dest = Path(tmp_ctx.name) + # verify=False: домрф.рф отдаёт RU-сертификат, не верифицируемый дефолтным + # trust store'ом (та же ситуация, что sber_index.py). Публичные open data, + # без auth/PII — приемлемо skip'нуть TLS-верификацию. + with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client: + if kr11_path is None: + kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest) + if kr12_path is None: + kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest) + + try: + kr11 = parse_kr11_csv(kr11_path) + wall_materials = parse_kr12_wall_materials(kr12_path) + houses = build_domrf_houses(kr11, wall_materials) + upserted = 0 if dry_run else upsert_domrf_kapremont(db, houses, chunk_size=chunk_size) + finally: + if tmp_ctx is not None: + tmp_ctx.cleanup() + + result = { + "kr11_rows": len(kr11), + "kr12_wall_rows": len(wall_materials), + "houses_built": len(houses), + "upserted": upserted, + } + logger.info("domrf_kapremont load DONE (dry_run=%s): %s", dry_run, result) + return result + + +# ───────────────────────────────────────────────────────────────────────────── +# Backfill houses.year_built/material_walls/total_floors +# ───────────────────────────────────────────────────────────────────────────── +# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf, +# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком внутри ТОЙ ЖЕ строки COALESCE +# (COALESCE(year_built, domrf.commission_year, zhkh_year) — ровно как в issue). +# Только заполняет NULL — никогда не перезаписывает существующий non-null year_built. +_BACKFILL_HOUSES_FROM_DOMRF_SQL = text( + """ + UPDATE houses h + SET year_built = COALESCE(h.year_built, d.commission_year, h.zhkh_year), + material_walls = COALESCE(h.material_walls, d.wall_material), + total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors) + FROM domrf_kapremont d + WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid) + AND ( + (h.year_built IS NULL AND COALESCE(d.commission_year, h.zhkh_year) IS NOT NULL) + OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL) + OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL) + ) + """ +) + +_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text( + """ + SELECT count(*) + FROM houses h + JOIN domrf_kapremont d + ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid) + WHERE (h.year_built IS NULL AND COALESCE(d.commission_year, h.zhkh_year) IS NOT NULL) + OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL) + OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL) + """ +) + +# Шаг 2: ЛЮБОЙ дом (включая без domrf-матча вообще — шаг 1 INNER JOIN их не трогает) +# добивается zhkh_year/zhkh_floors, если ещё NULL. Домам, уже тронутым шагом 1, шаг 2 +# ничего не меняет: их COALESCE в шаге 1 уже включал zhkh_* как fallback, поэтому +# гейт `IS NULL AND zhkh_* IS NOT NULL` здесь для них ложен — шаги взаимоисключающи +# (не двойной счёт при суммировании houses_updated). +_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL = text( + """ + UPDATE houses + SET year_built = COALESCE(year_built, zhkh_year), + total_floors = COALESCE(total_floors, zhkh_floors) + WHERE (year_built IS NULL AND zhkh_year IS NOT NULL) + OR (total_floors IS NULL AND zhkh_floors IS NOT NULL) + """ +) + +_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL = text( + """ + SELECT count(*) + FROM houses + WHERE (year_built IS NULL AND zhkh_year IS NOT NULL) + OR (total_floors IS NULL AND zhkh_floors IS NOT NULL) + """ +) + + +def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[str, int]: + """COALESCE(year_built, domrf.commission_year, zhkh_year) + material_walls + total_floors. + + Двухшаговый UPDATE (мирроринг zhkh guid-match → cadastre-fallback паттерна из + zhkh_flats_loader.py): + 1) дома с domrf-матчем (COALESCE(gar_house_guid, house_fias_id, zhkh_house_guid) = + domrf_kapremont.houseguid) — заполняются из domrf, с zhkh_year/zhkh_floors + фоллбеком внутри той же COALESCE. + 2) ЛЮБОЙ дом (в т.ч. без domrf-матча) — добивает year_built/total_floors из + zhkh_year/zhkh_floors, если шаг 1 их не тронул. + Только COALESCE (заполняет NULL, никогда не перезаписывает existing non-null). + Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тем же + предикатам. + """ + if dry_run: + domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL).scalar_one() + zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one() + result = { + "domrf_matched": domrf_matched, + "zhkh_fallback": zhkh_fallback, + "houses_updated": 0, + } + logger.info("backfill_houses_from_domrf DRY-RUN: %s", result) + return result + + domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL).rowcount + zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount + + result = { + "domrf_matched": domrf_updated, + "zhkh_fallback": zhkh_updated, + "houses_updated": domrf_updated + zhkh_updated, + } + logger.info("backfill_houses_from_domrf DONE: %s", result) + return result + + +# ───────────────────────────────────────────────────────────────────────────── +# Propagate houses.year_built → listings.year_built +# ───────────────────────────────────────────────────────────────────────────── +# Только NULL listings.year_built, только когда houses.year_built уже известен — +# никогда не перезаписывает существующий listings.year_built (source-provided данные +# приоритетнее houses-агрегата). +_PROPAGATE_LISTINGS_YEAR_SQL = text( + """ + UPDATE listings l + SET year_built = h.year_built + FROM houses h + WHERE l.house_id_fk = h.id + AND l.year_built IS NULL + AND h.year_built IS NOT NULL + """ +) + +_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text( + """ + SELECT count(*) + FROM listings l + JOIN houses h ON l.house_id_fk = h.id + WHERE l.year_built IS NULL + AND h.year_built IS NOT NULL + """ +) + + +def propagate_listings_year_from_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]: + """UPDATE listings.year_built = houses.year_built где listing.year_built ещё NULL. + + Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тому же + предикату (ключ результата — would_update вместо listings_updated). + """ + if dry_run: + would_update = db.execute(_PROPAGATE_LISTINGS_YEAR_COUNT_SQL).scalar_one() + logger.info("propagate_listings_year_from_houses DRY-RUN: would_update=%d", would_update) + return {"listings_updated": 0, "would_update": would_update} + + updated = db.execute(_PROPAGATE_LISTINGS_YEAR_SQL).rowcount + logger.info("propagate_listings_year_from_houses DONE: listings_updated=%d", updated) + return {"listings_updated": updated} diff --git a/tradein-mvp/backend/app/tasks/domrf_kapremont_load.py b/tradein-mvp/backend/app/tasks/domrf_kapremont_load.py new file mode 100644 index 00000000..028040b2 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/domrf_kapremont_load.py @@ -0,0 +1,124 @@ +"""CLI: ДОМ.РФ капремонт open data → houses.year_built/material_walls (issue #2013). + +Тянет КР1.1 (house registry) + КР1.2 (constructive elements) с открытых данных +ДОМ.РФ (region 66), UPSERT'ит в staging `domrf_kapremont` (мигр. 176), затем: + 1. backfill_houses_from_domrf — COALESCE(houses.year_built, domrf.commission_year, + houses.zhkh_year) + material_walls + total_floors (fold-in уже загруженного + ГИС-ЖКХ года — houses без domrf-матча тоже выигрывают). + 2. propagate_listings_year_from_houses — UPDATE listings.year_built = houses.year_built + там, где listing ещё NULL. Именно это открывает estimator'у (year_built BETWEEN + когорта-фильтр) реальные годы на источниках вроде avito (0% year_built, 52.5% + linked к houses). + +Запуск из контейнера tradein-backend (у прода есть интернет к домрф.рф): + + python -m app.tasks.domrf_kapremont_load # полный прогон + python -m app.tasks.domrf_kapremont_load --dry-run # без записи, только подсчёт + python -m app.tasks.domrf_kapremont_load --load-only # только staging, без backfill + python -m app.tasks.domrf_kapremont_load --backfill-only # staging уже загружена — только + # backfill+propagate + python -m app.tasks.domrf_kapremont_load \\ + --kr11-path /tmp/kr11.csv --kr12-path /tmp/kr12.csv # локальные CSV, без скачивания + +В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи +в БД не делается. +""" + +from __future__ import annotations + +import argparse +import logging + +from app.core.db import SessionLocal +from app.services.domrf_kapremont_loader import ( + backfill_houses_from_domrf, + load_domrf_kapremont, + propagate_listings_year_from_houses, +) + +logger = logging.getLogger(__name__) + + +def build_parser() -> argparse.ArgumentParser: + """Парсер CLI (вынесен для тестируемости флагов без запуска main).""" + parser = argparse.ArgumentParser( + description=( + "ДОМ.РФ капремонт loader: КР1.1+КР1.2 → domrf_kapremont staging → " + "houses.year_built/material_walls/total_floors → listings.year_built" + ) + ) + parser.add_argument( + "--kr11-path", default=None, help="локальный CSV КР1.1 (пропустить скачивание)" + ) + parser.add_argument( + "--kr12-path", default=None, help="локальный CSV КР1.2 (пропустить скачивание)" + ) + parser.add_argument( + "--work-dir", + default=None, + help="каталог для скачанных/распакованных CSV (по умолчанию — временный, удаляется после)", + ) + parser.add_argument( + "--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт" + ) + parser.add_argument( + "--load-only", + action="store_true", + help="только скачать+распарсить+UPSERT staging, без backfill houses/propagate listings", + ) + parser.add_argument( + "--backfill-only", + action="store_true", + help="пропустить скачивание/load staging (считаем что уже загружена этим же " + "или предыдущим прогоном) — только backfill houses + propagate listings", + ) + 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() + + if args.load_only and args.backfill_only: + parser.error("--load-only и --backfill-only взаимоисключающи") + + db = SessionLocal() + try: + load_counts: dict[str, int] = {} + if not args.backfill_only: + load_counts = load_domrf_kapremont( + db, + kr11_path=args.kr11_path, + kr12_path=args.kr12_path, + work_dir=args.work_dir, + dry_run=args.dry_run, + ) + if not args.dry_run: + db.commit() + logger.info("domrf_kapremont_load: load stage done: %s", load_counts) + + houses_counts: dict[str, int] = {} + listings_counts: dict[str, int] = {} + if not args.load_only: + houses_counts = backfill_houses_from_domrf(db, dry_run=args.dry_run) + listings_counts = propagate_listings_year_from_houses(db, dry_run=args.dry_run) + if not args.dry_run: + db.commit() + + logger.info( + "domrf_kapremont_load DONE: dry_run=%s load=%s houses=%s listings=%s", + args.dry_run, + load_counts, + houses_counts, + listings_counts, + ) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tradein-mvp/backend/data/sql/176_domrf_kapremont.sql b/tradein-mvp/backend/data/sql/176_domrf_kapremont.sql new file mode 100644 index 00000000..585d3db8 --- /dev/null +++ b/tradein-mvp/backend/data/sql/176_domrf_kapremont.sql @@ -0,0 +1,67 @@ +-- 176_domrf_kapremont.sql +-- +-- CONTEXT: issue #2013 — houses.year_built заполнен только на 38%, houses.material_walls +-- на 0%. Существующий ГИС-ЖКХ loader (app/tasks/zhkh_flats_load.py, мигр. 146/149) +-- заполняет houses.zhkh_year (70% = 5002/7128) и houses.zhkh_floors (70%), НО НИКОГДА +-- не копирует их в houses.year_built/total_floors — это основной пробел. material_walls +-- вообще ни разу не заполнялся никаким источником (0%). Estimator +-- (app/services/estimator.py) фильтрует когорту по `year_built BETWEEN ...` — реальные +-- годы напрямую двигают точность оценки. Это REAL fix для #2013 (взамен запаркованного +-- когорт-исключения PR #2474). +-- +-- Источник: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.): +-- КР1.1 house registry (export/190) — zip → CSV. mkd_code, houseguid (ФИАС GUID дома), +-- address, commission_year (год ввода, int), total_sq, number_floors_max. +-- КР1.2 constructive elements (export/275) — zip → CSV, LONG FORMAT (строка на +-- конструктивный элемент на mkd_code); wall_material заполнен только на строке +-- элемента «фасад». +-- listings→houses линковка через listings.house_id_fk (avito 52.5% linked, year_built +-- 0% на avito → backfill поднимет до ~36-52% через houses.year_built propagation). +-- +-- WHAT: +-- Таблица domrf_kapremont — staging-приёмник КР1.1+КР1.2 (region 66), ключ mkd_code. +-- Заполняется отдельным loader'ом (app/services/domrf_kapremont_loader.py, +-- app/tasks/domrf_kapremont_load.py). Backfill houses.year_built/material_walls/ +-- total_floors и propagation listings.year_built — отдельный код-путь той же таски, +-- НЕ в этой миграции (только схема здесь, никакого DML над houses/listings). +-- +-- DEPENDENCIES (existing prod objects): houses (gar_house_guid, house_fias_id, +-- zhkh_house_guid, zhkh_year, zhkh_floors, year_built, material_walls, total_floors — +-- все уже существуют: мигр. 009/010/070/146/149/152), listings (house_id_fk, year_built — +-- мигр. 002/130). +-- +-- SAFETY / IDEMPOTENCY: только CREATE TABLE/INDEX IF NOT EXISTS + COMMENT. Повторный +-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ +-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 143/149. +-- +-- NB по нумерации: последний занятый = 175 (ls data/sql | grep '^17[0-9]'); следующий +-- свободный sequential = 176. +-- +-- Deploy order: после 175_scrape_schedules_seed_domclick_detail_backfill.sql. + +BEGIN; + +CREATE TABLE IF NOT EXISTS domrf_kapremont ( + mkd_code text PRIMARY KEY, -- ДОМ.РФ код МКД (КР1.1/КР1.2 join-ключ) + houseguid text, -- ФИАС GUID дома (КР1.1 houseguid) + address text, + commission_year smallint, -- год ввода в эксплуатацию (КР1.1 commission_year) + number_floors_max int, -- этажность (КР1.1 number_floors_max) + total_sq numeric(10, 2), -- общая площадь дома, м² (КР1.1 total_sq) + wall_material text, -- материал стен (КР1.2, строка «фасад»; wall_material) + loaded_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS domrf_kapremont_houseguid_idx + ON domrf_kapremont (houseguid) + WHERE houseguid IS NOT NULL; + +COMMENT ON TABLE domrf_kapremont IS + 'ДОМ.РФ капремонт open data (КР1.1 house registry export/190 + КР1.2 constructive ' + 'elements export/275), region 66 (Свердловская обл.). Ключ mkd_code. houseguid — ФИАС ' + 'GUID дома, матчится к houses через COALESCE(gar_house_guid, house_fias_id, ' + 'zhkh_house_guid) (app/services/domrf_kapremont_loader.py). wall_material берётся из ' + 'КР1.2 со строки конструктивного элемента «фасад» (единственная строка на mkd_code, ' + 'где это поле непустое). Заполняется app/tasks/domrf_kapremont_load.py (issue #2013).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_domrf_kapremont_loader.py b/tradein-mvp/backend/tests/test_domrf_kapremont_loader.py new file mode 100644 index 00000000..8522936c --- /dev/null +++ b/tradein-mvp/backend/tests/test_domrf_kapremont_loader.py @@ -0,0 +1,389 @@ +"""Тесты ДОМ.РФ капремонт loader'а (app/services/domrf_kapremont_loader.py, мигр. 176, #2013). + +Coverage: + - Чистые парс-хелперы: parse_decimal_comma / parse_int_field. + - parse_kr11_csv / parse_kr12_wall_materials — фикстуры собраны из РЕАЛЬНЫХ строк + региона 66 (export-kr1_1-66-20260701.csv / export-kr1_2-66-20260701.csv, дом + 2170017185 «Ломоносова 3» и 2170025270 «Ломоносова 4»): КР1.2 long-format, + wall_material непустой только на строке construction_element_type='фасад'. + - build_domrf_houses — join КР1.1+КР1.2 по mkd_code. + - fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport + (никакой живой сети). + - Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST), + backfill houses (COALESCE-порядок year_built/domrf/zhkh_year, only-fill-NULL), + propagate listings (только NULL listings.year_built). + - backfill_houses_from_domrf / propagate_listings_year_from_houses — dry_run вызывает + COUNT-SQL и не пишет, real-run суммирует rowcount обоих шагов (MagicMock db). +""" + +from __future__ import annotations + +import io +import os +import re +import zipfile +from pathlib import Path +from unittest.mock import MagicMock + +# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в +# sibling-тестах (test_zhkh_flats_loader) задаём заглушку (ядро статично/без реальной БД). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import httpx +import pytest + +from app.services import domrf_kapremont_loader as dkl + +# ───────────────────────────────────────────────────────────────────────────── +# Фикстуры: РЕАЛЬНЫЕ строки региона 66 (export-kr1_1/kr1_2-66-20260701.csv) +# ───────────────────────────────────────────────────────────────────────────── +# КР1.1 house registry — 2 дома (Ломоносова 3 / Ломоносова 4, п. Арамиль). +_KR11_HEADER = ( + "subject_rf;mun_obr_oktmo;mun_obr;mkd_code;houseguid;address;commission_year;" + "architectural_monument_category;total_sq;total_rooms_amount;living_rooms_amount;" + "total_rooms_sq;living_rooms_sq;total_ppl;number_floors_max;money_collecting_way;" + "money_ppl_collected;money_ppl_collected_debts;overhaul_funds_spent_all;" + "overhaul_funds_spent_subsidy;overhaul_fund_spent_other;overhaul_funds_balance;" + "update_date_of_information;money_ppl_collected_date;owners_payment;energy_efficiency;" + "alarm_document_date;exclude_date_from_program;inclusion_date_to_program;comment;" + "last_update;house_id" +) +_KR11_ROW_3 = ( + '"Свердловская область";65729000;"Арамильский городской округ Свердловской области";' + "2170017185;04b98bcb-3232-4b26-b0fa-3c4a47adf394;" + '"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";' + '1964;;399,90;;;366,60;333,30;20;2;"Счет регионального оператора";486,84407;72,59638;' + "1580,29712;0,00000;0,00000;-1093,45305;2026-05-31;01.10.2014;18,81;" + '"не присвоен";;;22.04.2014;;15.05.2026;8857551' +) +_KR11_ROW_4 = ( + '"Свердловская область";65729000;"Арамильский городской округ Свердловской области";' + "2170025270;15064d04-3aec-4266-985b-da26e9a5f392;" + '"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";' + '1984;;906,40;;;;;;2;"Счет регионального оператора";1046,79431;204,75296;0,00000;' + '0,00000;0,00000;1046,79431;2026-05-31;01.10.2014;18,81;"не присвоен";;;22.04.2014;;' + "15.05.2026;8857552" +) +KR11_CSV = "\n".join([_KR11_HEADER, _KR11_ROW_3, _KR11_ROW_4]) + "\n" + +# КР1.2 constructive elements — long-format: обычная строка (wall_material пуст) + +# строка "фасад" (wall_material='кирпич') на каждый из тех же 2 домов. +_KR12_HEADER = ( + "subject_rf;mun_obr_oktmo;mun_obr;mkd_code;address;construction_element_code;" + "construction_element_type;system_type;lift_type;stops_count;commissioning_date;" + "decommissioning_date;roof_type;roofing_area;basement_area;facade_type;facade_area;" + "foundation_type;wall_material;comment;last_update" +) +_KR12_ROW_3_OTHER = ( + '"Свердловская область";65729000;"Арамильский городской округ Свердловской области";' + "2170017185;" + '"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";' + '580000;"система дымоудаления и (или) противопожарной автоматики";;;;;;;;;;;;;;15.05.2026' +) +_KR12_ROW_3_FACADE = ( + '"Свердловская область";65729000;"Арамильский городской округ Свердловской области";' + "2170017185;" + '"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";' + '60000;фасад;;;;;;;;;"облицованный камнем";480,00;;кирпич;;15.05.2026' +) +_KR12_ROW_4_OTHER = ( + '"Свердловская область";65729000;"Арамильский городской округ Свердловской области";' + "2170025270;" + '"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";' + '100000;"инженерная система водоснабжения (ХВС)";центральная;;;;;;;;;;;;;15.05.2026' +) +_KR12_ROW_4_FACADE = ( + '"Свердловская область";65729000;"Арамильский городской округ Свердловской области";' + "2170025270;" + '"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";' + "60000;фасад;;;;;;;;;;;;кирпич;;15.05.2026" +) +KR12_CSV = ( + "\n".join( + [_KR12_HEADER, _KR12_ROW_3_OTHER, _KR12_ROW_3_FACADE, _KR12_ROW_4_OTHER, _KR12_ROW_4_FACADE] + ) + + "\n" +) + + +@pytest.fixture +def kr11_path(tmp_path: Path) -> Path: + p = tmp_path / "kr11.csv" + p.write_text(KR11_CSV, encoding="utf-8-sig") + return p + + +@pytest.fixture +def kr12_path(tmp_path: Path) -> Path: + p = tmp_path / "kr12.csv" + p.write_text(KR12_CSV, encoding="utf-8-sig") + return p + + +# ───────────────────────────────────────────────────────────────────────────── +# parse_decimal_comma / parse_int_field +# ───────────────────────────────────────────────────────────────────────────── +def test_parse_decimal_comma() -> None: + assert dkl.parse_decimal_comma("909,80") == pytest.approx(909.80) + assert dkl.parse_decimal_comma("399,90") == pytest.approx(399.90) + assert dkl.parse_decimal_comma("100.5") == pytest.approx(100.5) + assert dkl.parse_decimal_comma("") is None + assert dkl.parse_decimal_comma(None) is None + assert dkl.parse_decimal_comma("не число") is None + + +def test_parse_int_field() -> None: + assert dkl.parse_int_field("1964") == 1964 + assert dkl.parse_int_field("2") == 2 + assert dkl.parse_int_field("") is None + assert dkl.parse_int_field(None) is None + assert dkl.parse_int_field("x") is None + assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек + + +# ───────────────────────────────────────────────────────────────────────────── +# parse_kr11_csv — house registry +# ───────────────────────────────────────────────────────────────────────────── +def test_parse_kr11_csv_real_rows(kr11_path: Path) -> None: + rows = dkl.parse_kr11_csv(kr11_path) + assert set(rows.keys()) == {"2170017185", "2170025270"} + + house3 = rows["2170017185"] + assert house3.houseguid == "04b98bcb-3232-4b26-b0fa-3c4a47adf394" + assert house3.commission_year == 1964 + assert house3.number_floors_max == 2 + assert house3.total_sq == pytest.approx(399.90) + assert "Ломоносова, д. 3" in (house3.address or "") + + house4 = rows["2170025270"] + assert house4.houseguid == "15064d04-3aec-4266-985b-da26e9a5f392" + assert house4.commission_year == 1984 + assert house4.total_sq == pytest.approx(906.40) + + +def test_parse_kr11_csv_skips_blank_mkd_code(tmp_path: Path) -> None: + csv_text = _KR11_HEADER + "\n" + (";" * (_KR11_HEADER.count(";"))) + "\n" + p = tmp_path / "blank.csv" + p.write_text(csv_text, encoding="utf-8-sig") + assert dkl.parse_kr11_csv(p) == {} + + +# ───────────────────────────────────────────────────────────────────────────── +# parse_kr12_wall_materials — long-format, wall_material только на строке 'фасад' +# ───────────────────────────────────────────────────────────────────────────── +def test_parse_kr12_wall_materials_extracts_facade_row_only(kr12_path: Path) -> None: + materials = dkl.parse_kr12_wall_materials(kr12_path) + # Обе non-facade строки (дымоудаление / ХВС) имели wall_material='' — исключены. + assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"} + + +def test_parse_kr12_wall_materials_empty_when_no_facade_row(tmp_path: Path) -> None: + csv_text = "\n".join([_KR12_HEADER, _KR12_ROW_3_OTHER]) + "\n" + p = tmp_path / "no_facade.csv" + p.write_text(csv_text, encoding="utf-8-sig") + assert dkl.parse_kr12_wall_materials(p) == {} + + +# ───────────────────────────────────────────────────────────────────────────── +# build_domrf_houses — join КР1.1 + КР1.2(wall_material) +# ───────────────────────────────────────────────────────────────────────────── +def test_build_domrf_houses_joins_wall_material(kr11_path: Path, kr12_path: Path) -> None: + kr11 = dkl.parse_kr11_csv(kr11_path) + walls = dkl.parse_kr12_wall_materials(kr12_path) + houses = dkl.build_domrf_houses(kr11, walls) + + by_code = {h.mkd_code: h for h in houses} + assert len(houses) == 2 + assert by_code["2170017185"].wall_material == "кирпич" + assert by_code["2170017185"].commission_year == 1964 + assert by_code["2170025270"].wall_material == "кирпич" + + +# Дом без строки 'фасад' в КР1.2 → wall_material остаётся None (не KeyError/пустая строка). +def test_wall_material_none_when_house_missing_from_kr12(tmp_path: Path, kr11_path: Path) -> None: + kr11 = dkl.parse_kr11_csv(kr11_path) + houses = dkl.build_domrf_houses(kr11, {}) # пустой КР1.2 lookup + assert all(h.wall_material is None for h in houses) + + +# ───────────────────────────────────────────────────────────────────────────── +# fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан (MockTransport) +# ───────────────────────────────────────────────────────────────────────────── +def _zip_bytes(inner_name: str, content: str) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr(inner_name, content.encode("utf-8-sig")) + return buf.getvalue() + + +def test_fetch_domrf_csvs_downloads_and_extracts(tmp_path: Path) -> None: + kr11_zip = _zip_bytes("export-kr1_1-66-20260701.csv", KR11_CSV) + kr12_zip = _zip_bytes("export-kr1_2-66-20260701.csv", KR12_CSV) + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if url == dkl.KR11_URL: + return httpx.Response(200, content=kr11_zip) + if url == dkl.KR12_URL: + return httpx.Response(200, content=kr12_zip) + raise AssertionError(f"unexpected URL {url}") + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as client: + kr11_out, kr12_out = dkl.fetch_domrf_csvs(tmp_path, client=client) + + assert kr11_out.exists() and kr11_out.name.endswith(".csv") + assert kr12_out.exists() and kr12_out.name.endswith(".csv") + + # Круглый путь: распакованные файлы парсятся так же, как локальные фикстуры. + rows = dkl.parse_kr11_csv(kr11_out) + assert set(rows.keys()) == {"2170017185", "2170025270"} + materials = dkl.parse_kr12_wall_materials(kr12_out) + assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"} + + +def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("readme.txt", "not a csv") + with pytest.raises(ValueError, match="csv"): + dkl._extract_csv_from_zip(buf.getvalue(), tmp_path) + + +# ───────────────────────────────────────────────────────────────────────────── +# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность) +# ───────────────────────────────────────────────────────────────────────────── +_UPSERT_SQL = str(dkl._UPSERT_SQL.text) + + +def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None: + assert not re.search(r":\w+::", _UPSERT_SQL) + assert "CAST(:mkd_code AS text)" in _UPSERT_SQL + assert "CAST(:commission_year AS smallint)" in _UPSERT_SQL + assert "CAST(:total_sq AS numeric)" in _UPSERT_SQL + + +def test_upsert_sql_idempotent_on_conflict() -> None: + flat = re.sub(r"\s+", " ", _UPSERT_SQL) + assert "INSERT INTO domrf_kapremont" in flat + assert "ON CONFLICT (mkd_code) DO UPDATE SET" in flat + assert "domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid" in flat + assert "domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat + + +def test_upsert_uses_savepoint_per_chunk() -> None: + import inspect + + src = inspect.getsource(dkl.upsert_domrf_kapremont) + assert "with db.begin_nested():" in src + assert "db.commit()" not in src # не коммитит сам (коммитит caller) + + +# ───────────────────────────────────────────────────────────────────────────── +# Статические asserts: backfill houses — COALESCE(year_built, domrf, zhkh_year) +# ───────────────────────────────────────────────────────────────────────────── +_BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQL.text)) +_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_SQL.text)) + + +def test_backfill_domrf_sql_coalesce_order_matches_spec() -> None: + # COALESCE(year_built, domrf.commission_year, zhkh_year) — ровно в этом порядке (#2013). + assert "COALESCE(h.year_built, d.commission_year, h.zhkh_year)" in _BACKFILL_DOMRF_SQL + assert "COALESCE(h.material_walls, d.wall_material)" in _BACKFILL_DOMRF_SQL + assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL + + +def test_backfill_domrf_sql_matches_by_guid_coalesce_priority() -> None: + # gar_house_guid первый (78% coverage), затем house_fias_id, затем zhkh_house_guid. + assert ( + "d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)" + in _BACKFILL_DOMRF_SQL + ) + + +def test_backfill_domrf_sql_only_fills_null_never_overwrites() -> None: + # Гейт: каждый SET-таргет требует h. IS NULL — COALESCE никогда не перезапишет + # существующее non-null значение (SQL COALESCE тоже это гарантирует, но гейт защищает + # от no-op UPDATE на уже заполненных строках). + assert "h.year_built IS NULL" in _BACKFILL_DOMRF_SQL + assert "h.material_walls IS NULL" in _BACKFILL_DOMRF_SQL + assert "h.total_floors IS NULL" in _BACKFILL_DOMRF_SQL + + +def test_backfill_zhkh_fallback_sql_folds_zhkh_for_unmatched_houses() -> None: + # Шаг 2 — без JOIN на domrf_kapremont вообще: применяется к ЛЮБОМУ дому, включая + # те, что не заматчились в шаге 1 (issue: "houses without a ДОМ.РФ match still benefit"). + assert "domrf_kapremont" not in _BACKFILL_ZHKH_SQL + assert "COALESCE(year_built, zhkh_year)" in _BACKFILL_ZHKH_SQL + assert "COALESCE(total_floors, zhkh_floors)" in _BACKFILL_ZHKH_SQL + assert "year_built IS NULL AND zhkh_year IS NOT NULL" in _BACKFILL_ZHKH_SQL + + +def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None: + assert not re.search(r":\w+::", _BACKFILL_DOMRF_SQL) + assert not re.search(r":\w+::", _BACKFILL_ZHKH_SQL) + + +# ───────────────────────────────────────────────────────────────────────────── +# backfill_houses_from_domrf — dry_run vs real-run (MagicMock db) +# ───────────────────────────────────────────────────────────────────────────── +def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None: + db = MagicMock() + results = [MagicMock(rowcount=3), MagicMock(rowcount=2)] + db.execute.side_effect = results + + out = dkl.backfill_houses_from_domrf(db, dry_run=False) + + assert out == {"domrf_matched": 3, "zhkh_fallback": 2, "houses_updated": 5} + assert db.execute.call_count == 2 + executed_sql = [str(call.args[0]) for call in db.execute.call_args_list] + assert "UPDATE houses" in executed_sql[0] + assert "UPDATE houses" in executed_sql[1] + + +def test_backfill_houses_dry_run_only_counts_no_update() -> None: + db = MagicMock() + db.execute.return_value.scalar_one.side_effect = [7, 4] + + out = dkl.backfill_houses_from_domrf(db, dry_run=True) + + assert out == {"domrf_matched": 7, "zhkh_fallback": 4, "houses_updated": 0} + for call in db.execute.call_args_list: + sql_text = str(call.args[0]).upper() + assert "UPDATE" not in sql_text + assert "SELECT COUNT(*)" in sql_text + + +# ───────────────────────────────────────────────────────────────────────────── +# Статические asserts + behavior: propagate_listings_year_from_houses +# ───────────────────────────────────────────────────────────────────────────── +_PROPAGATE_SQL = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_SQL.text)) + + +def test_propagate_sql_only_fills_null_listings_year() -> None: + assert "SET year_built = h.year_built" in _PROPAGATE_SQL + assert "l.year_built IS NULL" in _PROPAGATE_SQL + assert "h.year_built IS NOT NULL" in _PROPAGATE_SQL + assert "l.house_id_fk = h.id" in _PROPAGATE_SQL + + +def test_propagate_listings_year_real_run_returns_rowcount() -> None: + db = MagicMock() + db.execute.return_value.rowcount = 42 + + out = dkl.propagate_listings_year_from_houses(db, dry_run=False) + + assert out == {"listings_updated": 42} + sql_text = str(db.execute.call_args[0][0]) + assert "UPDATE listings" in sql_text + + +def test_propagate_listings_year_dry_run_uses_count_sql() -> None: + db = MagicMock() + db.execute.return_value.scalar_one.return_value = 99 + + out = dkl.propagate_listings_year_from_houses(db, dry_run=True) + + assert out == {"listings_updated": 0, "would_update": 99} + sql_text = str(db.execute.call_args[0][0]).upper() + assert "UPDATE" not in sql_text