feat: Gleich aussehende Buchungen lassen sich behalten, statt loeschen zu muessen
Die Liste sagt ausdruecklich, dass sie nichts entscheidet: Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein – das weiss nur der Nutzer. Dann darf sie aber auch nicht zu einer Entscheidung draengen, und genau das tat sie: Der einzige Knopf war der Papierkorb. Wer die Meldung loswerden wollte, musste eine richtige Buchung wegwerfen. Jetzt steht neben jeder Gruppe "Alle behalten". An den Buchungen aendert sich dabei nichts; es ist ein Haken an der Frage. Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt: Eine Gruppe faellt erst weg, wenn ALLE ihre Zeilen ihn tragen. Kommt spaeter eine dritte gleich aussehende Buchung dazu, steht die Frage wieder da, und zwar fuer alle drei. Ein Test haelt das fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6fc2a924d2
commit
b867b272ba
@@ -501,6 +501,15 @@ class Transaction(Base):
|
|||||||
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# „Sieht gleich aus, ist aber richtig so."
|
||||||
|
#
|
||||||
|
# Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche
|
||||||
|
# Einkaeufe koennen es auch sein. Wer das einmal entschieden hat, soll
|
||||||
|
# nicht bei jedem Blick in die Liste erneut gefragt werden – und schon gar
|
||||||
|
# nicht zum Loeschen gedraengt, nur damit die Meldung verschwindet.
|
||||||
|
#
|
||||||
|
# Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
|
||||||
|
duplicate_ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
|
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
|
||||||
import_batch_id: Mapped[int | None] = mapped_column(
|
import_batch_id: Mapped[int | None] = mapped_column(
|
||||||
|
|||||||
@@ -225,6 +225,12 @@ def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_u
|
|||||||
continue
|
continue
|
||||||
if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1:
|
if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1:
|
||||||
continue
|
continue
|
||||||
|
# Schon entschieden. Nicht die einzelne Zeile ausblenden, sondern die
|
||||||
|
# Gruppe erst, wenn ALLE ihren Haken tragen: Kommt spaeter eine dritte
|
||||||
|
# gleich aussehende dazu, ist die Frage wieder offen – und zwar fuer
|
||||||
|
# alle drei.
|
||||||
|
if all(t.duplicate_ok for t in zeilen):
|
||||||
|
continue
|
||||||
verdaechtig[schluessel] = zeilen
|
verdaechtig[schluessel] = zeilen
|
||||||
if not verdaechtig:
|
if not verdaechtig:
|
||||||
return {"gruppen": []}
|
return {"gruppen": []}
|
||||||
@@ -266,6 +272,26 @@ def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_u
|
|||||||
][:100]}
|
][:100]}
|
||||||
|
|
||||||
|
|
||||||
|
class BehaltenIn(BaseModel):
|
||||||
|
ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dubletten/behalten", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def dubletten_behalten(
|
||||||
|
payload: BehaltenIn, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
"""Beide behalten – die Frage ist beantwortet.
|
||||||
|
|
||||||
|
An den Buchungen aendert sich nichts. Ohne diesen Weg blieb nur Loeschen,
|
||||||
|
um die Meldung loszuwerden: Die Liste draengte zu einer Entscheidung, die
|
||||||
|
sie selbst ausdruecklich nicht treffen will.
|
||||||
|
"""
|
||||||
|
for txn_id in payload.ids:
|
||||||
|
get_owned(db, Transaction, txn_id, user).duplicate_ok = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_transaction(
|
def create_transaction(
|
||||||
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""„Sieht gleich aus, ist aber richtig so."
|
||||||
|
|
||||||
|
Die Dubletten-Liste konnte man bisher nur leeren, indem man loeschte. Drei
|
||||||
|
Aufrundungen ueber 1.00 am selben Tag sind aber echt – wer das entschieden
|
||||||
|
hat, soll nicht bei jedem Blick erneut gefragt und zum Loeschen gedraengt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
|
||||||
|
|
||||||
|
Revision ID: 0037
|
||||||
|
Revises: 0036
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0037"
|
||||||
|
down_revision = "0036"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Ohne batch_alter_table, wie 0034-0036: Eine Spalte anhaengen kann SQLite
|
||||||
|
# direkt, der Tabellen-Umbau risse ueber ON DELETE CASCADE haengende
|
||||||
|
# Zeilen mit (tests/test_migrations.py).
|
||||||
|
op.execute(sa.text(
|
||||||
|
"ALTER TABLE transactions ADD COLUMN duplicate_ok BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(sa.text("ALTER TABLE transactions DROP COLUMN duplicate_ok"))
|
||||||
@@ -187,3 +187,70 @@ def test_verschiedene_kennungen_desselben_kontos_gelten_als_geklaert(admin):
|
|||||||
gruppen = [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
|
gruppen = [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
|
||||||
if g["account_id"] == konto["id"]]
|
if g["account_id"] == konto["id"]]
|
||||||
assert not gruppen, "die Bank hat sie als verschieden gemeldet"
|
assert not gruppen, "die Bank hat sie als verschieden gemeldet"
|
||||||
|
|
||||||
|
|
||||||
|
# --- „Sieht gleich aus, ist aber richtig so." --------------------------------
|
||||||
|
|
||||||
|
def test_dubletten_lassen_sich_behalten_statt_loeschen(admin):
|
||||||
|
"""Ohne diesen Weg blieb nur Loeschen, um die Meldung loszuwerden.
|
||||||
|
|
||||||
|
Die Liste will ausdruecklich nichts entscheiden – dann darf sie auch nicht
|
||||||
|
zu einer Entscheidung draengen.
|
||||||
|
"""
|
||||||
|
konto = admin.post("/api/accounts", json={
|
||||||
|
"name": "Behalten-Konto", "currency": "CHF"}).json()
|
||||||
|
tag = "2026-09-12"
|
||||||
|
|
||||||
|
def buche(text):
|
||||||
|
return admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": tag, "account_id": konto["id"],
|
||||||
|
"amount_cents": 1043, "payee": text}).json()["id"]
|
||||||
|
|
||||||
|
ids = [buche("Migrolino"), buche("Migrolino")]
|
||||||
|
|
||||||
|
def meine():
|
||||||
|
return [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
|
||||||
|
if g["account_id"] == konto["id"]]
|
||||||
|
|
||||||
|
assert len(meine()) == 1
|
||||||
|
|
||||||
|
r = admin.post("/api/transactions/dubletten/behalten", json={"ids": ids})
|
||||||
|
assert r.status_code == 204, r.text
|
||||||
|
assert meine() == [], "beantwortet – und damit weg"
|
||||||
|
|
||||||
|
# Und beide Buchungen stehen noch.
|
||||||
|
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -2086
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_dritte_gleiche_buchung_stellt_die_frage_neu(admin):
|
||||||
|
"""Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt.
|
||||||
|
|
||||||
|
Deshalb faellt eine Gruppe erst weg, wenn ALLE ihre Zeilen ihn tragen.
|
||||||
|
"""
|
||||||
|
konto = admin.post("/api/accounts", json={
|
||||||
|
"name": "Dritte-Konto", "currency": "CHF"}).json()
|
||||||
|
tag = "2026-09-13"
|
||||||
|
|
||||||
|
def buche():
|
||||||
|
return admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": tag, "account_id": konto["id"],
|
||||||
|
"amount_cents": 1077, "payee": "Migrolino"}).json()["id"]
|
||||||
|
|
||||||
|
def meine():
|
||||||
|
return [g for g in admin.get("/api/transactions/dubletten").json()["gruppen"]
|
||||||
|
if g["account_id"] == konto["id"]]
|
||||||
|
|
||||||
|
ids = [buche(), buche()]
|
||||||
|
admin.post("/api/transactions/dubletten/behalten", json={"ids": ids})
|
||||||
|
assert meine() == []
|
||||||
|
|
||||||
|
buche()
|
||||||
|
gruppen = meine()
|
||||||
|
assert len(gruppen) == 1
|
||||||
|
assert len(gruppen[0]["zeilen"]) == 3, "die Frage gilt wieder fuer alle drei"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_buchung_laesst_sich_nicht_behalten(admin, client):
|
||||||
|
r = admin.post("/api/transactions/dubletten/behalten", json={"ids": [999999]})
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert client
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ export const api = {
|
|||||||
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
||||||
// Buchungen, die gleich aussehen – gemeldet, nicht entschieden.
|
// Buchungen, die gleich aussehen – gemeldet, nicht entschieden.
|
||||||
duplicateTransactions: () => request("/transactions/dubletten"),
|
duplicateTransactions: () => request("/transactions/dubletten"),
|
||||||
|
// Beide behalten: Die Frage ist beantwortet, die Buchungen bleiben.
|
||||||
|
keepDuplicates: (ids) =>
|
||||||
|
request("/transactions/dubletten/behalten", { method: "POST", body: { ids } }),
|
||||||
transactionLinks: () => request("/transactions/links"),
|
transactionLinks: () => request("/transactions/links"),
|
||||||
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
|
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
|
||||||
// dass hier ein Uebertrag stecken koennte.
|
// dass hier ein Uebertrag stecken koennte.
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ export default function Dubletten({ accountById, onChange }) {
|
|||||||
}
|
}
|
||||||
useEffect(() => { laden(); }, []);
|
useEffect(() => { laden(); }, []);
|
||||||
|
|
||||||
|
// Ohne diesen Weg blieb nur Loeschen, um die Meldung loszuwerden – die
|
||||||
|
// Liste draengte damit zu einer Entscheidung, die sie selbst ausdruecklich
|
||||||
|
// nicht treffen will.
|
||||||
|
async function behalten(gruppe) {
|
||||||
|
try {
|
||||||
|
await api.keepDuplicates(gruppe.zeilen.map((z) => z.id));
|
||||||
|
laden();
|
||||||
|
onChange?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loeschen(zeile) {
|
async function loeschen(zeile) {
|
||||||
if (!(await confirm({
|
if (!(await confirm({
|
||||||
title: t("dupes.delete"),
|
title: t("dupes.delete"),
|
||||||
@@ -66,6 +79,10 @@ export default function Dubletten({ accountById, onChange }) {
|
|||||||
accountById[g.account_id]?.currency)}
|
accountById[g.account_id]?.currency)}
|
||||||
</strong>
|
</strong>
|
||||||
{" · "}{t("dupes.count", { n: g.zeilen.length })}
|
{" · "}{t("dupes.count", { n: g.zeilen.length })}
|
||||||
|
{" "}
|
||||||
|
<button className="btn sm" onClick={() => behalten(g)}>
|
||||||
|
<Icon name="check" size={14} /> {t("dupes.keepAll")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{g.zeilen.map((z) => (
|
{g.zeilen.map((z) => (
|
||||||
<div className="dubletten-zeile" key={z.id}>
|
<div className="dubletten-zeile" key={z.id}>
|
||||||
|
|||||||
@@ -839,6 +839,7 @@
|
|||||||
"title": "Gleich aussehende Buchungen ({{n}})",
|
"title": "Gleich aussehende Buchungen ({{n}})",
|
||||||
"intro": "Diese Zeilen sehen aus wie dieselbe Zahlung – gleiches Konto, gleicher Tag, gleicher Betrag, gleicher Text. Entscheiden musst du: Steht überall dieselbe Kennung der Bank, ist es sicher dieselbe Zahlung. Stehen verschiedene, hat die Bank sie als verschieden gemeldet – dann können es auch wirklich mehrere sein.",
|
"intro": "Diese Zeilen sehen aus wie dieselbe Zahlung – gleiches Konto, gleicher Tag, gleicher Betrag, gleicher Text. Entscheiden musst du: Steht überall dieselbe Kennung der Bank, ist es sicher dieselbe Zahlung. Stehen verschiedene, hat die Bank sie als verschieden gemeldet – dann können es auch wirklich mehrere sein.",
|
||||||
"count": "{{n}} mal",
|
"count": "{{n}} mal",
|
||||||
|
"keepAll": "Alle behalten",
|
||||||
"delete": "Buchung löschen",
|
"delete": "Buchung löschen",
|
||||||
"deleteConfirm": "Diese Buchung wird gelöscht. Kam sie von der Bank, holt kein späterer Abruf sie zurück – der Umsatz gilt dann als erledigt.",
|
"deleteConfirm": "Diese Buchung wird gelöscht. Kam sie von der Bank, holt kein späterer Abruf sie zurück – der Umsatz gilt dann als erledigt.",
|
||||||
"noRef": "keine Kennung"
|
"noRef": "keine Kennung"
|
||||||
|
|||||||
@@ -839,6 +839,7 @@
|
|||||||
"title": "Transactions that look alike ({{n}})",
|
"title": "Transactions that look alike ({{n}})",
|
||||||
"intro": "These rows look like the same payment – same account, same day, same amount, same text. You decide: if they all carry the same bank reference it is certainly the same payment. Different references mean the bank reported them as different.",
|
"intro": "These rows look like the same payment – same account, same day, same amount, same text. You decide: if they all carry the same bank reference it is certainly the same payment. Different references mean the bank reported them as different.",
|
||||||
"count": "{{n}} times",
|
"count": "{{n}} times",
|
||||||
|
"keepAll": "Keep all",
|
||||||
"delete": "Delete transaction",
|
"delete": "Delete transaction",
|
||||||
"deleteConfirm": "This transaction will be deleted. If it came from the bank, no later fetch will bring it back – it counts as handled.",
|
"deleteConfirm": "This transaction will be deleted. If it came from the bank, no later fetch will bring it back – it counts as handled.",
|
||||||
"noRef": "no reference"
|
"noRef": "no reference"
|
||||||
|
|||||||
Reference in New Issue
Block a user