feat(ledger): резолв несматченных линков ZenMoney↔брокер

GET /links/unmatched, POST /links, DELETE /links/{id}: ручная привязка того,
что ledger/matching.py не смог сопоставить автоматически.
This commit is contained in:
Dmitry
2026-09-19 10:38:39 +03:00
parent fcb3fe964e
commit ae82df6fa7
4 changed files with 312 additions and 0 deletions
+2
View File
@@ -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)
+151
View File
@@ -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