gendesign/backend/tests/scrapers/test_emiss_sdmx.py
bot-backend 9dbfb03a3d
Some checks failed
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m38s
CI / backend-tests (pull_request) Failing after 8m41s
fix(scrapers): disambiguate EMISS yearly vs Q1 in dedup key (#1606)
_YEAR_PERIODS ('год') → month=1 collided with Q1 ('I квартал') → month=1
for the same region/year: both produced obs_date=YYYY-01-01, so the second
series in the XML silently overwrote the first in the in-memory by_key dict.

Fix: add _emiss_period_granularity() and extend the dedup key from
(region, obs_date) to (region, obs_date, granularity). 'year' and 'quarter'
are now distinct slots, so both rows survive the parser and reach the upsert.

Test: test_yearly_and_q1_both_survive_dedup verifies len==2 with both
values present; test_period_granularity covers all classification branches.
2026-06-17 20:39:52 +03:00

273 lines
14 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_granularity,
_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
def test_period_granularity() -> None:
"""_emiss_period_granularity корректно классифицирует все под-периоды (#1606)."""
assert _emiss_period_granularity("год") == "year"
assert _emiss_period_granularity("значение показателя за год") == "year"
assert _emiss_period_granularity("за год") == "year"
assert _emiss_period_granularity("I квартал") == "quarter"
assert _emiss_period_granularity("IV квартал") == "quarter"
assert _emiss_period_granularity("январь") == "month"
assert _emiss_period_granularity("декабрь") == "month"
assert _emiss_period_granularity("3 неделя") == "unknown"
assert _emiss_period_granularity("") == "unknown"
def test_yearly_and_q1_both_survive_dedup() -> None:
"""Годовой ряд ('год') и Q1 ('I квартал') за один год — оба остаются (#1606).
До фикса: оба давали obs_date=YYYY-01-01, ключ (region, obs_date) был один →
второй по XML-порядку перезаписывал первого. После фикса: ключ трёхкомпонентный
(region, obs_date, granularity) → 'year' и 'quarter' — разные слоты.
"""
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>"
# годовой ряд за 2023 — значение 48000
"<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>2023</generic:Time>"
'<generic:ObsValue value="48000"/></generic:Obs></generic:Series>'
# Q1 того же 2023 — значение 42000; obs_date тоже 2023-01-01
"<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>2023</generic:Time>"
'<generic:ObsValue value="42000"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
# Оба наблюдения должны присутствовать — разные гранулярности
assert len(rows) == 2, f"ожидались 2 строки, получили {len(rows)}: {rows}"
values = {r.value for r in rows}
assert Decimal("48000") in values, "годовое наблюдение потеряно"
assert Decimal("42000") in values, "Q1-наблюдение потеряно"
# obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert)
assert all(r.obs_date == date(2023, 1, 1) for r in rows)
# ── 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"