"""Ручной ввод операций, которых нет в выгрузке QUIK (D21): купон (CPN), амортизация (NOM), комиссия за андеррайтинг (UWFEE, D22), корректировка (ADJ). Купля/продажа вручную не вводятся — только загрузкой файла. TODO(BIQ-38115): права на ручной ввод — Q16; пока может любой пользователь. """ from __future__ import annotations import re from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal, InvalidOperation from app.db.base import SessionLocal from app.db.models import OperationCode from app.repositories.operations import OperationRepository from app.repositories.reference import ReferenceRepository from app.repositories.securities import SecurityRepository MANUAL_TYPES = { OperationCode.CPN: "Купон", OperationCode.NOM: "Амортизация (частичное погашение номинала)", OperationCode.UWFEE: "Комиссия за андеррайтинг", OperationCode.ADJ: "Корректировка", } # Комиссия за андеррайтинг — только в портфеле андеррайтинга (D22). UWFEE_PORTFOLIO = "UW" _POSITIVE_AMOUNT = (OperationCode.CPN, OperationCode.NOM, OperationCode.UWFEE) _NUMBER_JUNK = re.compile(r"[\s ]") class ManualOperationError(RuntimeError): """Ошибка с понятным пользователю текстом.""" @dataclass(frozen=True) class ManualOperationInput: operation_code: str portfolio_code: str isin: str trade_date: date amount: Decimal | None quantity: Decimal | None comment: str currency_code: str | None = None # None — валюта бумаги @dataclass(frozen=True) class SecurityOption: isin: str name: str currency: str @dataclass(frozen=True) class FormOptions: portfolios: dict[str, str] # код -> название securities: list[SecurityOption] currencies: list[str] @dataclass(frozen=True) class ManualOperationRow: trade_date: date operation: str portfolio: str isin: str name: str quantity: Decimal | None amount: Decimal | None currency: str comment: str | None created_by: str created_at: datetime def parse_decimal(text: str, field: str) -> Decimal | None: """Поле формы -> Decimal (D15): пусто — None, запятая и пробелы допустимы.""" value = _NUMBER_JUNK.sub("", text or "").replace(",", ".") if not value: return None try: number = Decimal(value) except InvalidOperation: raise ManualOperationError(f"«{field}»: не число: «{text}».") from None if not number.is_finite(): raise ManualOperationError(f"«{field}»: не число: «{text}».") return number def validate(operation: ManualOperationInput) -> list[str]: code = operation.operation_code amount, quantity = operation.amount, operation.quantity errors = [] if code not in MANUAL_TYPES: errors.append(f"Тип операции {code} вручную не вводится.") if code in _POSITIVE_AMOUNT: if amount is None: errors.append("Укажите сумму.") elif amount <= 0: errors.append("Сумма должна быть больше нуля.") if code in (OperationCode.CPN, OperationCode.NOM): if quantity is not None and quantity <= 0: errors.append("Количество должно быть больше нуля.") if code == OperationCode.UWFEE: if operation.portfolio_code != UWFEE_PORTFOLIO: errors.append( "Комиссия за андеррайтинг вводится только в портфеле " f"{UWFEE_PORTFOLIO}." ) if quantity is not None: errors.append("У комиссии за андеррайтинг нет количества.") if code == OperationCode.ADJ: if not operation.comment.strip(): errors.append( "У корректировки обязателен комментарий: что и почему корректируется." ) if not amount and not quantity: errors.append("Укажите сумму и/или количество (со знаком).") return errors async def load_form_options() -> FormOptions: async with SessionLocal() as session: reference = ReferenceRepository(session) portfolios = await reference.list_portfolios() currencies = await reference.currency_ids() securities = await SecurityRepository(session).list_with_currency() return FormOptions( portfolios={p.code: p.name for p in portfolios}, securities=[ SecurityOption(security.isin, security.name, currency) for security, currency in securities ], currencies=sorted(currencies), ) async def add_manual_operation(operation: ManualOperationInput, user: str) -> int: errors = validate(operation) if errors: raise ManualOperationError(" ".join(errors)) async with SessionLocal() as session: security = await SecurityRepository(session).get_by_isin(operation.isin) if security is None: raise ManualOperationError(f"Бумаги {operation.isin} нет в справочнике.") reference = ReferenceRepository(session) portfolio_id = next( ( p.id for p in await reference.list_portfolios() if p.code == operation.portfolio_code ), None, ) if portfolio_id is None: raise ManualOperationError(f"Нет портфеля {operation.portfolio_code}.") currency_id = security.currency_id if operation.currency_code is not None: currency_id = (await reference.currency_ids()).get(operation.currency_code) if currency_id is None: raise ManualOperationError( f"Валюты {operation.currency_code} нет в справочнике." ) type_id = (await reference.operation_type_ids())[operation.operation_code] # TODO(BIQ-38115): сумма CPN/NOM вводится, а не считается — Q17; # участие NOM в реализованном результате — Q6; смысл ADJ — Q28. created = await OperationRepository(session).add( security_id=security.id, portfolio_id=portfolio_id, operation_type_id=type_id, currency_id=currency_id, trade_date=operation.trade_date, quantity=operation.quantity, amount=operation.amount, comment=operation.comment.strip() or None, created_by=user, updated_by=user, ) await session.commit() return created.id async def list_manual_operations(limit: int = 50) -> list[ManualOperationRow]: async with SessionLocal() as session: rows = await OperationRepository(session).list_manual(limit) return [ ManualOperationRow( trade_date=operation.trade_date, operation=MANUAL_TYPES.get(code, code), portfolio=portfolio, isin=isin, name=name, quantity=operation.quantity, amount=operation.amount, currency=currency, comment=operation.comment, created_by=operation.created_by, created_at=operation.created_at, ) for operation, code, portfolio, isin, name, currency in rows ]