feat(reports): протокол и парсеры отчётов Сбера, ВТБ и Snowball CSV
sources/reports/base.py — контракт ReportParser (плагины: sniff/parse, чистые функции без БД). registry.py выбирает парсер по содержимому файла, CSV последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой формат. Комиссия капитализируется в сделку, отдельным событием не эмитится: и Сбер, и ВТБ печатают её дважды — колонками в сделках и строками в движении денег, суммы совпадают, второе прочтение задвоило бы её. Расчётные строки («Сделка от …», «Сальдо расчетов по сделкам») не эмитятся — это денежные ноги уже учтённых сделок. У Сбера таблица «Информация о зачислениях на ИИС» кумулятивна за календарный год и в леджер не идёт, иначе два пересекающихся отчёта задвоили бы пополнения. CSV Snowball сводный по всем брокерам — годится как сверка потоков и сделок на уровне портфеля, но не позиций по счетам; CUSTOM_HOLDING_PRICE не событие, а цена — уходит в meta для price_manual. Обезличивание — anonymize.py + scripts/anonymize_reports.py, секреты собираются по всему корпусу отчётов разом (Snowball цитирует номер договора Сбера в примечании к переводу). test_fixtures_anonymized.py падает, если в tests/fixtures/reports/ вне raw/ найдётся ИНН, ФИО или номер счёта — и по форме (работает в CI без raw/), и по фактическому содержимому raw/, когда оно на месте.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Реестр парсеров отчётов: какой файл достаётся какому парсеру (plan §2).
|
||||
|
||||
Проверка выглядит тривиальной, но ловит ровно ту ошибку, которую больше не поймает никто:
|
||||
универсальный CSV узнаёт файл по набору колонок, то есть по определению шире любого
|
||||
брокерского формата, и достаточно поставить его в списке первым, чтобы отчёт Сбера тихо
|
||||
уехал в чужой парсер. Порядок в `PARSERS` — это поведение, а не оформление.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.sources.reports import registry
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
|
||||
CASES = [
|
||||
(FIXTURES / "sber" / "S930W42_11022026_17092026.html", "report_sber"),
|
||||
(FIXTURES / "sber" / "S930W42_01082026_31082026.html", "report_sber"),
|
||||
(FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx", "report_vtb"),
|
||||
(FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx", "report_vtb"),
|
||||
(next((FIXTURES / "snowball").glob("*.csv")), "csv"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,expected", CASES, ids=lambda v: getattr(v, "name", v))
|
||||
def test_pick_routes_each_fixture_to_its_parser(path: Path, expected: str) -> None:
|
||||
chosen = registry.pick(path.read_bytes(), path.name)
|
||||
assert chosen is not None, f"{path.name}: ни один парсер не узнал файл"
|
||||
assert chosen.name == expected
|
||||
|
||||
|
||||
def test_every_registered_parser_is_reachable_by_name() -> None:
|
||||
assert set(registry.names()) == {"report_sber", "report_vtb", "csv"}
|
||||
for name in registry.names():
|
||||
assert registry.get(name).name == name
|
||||
|
||||
|
||||
def test_unknown_format_is_a_none_not_an_exception() -> None:
|
||||
"""`pick` возвращает None, и роутер превращает это в 415 — не в 500."""
|
||||
assert registry.pick(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "statement.pdf") is None
|
||||
assert registry.pick(b"", "empty.txt") is None
|
||||
|
||||
|
||||
def test_parser_metadata_is_filled_in() -> None:
|
||||
"""`parser_name` и `parser_version` уходят в `raw_report_file` — пустых быть не должно."""
|
||||
for parser in registry.PARSERS:
|
||||
assert parser.name and parser.broker and parser.version
|
||||
assert parser.formats
|
||||
|
||||
|
||||
def test_csv_is_last_so_it_cannot_shadow_a_broker_format() -> None:
|
||||
names = registry.names()
|
||||
assert names.index("csv") == len(names) - 1
|
||||
Reference in New Issue
Block a user