gendesign/backend/tests/scrapers/test_rosstat_emiss.py
Light1YT f9e045028c
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 6m27s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m29s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m15s
feat(macro): land construction price index (СМР) via open Rosstat xlsx (#946)
Closes the last #946 gap (СМР). Fetches «Индексы цен производителей на
строительную продукцию по РФ» from the OPEN Rosstat xlsx
(rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_MM-YYYY.xlsx),
parses the monthly «к предыдущему месяцу» section → macro_indicator
(indicator_type='construction_price_index', region='rf', monthly, %).

The reCAPTCHA-gated fedstat dataGrid.do path is DELIBERATELY NOT used —
the open Rosstat source needs no captcha. EMISS id=31108 documented as a
prod-only SDMX alternative (not wired).

URL is date-derived with bounded look-back (filename changes monthly;
_fetch_construction_latest walks current month back to 6, first 200 wins;
year-boundary handled, exhaustion raises, 404 breaks fast — no hang).
Wired into rosstat_macro_sync with per-source try/except (can't break
demography/income) + SAVEPOINT-per-row. None-not-0 on blank months.
33 tests (incl look-back + year-boundary + graceful), ruff clean.

Note: no construction-cost channel in macro_coefficient yet (demand-side
only) — series is ingested/available for future cost-side use + display.

Refs #946
2026-06-09 11:28:05 +05:00

316 lines
16 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.

"""Тесты pure-парсеров Росстат-скрейпера (offline, без живой сети).
Покрывают: разбор meta.csv (URL данных + год), парсинг значений (запятая-десятичный,
разделители тысяч), извлечение целевых ОКТМО-строк из data-CSV (Свердловская обл. /
ЕКБ), фоллбэк года из meta, дедуп по (region, obs_date), graceful на пустом /
без-нужных-колонок CSV. Фикстуры — из реального формата open-data Росстата 4.0
(verified live 2026-06-07: датасет 7708234640-population).
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from app.services.scrapers.rosstat_emiss import (
CONSTRUCTION_PRICE_SPEC,
OKTMO_EKATERINBURG,
OKTMO_SVERDLOVSK_OBLAST,
POPULATION_SPEC,
DatasetSpec,
RegionTarget,
_parse_value,
_parse_year_cell,
parse_construction_index_xlsx,
parse_dataset_rows,
parse_meta_data_url,
parse_meta_modified_year,
)
# Фикстура: обрезанный РЕАЛЬНЫЙ xlsx Росстата «Индексы цен производителей на строительную
# продукцию по РФ» (лист '1', секции «К предыдущему месяцу» 2022-2025 + «К декабрю
# предыдущего года»; снято live 2026-06-08 с Invest_ind_stroitel_*.xlsx). Декабрь-2025
# намеренно ПУСТ (будущий/неопубликованный месяц → проверка None-not-0 пропуска).
_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_bytes()
# Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных —
# property 'data-<ts>-structure-<ts>.csv' → абсолютный URL CSV-данных.
FIXTURE_META = (
"property,value\n"
"standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n"
'identifier,7708234640-population\n'
'title,"Численность постоянного населения"\n'
"created,20180905\n"
"modified,20140919\n"
"format,CSV\n"
"data-20140919T1709-structure-20141013T1710.csv,"
"https://rosstat.gov.ru/opendata/7708234640-population/"
"data-20140919T1709-structure-20141013T1710.csv\n"
"structure-20141013T1710.csv,"
"https://rosstat.gov.ru/opendata/7708234640-population/structure-20141013T1710.csv\n"
)
# Реальный shape data-CSV (population): header + муниципальные строки, ОКТМО в 'kode',
# значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые.
FIXTURE_POPULATION_DATA = (
"number,year,kode,region,municipalities,total,urban,rural\n"
'1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,'
"446406,209929,236477\n"
"2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области,"
"4320677,3500000,820677\n"
"3,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,1412346,0\n"
"4,2014,657013640011000,Свердловская область,Верх-Исетский район,201817,201817,0\n"
)
def test_parse_meta_data_url() -> None:
"""meta.csv → URL последнего data-файла."""
url = parse_meta_data_url(FIXTURE_META)
assert url == (
"https://rosstat.gov.ru/opendata/7708234640-population/"
"data-20140919T1709-structure-20141013T1710.csv"
)
def test_parse_meta_modified_year() -> None:
"""meta.csv 'modified' (YYYYMMDD) → год."""
assert parse_meta_modified_year(FIXTURE_META) == 2014
def test_parse_meta_missing_data_url() -> None:
"""meta без строки данных → None (не бросает)."""
assert parse_meta_data_url("property,value\nidentifier,foo\n") is None
def test_parse_value_comma_decimal() -> None:
"""Запятая как десятичный разделитель: '1,5' → Decimal('1.5')."""
assert _parse_value("1,5") == Decimal("1.5")
def test_parse_value_thousands_separator() -> None:
"""Пробелы-разделители тысяч вырезаются: '4 320 677' → Decimal('4320677')."""
assert _parse_value("4 320 677") == Decimal("4320677")
# узкий неразрывный пробел (Росстат иногда так)
assert _parse_value("1 412 346") == Decimal("1412346")
def test_parse_value_empty_and_dash() -> None:
"""Пустое / прочерк / многоточие → None (строка пропускается)."""
assert _parse_value("") is None
assert _parse_value("-") is None
assert _parse_value("") is None
assert _parse_value("не число") is None
def test_parse_dataset_targets_only() -> None:
"""Из data-CSV извлекаются ТОЛЬКО целевые ОКТМО (субъект + ЕКБ), не районы/др.регионы."""
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
by_region = {r.region: r for r in rows}
assert set(by_region) == {"sverdl", "ekb"}
# Адыгея и Верх-Исетский район — НЕ среди целей
assert all(r.region in {"sverdl", "ekb"} for r in rows)
def test_parse_dataset_values_and_dates() -> None:
"""Значения и obs_date (1 января года из колонки 'year') корректны."""
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
by_region = {r.region: r for r in rows}
assert by_region["sverdl"].value == Decimal("4320677")
assert by_region["sverdl"].obs_date == date(2014, 1, 1)
assert by_region["ekb"].value == Decimal("1412346")
assert by_region["ekb"].obs_date == date(2014, 1, 1)
# тип / единица из spec
assert by_region["sverdl"].indicator_type == "population_total"
assert by_region["sverdl"].unit == "чел"
def test_parse_dataset_fallback_year_when_no_year_col() -> None:
"""Если в spec year_col_header=None — год берётся из fallback_year (meta modified)."""
spec_no_year = DatasetSpec(
code=POPULATION_SPEC.code,
indicator_type="population_total",
unit="чел",
value_col_header="total",
oktmo_col_header="kode",
year_col_header=None, # год не из данных
targets=(RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"),),
)
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, spec_no_year, fallback_year=2014)
assert len(rows) == 1
assert rows[0].obs_date == date(2014, 1, 1)
def test_parse_dataset_dedup_last_wins() -> None:
"""Дубль строки по (region, obs_date) схлопывается — последняя выигрывает."""
data = (
"number,year,kode,region,municipalities,total,urban,rural\n"
"1,2014,6500000000,Свердловская область,МО,4320677,0,0\n"
"2,2014,6500000000,Свердловская область,МО,4400000,0,0\n" # дубль ОКТМО+год
)
rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
assert len(rows) == 1
assert rows[0].value == Decimal("4400000")
def test_parse_dataset_skips_unparsable_value() -> None:
"""Строка с непарсящимся значением пропускается, валидные остаются."""
data = (
"number,year,kode,region,municipalities,total,urban,rural\n"
"1,2014,6500000000,Свердловская область,МО,-,0,0\n" # value '-'
"2,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,0,0\n"
)
rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
assert len(rows) == 1
assert rows[0].region == "ekb"
def test_parse_dataset_missing_columns_graceful() -> None:
"""CSV без нужных колонок (kode/total) → [] (+warning), не бросает."""
data = "foo,bar\n1,2\n3,4\n"
assert parse_dataset_rows(data, POPULATION_SPEC, fallback_year=2014) == []
def test_parse_dataset_empty_graceful() -> None:
"""Пустой CSV → []."""
assert parse_dataset_rows("", POPULATION_SPEC, fallback_year=2014) == []
def test_oktmo_constants_used_in_spec() -> None:
"""Спека population таргетит именно субъект-ОКТМО и ЕКБ-ОКТМО (контракт ТЗ §7.11)."""
oktmos = {t.oktmo for t in POPULATION_SPEC.targets}
assert OKTMO_SVERDLOVSK_OBLAST in oktmos
assert OKTMO_EKATERINBURG in oktmos
# ── индекс цен на СМР (открытый xlsx Росстата) ────────────────────────────────────
def test_parse_year_cell_plain_and_footnote() -> None:
"""Год из ячейки: чистый int, строка-год и сноска-маркер ('2022 1)' → 2022)."""
assert _parse_year_cell(2024) == 2024
assert _parse_year_cell("2022 1)") == 2022
assert _parse_year_cell("2025") == 2025
# не-год / пусто / вне диапазона → None
assert _parse_year_cell(None) is None
assert _parse_year_cell("Январь") is None
assert _parse_year_cell(1800) is None
def test_construction_spec_contract() -> None:
"""Спека СМР: тип, region='rf', monthly, % — открытый xlsx, без dataGrid.do/captcha."""
assert CONSTRUCTION_PRICE_SPEC.indicator_type == "construction_price_index"
assert CONSTRUCTION_PRICE_SPEC.region == "rf"
assert CONSTRUCTION_PRICE_SPEC.frequency == "monthly"
assert CONSTRUCTION_PRICE_SPEC.unit == "%"
assert "dataGrid" not in CONSTRUCTION_PRICE_SPEC.url_template
assert CONSTRUCTION_PRICE_SPEC.url_template.startswith("https://rosstat.gov.ru/")
assert "{mm:02d}" in CONSTRUCTION_PRICE_SPEC.url_template # резолв по дате, НЕ хардкод месяца
def test_construction_url_resolves_with_lookback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя файла меняется ежемесячно → резолвер идёт назад до первого 200 (не хардкод)."""
from app.services.scrapers import rosstat_emiss as mod
tried: list[str] = []
def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
tried.append(url)
if len(tried) < 3: # первые два месяца → 404
raise mod.RosstatScraperError(f"HTTP 404 {url}")
return CONSTRUCTION_XLSX
monkeypatch.setattr(mod, "_http_get", fake_get)
content = mod._fetch_construction_latest(
client=object(), # type: ignore[arg-type] # не используется (get замокан)
url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
today=date(2026, 6, 15),
)
assert content == CONSTRUCTION_XLSX
assert tried == [
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_06-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_05-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_04-2026.xlsx",
]
def test_construction_url_lookback_year_boundary_and_exhaustion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Все кандидаты 404 → RosstatScraperError; look-back корректно переходит через год."""
from app.services.scrapers import rosstat_emiss as mod
tried: list[str] = []
def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
tried.append(url)
raise mod.RosstatScraperError(f"HTTP 404 {url}")
monkeypatch.setattr(mod, "_http_get", fake_get)
with pytest.raises(mod.RosstatScraperError):
mod._fetch_construction_latest(
client=object(), # type: ignore[arg-type]
url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
today=date(2026, 2, 15),
max_lookback=3,
)
assert tried == [ # переход через год: 02-2026 → 01-2026 → 12-2025 → 11-2025
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_02-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_01-2026.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_12-2025.xlsx",
"https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_11-2025.xlsx",
]
def test_parse_construction_monthly_series() -> None:
"""xlsx → месячный ряд СМР: region='rf', obs_date к 1-му числу, значение в %."""
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
assert rows, "ожидались строки из фикстуры"
assert all(r.region == "rf" for r in rows)
assert all(r.indicator_type == "construction_price_index" for r in rows)
assert all(r.unit == "%" for r in rows)
assert all(r.obs_date.day == 1 for r in rows)
by_date = {r.obs_date: r.value for r in rows}
# реальные значения «к предыдущему месяцу» из фикстуры (2022-2025)
assert by_date[date(2022, 1, 1)] == Decimal("100.6")
assert by_date[date(2025, 1, 1)] == Decimal("100.8")
# годы покрывают 2022..2025
assert {d.year for d in by_date} == {2022, 2023, 2024, 2025}
def test_parse_construction_skips_blank_future_month() -> None:
"""Пустая ячейка будущего/неопубликованного месяца → пропуск (None-not-0)."""
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
dates = {r.obs_date for r in rows}
# Декабрь-2025 в фикстуре намеренно пуст → его НЕ должно быть (а не 0)
assert date(2025, 12, 1) not in dates
assert date(2025, 11, 1) in dates # соседний заполненный — есть
def test_parse_construction_ignores_annual_section() -> None:
"""Секция «К декабрю предыдущего года» (годовой базис) НЕ попадает в месячный ряд.
Годовые декабрьские значения большие (>105), месячные — около 100. Убеждаемся, что
ни одно декабрьское наблюдение не унаследовало годовое значение.
"""
rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
dec_vals = [r.value for r in rows if r.obs_date.month == 12]
assert dec_vals, "ожидались декабрьские месячные наблюдения"
assert all(v < Decimal("105") for v in dec_vals)
def test_parse_construction_empty_and_garbage_graceful() -> None:
"""Пустые байты / не-xlsx → [] (+warning), не бросает."""
assert parse_construction_index_xlsx(b"", CONSTRUCTION_PRICE_SPEC) == []
assert parse_construction_index_xlsx(b"not a zip", CONSTRUCTION_PRICE_SPEC) == []
def test_parse_construction_missing_sheet_graceful() -> None:
"""Если запрошенного листа нет в книге → [] (+warning)."""
from dataclasses import replace
spec_bad = replace(CONSTRUCTION_PRICE_SPEC, sheet="НЕТ_ТАКОГО")
assert parse_construction_index_xlsx(CONSTRUCTION_XLSX, spec_bad) == []