"""Ручные операции (D21, D22): проверки по типам и запись на временном SQLite.""" from __future__ import annotations from datetime import date from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import select from app.db.base import run_db from app.db.models import Currency, Operation, Security from app.imports.quik import parse_quik_xlsx from app.services import quik_import from app.services.manual_operations import ( ManualOperationError, ManualOperationInput, add_manual_operation, list_manual_operations, load_form_options, parse_decimal, validate, ) USER = "analyst@rshb.ru" ISIN = "RU000A10EVH2" def _input(**changes) -> ManualOperationInput: # noqa: ANN003 values = { "operation_code": "CPN", "portfolio_code": "SALES", "isin": ISIN, "trade_date": date(2026, 4, 15), "amount": Decimal("125000.50"), "quantity": None, "comment": "", } values.update(changes) return ManualOperationInput(**values) @pytest.fixture def security(session_local) -> None: async def _add() -> None: async with session_local() as session: rub = ( await session.execute(select(Currency.id).where(Currency.code == "RUB")) ).scalar_one() session.add( Security( isin=ISIN, secid=ISIN, name="ЕврХол3P06", currency_id=rub, created_by=USER, updated_by=USER, ) ) await session.commit() run_db(_add()) # --- разбор и проверки -------------------------------------------------------- @pytest.mark.parametrize( ("text", "expected"), [ ("125 000,50", Decimal("125000.50")), ("-3", Decimal("-3")), ("0.1", Decimal("0.1")), ("", None), (" ", None), ], ) def test_parse_decimal(text: str, expected: Decimal | None) -> None: assert parse_decimal(text, "Сумма") == expected @pytest.mark.parametrize("text", ["abc", "1,2,3", "nan", "inf"]) def test_parse_decimal_rejects_garbage(text: str) -> None: with pytest.raises(ManualOperationError, match="«Сумма»: не число"): parse_decimal(text, "Сумма") @pytest.mark.parametrize( ("changes", "error"), [ ({"amount": None}, "Укажите сумму."), ({"amount": Decimal("0")}, "Сумма должна быть больше нуля."), ({"quantity": Decimal("-1")}, "Количество должно быть больше нуля."), ( {"operation_code": "NOM", "amount": Decimal("-5")}, "Сумма должна быть больше нуля.", ), ( {"operation_code": "UWFEE"}, "Комиссия за андеррайтинг вводится только в портфеле UW.", ), ( { "operation_code": "UWFEE", "portfolio_code": "UW", "quantity": Decimal("10"), }, "У комиссии за андеррайтинг нет количества.", ), ( {"operation_code": "ADJ", "amount": Decimal("-100")}, "У корректировки обязателен комментарий: что и почему корректируется.", ), ( {"operation_code": "ADJ", "amount": None, "comment": "сверка"}, "Укажите сумму и/или количество (со знаком).", ), ({"operation_code": "BUY"}, "Тип операции BUY вручную не вводится."), ], ) def test_validate_rejects(changes: dict, error: str) -> None: assert error in validate(_input(**changes)) @pytest.mark.parametrize( "changes", [ {}, {"quantity": Decimal("1000")}, {"operation_code": "NOM", "amount": Decimal("250000")}, {"operation_code": "UWFEE", "portfolio_code": "UW"}, {"operation_code": "ADJ", "amount": Decimal("-100"), "comment": "сверка"}, { "operation_code": "ADJ", "amount": None, "quantity": Decimal("-5"), "comment": "сверка остатка", }, ], ) def test_validate_accepts(changes: dict) -> None: assert validate(_input(**changes)) == [] # --- БД -------------------------------------------------------------------- def test_add_and_list_manual_operations(session_local, security: None) -> None: coupon_id = run_db(add_manual_operation(_input(comment=" купон №3 "), USER)) run_db( add_manual_operation( _input( operation_code="UWFEE", portfolio_code="UW", amount=Decimal("1500000"), currency_code="CNY", ), USER, ) ) rows = run_db(list_manual_operations()) assert [(r.operation, r.portfolio, r.currency) for r in rows] == [ ("Комиссия за андеррайтинг", "UW", "CNY"), ("Купон", "SALES", "RUB"), # по умолчанию — валюта бумаги ] assert rows[1].amount == Decimal("125000.50") assert rows[1].comment == "купон №3" assert rows[1].created_by == USER async def _stored() -> Operation: async with session_local() as session: return await session.get(Operation, coupon_id) stored = run_db(_stored()) assert stored.import_batch_id is None and stored.source_row is None assert stored.trade_date == date(2026, 4, 15) def test_add_refuses_invalid_input(session_local, security: None) -> None: with pytest.raises(ManualOperationError, match="только в портфеле UW"): run_db(add_manual_operation(_input(operation_code="UWFEE"), USER)) with pytest.raises(ManualOperationError, match="нет в справочнике"): run_db(add_manual_operation(_input(isin="RU000A000000"), USER)) with pytest.raises(ManualOperationError, match="Валюты XXX нет"): run_db(add_manual_operation(_input(currency_code="XXX"), USER)) assert run_db(list_manual_operations()) == [] def test_quik_operations_are_not_listed_as_manual( session_local, security: None, fixtures_dir: Path ) -> None: parsed = parse_quik_xlsx( (fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes() ) run_db( quik_import.save_import( parsed, file_name="сделки.xlsx", portfolio_code="SALES", decisions={}, expected_rows=[1, 2, 3, 4, 5, 6], user=USER, ) ) run_db(add_manual_operation(_input(), USER)) assert [r.operation for r in run_db(list_manual_operations())] == ["Купон"] def test_form_options(session_local, security: None) -> None: options = run_db(load_form_options()) assert options.portfolios == {"UW": "Андеррайтинг", "SALES": "Продажи"} assert [(s.isin, s.currency) for s in options.securities] == [(ISIN, "RUB")] assert options.currencies == ["CNY", "RUB", "USD"]