220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
"""Ручные операции (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"]
|