gendesign/backend/tests/services/test_ekburg_permits.py
lekss361 ef15190690
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m34s
Deploy / deploy (push) Successful in 1m21s
feat(sf): geocode geom для ekburg_construction_permits из МСК-66 (#1124)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 11:16:45 +00:00

521 lines
23 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.

"""Тесты для ekburg_permits.py (Issue #105).
Использует mock httpx + синтетические данные xlsx (openpyxl в памяти).
Не требует реального PostgreSQL или сетевого доступа.
"""
from __future__ import annotations
from datetime import date, datetime
from io import BytesIO
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
from openpyxl import Workbook
from app.services.scrapers.ekburg_permits import (
EKBURG_PERMITS_URLS,
EkburgPermitsClient,
_clean_inn,
_detect_permit_type,
_to_date,
_to_float,
_to_str,
msk66_to_wgs84,
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_xlsx_bytes(sheets: dict[str, list[tuple[Any, ...]]]) -> bytes:
"""Создать xlsx в памяти с заданными листами и строками."""
wb = Workbook()
wb.remove(wb.active) # удалить дефолтный лист
for sheet_name, rows in sheets.items():
ws = wb.create_sheet(sheet_name)
for row in rows:
ws.append(list(row))
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _rns_sheet_rows() -> list[tuple[Any, ...]]:
"""Синтетические строки для листа РНС (Form 3).
Воспроизводит структуру 2024-2026: header row 4, данные с row 7.
"""
return [
# row 1: пусто
(None,) * 14,
# row 2: заголовок таблицы
(
"Таблица 3. Реестр выданных разрешений на строительство "
"объектов капитального строительства",
)
+ (None,) * 13,
# row 3: пусто
(None,) * 14,
# row 4: заголовки колонок
(
"Наименование застройщика",
"ИНН",
"Адрес застройщика",
"Тип строительного объекта1",
"Наименование объекта КС",
"Кадастровый номер ЗУ",
"Координаты X",
None,
"Адрес объекта",
"Реквизиты разрешения (номер)",
None,
"Дата окончания",
"Общая площадь, м2",
"Площадь жилых помещений по проекту, м2",
),
# row 5: подзаголовки
(None,) * 7 + ("X", "Y") + (None,) * 3 + ("номер", "дата") + (None,) * 2,
# row 6: нумерация столбцов
tuple(range(1, 15)),
# row 7: первая строка данных (МКД — ООО "Тест")
(
'ООО "Специализированный застройщик "Тест"',
6685000001,
"620000, г. Екатеринбург, ул. Тестовая, д. 1",
"многоквартирные жилые дома;",
"Многоквартирный жилой дом (№ 1 по ПЗУ)",
"66:41:0101001:123",
"1530000.0000",
"380000.0000",
"Свердловская обл., г. Екатеринбург, ул. Тестовая",
"66-41-99-2024",
datetime(2024, 3, 15),
datetime(2027, 3, 15),
12345.67,
8000.0,
),
# row 8: строка без permit_number (должна быть пропущена)
(
"ООО «Продолжение»",
6685000002,
"620001, г. Екатеринбург",
"производственные здания;",
"Производственный корпус",
"66:41:0101002:456",
"1531000.0000",
"381000.0000",
"Свердловская обл., г. Екатеринбург, ул. Другая",
None, # no permit_number → skip
datetime(2024, 5, 20),
datetime(2026, 5, 20),
500.0,
None,
),
# row 9: empty row (all None) — should be skipped
(None,) * 14,
]
def _rve_sheet_rows() -> list[tuple[Any, ...]]:
"""Синтетические строки для листа РВЭ (Form 4)."""
return [
(None,) * 19,
("Таблица 4. Реестр выданных разрешений на ввод в эксплуатацию",) + (None,) * 18,
(None,) * 19,
# row 4: заголовки
(
"Наименование застройщика",
"ИНН",
"Адрес застройщика",
"Тип строительного объекта1",
"Наименование объекта КС",
"Кадастровый номер ЗУ",
"Координаты X",
None,
"Адрес объекта",
"Реквизиты РНС (номер)",
None,
"Дата окончания РНС",
"Общая площадь, м2",
"Площадь жилых по проекту, м2",
"Площадь жилых фактически, м2",
"Реквизиты РВЭ (номер)",
None,
"Введённые мощности",
None,
),
(None,) * 7 + ("X", "Y") + (None,) * 5 + ("номер", "дата") + (None,) * 4,
tuple(range(1, 19)),
# row 7: первая строка данных
(
'ООО "Застройщик Ввода"',
6658000002,
"620100, г. Екатеринбург, ул. Вводная, д. 2",
"многоквартирные жилые дома;",
"Жилой дом (1 очередь) (№ 1 по ПЗУ)",
"66:41:0303001:789",
"1535000.0000",
"385000.0000",
"Свердловская обл., г. Екатеринбург, ул. Вводная",
"66-41-50-2022",
datetime(2022, 10, 1),
datetime(2026, 1, 1),
30000.0,
20000.0,
19800.5, # living_area_fact
"66-41-5-2026",
datetime(2026, 1, 13),
None,
None,
),
]
# ── unit tests: helpers ───────────────────────────────────────────────────────
class TestHelpers:
def test_to_date_datetime(self) -> None:
dt = datetime(2024, 3, 15, 0, 0)
assert _to_date(dt) == date(2024, 3, 15)
def test_to_date_string_dot(self) -> None:
assert _to_date("15.03.2024") == date(2024, 3, 15)
def test_to_date_none(self) -> None:
assert _to_date(None) is None
def test_to_date_invalid(self) -> None:
assert _to_date("not-a-date") is None
def test_to_float_comma(self) -> None:
assert _to_float("12345,67") == pytest.approx(12345.67)
def test_to_float_dash(self) -> None:
assert _to_float("-") is None
def test_to_float_int(self) -> None:
assert _to_float(1490) == pytest.approx(1490.0)
def test_to_str_nbsp(self) -> None:
assert _to_str("г.\xa0Екатеринбург") == "г. Екатеринбург"
def test_to_str_none(self) -> None:
assert _to_str(None) is None
def test_clean_inn_int(self) -> None:
assert _clean_inn(6685180480) == "6685180480"
def test_clean_inn_float(self) -> None:
# openpyxl иногда выдаёт float для длинных чисел
assert _clean_inn(6685180480.0) == "6685180480"
def test_clean_inn_string(self) -> None:
assert _clean_inn("6685180480") == "6685180480"
def test_clean_inn_none(self) -> None:
assert _clean_inn(None) is None
def test_clean_inn_short(self) -> None:
# 9 цифр — не ИНН
assert _clean_inn(123456789) is None
def test_detect_permit_type_rns(self) -> None:
assert _detect_permit_type("реестр разрешений на строительс") == "RNS"
def test_detect_permit_type_rve(self) -> None:
assert _detect_permit_type("реестр разрешений на ввод") == "RVE"
def test_detect_permit_type_unknown(self) -> None:
assert _detect_permit_type("Справочник") is None
# ── unit tests: xlsx parsing ──────────────────────────────────────────────────
class TestParseXlsx:
def _make_client(self) -> EkburgPermitsClient:
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
# Создаём mock httpx.Client чтобы не открывать реальные соединения
client._client = MagicMock()
return client
def test_parse_rns_yields_permit_row(self) -> None:
content = _make_xlsx_bytes(
{
"реестр разрешений на строительс": _rns_sheet_rows(),
"Справочник": [("многоквартирные жилые дома;",)],
}
)
client = self._make_client()
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
# Должна быть ровно одна строка (вторая без permit_number пропущена)
assert len(rows) == 1
row = rows[0]
assert row.permit_type == "RNS"
assert row.permit_number == "66-41-99-2024"
assert row.issue_date == date(2024, 3, 15)
assert row.expiry_date == date(2027, 3, 15)
assert row.developer_inn == "6685000001"
assert row.object_type == "многоквартирные жилые дома;"
assert row.total_area_sqm == pytest.approx(12345.67)
assert row.living_area_sqm == pytest.approx(8000.0)
assert row.source_year == 2024
# Тестовые координаты X=1530000, Y=380000 — должны попасть в ЕКБ-регион
assert row.geocoded_lat is not None
assert row.geocoded_lon is not None
assert 56.3 < row.geocoded_lat < 57.5
assert 59.5 < row.geocoded_lon < 61.5
def test_parse_rve_yields_permit_row_with_rve_fields(self) -> None:
content = _make_xlsx_bytes({"реестр разрешений на ввод": _rve_sheet_rows()})
client = self._make_client()
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
assert len(rows) == 1
row = rows[0]
assert row.permit_type == "RVE"
assert row.permit_number == "66-41-50-2022"
assert row.living_area_fact_sqm == pytest.approx(19800.5)
assert row.rve_number == "66-41-5-2026"
assert row.rve_date == date(2026, 1, 13)
def test_parse_skips_справочник_sheet(self) -> None:
content = _make_xlsx_bytes(
{
"Справочник": [("многоквартирные жилые дома;",)],
"Лист1": [(None,)],
}
)
client = self._make_client()
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
assert rows == []
def test_raw_row_stored(self) -> None:
content = _make_xlsx_bytes({"реестр разрешений на строительс": _rns_sheet_rows()})
client = self._make_client()
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
assert len(rows) == 1
# raw_row должен содержать dict с string keys
assert isinstance(rows[0].raw_row, dict)
assert "9" in rows[0].raw_row # колонка 9 = permit_number
# ── unit tests: download_xlsx ─────────────────────────────────────────────────
class TestDownloadXlsx:
def _client_with_mock_http(self, mock_response: MagicMock) -> EkburgPermitsClient:
"""Создать EkburgPermitsClient с замоканным внутренним httpx.Client."""
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
mock_http = MagicMock()
mock_http.get.return_value = mock_response
client._client = mock_http
return client
def test_download_returns_bytes(self) -> None:
fake_content = b"PK\x03\x04fake_xlsx_content"
mock_response = MagicMock()
mock_response.content = fake_content
mock_response.raise_for_status = MagicMock()
client = self._client_with_mock_http(mock_response)
result = client.download_xlsx(2026)
assert result == fake_content
def test_download_raises_for_unknown_year(self) -> None:
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
client._client = MagicMock()
with pytest.raises(ValueError, match="year=9999"):
client.download_xlsx(9999)
def test_download_raises_on_http_error(self) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"404", request=MagicMock(), response=MagicMock()
)
client = self._client_with_mock_http(mock_response)
with pytest.raises(httpx.HTTPStatusError):
client.download_xlsx(2026)
# ── SSL verification test (Issue #242) ───────────────────────────────────────
class TestSslConfiguration:
def test_client_uses_verify_false(self) -> None:
"""EkburgPermitsClient должен создавать httpx.Client с verify=False.
екатеринбург.рф использует CA Минцифры РФ — не в certifi bundle.
Issue #242: SSL: CERTIFICATE_VERIFY_FAILED для всех 5 годов.
"""
with EkburgPermitsClient() as client:
# httpx.Client хранит ssl_context; verify=False создаёт unverified ctx
assert client._client._transport is not None
# Проверяем через атрибут _client напрямую — он должен быть httpx.Client
assert isinstance(client._client, httpx.Client)
# ── unit tests: msk66_to_wgs84 ───────────────────────────────────────────────
# Параметры допуска для cad_parcels cross-check (≈50м по Хаверсину)
# На широте ЕКБ (~56.9°): 1° широты ≈ 111320м, 1° долготы ≈ 60730м
# 50м ÷ 111320 ≈ 0.00045° по широте; 50м ÷ 60730 ≈ 0.00082° по долготе
_TOL_LAT_DEG = 0.00045 # ~50м по широте
_TOL_LON_DEG = 0.00082 # ~50м по долготе
class TestMsk66ToWgs84:
"""Тесты трансформации МСК-66 → WGS84.
Реальные данные из ekburg_construction_permits (сверены с cad_parcels ground-truth).
Калиброванный proj4: x_0=1496944, y_0=-5911077
— avg_err≈14м, median≈7м, p95≈58м на 738 точках с кадастровым номером.
Точки для cross-check получены из cad_parcels.geom (ST_Centroid) и проверены SQL:
X=1534814.7997, Y=394813.2001 → lon≈60.619637, lat≈56.871948
(cad 66:41:0110016:1046, погрешность <50м от центроида участка)
X=1544026.7997, Y=381585.4401 → lon≈60.768255, lat≈56.752330
(cad 66:41:0503018:248, аэропорт Кольцово)
X=1526550.1397, Y=387129.3801 → lon≈60.483224, lat≈56.803547
(cad 66:41:0306057:89, западная часть ЕКБ)
X=1531655.2797, Y=398430.2401 → lon≈60.568320, lat≈56.904674
(cad 66:41:0106051:10, ул. Республиканская 1а; err_m=12)
"""
def test_kosmonavtov_cad_crosscheck(self) -> None:
"""X=1534814.7997/Y=394813.2001 — сверка с centroid cad_parcels 66:41:0110016:1046.
Ожидаемые координаты получены из PostGIS: lon≈60.619637, lat≈56.871948.
Допуск ±0.00082° по долготе (~50м), ±0.00045° по широте (~50м).
"""
result = msk66_to_wgs84("1534814.7997", "394813.2001")
assert result is not None
lon, lat = result
assert (
abs(lon - 60.619637) < _TOL_LON_DEG
), f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)"
assert (
abs(lat - 56.871948) < _TOL_LAT_DEG
), f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)"
def test_koltsovo_cad_crosscheck(self) -> None:
"""X=1544026.7997/Y=381585.4401 — сверка с centroid cad_parcels 66:41:0503018:248.
Ожидаемые координаты: lon≈60.768255, lat≈56.752330 (аэропорт Кольцово).
"""
result = msk66_to_wgs84("1544026.7997", "381585.4401")
assert result is not None
lon, lat = result
assert (
abs(lon - 60.768255) < _TOL_LON_DEG
), f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)"
assert (
abs(lat - 56.752330) < _TOL_LAT_DEG
), f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)"
def test_smorodinovaya_cad_crosscheck(self) -> None:
"""X=1526550.1397/Y=387129.3801 — сверка с centroid cad_parcels 66:41:0306057:89.
Ожидаемые координаты: lon≈60.483224, lat≈56.803547 (западная часть ЕКБ).
"""
result = msk66_to_wgs84("1526550.1397", "387129.3801")
assert result is not None
lon, lat = result
assert (
abs(lon - 60.483224) < _TOL_LON_DEG
), f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)"
assert (
abs(lat - 56.803547) < _TOL_LAT_DEG
), f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)"
def test_respublikanskaya_cad_crosscheck(self) -> None:
"""X=1531655.2797/Y=398430.2401 — ул. Республиканская 1а, cad 66:41:0106051:10.
Ожидаемые координаты: lon≈60.568320, lat≈56.904674 (err_m=12 по cad_parcels).
Этот тест ловит регрессию easting-сдвига: при x_0=1500000 computed был бы ~60.519°.
"""
result = msk66_to_wgs84("1531655.2797", "398430.2401")
assert result is not None
lon, lat = result
assert (
abs(lon - 60.568320) < _TOL_LON_DEG
), f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)"
assert (
abs(lat - 56.904674) < _TOL_LAT_DEG
), f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)"
def test_kosmonavtov_in_ekb(self) -> None:
"""Пр. Космонавтов — попадает в ЕКБ-регион."""
result = msk66_to_wgs84("1534814.7997", "394813.2001")
assert result is not None
lon, lat = result
assert 56.5 < lat < 57.2, f"lat={lat} вне ЕКБ"
assert 60.0 < lon < 61.0, f"lon={lon} вне ЕКБ"
def test_koltsovo_in_ekb(self) -> None:
"""Район аэропорта Кольцово — попадает в ЕКБ-регион."""
result = msk66_to_wgs84("1544026.7997", "381585.4401")
assert result is not None
lon, lat = result
assert 56.5 < lat < 57.2, f"lat={lat} вне ЕКБ"
assert 60.3 < lon < 61.2, f"lon={lon} вне ЕКБ"
def test_smorodinovaya_in_ekb(self) -> None:
"""Ул. Смородиновая, западная часть ЕКБ."""
result = msk66_to_wgs84("1526550.1397", "387129.3801")
assert result is not None
lon, lat = result
assert 56.5 < lat < 57.2, f"lat={lat} вне ЕКБ"
assert 59.5 < lon < 61.0, f"lon={lon} вне ЕКБ"
def test_multi_point_cell_takes_first(self) -> None:
"""Если ячейка содержит несколько точек через пробел — берём первую."""
# Реальный пример: "1528834.9797 1528819.0197"
result = msk66_to_wgs84("1528834.9797 1528819.0197", "389190.2401 389136.0601")
assert result is not None
lon, lat = result
assert 56.3 < lat < 57.5
assert 59.5 < lon < 61.5
def test_comma_decimal_separator(self) -> None:
"""Запятая как десятичный разделитель (встречается в xlsx)."""
# "1534655,7197" — реальный случай
result = msk66_to_wgs84("1534655,7197", "387724.5201")
assert result is not None
lon, lat = result
assert 56.3 < lat < 57.5
assert 59.5 < lon < 61.5
def test_none_input_returns_none(self) -> None:
assert msk66_to_wgs84(None, None) is None
def test_empty_string_returns_none(self) -> None:
assert msk66_to_wgs84("", "") is None
def test_non_numeric_returns_none(self) -> None:
assert msk66_to_wgs84("нет данных", "нет данных") is None
def test_out_of_range_x_returns_none(self) -> None:
"""X вне диапазона МСК-66 (например, случайное число) → None."""
# X=500000 — это не МСК-66 (false_easting ~1500000, диапазон 1450000-1600000)
result = msk66_to_wgs84("500000.0", "380000.0")
assert result is None
def test_result_is_lon_lat_order(self) -> None:
"""Результат — (lon, lat), а не (lat, lon).
ЕКБ: lon ~60.5E, lat ~56.9N → lon > lat числово.
"""
result = msk66_to_wgs84("1534814.7997", "394813.2001")
assert result is not None
lon, lat = result
# Проверяем что первый элемент — долгота (60.x), второй — широта (56.x)
assert 56.0 < lat < 58.0, f"ожидали широту, получили {lat}"
assert 59.0 < lon < 62.0, f"ожидали долготу, получили {lon}"
assert lon > lat # lon ~60.6 > lat ~56.9