gendesign/backend/tests/scrapers/test_emiss_sdmx.py
Light1YT 1f66dfd400
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 1m12s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m31s
feat(macro): ЕМИСС среднедушевые доходы (id=57039) -> macro_indicator (#946 part2)
Расширяет Росстат-скрейпер ЕМИСС/fedstat-рядом «Среднедушевые денежные доходы
населения» (fedstat id=57039, OKATO 65=Свердл, квартальный, руб). Добавляет pure
SDMX-ML v1.0 GenericData-парсер (stdlib ElementTree, dev-тестируем на фикстурах) +
EMISS-ветку Celery-таски rosstat_macro_sync (open-data + ЕМИСС, per-source guard,
source='emiss', frequency параметризован, CAST not ::, SAVEPOINT per-row).
income_per_capita проброшен в site_finder/macro (region='sverdl').

ЕМИСС за WAF с dev -> fetch исполняется на проде (verified: httpx POST из
gendesign-worker-1 -> 200 + SDMX, парсер извлёк 9 Свердл-строк). ИПЦ (id=31074) /
индекс цен СМР НЕ landed: многомерные, дефолт-экспорт data.do без Свердл, нужна
dataGrid-выборка (selectedFilterIds + filter-tree AJAX + reCAPTCHA) -- задокументировано.

Refs #946.
2026-06-07 18:44:38 +05:00

222 lines
11 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.

"""Тесты SDMX-парсера ЕМИСС/fedstat (offline, на фикстурах — без живой сети).
Парсер ``parse_emiss_sdmx`` — pure (SDMX-байты → list[EmissRow]); fetch с fedstat
исполняется только на проде (за WAF с dev), поэтому здесь тестируем РАЗБОР на реальных
(обрезанных) SDMX-выгрузках, снятых с прода 2026-06-07:
• emiss_income_57039.xml — «Среднедушевые денежные доходы населения» (id=57039),
размерность s_OKATO, квартальный (рубль/мес). Свердл = ОКАТО 65000000000.
• emiss_empty.xml — валидный конверт с пустым <DataSet>.
Покрывают: извлечение конкретных строк (Свердл, реальные период+значение),
region-фильтрацию (др. регионы/федокруга исключены), маппинг квартала→obs_date,
дедуп, graceful на пустом/битом/без-DataSet SDMX → [].
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from pathlib import Path
from app.services.scrapers.rosstat_emiss import (
INCOME_PER_CAPITA_SPEC,
OKATO_SVERDLOVSK_OBLAST,
EmissIndicatorSpec,
RegionTarget,
_emiss_period_to_month,
parse_emiss_sdmx,
)
_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
def _load(name: str) -> bytes:
return (_FIXTURES / name).read_bytes()
# ── period mapping ────────────────────────────────────────────────────────────────
def test_period_quarter_to_start_month() -> None:
"""Квартал → стартовый месяц квартала (регистр/пробелы игнор)."""
assert _emiss_period_to_month("I квартал") == 1
assert _emiss_period_to_month("II квартал") == 4
assert _emiss_period_to_month("III квартал") == 7
assert _emiss_period_to_month(" iv КВАРТАЛ ") == 10
def test_period_month_name_to_num() -> None:
"""Русское название месяца → номер месяца."""
assert _emiss_period_to_month("январь") == 1
assert _emiss_period_to_month("Декабрь") == 12
def test_period_year_and_unknown() -> None:
"""Годовой под-период → январь; незнакомый/пустой → None."""
assert _emiss_period_to_month("год") == 1
assert _emiss_period_to_month("значение показателя за год") == 1
assert _emiss_period_to_month("3 неделя") is None
assert _emiss_period_to_month("") is None
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
def test_income_extracts_sverdlovsk_only() -> None:
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет."""
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
assert rows, "ожидались строки по Свердл"
assert {r.region for r in rows} == {"sverdl"}
assert all(r.indicator_type == "income_per_capita" for r in rows)
assert all(r.unit == "руб" for r in rows)
assert all(r.frequency == "quarterly" for r in rows)
def test_income_concrete_values_and_dates() -> None:
"""Конкретные реальные значения Свердл + obs_date = 1-е число стартового месяца квартала.
Реальные данные fedstat (Свердл, 2024): I=54006, II=61136, III=66849, IV=76701 ₽/мес;
I кв.2025 = 64361. Квартал → стартовый месяц (I→01, II→04, III→07, IV→10).
"""
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
by_date = {r.obs_date: r.value for r in rows}
assert by_date[date(2024, 1, 1)] == Decimal("54006")
assert by_date[date(2024, 4, 1)] == Decimal("61136")
assert by_date[date(2024, 7, 1)] == Decimal("66849")
assert by_date[date(2024, 10, 1)] == Decimal("76701")
assert by_date[date(2025, 1, 1)] == Decimal("64361")
# ровно 5 свердловских наблюдений в фикстуре (4 квартала 2024 + I кв.2025)
assert len(rows) == 5
# отсортировано по дате ASC
assert [r.obs_date for r in rows] == sorted(by_date)
def test_income_other_regions_excluded() -> None:
"""ОКАТО других субъектов/агрегатов (Адыгея 79, ЦФО 030, РФ 643) НЕ попадают.
Значение Адыгеи (40190) и ЦФО (68110) присутствуют в фикстуре, но НЕ должны
появиться среди извлечённых (фильтр строго по целевому ОКАТО 65000000000).
"""
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
values = {r.value for r in rows}
assert Decimal("40190") not in values # Адыгея
assert Decimal("68110") not in values # ЦФО
assert Decimal("53221") not in values # РФ
def test_income_region_filter_targets_adygea() -> None:
"""Подменив целевой ОКАТО на Адыгею — извлекается ИМЕННО Адыгея (доказывает фильтр)."""
spec = EmissIndicatorSpec(
indicator_id="57039",
indicator_type="income_per_capita",
unit="руб",
frequency="quarterly",
targets=(RegionTarget(oktmo="79000000000", region="adygea"),),
)
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), spec)
assert len(rows) == 1
assert rows[0].region == "adygea"
assert rows[0].value == Decimal("40190")
assert rows[0].obs_date == date(2024, 1, 1)
# ── graceful degradation ──────────────────────────────────────────────────────────
def test_empty_dataset_returns_empty() -> None:
"""Валидный SDMX с пустым <DataSet> → [] (не падает)."""
assert parse_emiss_sdmx(_load("emiss_empty.xml"), INCOME_PER_CAPITA_SPEC) == []
def test_malformed_xml_returns_empty() -> None:
"""Битый XML → [] (+warning), не бросает."""
assert parse_emiss_sdmx(b"<GenericData><DataSet><not closed", INCOME_PER_CAPITA_SPEC) == []
assert parse_emiss_sdmx("не xml вовсе", INCOME_PER_CAPITA_SPEC) == []
def test_no_dataset_element_returns_empty() -> None:
"""SDMX-конверт без <DataSet> → [] (не падает)."""
xml = (
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message">'
"<Header/></GenericData>"
)
assert parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC) == []
def test_unknown_period_series_skipped() -> None:
"""Серия с незнакомым PERIOD (неделя) пропускается, валидная остаётся."""
xml = (
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
"<DataSet>"
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
'<generic:ObsValue value="999"/></generic:Obs></generic:Series>'
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
assert len(rows) == 1
assert rows[0].value == Decimal("50000")
assert rows[0].obs_date == date(2024, 1, 1)
def test_comma_decimal_value_parsed() -> None:
"""Значение с запятой-десятичным (ИПЦ-вариант '113,2') → Decimal('113.2')."""
xml = (
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
"<DataSet><generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
'<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>'
"</GenericData>"
)
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
assert len(rows) == 1
assert rows[0].value == Decimal("113.2")
assert rows[0].obs_date == date(2024, 1, 1)
def test_dedup_last_wins() -> None:
"""Дубль (region, obs_date) схлопывается — последний выигрывает (PK всё равно один)."""
xml = (
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
"<DataSet>"
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
'<generic:ObsValue value="100"/></generic:Obs></generic:Series>'
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>'
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
assert len(rows) == 1
assert rows[0].value == Decimal("200")
def test_spec_targets_sverdlovsk_okato() -> None:
"""Активная спека доходов целит ИМЕННО ОКАТО Свердл (контракт §7.11)."""
assert OKATO_SVERDLOVSK_OBLAST == "65000000000"
assert any(t.oktmo == OKATO_SVERDLOVSK_OBLAST for t in INCOME_PER_CAPITA_SPEC.targets)
assert INCOME_PER_CAPITA_SPEC.indicator_id == "57039"
assert INCOME_PER_CAPITA_SPEC.frequency == "quarterly"