fix(tradein/ingest): 4 data-integrity дефекта в бэкфилле годов и импорте ДКП
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
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
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).
This commit is contained in:
parent
fb123bd97e
commit
6487813ec5
4 changed files with 315 additions and 33 deletions
|
|
@ -38,6 +38,7 @@ import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -61,6 +62,19 @@ DOWNLOAD_TIMEOUT_SEC = 180
|
||||||
# сбойный чанк откатывается изолированно, остальные доезжают.
|
# сбойный чанк откатывается изолированно, остальные доезжают.
|
||||||
UPSERT_CHUNK_SIZE = 500
|
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
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
||||||
|
|
@ -78,25 +92,40 @@ def parse_decimal_comma(raw: str | None) -> float | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_int_field(raw: str | None) -> int | 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).
|
"""Устойчивый str → int|None (commission_year / number_floors_max).
|
||||||
|
|
||||||
Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек —
|
Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек —
|
||||||
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
|
ДОМ.РФ 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:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
s = raw.strip()
|
s = raw.strip()
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
return None
|
||||||
|
value: int | None = None
|
||||||
try:
|
try:
|
||||||
return int(s)
|
value = int(s)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
return int(float(s.replace(",", ".")))
|
value = int(float(s.replace(",", ".")))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
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)
|
@dataclass(slots=True)
|
||||||
|
|
@ -146,7 +175,13 @@ def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]:
|
||||||
mkd_code=mkd_code,
|
mkd_code=mkd_code,
|
||||||
houseguid=(raw.get("houseguid") or "").strip() or None,
|
houseguid=(raw.get("houseguid") or "").strip() or None,
|
||||||
address=(raw.get("address") or "").strip() or None,
|
address=(raw.get("address") or "").strip() or None,
|
||||||
commission_year=parse_int_field(raw.get("commission_year")),
|
# 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")),
|
number_floors_max=parse_int_field(raw.get("number_floors_max")),
|
||||||
total_sq=parse_decimal_comma(raw.get("total_sq")),
|
total_sq=parse_decimal_comma(raw.get("total_sq")),
|
||||||
)
|
)
|
||||||
|
|
@ -342,19 +377,46 @@ def load_domrf_kapremont(
|
||||||
# Backfill houses.year_built/material_walls/total_floors
|
# Backfill houses.year_built/material_walls/total_floors
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
|
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
|
||||||
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком внутри ТОЙ ЖЕ строки COALESCE
|
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком (COALESCE, как в issue #2013).
|
||||||
# (COALESCE(year_built, domrf.commission_year, zhkh_year) — ровно как в issue).
|
#
|
||||||
# Только заполняет NULL — никогда не перезаписывает существующий non-null year_built.
|
# ПРИОРИТЕТ 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(
|
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
|
||||||
"""
|
"""
|
||||||
UPDATE houses h
|
UPDATE houses h
|
||||||
SET year_built = COALESCE(h.year_built, d.commission_year, h.zhkh_year),
|
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),
|
material_walls = COALESCE(h.material_walls, d.wall_material),
|
||||||
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
|
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
|
||||||
FROM domrf_kapremont d
|
FROM domrf_kapremont d
|
||||||
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||||
AND (
|
AND (
|
||||||
(h.year_built IS NULL AND COALESCE(d.commission_year, h.zhkh_year) IS NOT NULL)
|
(
|
||||||
|
(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.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)
|
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||||
)
|
)
|
||||||
|
|
@ -367,7 +429,16 @@ _BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text(
|
||||||
FROM houses h
|
FROM houses h
|
||||||
JOIN domrf_kapremont d
|
JOIN domrf_kapremont d
|
||||||
ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
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)
|
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.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)
|
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||||
"""
|
"""
|
||||||
|
|
@ -412,8 +483,9 @@ def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[st
|
||||||
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тем же
|
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тем же
|
||||||
предикатам.
|
предикатам.
|
||||||
"""
|
"""
|
||||||
|
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
|
||||||
if dry_run:
|
if dry_run:
|
||||||
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL).scalar_one()
|
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()
|
zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one()
|
||||||
result = {
|
result = {
|
||||||
"domrf_matched": domrf_matched,
|
"domrf_matched": domrf_matched,
|
||||||
|
|
@ -423,7 +495,7 @@ def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[st
|
||||||
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
|
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL).rowcount
|
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL, year_bounds).rowcount
|
||||||
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
|
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
|
|
@ -441,24 +513,51 @@ def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[st
|
||||||
# Только NULL listings.year_built, только когда houses.year_built уже известен —
|
# Только NULL listings.year_built, только когда houses.year_built уже известен —
|
||||||
# никогда не перезаписывает существующий listings.year_built (source-provided данные
|
# никогда не перезаписывает существующий listings.year_built (source-provided данные
|
||||||
# приоритетнее houses-агрегата).
|
# приоритетнее houses-агрегата).
|
||||||
_PROPAGATE_LISTINGS_YEAR_SQL = text(
|
#
|
||||||
|
# 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
|
UPDATE listings l
|
||||||
SET year_built = h.year_built
|
SET year_built = h.year_built
|
||||||
FROM houses h
|
FROM houses h
|
||||||
WHERE l.house_id_fk = h.id
|
WHERE l.house_id_fk = h.id
|
||||||
AND l.year_built IS NULL
|
AND l.year_built IS NULL
|
||||||
AND h.year_built IS NOT NULL
|
AND h.year_built IS NOT NULL
|
||||||
|
{_PROPAGATE_LINK_GUARD}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
|
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
|
||||||
"""
|
f"""
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM listings l
|
FROM listings l
|
||||||
JOIN houses h ON l.house_id_fk = h.id
|
JOIN houses h ON l.house_id_fk = h.id
|
||||||
WHERE l.year_built IS NULL
|
WHERE l.year_built IS NULL
|
||||||
AND h.year_built IS NOT NULL
|
AND h.year_built IS NOT NULL
|
||||||
|
{_PROPAGATE_LINK_GUARD}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||||
|
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
|
||||||
|
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
|
||||||
|
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
|
||||||
def compute_next_run_at(
|
def compute_next_run_at(
|
||||||
window_start_hour: int,
|
window_start_hour: int,
|
||||||
|
|
@ -217,7 +222,16 @@ def import_rosreestr_dkp(
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"rows_fetched": 0,
|
"rows_fetched": 0,
|
||||||
"rows_inserted": 0,
|
"rows_inserted": 0,
|
||||||
|
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
|
||||||
|
# (исправленный/переопубликованный квартал — Fix D).
|
||||||
|
"rows_updated": 0,
|
||||||
|
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
|
||||||
|
# идентичны — DO UPDATE ... WHERE distinct не сработал).
|
||||||
"rows_skipped": 0,
|
"rows_skipped": 0,
|
||||||
|
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
|
||||||
|
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
|
||||||
|
# как обычный дедуп и прогон рапортовал success.
|
||||||
|
"rows_errored": 0,
|
||||||
"batches_done": 0,
|
"batches_done": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,8 +269,9 @@ def import_rosreestr_dkp(
|
||||||
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
||||||
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
||||||
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
||||||
# пере-сканирует с id=0 (INSERT ... ON CONFLICT DO NOTHING идемпотентен)
|
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
|
||||||
# → уже вставленные строки пропускаются, остаток до-импортируется.
|
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
|
||||||
|
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
|
||||||
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
||||||
|
|
@ -327,7 +342,9 @@ def import_rosreestr_dkp(
|
||||||
|
|
||||||
total_batches += 1
|
total_batches += 1
|
||||||
batch_inserted = 0
|
batch_inserted = 0
|
||||||
|
batch_updated = 0
|
||||||
batch_skipped = 0
|
batch_skipped = 0
|
||||||
|
batch_errored = 0
|
||||||
batch_max_id = last_id
|
batch_max_id = last_id
|
||||||
|
|
||||||
for row in batch_rows:
|
for row in batch_rows:
|
||||||
|
|
@ -337,7 +354,17 @@ def import_rosreestr_dkp(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with db.begin_nested(): # SAVEPOINT per row
|
with db.begin_nested(): # SAVEPOINT per row
|
||||||
inserted = db.execute(
|
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
|
||||||
|
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
|
||||||
|
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
|
||||||
|
# неизменные строки нетронутыми (идемпотентность resume: строка
|
||||||
|
# без изменений → 0 returned → legit dedup-skip). source/source_id
|
||||||
|
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
|
||||||
|
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
|
||||||
|
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
|
||||||
|
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
|
||||||
|
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
|
||||||
|
result = db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO deals (
|
INSERT INTO deals (
|
||||||
source, dedup_hash, source_id, address, region_code, city,
|
source, dedup_hash, source_id, address, region_code, city,
|
||||||
|
|
@ -359,8 +386,28 @@ def import_rosreestr_dkp(
|
||||||
CAST(:price_per_m2 AS int),
|
CAST(:price_per_m2 AS int),
|
||||||
CAST(:deal_date AS date)
|
CAST(:deal_date AS date)
|
||||||
)
|
)
|
||||||
ON CONFLICT (dedup_hash) DO NOTHING
|
ON CONFLICT (dedup_hash) DO UPDATE SET
|
||||||
RETURNING id
|
address = EXCLUDED.address,
|
||||||
|
region_code = EXCLUDED.region_code,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
rooms = EXCLUDED.rooms,
|
||||||
|
area_m2 = EXCLUDED.area_m2,
|
||||||
|
floor = EXCLUDED.floor,
|
||||||
|
year_built = EXCLUDED.year_built,
|
||||||
|
price_rub = EXCLUDED.price_rub,
|
||||||
|
price_per_m2 = EXCLUDED.price_per_m2,
|
||||||
|
deal_date = EXCLUDED.deal_date
|
||||||
|
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
||||||
|
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
||||||
|
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
||||||
|
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
|
||||||
|
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
|
||||||
|
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
|
||||||
|
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
|
||||||
|
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
||||||
|
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
||||||
|
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
||||||
|
RETURNING (xmax = 0) AS was_inserted
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"dedup_hash": row["dedup_hash"],
|
"dedup_hash": row["dedup_hash"],
|
||||||
|
|
@ -377,25 +424,33 @@ def import_rosreestr_dkp(
|
||||||
"deal_date": row["deal_date"],
|
"deal_date": row["deal_date"],
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if inserted is not None:
|
if result is None:
|
||||||
|
# DO UPDATE ... WHERE distinct не сработал → строка есть и
|
||||||
|
# факты идентичны = легитимный dedup-skip (Fix C: только это
|
||||||
|
# теперь считается skip, INSERT-ошибки — отдельно ниже).
|
||||||
|
batch_skipped += 1
|
||||||
|
elif result[0]:
|
||||||
batch_inserted += 1
|
batch_inserted += 1
|
||||||
else:
|
else:
|
||||||
batch_skipped += 1
|
batch_updated += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
||||||
run_id,
|
run_id,
|
||||||
row_id,
|
row_id,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
batch_skipped += 1
|
batch_errored += 1
|
||||||
|
|
||||||
last_id = batch_max_id
|
last_id = batch_max_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
counters["rows_fetched"] += len(batch_rows)
|
counters["rows_fetched"] += len(batch_rows)
|
||||||
counters["rows_inserted"] += batch_inserted
|
counters["rows_inserted"] += batch_inserted
|
||||||
|
counters["rows_updated"] += batch_updated
|
||||||
counters["rows_skipped"] += batch_skipped
|
counters["rows_skipped"] += batch_skipped
|
||||||
|
counters["rows_errored"] += batch_errored
|
||||||
counters["batches_done"] = total_batches
|
counters["batches_done"] = total_batches
|
||||||
counters["last_id"] = last_id # type: ignore[assignment]
|
counters["last_id"] = last_id # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
@ -403,26 +458,42 @@ def import_rosreestr_dkp(
|
||||||
runs_mod.update_heartbeat(db, run_id, counters)
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
||||||
"inserted=%d skipped=%d last_id=%d",
|
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
|
||||||
run_id,
|
run_id,
|
||||||
total_batches,
|
total_batches,
|
||||||
len(batch_rows),
|
len(batch_rows),
|
||||||
batch_inserted,
|
batch_inserted,
|
||||||
|
batch_updated,
|
||||||
batch_skipped,
|
batch_skipped,
|
||||||
|
batch_errored,
|
||||||
last_id,
|
last_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(batch_rows) < batch_size:
|
if len(batch_rows) < batch_size:
|
||||||
break # Last partial batch — no more rows
|
break # Last partial batch — no more rows
|
||||||
|
|
||||||
|
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
|
||||||
|
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
|
||||||
|
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
|
||||||
|
fetched = counters["rows_fetched"]
|
||||||
|
errored = counters["rows_errored"]
|
||||||
|
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"rosreestr_dkp_import: per-row INSERT error rate "
|
||||||
|
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
|
||||||
|
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
|
||||||
|
)
|
||||||
|
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"rosreestr_dkp_import run_id=%d done: "
|
"rosreestr_dkp_import run_id=%d done: "
|
||||||
"total_fetched=%d inserted=%d skipped=%d batches=%d",
|
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
|
||||||
run_id,
|
run_id,
|
||||||
counters["rows_fetched"],
|
counters["rows_fetched"],
|
||||||
counters["rows_inserted"],
|
counters["rows_inserted"],
|
||||||
|
counters["rows_updated"],
|
||||||
counters["rows_skipped"],
|
counters["rows_skipped"],
|
||||||
|
counters["rows_errored"],
|
||||||
total_batches,
|
total_batches,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,28 @@ def test_parse_int_field() -> None:
|
||||||
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
|
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_int_field_year_sanity_gate() -> None:
|
||||||
|
# Fix A: значение вне [min_value, max_value] → None (мусорный год не в staging).
|
||||||
|
ymax = dkl.plausible_year_max()
|
||||||
|
assert dkl.parse_int_field("1841", min_value=1850, max_value=ymax) is None # слишком старый
|
||||||
|
assert dkl.parse_int_field("0", min_value=1850, max_value=ymax) is None # ноль
|
||||||
|
assert dkl.parse_int_field(str(ymax + 5), min_value=1850, max_value=ymax) is None # будущее
|
||||||
|
assert dkl.parse_int_field("1964", min_value=1850, max_value=ymax) == 1964 # валидный
|
||||||
|
assert dkl.parse_int_field("1850", min_value=1850, max_value=ymax) == 1850 # граница вкл.
|
||||||
|
# Без границ (number_floors_max) — гейт не применяется.
|
||||||
|
assert dkl.parse_int_field("1841") == 1841
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kr11_gates_impossible_commission_year(tmp_path: Path) -> None:
|
||||||
|
# Fix A: строка КР1.1 с невозможным годом (1200) → commission_year=None.
|
||||||
|
bad_row = _KR11_ROW_3.replace("1964;", "1200;", 1)
|
||||||
|
csv_text = "\n".join([_KR11_HEADER, bad_row]) + "\n"
|
||||||
|
p = tmp_path / "bad_year.csv"
|
||||||
|
p.write_text(csv_text, encoding="utf-8-sig")
|
||||||
|
rows = dkl.parse_kr11_csv(p)
|
||||||
|
assert rows["2170017185"].commission_year is None
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# parse_kr11_csv — house registry
|
# parse_kr11_csv — house registry
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -286,9 +308,17 @@ _BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQ
|
||||||
_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_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:
|
def test_backfill_domrf_sql_year_priority_and_plausibility_guard() -> None:
|
||||||
# COALESCE(year_built, domrf.commission_year, zhkh_year) — ровно в этом порядке (#2013).
|
bound = "BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)"
|
||||||
assert "COALESCE(h.year_built, d.commission_year, h.zhkh_year)" in _BACKFILL_DOMRF_SQL
|
# Fix A: year_built — валидное существующее (в границах :ymin..:ymax) выигрывает,
|
||||||
|
# иначе (NULL/impossible) заменяется авторитетным commission_year → zhkh_year.
|
||||||
|
assert f"WHEN h.year_built {bound}" in _BACKFILL_DOMRF_SQL
|
||||||
|
assert "THEN h.year_built" in _BACKFILL_DOMRF_SQL
|
||||||
|
# commission_year и zhkh_year тоже проходят плаузибилити-гейт перед записью.
|
||||||
|
assert f"CASE WHEN d.commission_year {bound}" in _BACKFILL_DOMRF_SQL
|
||||||
|
# Невозможный существующий год НЕ экранирует замену (WHERE ловит NOT BETWEEN).
|
||||||
|
assert f"h.year_built NOT {bound}" in _BACKFILL_DOMRF_SQL
|
||||||
|
# material_walls / total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||||
assert "COALESCE(h.material_walls, d.wall_material)" 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
|
assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL
|
||||||
|
|
||||||
|
|
@ -339,6 +369,10 @@ def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None:
|
||||||
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
|
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[0]
|
||||||
assert "UPDATE houses" in executed_sql[1]
|
assert "UPDATE houses" in executed_sql[1]
|
||||||
|
# Fix A: domrf-шаг прокидывает плаузибилити-границы года как bind-параметры.
|
||||||
|
domrf_params = db.execute.call_args_list[0].args[1]
|
||||||
|
assert domrf_params["ymin"] == dkl.YEAR_BUILT_MIN
|
||||||
|
assert domrf_params["ymax"] == dkl.plausible_year_max()
|
||||||
|
|
||||||
|
|
||||||
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
|
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
|
||||||
|
|
@ -367,6 +401,26 @@ def test_propagate_sql_only_fills_null_listings_year() -> None:
|
||||||
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
|
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_sql_has_link_consistency_distance_guard() -> None:
|
||||||
|
# Fix B: пропагация только если координаты объявления в пределах порога от дома
|
||||||
|
# (ST_DWithin, geography → метры) — перепривязанный FK не впрыснет чужой год.
|
||||||
|
assert "ST_DWithin(" in _PROPAGATE_SQL
|
||||||
|
assert "CAST(l.geom AS geography)" in _PROPAGATE_SQL
|
||||||
|
assert "CAST(h.geom AS geography)" in _PROPAGATE_SQL
|
||||||
|
assert str(dkl.PROPAGATE_MAX_DISTANCE_M) in _PROPAGATE_SQL
|
||||||
|
# Оба SQL (UPDATE и COUNT) несут один и тот же guard.
|
||||||
|
count_sql = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_COUNT_SQL.text))
|
||||||
|
assert "ST_DWithin(" in count_sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagate_sql_no_coords_falls_back_to_address_check() -> None:
|
||||||
|
# Fix B: объявление без координат → консервативный address-фоллбек
|
||||||
|
# (адрес объявления содержит short_address дома), не пропагируем «вслепую».
|
||||||
|
assert "l.geom IS NULL" in _PROPAGATE_SQL
|
||||||
|
assert "h.short_address IS NOT NULL" in _PROPAGATE_SQL
|
||||||
|
assert "l.address ILIKE" in _PROPAGATE_SQL
|
||||||
|
|
||||||
|
|
||||||
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
|
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value.rowcount = 42
|
db.execute.return_value.rowcount = 42
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,67 @@ def test_import_populates_source_id() -> None:
|
||||||
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
|
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
def test_import_keeps_on_conflict_dedup_hash() -> None:
|
def test_import_uses_on_conflict_do_update_for_corrections() -> None:
|
||||||
"""Дедуп по-прежнему через ON CONFLICT (dedup_hash) DO NOTHING."""
|
"""Fix D: дедуп по dedup_hash, но ON CONFLICT DO UPDATE (не DO NOTHING) —
|
||||||
assert "ON CONFLICT (dedup_hash) DO NOTHING" in _IMPORT_SRC
|
|
||||||
|
исправленный/переопубликованный квартал обновляет сырые факты Росреестра.
|
||||||
|
"""
|
||||||
|
assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC
|
||||||
|
assert "DO NOTHING" not in _IMPORT_BODY
|
||||||
|
# Обновляются изменяемые сырые факты...
|
||||||
|
for col in (
|
||||||
|
"address = EXCLUDED.address",
|
||||||
|
"region_code = EXCLUDED.region_code",
|
||||||
|
"city = EXCLUDED.city",
|
||||||
|
"rooms = EXCLUDED.rooms",
|
||||||
|
"area_m2 = EXCLUDED.area_m2",
|
||||||
|
"floor = EXCLUDED.floor",
|
||||||
|
"year_built = EXCLUDED.year_built",
|
||||||
|
"price_rub = EXCLUDED.price_rub",
|
||||||
|
"price_per_m2 = EXCLUDED.price_per_m2",
|
||||||
|
"deal_date = EXCLUDED.deal_date",
|
||||||
|
):
|
||||||
|
assert col in _IMPORT_SRC, f"missing DO UPDATE SET column: {col!r}"
|
||||||
|
# ...но идемпотентность через IS DISTINCT FROM (неизменные строки не трогаются).
|
||||||
|
assert "IS DISTINCT FROM EXCLUDED.price_rub" in _IMPORT_SRC
|
||||||
|
# xmax-дискриминатор insert vs update.
|
||||||
|
assert "RETURNING (xmax = 0)" in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_do_update_never_overwrites_enrichment_columns() -> None:
|
||||||
|
"""Fix D: обогащение (lat/lon/geom/geocode/cadastral_number...) не в SET-списке.
|
||||||
|
|
||||||
|
Эти колонки заполняются пост-импортом (geocode_deals_*) — повторный импорт не
|
||||||
|
должен их затирать. Отсутствие в SET DO UPDATE гарантирует сохранность.
|
||||||
|
"""
|
||||||
|
conflict_clause = _IMPORT_SRC.split("ON CONFLICT (dedup_hash) DO UPDATE SET", 1)[1]
|
||||||
|
set_and_where = conflict_clause.split("RETURNING", 1)[0]
|
||||||
|
for enrich in (
|
||||||
|
"lat =",
|
||||||
|
"lon =",
|
||||||
|
"geom =",
|
||||||
|
"geocode_tried_at =",
|
||||||
|
"cadastral_number =",
|
||||||
|
"total_floors =",
|
||||||
|
"house_type =",
|
||||||
|
):
|
||||||
|
assert enrich not in set_and_where, f"enrichment column leaked into UPDATE: {enrich!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_separates_errors_from_dedup_skips() -> None:
|
||||||
|
"""Fix C: INSERT-ошибки считаются отдельным счётчиком, не как dedup-skip."""
|
||||||
|
assert '"rows_errored": 0' in _IMPORT_SRC
|
||||||
|
assert '"rows_updated": 0' in _IMPORT_SRC
|
||||||
|
assert "batch_errored += 1" in _IMPORT_SRC
|
||||||
|
# Per-row except-ветка (после лога "INSERT failed", до конца батча) инкрементит
|
||||||
|
# ТОЛЬКО errored — больше НЕ skip (это был баг silent-green: ошибки маскировались
|
||||||
|
# под дедуп).
|
||||||
|
per_row_except = _IMPORT_SRC.split("INSERT failed", 1)[1].split("last_id = batch_max_id", 1)[0]
|
||||||
|
assert "batch_errored += 1" in per_row_except
|
||||||
|
assert "batch_skipped += 1" not in per_row_except
|
||||||
|
# Порог ошибок → run failed, а не silent-green.
|
||||||
|
assert "DKP_IMPORT_ERROR_RATE_THRESHOLD" in _IMPORT_SRC
|
||||||
|
assert "marking run failed" in _IMPORT_SRC
|
||||||
|
|
||||||
|
|
||||||
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
|
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue