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,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())
|
||||
Reference in New Issue
Block a user