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
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.
284 lines
15 KiB
Python
284 lines
15 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_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"
|