gendesign/tradein-mvp/backend/app/services/domrf_kapremont_loader.py
bot-backend 6487813ec5
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 57s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
fix(tradein/ingest): 4 data-integrity дефекта в бэкфилле годов и импорте ДКП
A. ДОМ.РФ year backfill: авторитетный commission_year больше не экранируется
   невозможным существующим year_built. Валидное существующее значение
   выигрывает (COALESCE-семантика #2013 сохранена), но NULL/impossible
   (< 1850, > текущий+2, 0) заменяется валидным commission_year → zhkh_year.
   parse_int_field получил sanity-гейт [min,max] — мусорный commission_year
   не попадает в staging (7 таких строк в текущем staging).

B. propagate_listings_year: добавлен link-consistency guard. Раньше копировал
   houses.year_built на listings по house_id_fk без проверки — перепривязанный
   FK впрыскивал чужой когортный год. Теперь пропагация только если координаты
   объявления в пределах 500м от геометрии дома (ST_DWithin), либо (без коорд.)
   консервативный address-фоллбек по short_address. Live: блокирует 1 из 10
   текущих кандидатов (>500м mislink).

C. rosreestr_dkp_import: per-row INSERT-ошибки отделены от dedup-skip. Раньше
   except инкрементил тот же batch_skipped, что и легитимный ON CONFLICT — сбой
   маскировался под дедуп и run рапортовал success. Отдельный rows_errored +
   гейт по доле ошибок (> 5% → run FAILED, не silent-green).

D. rosreestr_dkp_import: ON CONFLICT DO NOTHING → DO UPDATE изменяемых сырых
   фактов Росреестра (price/area/rooms/floor/year/deal_date/...), чтобы
   исправленный/переопубликованный квартал обновлялся. Обогащение
   (lat/lon/geom/geocode_tried_at и пр.) не в SET-списке — не затирается.
   IS DISTINCT FROM guard сохраняет идемпотентность resume; RETURNING (xmax=0)
   отличает insert от update (rows_inserted vs rows_updated).
2026-07-12 22:12:31 +03:00

578 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ДОМ.РФ капремонт 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 datetime import date
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
# Правдоподобные границы года постройки. Значение вне диапазона — почти наверняка
# опечатка/мусор: для МКД-реестра капремонта дом < 1850 нереален, а > текущий+2 ещё
# физически не введён в эксплуатацию. Такой год НЕ должен (а) попадать из ДОМ.РФ CSV
# в staging (parse-гейт в parse_int_field), (б) экранировать авторитетный ДОМ.РФ
# commission_year при бэкфилле houses.year_built (SQL-гейт в _BACKFILL_*_SQL).
YEAR_BUILT_MIN = 1850
YEAR_BUILT_FUTURE_SLACK = 2 # дом «в стройке» — текущий год + 2 ещё допустим
def plausible_year_max() -> int:
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
return date.today().year + YEAR_BUILT_FUTURE_SLACK
# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
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,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int | None:
"""Устойчивый str → int|None (commission_year / number_floors_max).
Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек —
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
min_value/max_value — опциональный sanity-гейт: распарсенное значение вне
[min_value, max_value] трактуется как None (для commission_year передаём
YEAR_BUILT_MIN..plausible_year_max(), чтобы мусорный год не попал в staging;
для number_floors_max границы не задаём — там гейт не требуется).
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
value: int | None = None
try:
value = int(s)
except ValueError:
try:
value = int(float(s.replace(",", ".")))
except ValueError:
return None
if min_value is not None and value < min_value:
return None
if max_value is not None and value > max_value:
return None
return value
@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,
# sanity-гейт: год вне [1850, текущий+2] — мусор, не пускаем в staging
# (иначе он позже мог бы записаться в houses.year_built как «авторитетный»).
commission_year=parse_int_field(
raw.get("commission_year"),
min_value=YEAR_BUILT_MIN,
max_value=plausible_year_max(),
),
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, как в issue #2013).
#
# ПРИОРИТЕТ year_built (Fix A): ВАЛИДНОЕ существующее значение выигрывает (COALESCE
# семантика сохранена для valid). НО невозможное существующее значение (< :ymin,
# > :ymax или NULL) НЕ должно экранировать авторитетный ДОМ.РФ commission_year —
# для домов реестра капремонта commission_year это источник истины по году ввода.
# Поэтому impossible/NULL заменяется валидным commission_year → затем валидным
# zhkh_year; если валидной замены нет — оставляем существующее как есть (не обнуляем
# даже мусор, чтобы не терять данные без выигрыша). Кандидаты замены тоже проходят
# плаузибилити-гейт [:ymin, :ymax], чтобы в houses.year_built не попал мусорный год.
# material_walls/total_floors — прежняя COALESCE-семантика (только заполнить NULL).
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
"""
UPDATE houses h
SET year_built = CASE
WHEN h.year_built BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.year_built
ELSE COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END,
h.year_built
)
END,
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
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
AND COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END
) 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
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
AND COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END
) 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(*) по тем же
предикатам.
"""
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
if dry_run:
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL, year_bounds).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, year_bounds).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-агрегата).
#
# LINK-CONSISTENCY GUARD (Fix B): раньше пропагация шла по house_id_fk БЕЗ проверки,
# что listing реально относится к этому дому. Перепривязанный FK (house dedup/merge
# «съезжает» на дом за сотни метров) впрыскивал бы чужой когортный год в живой comp.
# Пропагируем только если координаты самого объявления в пределах
# PROPAGATE_MAX_DISTANCE_M от геометрии дома. Если у объявления нет координат —
# консервативный address-фоллбек: адрес объявления должен содержать short_address
# дома (тот же дом по адресной строке); нет short_address — не пропагируем.
PROPAGATE_MAX_DISTANCE_M = 500
# Общий link-consistency предикат для UPDATE и COUNT (единый источник — одна правка
# порога PROPAGATE_MAX_DISTANCE_M меняет обе SQL). Порог — int-константа, не bind-param
# (в ST_DWithin проще литерал; значение доверенное, не из пользовательского ввода).
_PROPAGATE_LINK_GUARD = f"""
AND (
(l.geom IS NOT NULL AND h.geom IS NOT NULL
AND ST_DWithin(
CAST(l.geom AS geography), CAST(h.geom AS geography),
{PROPAGATE_MAX_DISTANCE_M}))
OR (l.geom IS NULL
AND h.short_address IS NOT NULL AND l.address IS NOT NULL
AND l.address ILIKE '%' || h.short_address || '%')
)
"""
_PROPAGATE_LISTINGS_YEAR_SQL = text(
f"""
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_LINK_GUARD}
"""
)
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
f"""
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
{_PROPAGATE_LINK_GUARD}
"""
)
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}