feat: Certain fragt in der Buchungsliste nach, ob es ein Uebertrag ist
Die Erkennung gab es laengst – aber nur auf der Seite "Zu erledigen", in einer Karte, die man erst finden muss. Wer in der Buchungsliste steht und die zweite Haelfte eines Uebertrags sieht, greift stattdessen zum ↔-Knopf und legt einen zweiten Uebertrag an. Genau so ist heute eine Zahlung doppelt verschoben worden. Jetzt steht die Frage in der Zeile: "Uebertrag von/nach Revolut CHF?" mit Verbinden und Ignorieren. Ignorieren wird gemerkt, sonst fragte Certain bei jedem Laden erneut. Dafuer gibt es die Suche jetzt auch als reines Nachschlagen: ``finde`` mit ``nur_vorschlaege`` verknuepft nichts, und dahinter liegt GET /uebertraege/vorschlaege. Eine Ansicht, die bloss fragt, darf beim Hinsehen nichts veraendern – sonst entschiede das Anschauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,16 @@ def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_u
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vorschlaege")
|
||||||
|
def vorschlaege(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Nur fragen, nichts tun.
|
||||||
|
|
||||||
|
Getrennt von ``/suchen``: Eine Ansicht, die bloss „ist das ein Übertrag?"
|
||||||
|
anzeigt, darf beim Hinsehen nichts verknüpfen.
|
||||||
|
"""
|
||||||
|
return uebertraege.finde(db, user, nur_vorschlaege=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suchen")
|
@router.post("/suchen")
|
||||||
def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||||||
"""Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der
|
"""Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der
|
||||||
|
|||||||
@@ -226,8 +226,13 @@ def _eindeutig(a: Transaction, moegliche: list[Transaction],
|
|||||||
return moegliche[0] if len(konten) == 1 else None
|
return moegliche[0] if len(konten) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def finde(db: Session, user: User) -> dict:
|
def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||||||
"""Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden."""
|
"""Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden.
|
||||||
|
|
||||||
|
Mit ``nur_vorschlaege`` wird NICHTS verknüpft – dann kommt alles als
|
||||||
|
Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das
|
||||||
|
ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern.
|
||||||
|
"""
|
||||||
kandidaten = _kandidaten(db, user)
|
kandidaten = _kandidaten(db, user)
|
||||||
bezeichner = _kontobezeichner(db, user)
|
bezeichner = _kontobezeichner(db, user)
|
||||||
nach_id = {}
|
nach_id = {}
|
||||||
@@ -260,11 +265,15 @@ def finde(db: Session, user: User) -> dict:
|
|||||||
if not _hat_hinweis(a, gegen, bezeichner):
|
if not _hat_hinweis(a, gegen, bezeichner):
|
||||||
vorschlaege.append((a, gegen))
|
vorschlaege.append((a, gegen))
|
||||||
continue
|
continue
|
||||||
|
if nur_vorschlaege:
|
||||||
|
vorschlaege.append((a, gegen))
|
||||||
|
continue
|
||||||
verbinde(db, user, a, gegen, automatisch=True)
|
verbinde(db, user, a, gegen, automatisch=True)
|
||||||
verbraucht.update((a.id, gegen.id))
|
verbraucht.update((a.id, gegen.id))
|
||||||
verknuepft += 1
|
verknuepft += 1
|
||||||
|
|
||||||
db.commit()
|
if not nur_vorschlaege:
|
||||||
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"verknuepft": verknuepft,
|
"verknuepft": verknuepft,
|
||||||
"vorschlaege": [
|
"vorschlaege": [
|
||||||
|
|||||||
@@ -283,3 +283,29 @@ def test_ignorierte_paare_kehren_nicht_zurueck(admin):
|
|||||||
admin.post("/api/uebertraege/ignorieren", json={"a_id": aus["id"], "b_id": ein["id"]})
|
admin.post("/api/uebertraege/ignorieren", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||||||
danach = _suchen(admin)["vorschlaege"]
|
danach = _suchen(admin)["vorschlaege"]
|
||||||
assert not any({v["a"]["id"], v["b"]["id"]} == {aus["id"], ein["id"]} for v in danach)
|
assert not any({v["a"]["id"], v["b"]["id"]} == {aus["id"], ein["id"]} for v in danach)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorschlaege_veraendern_nichts(admin):
|
||||||
|
"""Eine Ansicht, die bloss fragt „ist das ein Uebertrag?", darf beim
|
||||||
|
Hinsehen nichts verknuepfen – sonst entscheidet das Anschauen."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
a = _konto(admin, name=f"V-A {secrets.token_hex(3)}")
|
||||||
|
b = _konto(admin, name=f"V-B {secrets.token_hex(3)}")
|
||||||
|
betrag = 30000 + secrets.randbelow(900)
|
||||||
|
# Mit Hinweis im Text – das wuerde /suchen automatisch verknuepfen.
|
||||||
|
raus = admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": "2026-08-20", "account_id": a["id"],
|
||||||
|
"amount_cents": betrag, "payee": f"Uebertrag auf {b['name']}"}).json()
|
||||||
|
rein = admin.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": "2026-08-20", "account_id": b["id"],
|
||||||
|
"amount_cents": betrag, "payee": "Gutschrift"}).json()
|
||||||
|
|
||||||
|
r = admin.get("/api/uebertraege/vorschlaege").json()
|
||||||
|
assert r["verknuepft"] == 0, "beim Fragen darf nichts verknuepft werden"
|
||||||
|
paare = [(v["a"]["id"], v["b"]["id"]) for v in r["vorschlaege"]]
|
||||||
|
assert any({raus["id"], rein["id"]} == set(p) for p in paare)
|
||||||
|
|
||||||
|
# Und es ist wirklich keine Verknuepfung entstanden.
|
||||||
|
links = admin.get("/api/transactions/links").json()
|
||||||
|
assert str(raus["id"]) not in links and str(rein["id"]) not in links
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ export const api = {
|
|||||||
applyRules: (vorschau = true) =>
|
applyRules: (vorschau = true) =>
|
||||||
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
||||||
transactionLinks: () => request("/transactions/links"),
|
transactionLinks: () => request("/transactions/links"),
|
||||||
|
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
|
||||||
|
// dass hier ein Uebertrag stecken koennte.
|
||||||
|
transferSuggestions: () => request("/uebertraege/vorschlaege"),
|
||||||
searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }),
|
searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }),
|
||||||
connectTransfer: (a_id, b_id) =>
|
connectTransfer: (a_id, b_id) =>
|
||||||
request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }),
|
request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }),
|
||||||
|
|||||||
@@ -1391,3 +1391,19 @@ tr.row-dup {
|
|||||||
.bank-verbindung .input-with-button > .muted {
|
.bank-verbindung .input-with-button > .muted {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rückfrage in der Buchungszeile: „Das könnte ein Übertrag sein." Bewusst
|
||||||
|
auffällig genug, um gesehen zu werden – sie verlangt eine Antwort, anders
|
||||||
|
als die Marke einer schon bestätigten Verknüpfung. */
|
||||||
|
.uebertrag-frage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--warn);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.uebertrag-frage .btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -683,7 +683,8 @@
|
|||||||
"hasCategory": "Eine der beiden hat eine Kategorie – sie fällt durchs Verbinden aus der Auswertung.",
|
"hasCategory": "Eine der beiden hat eine Kategorie – sie fällt durchs Verbinden aus der Auswertung.",
|
||||||
"linked": "Übertrag",
|
"linked": "Übertrag",
|
||||||
"release": "Verknüpfung lösen",
|
"release": "Verknüpfung lösen",
|
||||||
"releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme."
|
"releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme.",
|
||||||
|
"question": "Übertrag von/nach {{konto}}?"
|
||||||
},
|
},
|
||||||
"todo": {
|
"todo": {
|
||||||
"title": "Zu erledigen",
|
"title": "Zu erledigen",
|
||||||
|
|||||||
@@ -683,7 +683,8 @@
|
|||||||
"hasCategory": "One of them has a category – connecting takes it out of the evaluation.",
|
"hasCategory": "One of them has a category – connecting takes it out of the evaluation.",
|
||||||
"linked": "Transfer",
|
"linked": "Transfer",
|
||||||
"release": "Release link",
|
"release": "Release link",
|
||||||
"releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards."
|
"releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards.",
|
||||||
|
"question": "Transfer from/to {{konto}}?"
|
||||||
},
|
},
|
||||||
"todo": {
|
"todo": {
|
||||||
"title": "To do",
|
"title": "To do",
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export default function Transactions() {
|
|||||||
const [txns, setTxns] = useState([]);
|
const [txns, setTxns] = useState([]);
|
||||||
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
|
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
|
||||||
const [links, setLinks] = useState({});
|
const [links, setLinks] = useState({});
|
||||||
|
// Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt.
|
||||||
|
const [vorschlag, setVorschlag] = useState({});
|
||||||
// Buchung, die gerade zum Übertrag gemacht wird.
|
// Buchung, die gerade zum Übertrag gemacht wird.
|
||||||
const [uebertrag, setUebertrag] = useState(null);
|
const [uebertrag, setUebertrag] = useState(null);
|
||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
@@ -73,12 +75,21 @@ export default function Transactions() {
|
|||||||
try {
|
try {
|
||||||
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
|
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
|
||||||
// kein Feld an ihnen – deshalb auch ein eigener Abruf.
|
// kein Feld an ihnen – deshalb auch ein eigener Abruf.
|
||||||
const [liste, verkn] = await Promise.all([
|
const [liste, verkn, vorschlaege] = await Promise.all([
|
||||||
api.listTransactions({ account_id: filter.account_id, type: filter.type }),
|
api.listTransactions({ account_id: filter.account_id, type: filter.type }),
|
||||||
api.transactionLinks().catch(() => ({})),
|
api.transactionLinks().catch(() => ({})),
|
||||||
|
// Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts
|
||||||
|
// verknüpft werden.
|
||||||
|
api.transferSuggestions().catch(() => ({ vorschlaege: [] })),
|
||||||
]);
|
]);
|
||||||
setTxns(liste);
|
setTxns(liste);
|
||||||
setLinks(verkn);
|
setLinks(verkn);
|
||||||
|
const paare = {};
|
||||||
|
for (const v of vorschlaege.vorschlaege || []) {
|
||||||
|
paare[v.a.id] = v.b;
|
||||||
|
paare[v.b.id] = v.a;
|
||||||
|
}
|
||||||
|
setVorschlag(paare);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, "error");
|
toast(err.message, "error");
|
||||||
}
|
}
|
||||||
@@ -96,6 +107,29 @@ export default function Transactions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verbinden(tx) {
|
||||||
|
const gegen = vorschlag[tx.id];
|
||||||
|
if (!gegen) return;
|
||||||
|
try {
|
||||||
|
await api.connectTransfer(tx.id, gegen.id);
|
||||||
|
ladenBuchungen();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nichtVerbinden(tx) {
|
||||||
|
const gegen = vorschlag[tx.id];
|
||||||
|
if (!gegen) return;
|
||||||
|
try {
|
||||||
|
// Ablehnen wird gemerkt, sonst fragt Certain bei jedem Laden erneut.
|
||||||
|
await api.ignoreTransfer(tx.id, gegen.id);
|
||||||
|
ladenBuchungen();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loesen(tx) {
|
async function loesen(tx) {
|
||||||
const link = links[String(tx.id)];
|
const link = links[String(tx.id)];
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
@@ -280,6 +314,27 @@ export default function Transactions() {
|
|||||||
|| t("transfers.linked")}
|
|| t("transfers.linked")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Nachträglich erkannt: Auf einem anderen Konto steht der
|
||||||
|
gleiche Betrag am selben Tag in die andere Richtung. Fragen
|
||||||
|
statt tun – und die Antwort merken. */}
|
||||||
|
{!links[String(tx.id)] && vorschlag[tx.id] && (
|
||||||
|
<div className="uebertrag-frage">
|
||||||
|
<Icon name="arrow-left-right" size={12} />
|
||||||
|
<span className="ellipsis">
|
||||||
|
{t("transfers.question", {
|
||||||
|
konto: accountById[vorschlag[tx.id].account_id]?.name || "?",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn sm"
|
||||||
|
onClick={() => verbinden(tx)}>
|
||||||
|
{t("transfers.connect")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn sm ghost"
|
||||||
|
onClick={() => nichtVerbinden(tx)}>
|
||||||
|
{t("transfers.ignore")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -356,7 +411,7 @@ export default function Transactions() {
|
|||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
||||||
formatDate, links, t]);
|
formatDate, links, vorschlag, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user