Поток: upload -> raw_report_file (sha256 UNIQUE) -> parse -> raw_report_line,
событий в леджере ещё нет -> preview -> POST /imports/{id}/commit ->
ledger/ingest.py резолвит инструмент, считает dedupe_key, пишет event
confirmed | shadow (plan §1.6 B) — сравнивая account.primary_event_source
с источником отчёта, а не гадая. Нерезолвленный инструмент ждёт в
pending_instrument, никогда не угадывается; POST /instruments/pending/{id}/resolve
привязывает и пересобирает лоты.
ledger/dedupe.py — shadow-матчинг случая B двумя проходами (точная дата, затем
±1 рабочий день, жадно 1:1, |price| ±0,5 %). Шаги shadow_dedupe и
report_reconcile зарегистрированы перед quality: оба говорят через FINDINGS.
/instruments/pending регистрируется в app.py ДО routers/instruments.py:
FastAPI сопоставляет маршруты по порядку, и /instruments/{id} с типом int
отвечает 422 на нечисловой сегмент, а не проваливается дальше.
Контракт — docs/ai/import-contract.md, общий для бэкенда и Flutter.
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""`/imports` and `/instruments/pending` against `docs/ai/import-contract.md`.
|
|
|
|
The parser is a stub registered for the duration of a test: the real ones are a separate
|
|
contract, and these tests are about the HTTP surface the Flutter client was written against.
|
|
`create_app` mounts both routers, and the order it mounts them in matters — that is what
|
|
`test_pending_path_is_not_eaten_by_the_instrument_id_route` guards.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import func, select
|
|
|
|
from factories import make_instrument
|
|
from fintracker.db import get_sessionmaker
|
|
from fintracker.models import (
|
|
Account,
|
|
AccountKind,
|
|
AccountRole,
|
|
Event,
|
|
EventKind,
|
|
EventSource,
|
|
EventStatus,
|
|
Instrument,
|
|
PendingInstrument,
|
|
RawReportFile,
|
|
)
|
|
from fintracker.sources.reports import registry
|
|
from fintracker.sources.reports.base import (
|
|
BrokerEvent,
|
|
CashEnd,
|
|
InstrumentRef,
|
|
ParsedReport,
|
|
ParseError,
|
|
PositionEnd,
|
|
)
|
|
|
|
ACCOUNT_NO = "S930W42"
|
|
UNKNOWN_ISIN = "RU000A1035S8"
|
|
PREFIX = "/api/v1"
|
|
|
|
GAZP = InstrumentRef(ticker="GAZP", board="TQBR", source_key="TICKER:GAZP/TQBR", name="Газпром")
|
|
UNKNOWN = InstrumentRef(
|
|
isin=UNKNOWN_ISIN,
|
|
ticker="STME",
|
|
name="Первая-ВечныйПортф БПИФ",
|
|
currency="RUB",
|
|
asset_class_hint="fund",
|
|
source_key=f"ISIN:{UNKNOWN_ISIN}",
|
|
)
|
|
UNKNOWN2_ISIN = "RU000A105KR6"
|
|
UNKNOWN2 = InstrumentRef(
|
|
isin=UNKNOWN2_ISIN,
|
|
ticker="TRUR",
|
|
name="Тинькофф Вечный портфель",
|
|
currency="RUB",
|
|
asset_class_hint="fund",
|
|
source_key=f"ISIN:{UNKNOWN2_ISIN}",
|
|
)
|
|
UNKNOWN_BY_TRADE = {"U1": UNKNOWN, "U2": UNKNOWN2}
|
|
|
|
|
|
class StubParser:
|
|
"""Reads `FAKE|<account>|<trade numbers>|<padding>` and yields one buy per trade number.
|
|
|
|
The padding exists only so two files with the same trades can have different bytes —
|
|
which is how the overlapping-periods case is expressed without a real report.
|
|
"""
|
|
|
|
broker = "sber"
|
|
name = "report_sber"
|
|
formats: tuple[str, ...] = ("txt",)
|
|
version = "1"
|
|
|
|
def sniff(self, data: bytes, filename: str) -> bool:
|
|
return data.startswith(b"FAKE|")
|
|
|
|
def parse(self, data: bytes, filename: str) -> ParsedReport:
|
|
text = data.decode("utf-8")
|
|
_, account_no, trades, *_ = text.split("|")
|
|
if account_no == "BROKEN":
|
|
raise ParseError("раздел «Сделки» не найден")
|
|
events = []
|
|
for line_no, trade_no in enumerate(t for t in trades.split(",") if t):
|
|
ref = UNKNOWN_BY_TRADE.get(trade_no, GAZP)
|
|
events.append(
|
|
BrokerEvent(
|
|
kind=EventKind.buy,
|
|
trade_date=date(2026, 2, 24),
|
|
settle_date=date(2026, 2, 25),
|
|
amount=Decimal("-1000"),
|
|
currency="RUB",
|
|
instrument=ref,
|
|
quantity=Decimal("10"),
|
|
price=Decimal("100"),
|
|
price_currency="RUB",
|
|
fee=Decimal("0.39"),
|
|
trade_no=trade_no,
|
|
description="Покупка",
|
|
raw_line_no=line_no + 1,
|
|
)
|
|
)
|
|
return ParsedReport(
|
|
broker="sber",
|
|
account_external_id=account_no,
|
|
period_from=date(2026, 2, 11),
|
|
period_to=date(2026, 9, 17),
|
|
parser_version="1",
|
|
events=events,
|
|
positions_end=[PositionEnd(instrument=GAZP, qty=Decimal("10"))],
|
|
cash_end=[CashEnd(currency="RUB", balance=Decimal("-1000"))],
|
|
warnings=["Раздел «Купонный доход» отсутствует в файле"],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def parser():
|
|
# first in the list, so `registry.pick` and `registry.get("report_sber")` both find the
|
|
# stub rather than the real Sber parser, whose `sniff` is deliberately generous.
|
|
stub = StubParser()
|
|
registry.PARSERS.insert(0, stub)
|
|
yield stub
|
|
registry.PARSERS.remove(stub)
|
|
|
|
|
|
@pytest.fixture
|
|
async def http(app, client: AsyncClient) -> AsyncClient:
|
|
"""The import routes come mounted by `create_app` — nothing to wire up here.
|
|
|
|
Kept as a named fixture so every test in this module reads the same way, and so the
|
|
day the mounting changes there is one place to look.
|
|
"""
|
|
return client
|
|
|
|
|
|
def content(trades: str = "T1", account_no: str = ACCOUNT_NO, pad: str = "") -> bytes:
|
|
return f"FAKE|{account_no}|{trades}|{pad}".encode()
|
|
|
|
|
|
async def make_account(external_id: str = ACCOUNT_NO) -> int:
|
|
async with get_sessionmaker()() as session:
|
|
account = Account(
|
|
kind=AccountKind.broker,
|
|
source="report_sber",
|
|
source_id=external_id,
|
|
name="Сбер ИИС",
|
|
currency="RUB",
|
|
role=AccountRole.investment,
|
|
include_in_net_worth=False,
|
|
primary_event_source=EventSource.report_sber,
|
|
)
|
|
session.add(account)
|
|
await session.commit()
|
|
return account.id
|
|
|
|
|
|
async def upload(http: AsyncClient, headers, data: bytes, **form):
|
|
return await http.post(
|
|
f"{PREFIX}/imports",
|
|
headers=headers,
|
|
files={"file": ("report.txt", data, "text/plain")},
|
|
data={k: str(v) for k, v in form.items()},
|
|
)
|
|
|
|
|
|
async def test_same_bytes_twice_return_the_first_import(app, http, auth_headers, parser):
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
|
|
first = await upload(http, auth_headers, content())
|
|
second = await upload(http, auth_headers, content())
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert second.status_code == 200, second.text
|
|
assert first.json()["duplicate_of_id"] is None
|
|
assert second.json()["duplicate_of_id"] == first.json()["id"]
|
|
assert second.json()["id"] == first.json()["id"]
|
|
async with get_sessionmaker()() as session:
|
|
assert await session.scalar(select(func.count()).select_from(RawReportFile)) == 1
|
|
|
|
|
|
async def test_overlapping_reports_import_each_trade_once(app, http, auth_headers, parser):
|
|
"""The second file repeats T1 and adds T2: committing it creates exactly one event."""
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
|
|
january = (await upload(http, auth_headers, content("T1"))).json()
|
|
commit_one = await http.post(
|
|
f"{PREFIX}/imports/{january['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
assert commit_one.status_code == 200, commit_one.text
|
|
assert commit_one.json()["events_created"] == 1
|
|
|
|
april = (await upload(http, auth_headers, content("T1,T2", pad="april"))).json()
|
|
assert april["counts"]["events_duplicate"] == 1
|
|
assert april["counts"]["events_new"] == 1
|
|
commit_two = await http.post(
|
|
f"{PREFIX}/imports/{april['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
assert commit_two.status_code == 200, commit_two.text
|
|
assert commit_two.json()["events_created"] == 1
|
|
assert commit_two.json()["events_updated"] == 1
|
|
|
|
async with get_sessionmaker()() as session:
|
|
assert await session.scalar(select(func.count()).select_from(Event)) == 2
|
|
|
|
|
|
async def test_committing_a_committed_import_is_a_conflict(app, http, auth_headers, parser):
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
created = (await upload(http, auth_headers, content())).json()
|
|
|
|
first = await http.post(
|
|
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
second = await http.post(
|
|
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert second.status_code == 409, second.text
|
|
assert second.json()["status"] == 409
|
|
async with get_sessionmaker()() as session:
|
|
assert await session.scalar(select(func.count()).select_from(Event)) == 1
|
|
|
|
|
|
async def test_commit_without_an_account_is_refused(app, http, auth_headers, parser):
|
|
await make_instrument(ticker="GAZP", name="Газгром")
|
|
created = (await upload(http, auth_headers, content(account_no="NOSUCH"))).json()
|
|
assert created["account_id"] is None
|
|
assert created["account_suggestions"] == []
|
|
|
|
response = await http.post(
|
|
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
|
|
assert response.status_code == 422, response.text
|
|
body = response.json()
|
|
assert body["status"] == 422
|
|
assert "account_id" in body["detail"]
|
|
|
|
|
|
async def test_unreadable_file_is_kept_as_a_failed_import(app, http, auth_headers, parser):
|
|
response = await upload(http, auth_headers, content(account_no="BROKEN"))
|
|
|
|
assert response.status_code == 422, response.text
|
|
body = response.json()
|
|
import_id = body["errors"][0]["import_id"]
|
|
async with get_sessionmaker()() as session:
|
|
row = await session.get(RawReportFile, import_id)
|
|
assert row is not None
|
|
assert row.parse_status.value == "failed"
|
|
assert "Сделки" in (row.error or "")
|
|
|
|
|
|
async def test_unknown_format_is_415(app, http, auth_headers, parser):
|
|
response = await upload(http, auth_headers, b"not a report at all")
|
|
assert response.status_code == 415, response.text
|
|
|
|
|
|
async def test_pending_instrument_flows_through_all_three_modes(app, http, auth_headers, parser):
|
|
account_id = await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
created = (await upload(http, auth_headers, content("T1,U1"))).json()
|
|
|
|
assert created["counts"]["events_pending"] == 1
|
|
assert [p["source_key"] for p in created["pending_instruments"]] == [f"ISIN:{UNKNOWN_ISIN}"]
|
|
|
|
commit = await http.post(
|
|
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
assert commit.status_code == 200, commit.text
|
|
assert commit.json()["events_skipped"] == 1
|
|
assert commit.json()["pending_instruments"] == 1
|
|
|
|
listing = await http.get(f"{PREFIX}/instruments/pending", headers=auth_headers)
|
|
assert listing.status_code == 200, listing.text
|
|
row = listing.json()[0]
|
|
assert row["status"] == "pending"
|
|
assert row["asset_class_hint"] == "fund"
|
|
assert isinstance(row["sample_price"], str)
|
|
|
|
# (a) ignore: the events stop waiting but gain no instrument
|
|
ignored = await http.post(
|
|
f"{PREFIX}/instruments/pending/{row['id']}/resolve",
|
|
headers=auth_headers,
|
|
json={"action": "ignore"},
|
|
)
|
|
assert ignored.status_code == 200, ignored.text
|
|
assert ignored.json()["status"] == "ignored"
|
|
assert ignored.json()["events_bound"] == 1
|
|
assert ignored.json()["alias_created"] is False
|
|
async with get_sessionmaker()() as session:
|
|
statuses = set(
|
|
(await session.execute(select(Event.status).where(Event.instrument_id.is_(None))))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert EventStatus.pending not in statuses
|
|
|
|
# (b) create: a second unknown paper becomes a real instrument
|
|
second = (await upload(http, auth_headers, content("U2", pad="second"))).json()
|
|
pending_id = second["pending_instruments"][0]["id"]
|
|
await http.post(f"{PREFIX}/imports/{second['id']}/commit", headers=auth_headers, json={})
|
|
created_row = await http.post(
|
|
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
|
|
headers=auth_headers,
|
|
json={
|
|
"action": "create",
|
|
"instrument": {
|
|
"asset_class": "fund",
|
|
"isin": UNKNOWN2_ISIN,
|
|
"ticker": "TRUR",
|
|
"board": "TQTF",
|
|
"name": "Тинькофф Вечный портфель",
|
|
"currency": "RUB",
|
|
"lot": 1,
|
|
},
|
|
},
|
|
)
|
|
assert created_row.status_code == 200, created_row.text
|
|
assert created_row.json()["status"] == "resolved"
|
|
assert created_row.json()["alias_created"] is True
|
|
instrument_id = created_row.json()["instrument_id"]
|
|
|
|
# (c) link: resolving the same key again is a conflict, a fresh one links
|
|
again = await http.post(
|
|
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
|
|
headers=auth_headers,
|
|
json={"action": "link", "instrument_id": instrument_id},
|
|
)
|
|
assert again.status_code == 409, again.text
|
|
|
|
async with get_sessionmaker()() as session:
|
|
instrument = await session.get(Instrument, instrument_id)
|
|
assert instrument is not None
|
|
assert instrument.asset_class.value == "fund"
|
|
bound = (
|
|
(await session.execute(select(Event).where(Event.instrument_id == instrument_id)))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert bound
|
|
assert all(e.status == EventStatus.confirmed for e in bound)
|
|
assert all(e.account_id == account_id for e in bound)
|
|
|
|
|
|
async def test_link_mode_binds_the_waiting_events(app, http, auth_headers, parser):
|
|
await make_account()
|
|
target = await make_instrument(ticker="STME", name="БПИФ", board="TQTF")
|
|
created = (await upload(http, auth_headers, content("U1"))).json()
|
|
await http.post(f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={})
|
|
pending_id = created["pending_instruments"][0]["id"]
|
|
|
|
response = await http.post(
|
|
f"{PREFIX}/instruments/pending/{pending_id}/resolve",
|
|
headers=auth_headers,
|
|
json={"action": "link", "instrument_id": target},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"id": pending_id,
|
|
"status": "resolved",
|
|
"instrument_id": target,
|
|
"events_bound": 1,
|
|
"alias_created": True,
|
|
"metrics_refreshed": True,
|
|
}
|
|
|
|
|
|
async def test_money_is_a_string_and_asset_class_is_not_an_enum(app, http, auth_headers, parser):
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
|
|
body = (await upload(http, auth_headers, content("T1,U1"))).json()
|
|
|
|
sample = body["sample_events"][0]
|
|
assert sample["amount"] == "-1000"
|
|
assert sample["quantity"] == "10"
|
|
assert sample["price"] == "100"
|
|
assert isinstance(sample["kind"], str)
|
|
pending = body["pending_instruments"][0]
|
|
assert isinstance(pending["asset_class_hint"], str)
|
|
assert isinstance(pending["sample_quantity"], str)
|
|
recon = body["reconciliation"]
|
|
assert isinstance(recon["cash"][0]["balance_report"], str)
|
|
assert body["warnings"]
|
|
|
|
|
|
async def test_reconciliation_compares_the_report_against_the_ledger(
|
|
app, http, auth_headers, parser
|
|
):
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
created = (await upload(http, auth_headers, content("T1"))).json()
|
|
|
|
result = await http.post(
|
|
f"{PREFIX}/imports/{created['id']}/commit", headers=auth_headers, json={}
|
|
)
|
|
|
|
recon = result.json()["reconciliation"]
|
|
assert recon["as_of"] == "2026-09-17"
|
|
assert recon["positions"][0]["qty_report"] == "10"
|
|
assert recon["positions"][0]["qty_derived"] == "10.0000000000"
|
|
assert recon["cash"][0]["currency"] == "RUB"
|
|
assert recon["matches"] is True
|
|
|
|
|
|
async def test_uncommitted_import_can_be_deleted_and_a_committed_one_cannot(
|
|
app, http, auth_headers, parser
|
|
):
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
created = (await upload(http, auth_headers, content("T1"))).json()
|
|
|
|
dropped = await http.delete(f"{PREFIX}/imports/{created['id']}", headers=auth_headers)
|
|
assert dropped.status_code == 204, dropped.text
|
|
|
|
again = (await upload(http, auth_headers, content("T1"))).json()
|
|
await http.post(f"{PREFIX}/imports/{again['id']}/commit", headers=auth_headers, json={})
|
|
refused = await http.delete(f"{PREFIX}/imports/{again['id']}", headers=auth_headers)
|
|
assert refused.status_code == 409, refused.text
|
|
|
|
|
|
async def test_listing_and_fetching_one_import(app, http, auth_headers, parser):
|
|
await make_account()
|
|
await make_instrument(ticker="GAZP", name="Газпром")
|
|
created = (await upload(http, auth_headers, content("T1"))).json()
|
|
|
|
listing = await http.get(f"{PREFIX}/imports", headers=auth_headers)
|
|
one = await http.get(f"{PREFIX}/imports/{created['id']}", headers=auth_headers)
|
|
|
|
assert [r["id"] for r in listing.json()] == [created["id"]]
|
|
assert listing.json()[0]["account_name"] == "Сбер ИИС"
|
|
assert one.json()["id"] == created["id"]
|
|
assert one.json()["parse_status"] == "parsed"
|
|
|
|
|
|
async def test_pending_path_is_not_eaten_by_the_instrument_id_route(app, http, auth_headers):
|
|
"""`pending_router` is mounted first on purpose — otherwise this 422s on int('pending')."""
|
|
response = await http.get(f"{PREFIX}/instruments/pending", headers=auth_headers)
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
async def test_unknown_import_is_404(app, http, auth_headers):
|
|
response = await http.get(f"{PREFIX}/imports/999", headers=auth_headers)
|
|
assert response.status_code == 404, response.text
|
|
|
|
|
|
async def test_pending_rows_are_created_by_the_preview_alone(app, http, auth_headers, parser):
|
|
await make_account()
|
|
await upload(http, auth_headers, content("U1"))
|
|
|
|
async with get_sessionmaker()() as session:
|
|
assert await session.scalar(select(func.count()).select_from(PendingInstrument)) == 1
|
|
assert await session.scalar(select(func.count()).select_from(Event)) == 0
|