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
Расширяет Росстат-скрейпер ЕМИСС/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.
222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""Тесты 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"
|