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:
Dmitry
2026-09-19 10:39:37 +03:00
parent 6b32c79e79
commit 2e742a093b
25 changed files with 7284 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python
"""Regenerate the committed report fixtures from the real reports in `raw/`.
uv run python scripts/anonymize_reports.py # rewrite fixtures
uv run python scripts/anonymize_reports.py --check # fail if they are out of date
uv run python scripts/anonymize_reports.py --report # show what would be removed
`tests/fixtures/reports/raw/` is gitignored and holds the originals; everything one level
up is committed and must be clean. The transformation is deterministic (see
`fintracker.sources.reports.anonymize`), so running this twice is a no-op and a reviewer can
reproduce any fixture from the original.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from fintracker.sources.reports.anonymize import (
anonymize,
anonymize_filename,
collect_secrets,
)
FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "reports"
RAW = FIXTURES / "raw"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="exit 1 if a fixture is stale")
parser.add_argument("--report", action="store_true", help="print what was found")
args = parser.parse_args()
if not RAW.exists():
print(f"нет {RAW} — положите туда реальные отчёты", file=sys.stderr)
return 1
sources = [p for p in sorted(RAW.rglob("*")) if p.is_file() and not p.name.startswith(".")]
# One secret set for the whole corpus: a Snowball note quotes the Sber agreement number,
# so a per-file scrub would leak what another file names.
secrets = collect_secrets((p.read_bytes(), p.name) for p in sources)
if args.report:
for original, substitute in secrets.replacements.items():
print(f"{original!r} -> {substitute!r}")
stale: list[str] = []
written = 0
for source in sources:
data = source.read_bytes()
clean, _ = anonymize(data, source.name, secrets)
relative = source.relative_to(RAW)
target = FIXTURES / relative.parent / anonymize_filename(source.name, secrets)
if args.report:
continue # --report only inspects; it must never touch a fixture
if target.exists() and target.read_bytes() == clean:
continue
if args.check:
stale.append(str(target.relative_to(FIXTURES)))
continue
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(clean)
written += 1
print(f"{relative} -> {target.relative_to(FIXTURES)}")
if stale:
print("фикстуры устарели, перегенерируйте: " + ", ".join(stale), file=sys.stderr)
return 1
if not args.check and not args.report:
print(f"обновлено файлов: {written}")
return 0
if __name__ == "__main__":
raise SystemExit(main())