"""Сохранение загрузки QUIK: import_batch и операции одной транзакцией (D19, D20). Временный SQLite со схемой из миграций.""" from __future__ import annotations import time as timer from dataclasses import replace from datetime import date, time from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import func, select from app.db.base import run_db from app.db.models import ( Currency, ImportBatch, Operation, OperationType, Portfolio, Security, ) from app.imports.quik import QuikParseResult, parse_quik_xlsx from app.repositories.operations import OperationRepository from app.services import quik_import from app.services.quik_import import ( EXCLUDE, INCLUDE, QuikImportError, build_plan, load_context, save_import, ) USER = "analyst@rshb.ru" SHORT = "ЕврХол3P06" ISIN = "RU000A10EVH2" FILE_NAME = "сделки_300326.xlsx" @pytest.fixture def parsed(fixtures_dir: Path) -> QuikParseResult: return parse_quik_xlsx((fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes()) @pytest.fixture def security_id(session_local) -> int: async def _add() -> int: async with session_local() as session: rub = ( await session.execute(select(Currency.id).where(Currency.code == "RUB")) ).scalar_one() security = Security( isin=ISIN, secid=ISIN, name=SHORT, currency_id=rub, created_by=USER, updated_by=USER, ) session.add(security) await session.commit() return security.id return run_db(_add()) def _with_comments(parsed: QuikParseResult, comments: list[str]) -> QuikParseResult: trades = [ replace(t, comment=c) for t, c in zip(parsed.trades, comments, strict=True) ] return QuikParseResult(sheet=parsed.sheet, trades=trades) def _expected( parsed: QuikParseResult, code: str, decisions: dict[int, str] ) -> list[int]: context = run_db(load_context(parsed)) plan = build_plan(parsed.trades, context, code, decisions) return [row.trade.ordinal for row in plan.to_load] def _save( parsed: QuikParseResult, code: str, decisions: dict[int, str] | None = None, expected: list[int] | None = None, ): decisions = decisions or {} if expected is None: expected = _expected(parsed, code, decisions) return run_db( save_import( parsed, file_name=FILE_NAME, portfolio_code=code, decisions=decisions, expected_rows=expected, user=USER, ) ) def _operations(session_local) -> list[tuple[Operation, str, str, str]]: async def _read(): async with session_local() as session: result = await session.execute( select(Operation, OperationType.code, Portfolio.code, Currency.code) .join(OperationType, OperationType.id == Operation.operation_type_id) .join(Portfolio, Portfolio.id == Operation.portfolio_id) .join(Currency, Currency.id == Operation.currency_id) .order_by(Operation.id) ) return [tuple(row) for row in result.all()] return run_db(_read()) def _batches(session_local) -> list[ImportBatch]: async def _read(): async with session_local() as session: result = await session.execute(select(ImportBatch).order_by(ImportBatch.id)) return list(result.scalars().all()) return run_db(_read()) def _counts(session_local) -> tuple[int, int]: async def _read(): async with session_local() as session: batches = await session.scalar(select(func.count(ImportBatch.id))) operations = await session.scalar(select(func.count(Operation.id))) return batches, operations return run_db(_read()) # --- успешная загрузка -------------------------------------------------------- def test_save_writes_batch_and_operations( session_local, security_id: int, parsed: QuikParseResult ) -> None: result = _save(parsed, "SALES") assert (result.portfolio_code, result.rows_loaded, result.rows_excluded) == ( "SALES", 6, 0, ) [batch] = _batches(session_local) assert batch.id == result.batch_id assert (batch.source, batch.file_name, batch.content_sha256) == ( "QUIK", FILE_NAME, parsed.content_hash, ) assert (batch.date_from, batch.date_to) == (date(2026, 3, 30),) * 2 assert (batch.rows_total, batch.rows_loaded, batch.rows_excluded) == (6, 6, 0) assert batch.loaded_rows == [1, 2, 3, 4, 5, 6] assert batch.created_by == USER rows = _operations(session_local) assert [code for _, code, _, _ in rows] == [ "BUY", "BUY", "BUY", "SELL", "BUY", "SELL", ] assert {(portfolio, currency) for _, _, portfolio, currency in rows} == { ("SALES", "RUB") } assert [op.source_row for op, *_ in rows] == [1, 2, 3, 4, 5, 6] first = rows[0][0] assert first.security_id == security_id assert first.import_batch_id == batch.id assert (first.trade_date, first.trade_time, first.settlement_date) == ( date(2026, 3, 30), time(13, 53, 10), date(2026, 3, 30), ) assert (first.quantity, first.price) == (Decimal("54784"), Decimal("100")) assert first.amount == first.settlement_amount == Decimal("54784000") assert first.amount_with_accrued_interest is None # Q17 assert first.trade_number is None # Q2 # D7: пять комиссий по отдельности. assert first.commission_ts == Decimal("8217.6") assert first.commission_clearing == Decimal("3492.48") assert first.commission_exchange == Decimal("4725.12") assert first.commission_tc == first.commission_broker == Decimal("0") assert (first.account, first.comment, first.settlement_code) == ( "S00+00000000", "/pp", "Z0", ) assert (first.order_number, first.counterparty) == ("10000000001", "Контрагент 1") assert first.created_by == first.updated_by == USER def test_file_split_between_portfolios( session_local, security_id: int, parsed: QuikParseResult ) -> None: """Один файл — в оба портфеля по разу (D19). Строка без комментария, отнесённая к SALES, во второй загрузке не берётся.""" mixed = _with_comments(parsed, ["/pp", "/pp", "/uw", "/trde", "", "/pp"]) sales = _save(mixed, "SALES", {4: EXCLUDE, 5: INCLUDE}) uw = _save(mixed, "UW", {4: EXCLUDE}) assert (sales.rows_loaded, sales.rows_excluded) == (4, 1) assert (uw.rows_loaded, uw.rows_excluded) == (1, 1) in_sales, in_uw = _batches(session_local) assert in_sales.loaded_rows == [1, 2, 5, 6] assert in_uw.loaded_rows == [3] assert [ (op.source_row, portfolio) for op, _, portfolio, _ in _operations(session_local) ] == [(1, "SALES"), (2, "SALES"), (5, "SALES"), (6, "SALES"), (3, "UW")] summary = build_plan(mixed.trades, run_db(load_context(mixed)), "UW", {}).summary assert summary.loaded == {"UW": 1, "SALES": 4} assert summary.nowhere == 1 # /trde исключена в обоих def test_large_file_is_saved_in_one_go( session_local, security_id: int, parsed: QuikParseResult ) -> None: """День размещения — 1000+ строк (до 1660 сделок по выпуску).""" trades = [ replace( parsed.trades[i % 6], ordinal=i + 1, row=i + 2, order_number=str(20000000000 + i), ) for i in range(1700) ] big = QuikParseResult(sheet="Лист2", trades=trades) started = timer.perf_counter() result = _save(big, "SALES") elapsed = timer.perf_counter() - started assert result.rows_loaded == 1700 assert _counts(session_local) == (1, 1700) assert elapsed < 30, elapsed # --- отказы: ничего не записано --------------------------------------------------- def test_same_file_same_portfolio_is_refused( session_local, security_id: int, parsed: QuikParseResult ) -> None: expected = _expected(parsed, "SALES", {}) _save(parsed, "SALES") with pytest.raises(QuikImportError, match="уже загружен в SALES"): _save(parsed, "SALES", expected=expected) assert _counts(session_local) == (1, 6) def test_concurrent_duplicate_is_caught_by_unique_index( session_local, security_id: int, parsed: QuikParseResult, monkeypatch ) -> None: """Гонка: второй пользователь проверял файл до того, как первый сохранил. План у него без повтора — отказ даёт уникальный индекс, без частичной записи.""" _save(parsed, "SALES") original = quik_import._read_context async def _stale(session, parsed_result): # noqa: ANN001, ANN202 context = await original(session, parsed_result) context.same_file = [] return context monkeypatch.setattr(quik_import, "_read_context", _stale) with pytest.raises(QuikImportError, match="уже загружен в SALES"): _save(parsed, "SALES", expected=[1, 2, 3, 4, 5, 6]) assert _counts(session_local) == (1, 6) def test_failure_mid_save_rolls_back_everything( session_local, security_id: int, parsed: QuikParseResult, monkeypatch ) -> None: original = OperationRepository.add_many async def _broken(self, rows): # noqa: ANN001, ANN202 await original(self, rows[:3]) raise RuntimeError("сбой посреди записи") monkeypatch.setattr(OperationRepository, "add_many", _broken) with pytest.raises(RuntimeError, match="сбой"): _save(parsed, "SALES") assert _counts(session_local) == (0, 0) def test_refused_when_security_is_unknown( session_local, parsed: QuikParseResult ) -> None: with pytest.raises(QuikImportError, match=f"нет в справочнике бумаг: {SHORT}"): _save(parsed, "SALES", expected=[1, 2, 3, 4, 5, 6]) assert _counts(session_local) == (0, 0) def test_refused_while_rows_without_portfolio_are_pending( session_local, security_id: int, parsed: QuikParseResult ) -> None: mixed = _with_comments(parsed, ["/pp", "", "/pp", "/pp", "/pp", "/pp"]) with pytest.raises(QuikImportError, match="не решено строк без портфеля: 1"): _save(mixed, "SALES") assert _counts(session_local) == (0, 0) def test_refused_when_preview_is_stale( session_local, security_id: int, parsed: QuikParseResult ) -> None: with pytest.raises(QuikImportError, match="изменились"): _save(parsed, "SALES", expected=[1, 2]) assert _counts(session_local) == (0, 0) def test_refused_for_file_with_errors( session_local, security_id: int, parsed: QuikParseResult ) -> None: from app.imports.quik import RowIssue parsed.errors.append(RowIssue(3, "Цена", "не число")) with pytest.raises(QuikImportError, match="с ошибками"): _save(parsed, "SALES", expected=[1, 2, 3, 4, 5, 6]) assert _counts(session_local) == (0, 0)