"""Сквозной импорт настоящего отчёта: файл → реестр → парсер → леджер → сверка. Все остальные тесты фазы 3 честно изолированы: парсеры проверяются на фикстурах без БД, `ingest` — на `ParsedReport`, собранном руками. Это правильно, но между ними остаётся щель, в которую проваливаются ровно те ошибки, ради которых фаза затевалась: парсер отдаёт безупречный `ParsedReport`, ingest безупречно его пишет, а вместе они дают задвоенный леджер, потому что ключи считаются от того, что различается между двумя выгрузками. Поэтому здесь ни одного собранного вручную объекта — только байты обезличенных отчётов, `registry.pick` и публичный путь `upload → commit`. Проверки те же, что в плане §Фаза 3: тот же файл дважды даёт ноль новых событий; перекрывающиеся периоды дают каждую сделку по разу; закрывающие позиции и остаток денег из отчёта сходятся с derived; неизвестный ISIN уходит в `pending_instrument`, а после резолва лоты пересобираются. """ from __future__ import annotations from decimal import Decimal from pathlib import Path from sqlalchemy import func, select from fintracker.db import get_sessionmaker from fintracker.ledger.report_import import ( InstrumentSpec, build_preview, commit, resolve_pending, upload, ) from fintracker.models import ( Account, AccountKind, AccountRole, AssetClass, Broker, Event, EventKind, EventSource, EventStatus, Instrument, Lot, PendingInstrument, PendingInstrumentStatus, RawReportFile, ) from fintracker.sources.reports import registry FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports" / "sber" FULL = FIXTURES / "S930W42_11022026_17092026.html" AUGUST = FIXTURES / "S930W42_01082026_31082026.html" ACCOUNT_NO = "S930W42" #: Справочник ценных бумаг полного отчёта: ISIN → (тикер, класс). Заводится заранее, чтобы #: сверка закрывающих позиций проверяла сам импорт, а не резолв инструментов. SECURITIES = { "RU0009062285": ("AFLT", AssetClass.share), "RU0009024277": ("LKOH", AssetClass.share), "RU000A0JR4A1": ("MOEX", AssetClass.share), "RU0008958863": ("MSNG", AssetClass.share), "RU0007775219": ("MTSS", AssetClass.share), "RU000A1035S8": ("STME", AssetClass.etf), "RU0009029540": ("SBER", AssetClass.share), "RU0009046510": ("CHMF", AssetClass.share), "RU0009033591": ("TATN", AssetClass.share), "RU000A100P44": ("SBRB", AssetClass.etf), "RU000A0JRKT8": ("PHOR", AssetClass.share), } async def make_sber_account(*, primary: EventSource | None = EventSource.report_sber) -> int: async with get_sessionmaker()() as session: account = Account( kind=AccountKind.broker, source="report_sber", source_id=ACCOUNT_NO, broker=Broker.sber, name="Сбер ИИС", currency="RUB", role=AccountRole.investment, primary_event_source=primary, ) session.add(account) await session.commit() return account.id async def make_securities(skip: str | None = None) -> dict[str, int]: """Инструменты из справочника отчёта. `skip` оставляет один ISIN неизвестным.""" ids: dict[str, int] = {} async with get_sessionmaker()() as session: for isin, (ticker, asset_class) in SECURITIES.items(): if isin == skip: continue instrument = Instrument( asset_class=asset_class, isin=isin, ticker=ticker, board="TQBR", name=ticker, currency="RUB", ) session.add(instrument) await session.flush() ids[isin] = instrument.id await session.commit() return ids async def import_file(path: Path, account_id: int): """Полный публичный путь: загрузка, парсинг, commit.""" async with get_sessionmaker()() as session: outcome = await upload( session, data=path.read_bytes(), filename=path.name, account_id=account_id ) await session.commit() file_id = outcome.file.id duplicate_of_id = outcome.duplicate_of_id async with get_sessionmaker()() as session: row = await session.get(RawReportFile, file_id) assert row is not None result = await commit(session, row, account_id=account_id) await session.commit() return result, duplicate_of_id async def count_events(**where) -> int: async with get_sessionmaker()() as session: stmt = select(func.count()).select_from(Event) for column, value in where.items(): stmt = stmt.where(getattr(Event, column) == value) return (await session.execute(stmt)).scalar_one() # --- 1. формат доезжает до парсера через реестр -------------------------------------------- async def test_upload_routes_the_file_to_the_sber_parser(app) -> None: account_id = await make_sber_account() async with get_sessionmaker()() as session: outcome = await upload( session, data=FULL.read_bytes(), filename=FULL.name, account_id=account_id ) await session.commit() row = outcome.file assert row.parser_name == "report_sber" assert row.broker == "sber" assert row.account_external_id == ACCOUNT_NO assert row.period_from is not None and row.period_to is not None assert (row.period_from.isoformat(), row.period_to.isoformat()) == ( "2026-02-11", "2026-09-17", ) assert registry.pick(FULL.read_bytes(), FULL.name) is not None async def test_upload_writes_no_events(app) -> None: """Загрузка — это диагностика, а не запись: леджер меняет только commit.""" account_id = await make_sber_account() await make_securities() async with get_sessionmaker()() as session: await upload(session, data=FULL.read_bytes(), filename=FULL.name, account_id=account_id) await session.commit() assert await count_events() == 0 # --- 2. тот же файл дважды → ноль новых событий -------------------------------------------- async def test_the_same_file_twice_adds_nothing(app) -> None: account_id = await make_sber_account() await make_securities() first, duplicate = await import_file(FULL, account_id) assert duplicate is None assert first.events_created == 35 after_first = await count_events() async with get_sessionmaker()() as session: again = await upload( session, data=FULL.read_bytes(), filename=FULL.name, account_id=account_id ) await session.commit() assert again.duplicate_of_id is not None, "второй sha256 создал новый импорт" assert await count_events() == after_first == 35 # --- 3. перекрывающиеся периоды: каждая сделка ровно один раз ------------------------------- async def test_overlapping_reports_record_each_trade_once(app) -> None: """Август целиком входит в полный отчёт: второй импорт не должен ничего добавить. Это и есть проверка §1.6 A на живых данных — ключи считаются от номера сделки и от экономического отпечатка операции, а не от того, каким файлом её принесли. """ account_id = await make_sber_account() await make_securities() await import_file(FULL, account_id) total_after_full = await count_events() august_result, _ = await import_file(AUGUST, account_id) assert august_result.events_created == 0, "август задвоил операции полного отчёта" assert august_result.events_updated == 3 assert await count_events() == total_after_full async with get_sessionmaker()() as session: keys = (await session.execute(select(Event.dedupe_key))).scalars().all() assert len(keys) == len(set(keys)) async def test_august_first_then_full_history(app) -> None: """Обратный порядок: сначала месяц, потом вся история — итог тот же.""" account_id = await make_sber_account() await make_securities() august_result, _ = await import_file(AUGUST, account_id) assert august_result.events_created == 3 full_result, _ = await import_file(FULL, account_id) assert full_result.events_created == 32 assert full_result.events_updated == 3 assert await count_events() == 35 # --- 4. закрывающие позиции и деньги отчёта = derived --------------------------------------- async def test_closing_positions_and_cash_match_the_ledger(app) -> None: """Главная проверка фазы: то, что брокер напечатал, совпало с тем, что мы вывели.""" account_id = await make_sber_account() await make_securities() await import_file(FULL, account_id) async with get_sessionmaker()() as session: row = ( (await session.execute(select(RawReportFile).order_by(RawReportFile.id.desc()))) .scalars() .first() ) assert row is not None preview = await build_preview(session, row) mismatched = [p for p in preview.reconciliation.positions if not p.matches] assert not mismatched, [ (p.ticker or p.instrument_name, str(p.qty_report), str(p.qty_derived)) for p in mismatched ] rub = next(c for c in preview.reconciliation.cash if c.currency == "RUB") assert rub.balance_report == Decimal("3171.34") assert rub.balance_derived == rub.balance_report assert preview.reconciliation.matches async def test_derived_position_equals_the_reports_own_quantity(app) -> None: """Та же сверка, но из самого леджера: Σ `lot.qty_remaining` против отчёта.""" account_id = await make_sber_account() ids = await make_securities() await import_file(FULL, account_id) async with get_sessionmaker()() as session: rows = ( await session.execute( select(Lot.instrument_id, func.sum(Lot.qty_remaining)).group_by(Lot.instrument_id) ) ).all() held: dict[int, Decimal] = {instrument_id: qty for instrument_id, qty in rows} # «Портфель Ценных Бумаг» полного отчёта, колонка «Конец периода / Количество, шт» assert held[ids["RU0009062285"]] == Decimal("130") # Аэрофлот assert held[ids["RU0008958863"]] == Decimal("3000") # Мосэнерго assert held[ids["RU0009029540"]] == Decimal("20") # Сбербанк assert held.get(ids["RU000A1035S8"], Decimal(0)) == 0 # STME куплен и продан целиком assert held.get(ids["RU000A100P44"], Decimal(0)) == 0 # SBRB тоже закрыт # --- 5. неизвестный ISIN → pending → резолв → лоты ------------------------------------------ async def test_unknown_isin_parks_and_resolves(app) -> None: """Инструмент не угадывается: события ждут, пока его подтвердят, и только тогда считаются.""" account_id = await make_sber_account() ids = await make_securities(skip="RU0009062285") # Аэрофлот остаётся неизвестным result, _ = await import_file(FULL, account_id) assert result.pending_instruments >= 1 async with get_sessionmaker()() as session: pending = ( ( await session.execute( select(PendingInstrument).where( PendingInstrument.status == PendingInstrumentStatus.pending ) ) ) .scalars() .all() ) assert [p.isin for p in pending] == ["RU0009062285"] assert pending[0].occurrences >= 2 # Аэрофлот куплен двумя сделками pending_id = pending[0].id assert await count_events(status=EventStatus.pending) >= 2 async with get_sessionmaker()() as session: row = await session.get(PendingInstrument, pending_id) assert row is not None outcome = await resolve_pending( session, row, action="create", instrument=InstrumentSpec( asset_class="share", isin="RU0009062285", ticker="AFLT", board="TQBR", name="Аэрофлот", currency="RUB", ), ) await session.commit() assert outcome.events_bound >= 2 assert await count_events(status=EventStatus.pending) == 0 async with get_sessionmaker()() as session: instrument = ( await session.execute(select(Instrument).where(Instrument.isin == "RU0009062285")) ).scalar_one() qty = ( await session.execute( select(func.sum(Lot.qty_remaining)).where(Lot.instrument_id == instrument.id) ) ).scalar_one() assert qty == Decimal("130"), "после резолва лоты не пересобрались" assert ids # остальные инструменты были известны заранее # --- 6. чужой источник пишется тенью ------------------------------------------------------- async def test_report_on_an_api_driven_account_lands_as_shadow(app) -> None: """У счёта один primary_event_source; отчёт поверх API — evidence, а не леджер.""" account_id = await make_sber_account(primary=EventSource.tinvest_api) await make_securities() result, _ = await import_file(FULL, account_id) assert result.events_shadow == 35 assert await count_events(status=EventStatus.confirmed) == 0 assert await count_events(status=EventStatus.shadow) == 35 # --- 7. трассируемость --------------------------------------------------------------------- async def test_every_event_points_back_at_its_raw_line(app) -> None: """`raw_report_line` — то, по чему через полгода восстанавливают, откуда взялось число.""" account_id = await make_sber_account() await make_securities() await import_file(FULL, account_id) async with get_sessionmaker()() as session: linked = ( await session.execute( select(func.count()).select_from(Event).where(Event.raw_ref.is_not(None)) ) ).scalar_one() assert linked == 35 commission = ( await session.execute( select(func.count()).select_from(Event).where(Event.kind == EventKind.commission) ) ).scalar_one() assert commission == 0, "комиссия Сбера должна быть капитализирована в сделку"