gendesign/tradein-mvp/backend/tests/test_frt_mkd_loader.py
bot-backend 8aca820a46
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 5m14s
feat(mera): загрузчик реестра МКД АИС ФРТ → обогащение houses
Открытые данные АИС ППК «ФРТ» (бывш. Реформа ЖКХ), node 110 = реестр МКД
региона 66: 41 790 строк, houseguid (ФИАС GUID) заполнен на 100% → join к
houses.gar_house_guid без канонизации адреса. Анонимный GET, без ЕСИА.

Заполняет ТОЛЬКО NULL-поля houses: year_built, material_walls, material_floors,
total_floors, entrances, is_emergency, flat_count, heat_supply_type,
gas_supply_type, hot_water + новые area_land, foundation_type, elevators_total.

Замер по ЕКБ: wall_material 92.5% (было 75% от ДОМ.РФ), built_year 92.0%
(было 86%), area_land 86.8% и is_emergency — признаков, которых не давал
ни один из текущих источников.

Осознанно НЕ мапится:
- project_type → series_name: свободный текст и фактически дубль материала стен
  (пусто у 10 635 строк, «кирпичный» 1754, «нет данных» 1496);
- energy_efficiency: решение миграции 284 + реальный класс лишь у ~10% домов
  (у 25 595 из 41 790 значение «Не присвоен»);
- elevators_count → passenger_elevators: в источнике это ОБЩЕЕ число лифтов,
  в houses раздельно пассажирские и грузовые → отдельная колонка;
- playground/sportsground: в источнике id справочника (498/499/500), не флаг.

Дубликаты houseguid реальны и взаимодополняющи: 912 guid'ов, 968 лишних строк,
у одной строки пары заполнен area_total, у парной нет. Строки СЛИВАЮТСЯ по
полям (первое непустое побеждает), иначе бэкфилл терял бы данные ~2% домов.

estimator.py не тронут: аналоги подбираются FROM listings без JOIN к houses,
встраивание признаков в подбор когорты — отдельная задача.

Миграции 290 (staging frt_mkd + колонки houses) и 291 (сид расписания,
enabled=false, interval_days 30). robots.txt источника требует Crawl-delay 10.
2026-09-09 00:33:09 +03:00

387 lines
21 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.

"""Тесты АИС ППК ФРТ loader'а (app/services/frt_mkd_loader.py, мигр. 290/291, #frt-mkd).
Coverage:
- Чистые парс-хелперы: clean_text (мусорные значения-заглушки), parse_decimal_comma,
parse_int_field (+ sanity-гейт границ), parse_bool_da_net.
- parse_frt_mkd_csv — фикстуры собраны из РЕАЛЬНЫХ строк export-reestrmkd-66-20260901.csv
(id 6556123 «д. 41» — плотно заполненная строка; id 6477794 «д. 99» — почти вся строка
из значений-заглушек «Не заполнено»/пусто; id 9090614 — is_alarm='Да', project_type
в нижнем регистре 'нет').
- fetch_frt_mkd_csv — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport.
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
_BACKFILL_HOUSES_SQL/_COUNT_SQL (COALESCE-only-NULL-гейт, houseguid COALESCE-матч,
без `:x::type`).
- backfill_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
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest
from app.services import frt_mkd_loader as fml
# ─────────────────────────────────────────────────────────────────────────────
# Фикстуры: РЕАЛЬНЫЕ строки export-reestrmkd-66-20260901.csv (region 66)
# ─────────────────────────────────────────────────────────────────────────────
_HEADER = (
"id;region_id;area_id;city_id;street_id;shortname_region;formalname_region;"
"shortname_area;formalname_area;shortname_city;formalname_city;shortname_street;"
"formalname_street;house_number;building;block;letter;address;houseguid;"
"management_organization_id;built_year;exploitation_start_year;project_type;house_type;"
"is_alarm;method_of_forming_overhaul_fund;floor_count_max;floor_count_min;"
"entrance_count;elevators_count;energy_efficiency;quarters_count;"
"living_quarters_count;unliving_quarters_count;area_total;area_residential;"
"area_non_residential;area_common_property;area_land;parking_square;playground;"
"sportsground;other_beautification;foundation_type;floor_type;wall_material;"
"basement_area;chute_type;chute_count;electrical_type;electrical_entries_count;"
"heating_type;hot_water_type;cold_water_type;sewerage_type;"
"sewerage_cesspools_volume;gas_type;ventilation_type;firefighting_type;drainage_type"
)
# д. 41 — плотно заполненная строка, is_alarm='Нет', elevators_count=0.
_ROW_DENSE = (
'6556123;;;;;;;;;;;;;41;;;;"д. 41";d6bad775-f0c8-b0d6-87ab-07c43513529e;8915908;1917;'
'1917;Кирпичный;"Многоквартирный дом";Нет;"На счете регионального оператора";8;6;1;0;'
'"Не присвоен";13;13;0;930,60;930,60;0,00;80,40;1100,00;0,00;500;500;;Ленточный;'
"Деревянные;Кирпич;0,00;Отсутствует;0;Центральное;2;Центральное;"
'"Открытая с отбором сетевой воды на горячее водоснабжение из тепловой сети";'
'Центральное;Центральное;0,00;Отсутствует;"Приточная вентиляция";Отсутствует;'
'"Наружные водостоки"'
)
# д. 99 — почти вся строка из значений-заглушек «Не заполнено» / пусто.
_ROW_NOISE = (
'6477794;;;;;;;;;;;;;99;;;;"д. 99";c42aa407-54e8-0917-ca15-439b5b784903;;;1954;;'
'"Многоквартирный дом";Нет;"Не заполнено";;;;;"Не заполнено";0;;;10156,60;7039,70;;;;;'
'498;498;;"Не заполнено";"Не заполнено";"Не заполнено";;"Не заполнено";;'
'"Не заполнено";;"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено";;'
'"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено"'
)
# is_alarm='Да' (аварийный), project_type='нет' (нижний регистр — проверка noise-гейта).
_ROW_ALARM = (
"9090614;92b30014-4d52-4e2e-892d-928142b924bf;;a4ab722e-453a-4aed-bb73-728a05e2e27f;"
"36a4e8b6-5c87-4f67-9ea1-12ac63f32939;обл.;Свердловская;;;г.;Алапаевск;км;123;3;;;;"
'"обл. Свердловская, г. Алапаевск, км. 123, д. 3";'
"37e2d973-9a07-4faa-86f5-95e417f86059;8917294;1911;1911;нет;"
'"Многоквартирный дом";Да;"Не определен";1;1;0;0;"Не присвоен";5;5;0;110,50;110,50;'
"0,00;0,00;2400,00;0,00;500;500;нет;Ленточный;Деревянные;Деревянные;0,00;Отсутствует;"
"0;Центральное;1;Печное;Отсутствует;Отсутствует;Отсутствует;0,00;Отсутствует;"
"Отсутствует;Отсутствует;Отсутствует"
)
FRT_CSV = "\n".join([_HEADER, _ROW_DENSE, _ROW_NOISE, _ROW_ALARM]) + "\n"
@pytest.fixture
def frt_csv_path(tmp_path: Path) -> Path:
p = tmp_path / "frt_mkd.csv"
p.write_text(FRT_CSV, encoding="utf-8-sig")
return p
# ─────────────────────────────────────────────────────────────────────────────
# clean_text / parse_decimal_comma / parse_int_field / parse_bool_da_net
# ─────────────────────────────────────────────────────────────────────────────
def test_clean_text_filters_noise_values() -> None:
assert fml.clean_text("Кирпич") == "Кирпич"
assert fml.clean_text("") is None
assert fml.clean_text(None) is None
assert fml.clean_text("нет данных") is None
assert fml.clean_text("Не заполнено") is None
assert fml.clean_text("отсутствует") is None
assert fml.clean_text("данные отсутствуют") is None
assert fml.clean_text("нет") is None
assert fml.clean_text(" Кирпич ") == "Кирпич"
def test_parse_decimal_comma() -> None:
assert fml.parse_decimal_comma("930,60") == pytest.approx(930.60)
assert fml.parse_decimal_comma("100.5") == pytest.approx(100.5)
assert fml.parse_decimal_comma("") is None
assert fml.parse_decimal_comma(None) is None
assert fml.parse_decimal_comma("Не заполнено") is None
def test_parse_int_field() -> None:
assert fml.parse_int_field("1917") == 1917
assert fml.parse_int_field("8") == 8
assert fml.parse_int_field("") is None
assert fml.parse_int_field(None) is None
assert fml.parse_int_field("Не заполнено") is None
assert fml.parse_int_field("5,0") == 5
def test_parse_int_field_sanity_gate() -> None:
ymax = fml.plausible_year_max()
assert fml.parse_int_field("1200", min_value=1850, max_value=ymax) is None
assert fml.parse_int_field(str(ymax + 10), min_value=1850, max_value=ymax) is None
assert fml.parse_int_field("1917", min_value=1850, max_value=ymax) == 1917
def test_parse_bool_da_net() -> None:
assert fml.parse_bool_da_net("Да") is True
assert fml.parse_bool_da_net("да") is True
assert fml.parse_bool_da_net("Нет") is False
assert fml.parse_bool_da_net("") is None
assert fml.parse_bool_da_net(None) is None
assert fml.parse_bool_da_net("Не заполнено") is None
# ─────────────────────────────────────────────────────────────────────────────
# parse_frt_mkd_csv
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_frt_mkd_csv_dense_row(frt_csv_path: Path) -> None:
rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
assert set(rows.keys()) == {
"d6bad775-f0c8-b0d6-87ab-07c43513529e",
"c42aa407-54e8-0917-ca15-439b5b784903",
"37e2d973-9a07-4faa-86f5-95e417f86059",
}
dense = rows["d6bad775-f0c8-b0d6-87ab-07c43513529e"]
assert dense.region_code == 66
assert dense.built_year == 1917
assert dense.is_alarm is False
assert dense.floor_count_max == 8
assert dense.entrance_count == 1
assert dense.elevators_count == 0
assert dense.wall_material == "Кирпич"
assert dense.floor_type == "Деревянные"
assert dense.area_land == pytest.approx(1100.00)
assert dense.area_total == pytest.approx(930.60)
assert dense.living_quarters_count == 13
assert dense.heating_type == "Центральное"
assert dense.playground == 500 # id справочника, не булев
assert dense.management_organization_id == 8915908
def test_parse_frt_mkd_csv_noise_row_collapses_to_none(frt_csv_path: Path) -> None:
rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
noise = rows["c42aa407-54e8-0917-ca15-439b5b784903"]
# Почти вся строка — "Не заполнено"/пусто → None, не должно попасть в staging как текст.
assert noise.wall_material is None
assert noise.foundation_type is None
assert noise.floor_type is None
assert noise.built_year is None # built_year пуст в этой строке
assert noise.exploitation_start_year == 1954
assert noise.area_total == pytest.approx(10156.60)
assert noise.is_alarm is False
def test_parse_frt_mkd_csv_alarm_row_and_lowercase_noise(frt_csv_path: Path) -> None:
rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
alarm = rows["37e2d973-9a07-4faa-86f5-95e417f86059"]
assert alarm.is_alarm is True
# project_type='нет' (нижний регистр) — та же заглушка, что 'Нет'/'НЕТ' → None.
assert alarm.project_type is None
assert alarm.built_year == 1911
def test_parse_frt_mkd_csv_skips_blank_houseguid(tmp_path: Path) -> None:
csv_text = _HEADER + "\n" + (";" * _HEADER.count(";")) + "\n"
p = tmp_path / "blank.csv"
p.write_text(csv_text, encoding="utf-8-sig")
assert fml.parse_frt_mkd_csv(p, region_code=66) == {}
# ─────────────────────────────────────────────────────────────────────────────
# fetch_frt_mkd_csv — скачивание+распаковка 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_frt_mkd_csv_downloads_and_extracts(tmp_path: Path) -> None:
zip_bytes = _zip_bytes("export-reestrmkd-66-20260901.csv", FRT_CSV)
expected_url = f"{fml.BASE_URL}/opendata/export/110"
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == expected_url:
return httpx.Response(200, content=zip_bytes)
raise AssertionError(f"unexpected URL {request.url}")
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport) as client:
out_path = fml.fetch_frt_mkd_csv(tmp_path, node_id=110, client=client)
assert out_path.exists() and out_path.name.endswith(".csv")
rows = fml.parse_frt_mkd_csv(out_path, region_code=66)
assert len(rows) == 3
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"):
fml._extract_csv_from_zip(buf.getvalue(), tmp_path)
# ─────────────────────────────────────────────────────────────────────────────
# load_frt_mkd — dry_run не пишет (упаковано в тонкий MagicMock-flow)
# ─────────────────────────────────────────────────────────────────────────────
def test_load_frt_mkd_dry_run_parses_but_does_not_upsert(frt_csv_path: Path) -> None:
db = MagicMock()
result = fml.load_frt_mkd(db, src_path=frt_csv_path, region_code=66, dry_run=True)
assert result == {"rows": 3, "upserted": 0}
db.execute.assert_not_called()
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = str(fml._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(:houseguid AS text)" in _UPSERT_SQL
assert "CAST(:built_year AS smallint)" in _UPSERT_SQL
assert "CAST(:is_alarm AS boolean)" in _UPSERT_SQL
assert "CAST(:area_land AS numeric)" in _UPSERT_SQL
def test_upsert_sql_idempotent_on_conflict() -> None:
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
assert "INSERT INTO frt_mkd" in flat
assert "ON CONFLICT (houseguid) DO UPDATE SET" in flat
assert "frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
assert "frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land" in flat
def test_upsert_uses_savepoint_per_chunk_and_does_not_commit() -> None:
import inspect
src = inspect.getsource(fml.upsert_frt_mkd)
assert "with db.begin_nested():" in src
assert "db.commit()" not in src
def test_load_frt_mkd_does_not_commit() -> None:
import inspect
src = inspect.getsource(fml.load_frt_mkd)
assert "db.commit()" not in src
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts: _BACKFILL_HOUSES_SQL / _COUNT_SQL
# ─────────────────────────────────────────────────────────────────────────────
_BACKFILL_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_SQL.text))
_BACKFILL_COUNT_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_COUNT_SQL.text))
def test_backfill_sql_matches_by_houseguid_coalesce_priority() -> None:
assert (
"s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
in _BACKFILL_SQL
)
def test_backfill_sql_never_overwrites_existing_non_null() -> None:
# Каждый таргет своим гейтом h.<col> IS NULL — существующее значение не трогается.
assert "WHEN h.year_built IS NULL" in _BACKFILL_SQL
assert "COALESCE(h.material_walls, s.wall_material)" in _BACKFILL_SQL
assert "COALESCE(h.material_floors, s.floor_type)" in _BACKFILL_SQL
assert "WHEN h.total_floors IS NULL" in _BACKFILL_SQL
assert "WHEN h.entrances IS NULL" in _BACKFILL_SQL
assert "WHEN h.elevators_total IS NULL" in _BACKFILL_SQL
assert "COALESCE(h.is_emergency, s.is_alarm)" in _BACKFILL_SQL
assert "WHEN h.flat_count IS NULL" in _BACKFILL_SQL
assert "COALESCE(h.heat_supply_type, s.heating_type)" in _BACKFILL_SQL
assert "COALESCE(h.gas_supply_type, s.gas_type)" in _BACKFILL_SQL
assert "COALESCE(h.hot_water, s.hot_water_type)" in _BACKFILL_SQL
assert "WHEN h.area_land IS NULL" in _BACKFILL_SQL
assert "COALESCE(h.foundation_type, s.foundation_type)" in _BACKFILL_SQL
def test_backfill_sql_frt_matched_at_set_once() -> None:
assert "frt_matched_at = COALESCE(h.frt_matched_at, now())" in _BACKFILL_SQL
assert "h.frt_matched_at IS NULL" in _BACKFILL_SQL
def test_backfill_sql_does_not_touch_elevators_or_series_name() -> None:
# НЕ мапим elevators_count в passenger_elevators, НЕ мапим project_type в series_name.
assert "passenger_elevators" not in _BACKFILL_SQL
assert "series_name" not in _BACKFILL_SQL
assert "energy_efficiency" not in _BACKFILL_SQL
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
assert not re.search(r":\w+::", _BACKFILL_SQL)
assert not re.search(r":\w+::", _BACKFILL_COUNT_SQL)
def test_backfill_houses_does_not_commit() -> None:
import inspect
src = inspect.getsource(fml.backfill_houses)
assert "db.commit()" not in src
# ─────────────────────────────────────────────────────────────────────────────
# backfill_houses — dry_run vs real-run (MagicMock db)
# ─────────────────────────────────────────────────────────────────────────────
def test_backfill_houses_real_run_reads_rowcount() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 17
out = fml.backfill_houses(db, dry_run=False)
assert out == {"houses_updated": 17}
sql_text = str(db.execute.call_args[0][0])
assert "UPDATE houses" in sql_text
bind_params = db.execute.call_args[0][1]
assert bind_params["ymin"] == fml.YEAR_BUILT_MIN
assert bind_params["ymax"] == fml.plausible_year_max()
assert bind_params["area_land_max"] == fml.AREA_LAND_MAX_BOUND
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
db = MagicMock()
db.execute.return_value.scalar_one.return_value = 9
out = fml.backfill_houses(db, dry_run=True)
assert out == {"would_update": 9, "houses_updated": 0}
sql_text = str(db.execute.call_args[0][0]).upper()
assert "UPDATE" not in sql_text
assert "SELECT COUNT(*)" in sql_text
def test_duplicate_houseguid_rows_are_merged_field_wise(tmp_path: Path) -> None:
"""Дубли houseguid в источнике взаимодополняющи — слить, а не затереть.
Реальная выгрузка региона 66 (2026-09-01): 41 790 строк, 912 повторяющихся
houseguid = 968 лишних строк, причём у одной строки пары заполнен area_total,
а у парной — нет. «Последняя побеждает» теряла бы эти значения.
"""
header = "houseguid;address;built_year;area_total;wall_material"
csv_text = "\n".join(
[
header,
"6809f339-cf37-4e2c-b689-7ded226a9e51;обл. Свердловская, г. Алапаевск;1960;1698,20;",
"6809f339-cf37-4e2c-b689-7ded226a9e51;обл. Свердловская, г. Алапаевск;;;Кирпич",
]
)
path = tmp_path / "dup.csv"
path.write_text(csv_text, encoding="utf-8-sig")
rows = fml.parse_frt_mkd_csv(path, region_code=66)
assert len(rows) == 1
row = rows["6809f339-cf37-4e2c-b689-7ded226a9e51"]
assert row.built_year == 1960
assert row.area_total is not None
assert row.wall_material == "Кирпич"