gendesign/backend/tests/scrapers/test_emiss_sdmx.py
bot-backend 5170107405
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
fix(sql): EMISS PK includes period_type so yearly+Q1 coexist (#1606 follow-up)
Adds period_type TEXT NOT NULL DEFAULT 'unknown' to macro_indicator and
widens the PRIMARY KEY to (indicator_type, region, obs_date, period_type).

Before: yearly aggregate ('год'→obs_date YYYY-01-01) and Q1 ('I квартал'→
same date) shared the same PK slot → ON CONFLICT DO UPDATE made them
overwrite each other despite the in-memory dedup fix in #1687.

After: each EmissRow carries period_type from _emiss_period_granularity
('year'/'quarter'/'month'); DB ON CONFLICT targets include it, so
yearly+Q1 rows genuinely coexist.

Non-EMISS sources (CBR, rosstat open-data, domrf) use period_type='unknown'
(literal in SQL) — they are disambiguated by obs_date already, so the
wider PK is backward-compatible. All ON CONFLICT clauses in
cbr_macro_sync.py and rosstat_macro_sync.py updated accordingly.

Migration: 163_emiss_pk_period_type.sql (idempotent BEGIN/COMMIT).
Tests: 49 passed (emiss + rosstat suite), ruff clean, py_compile OK.
2026-06-17 21:27:34 +03:00

284 lines
15 KiB
Python
Raw Permalink 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)
# period_type: годовой → 'year', Q1 → 'quarter' (часть PK в macro_indicator, migration 163)
period_types = {r.period_type for r in rows}
assert period_types == {
"year",
"quarter",
}, f"ожидались period_type year+quarter, получили {period_types}"
# ── real-fixture extraction (income id=57039) ─────────────────────────────────────
def test_income_extracts_sverdlovsk_only() -> None:
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.
Все строки квартальные → period_type='quarter' (часть PK macro_indicator, migration 163).
"""
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)
assert all(
r.period_type == "quarter" for r in rows
), "квартальные строки должны иметь period_type='quarter'"
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"