208 lines
7.7 KiB
Python
208 lines
7.7 KiB
Python
"""Ручной ввод операций, которых нет в выгрузке 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
|
||
]
|