feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале

Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
Dmitry
2026-09-19 21:54:42 +03:00
parent 1b521be35e
commit 4236993106
12 changed files with 559 additions and 13 deletions
+87 -4
View File
@@ -5,19 +5,100 @@ from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.accounts import AccountOut, AccountPatch
from fintracker.models import Account
from fintracker.api.schemas.accounts import AccountCreate, AccountOut, AccountPatch
from fintracker.models import (
Account,
AccountKind,
AccountRole,
Broker,
EventSource,
MetricPortfolioValueDaily,
)
router = APIRouter(prefix="/accounts", tags=["accounts"])
async def _out(session: SessionDep, accounts: list[Account]) -> list[AccountOut]:
"""Rows as the API shows them, each with its latest ledger valuation (null when none)."""
latest = (
await session.execute(
select(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.total_rub)
.where(MetricPortfolioValueDaily.scope.like("account:%"))
.distinct(MetricPortfolioValueDaily.scope)
.order_by(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.d.desc())
)
).all()
values = {row.scope: row.total_rub for row in latest}
out: list[AccountOut] = []
for a in accounts:
row = AccountOut.model_validate(a, from_attributes=True)
row.value_rub = values.get(f"account:{a.id}")
out.append(row)
return out
@router.get("", name="list")
async def list_accounts(session: SessionDep, _: CurrentUser) -> list[AccountOut]:
"""Every account, archived ones included — the client decides what to show."""
rows = (
await session.execute(select(Account).order_by(Account.archived, Account.name, Account.id))
).scalars()
return [AccountOut.model_validate(a, from_attributes=True) for a in rows]
return await _out(session, list(rows))
_REPORT_SOURCES = {
Broker.sber: ("report_sber", EventSource.report_sber),
Broker.vtb: ("report_vtb", EventSource.report_vtb),
Broker.other: ("csv", None),
}
"""`account.source` and `primary_event_source` for a broker account fed by report imports."""
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
async def create_account(body: AccountCreate, session: SessionDep, _: CurrentUser) -> AccountOut:
"""Create the broker account a report import will write into.
`source_id` is the agreement number the report prints: it is how the import finds the
account on its own the next time.
"""
if body.broker not in _REPORT_SOURCES:
raise Problem(
status.HTTP_400_BAD_REQUEST,
"Bad request",
"Счета T-Invest создаёт синхронизация, вручную их не заводят",
)
name, source_id = body.name.strip(), body.source_id.strip()
if not name or not source_id:
raise Problem(
status.HTTP_400_BAD_REQUEST, "Bad request", "name and source_id must not be empty"
)
source, primary = _REPORT_SOURCES[body.broker]
taken = (
await session.execute(
select(Account.name).where(Account.source == source, Account.source_id == source_id)
)
).scalar_one_or_none()
if taken is not None:
raise Problem(
status.HTTP_409_CONFLICT,
"Conflict",
f"Счёт с номером договора {source_id} уже есть: «{taken}»",
)
account = Account(
kind=AccountKind.broker,
source=source,
source_id=source_id,
broker=body.broker,
name=name,
currency=body.currency.upper(),
role=AccountRole.investment,
primary_event_source=primary,
include_in_net_worth=True,
)
session.add(account)
await session.commit()
await session.refresh(account)
return (await _out(session, [account]))[0]
@router.patch("/{account_id}", name="patch")
@@ -38,6 +119,8 @@ async def patch_account(
)
if "role" in changes and changes["role"] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "role must not be null")
if "disabled" in changes and changes["disabled"] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "disabled must not be null")
mirror = changes.get("mirror_of_account_id")
if mirror is not None:
if mirror == account_id:
@@ -51,4 +134,4 @@ async def patch_account(
setattr(account, field, value)
await session.commit()
await session.refresh(account)
return AccountOut.model_validate(account, from_attributes=True)
return (await _out(session, [account]))[0]
+22 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from fintracker.api.schemas.common import MoneyOpt
from fintracker.models import AccountKind, AccountRole, Broker, EventSource
@@ -21,12 +21,17 @@ class AccountOut(BaseModel):
mirror_of_account_id: int | None
primary_event_source: EventSource | None
archived: bool
disabled: bool
"""User switch: the account is left out of net worth, portfolios and metrics."""
opened_at: date | None
balance: MoneyOpt
"""Native currency, as the source reported it; JSON string."""
balance_as_of: datetime | None
start_balance: MoneyOpt
credit_limit: MoneyOpt
value_rub: MoneyOpt = None
"""Latest ledger valuation in RUB (`metric_portfolio_value_daily`): what a broker account is
worth, since it has no `balance`. Null when the account has no valuation."""
class AccountPatch(BaseModel):
@@ -42,3 +47,19 @@ class AccountPatch(BaseModel):
role: AccountRole | None = None
mirror_of_account_id: int | None = None
primary_event_source: EventSource | None = None
disabled: bool | None = None
class AccountCreate(BaseModel):
"""A broker account for report imports. T-Invest accounts come from the sync, not from here."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=256)
broker: Broker
source_id: str = Field(
min_length=1,
max_length=128,
description="Agreement number exactly as the broker's report prints it",
)
currency: str = Field(default="RUB", min_length=3, max_length=3)