feat(ledger): резолв несматченных линков ZenMoney↔брокер
GET /links/unmatched, POST /links, DELETE /links/{id}: ручная привязка того,
что ledger/matching.py не смог сопоставить автоматически.
This commit is contained in:
@@ -23,6 +23,7 @@ from fintracker.api.routers import (
|
||||
events,
|
||||
health,
|
||||
instruments,
|
||||
links,
|
||||
metrics,
|
||||
networth,
|
||||
rules,
|
||||
@@ -86,6 +87,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(analytics.router, prefix=API_PREFIX)
|
||||
app.include_router(events.router, prefix=API_PREFIX)
|
||||
app.include_router(instruments.router, prefix=API_PREFIX)
|
||||
app.include_router(links.router, prefix=API_PREFIX)
|
||||
app.include_router(metrics.router, prefix=API_PREFIX)
|
||||
if settings.web_dir is not None:
|
||||
mount_web(app, settings.web_dir, API_PREFIX)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Manual review and confirmation of ZenMoney <-> broker cash-flow links (plan §1.6 C, §4).
|
||||
|
||||
`ledger/matching.py` runs automatically after every refresh and links what it can; this
|
||||
router is where the leftovers get resolved by hand. `GET /links/unmatched` recomputes the
|
||||
candidate pools live rather than reading the last refresh's `FINDINGS` (a snapshot that goes
|
||||
stale the moment a new sync runs), and shows only what has no `flow_link` yet, of either kind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.links import (
|
||||
LinkCreate,
|
||||
LinkOut,
|
||||
UnmatchedBrokerOut,
|
||||
UnmatchedOut,
|
||||
UnmatchedZmOut,
|
||||
)
|
||||
from fintracker.ledger.matching import (
|
||||
_load_broker_candidates,
|
||||
_load_zm_candidates,
|
||||
business_days_between,
|
||||
)
|
||||
from fintracker.models import CashTxn, Event, FlowLink, FlowLinkKind, FlowType
|
||||
|
||||
router = APIRouter(prefix="/links", tags=["links"])
|
||||
|
||||
ZERO = Decimal(0)
|
||||
|
||||
|
||||
@router.get("/unmatched", name="unmatched")
|
||||
async def unmatched(session: SessionDep, _: CurrentUser) -> UnmatchedOut:
|
||||
"""Every candidate that survives matching without a link, on either side."""
|
||||
linked_txns = set((await session.execute(select(FlowLink.cash_txn_id))).scalars().all())
|
||||
linked_events = set((await session.execute(select(FlowLink.event_id))).scalars().all())
|
||||
|
||||
zm = [c for c in await _load_zm_candidates(session) if c.cash_txn_id not in linked_txns]
|
||||
broker = [c for c in await _load_broker_candidates(session) if c.event_id not in linked_events]
|
||||
|
||||
txns: dict[int, CashTxn] = {}
|
||||
if zm:
|
||||
rows = (
|
||||
await session.execute(select(CashTxn).where(CashTxn.id.in_(c.cash_txn_id for c in zm)))
|
||||
).scalars()
|
||||
txns = {t.id: t for t in rows}
|
||||
|
||||
return UnmatchedOut(
|
||||
zm=[
|
||||
UnmatchedZmOut(
|
||||
cash_txn_id=c.cash_txn_id,
|
||||
date=c.date,
|
||||
amount=c.amount,
|
||||
currency=c.currency,
|
||||
direction=c.direction.value,
|
||||
broker_account_id=c.broker_account_id,
|
||||
route=c.route,
|
||||
payee=txns[c.cash_txn_id].payee if c.cash_txn_id in txns else None,
|
||||
comment=txns[c.cash_txn_id].comment if c.cash_txn_id in txns else None,
|
||||
)
|
||||
for c in zm
|
||||
],
|
||||
broker=[
|
||||
UnmatchedBrokerOut(
|
||||
event_id=c.event_id,
|
||||
account_id=c.account_id,
|
||||
date=c.date,
|
||||
amount=c.amount,
|
||||
currency=c.currency,
|
||||
direction=c.direction.value,
|
||||
)
|
||||
for c in broker
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
|
||||
async def create_link(body: LinkCreate, session: SessionDep, _: CurrentUser) -> LinkOut:
|
||||
"""Confirm a pairing the matcher missed. Never touched by the automatic rebuild afterwards."""
|
||||
txn = await session.get(CashTxn, body.cash_txn_id)
|
||||
if txn is None:
|
||||
raise Problem(404, "Not Found", f"Нет транзакции ZenMoney #{body.cash_txn_id}")
|
||||
event = await session.get(Event, body.event_id)
|
||||
if event is None:
|
||||
raise Problem(404, "Not Found", f"Нет события #{body.event_id}")
|
||||
|
||||
if (
|
||||
await session.execute(select(FlowLink).where(FlowLink.cash_txn_id == body.cash_txn_id))
|
||||
).scalar_one_or_none() is not None:
|
||||
raise Problem(409, "Conflict", f"Транзакция #{body.cash_txn_id} уже связана")
|
||||
if (
|
||||
await session.execute(select(FlowLink).where(FlowLink.event_id == body.event_id))
|
||||
).scalar_one_or_none() is not None:
|
||||
raise Problem(409, "Conflict", f"Событие #{body.event_id} уже связано")
|
||||
|
||||
zm_amount = txn.income if txn.income > ZERO else txn.outcome
|
||||
zm_currency = (txn.income_currency if txn.income > ZERO else txn.outcome_currency) or "RUB"
|
||||
event_amount = abs(event.amount or ZERO)
|
||||
event_currency = (event.currency or "RUB").upper()
|
||||
if zm_currency.upper() != event_currency:
|
||||
raise Problem(
|
||||
400, "Bad Request", f"Валюты не совпадают: {zm_currency.upper()} vs {event_currency}"
|
||||
)
|
||||
|
||||
link = FlowLink(
|
||||
cash_txn_id=body.cash_txn_id,
|
||||
event_id=body.event_id,
|
||||
kind=FlowLinkKind.manual,
|
||||
confidence=Decimal(1),
|
||||
amount_delta=abs(zm_amount - event_amount),
|
||||
currency=zm_currency.upper(),
|
||||
day_gap=business_days_between(txn.date, event.trade_date),
|
||||
note="manual",
|
||||
)
|
||||
session.add(link)
|
||||
# the point of the link: a linked transfer is not spending, same overwrite
|
||||
# `ledger/matching.py` does for the automatic ones
|
||||
txn.flow_type = FlowType.internal_transfer
|
||||
await session.commit()
|
||||
await session.refresh(link)
|
||||
return _link_out(link)
|
||||
|
||||
|
||||
@router.delete("/{link_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_link(link_id: int, session: SessionDep, _: CurrentUser) -> None:
|
||||
"""Undo a link, manual or automatic. `flow_type` reverts on the next `POST /metrics/refresh`,
|
||||
when `classify` rebuilds it from the rules and `matching` no longer overwrites it."""
|
||||
link = await session.get(FlowLink, link_id)
|
||||
if link is None:
|
||||
raise Problem(404, "Not Found", f"Нет связи #{link_id}")
|
||||
await session.delete(link)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _link_out(link: FlowLink) -> LinkOut:
|
||||
return LinkOut(
|
||||
id=link.id,
|
||||
cash_txn_id=link.cash_txn_id,
|
||||
event_id=link.event_id,
|
||||
kind=link.kind.value,
|
||||
confidence=link.confidence,
|
||||
amount_delta=link.amount_delta,
|
||||
currency=link.currency,
|
||||
day_gap=link.day_gap,
|
||||
note=link.note,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Schemas for manual review of ZenMoney <-> broker cash-flow links (plan §1.6 C, §4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fintracker.api.schemas.common import Money
|
||||
|
||||
|
||||
class UnmatchedZmOut(BaseModel):
|
||||
cash_txn_id: int
|
||||
date: date
|
||||
amount: Money
|
||||
"""Always positive: the magnitude that crossed the boundary."""
|
||||
currency: str
|
||||
direction: str
|
||||
"""to_broker | from_broker."""
|
||||
broker_account_id: int
|
||||
route: str
|
||||
"""account_link | mirror | rule — how the broker account was identified."""
|
||||
payee: str | None
|
||||
comment: str | None
|
||||
|
||||
|
||||
class UnmatchedBrokerOut(BaseModel):
|
||||
event_id: int
|
||||
account_id: int
|
||||
date: date
|
||||
amount: Money
|
||||
currency: str
|
||||
direction: str
|
||||
|
||||
|
||||
class UnmatchedOut(BaseModel):
|
||||
zm: list[UnmatchedZmOut]
|
||||
broker: list[UnmatchedBrokerOut]
|
||||
|
||||
|
||||
class LinkCreate(BaseModel):
|
||||
cash_txn_id: int
|
||||
event_id: int
|
||||
|
||||
|
||||
class LinkOut(BaseModel):
|
||||
id: int
|
||||
cash_txn_id: int
|
||||
event_id: int
|
||||
kind: str
|
||||
confidence: Money
|
||||
amount_delta: Money
|
||||
currency: str
|
||||
day_gap: int
|
||||
note: str | None
|
||||
Reference in New Issue
Block a user