diff --git a/backend/app/models.py b/backend/app/models.py index 63fc3df..1166588 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -501,6 +501,15 @@ class Transaction(Base): payee: 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) + # „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_batch_id: Mapped[int | None] = mapped_column( diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index a8eb594..879d86e 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -225,6 +225,12 @@ def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_u continue if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1: 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 if not verdaechtig: return {"gruppen": []} @@ -266,6 +272,26 @@ def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_u ][: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) def create_transaction( payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) diff --git a/backend/migrations/versions/0037_dublette_ist_in_ordnung.py b/backend/migrations/versions/0037_dublette_ist_in_ordnung.py new file mode 100644 index 0000000..00ae3d9 --- /dev/null +++ b/backend/migrations/versions/0037_dublette_ist_in_ordnung.py @@ -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")) diff --git a/backend/tests/test_transactions_offen.py b/backend/tests/test_transactions_offen.py index cdd3dd3..3d78bf7 100644 --- a/backend/tests/test_transactions_offen.py +++ b/backend/tests/test_transactions_offen.py @@ -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"] if g["account_id"] == konto["id"]] 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 diff --git a/web/src/api.js b/web/src/api.js index 2ca6d1e..13443b8 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -239,6 +239,9 @@ export const api = { request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }), // Buchungen, die gleich aussehen – gemeldet, nicht entschieden. 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"), // Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen, // dass hier ein Uebertrag stecken koennte. diff --git a/web/src/components/Dubletten.jsx b/web/src/components/Dubletten.jsx index b0d9e0a..ee68ce3 100644 --- a/web/src/components/Dubletten.jsx +++ b/web/src/components/Dubletten.jsx @@ -30,6 +30,19 @@ export default function Dubletten({ accountById, onChange }) { } 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) { if (!(await confirm({ title: t("dupes.delete"), @@ -66,6 +79,10 @@ export default function Dubletten({ accountById, onChange }) { accountById[g.account_id]?.currency)} {" · "}{t("dupes.count", { n: g.zeilen.length })} + {" "} + {g.zeilen.map((z) => (