feat: Buchen-Knopf auch unter der Prüfliste + Verlauf aufräumen

- Bei mehreren hundert Zeilen ist der Knopf oben längst aus dem Bild gescrollt;
  unter der Liste steht jetzt derselbe.
- Rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen. Nur diese:
  solange ein Import steht, ist sein Eintrag die einzige Möglichkeit, seine
  Buchungen wieder loszuwerden – der Server lehnt das sonst ab.

Test für beide Fälle des neuen Endpunkts; 92 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 09:20:29 +02:00
co-authored by Claude Opus 4.8
parent ab5410ca6f
commit 8d767e728a
7 changed files with 82 additions and 5 deletions
+20 -2
View File
@@ -2,12 +2,12 @@
import json import json
from fastapi import APIRouter, Depends, File, Form, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import Account, AmountMode, ImportBatch, ImportTemplate, User from ..models import Account, AmountMode, ImportBatch, ImportStatus, ImportTemplate, User
from ..schemas import ( from ..schemas import (
ImportBatchOut, ImportBatchOut,
ImportCommitIn, ImportCommitIn,
@@ -160,3 +160,21 @@ def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_
batch = get_owned(db, ImportBatch, batch_id, user) batch = get_owned(db, ImportBatch, batch_id, user)
deleted = csv_import.undo_batch(db, user, batch) deleted = csv_import.undo_batch(db, user, batch)
return {"deleted": deleted} return {"deleted": deleted}
@router.delete("/batches/{batch_id}/record", status_code=status.HTTP_204_NO_CONTENT)
def remove_batch_record(
batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
"""Entfernt einen rückgängig gemachten Eintrag aus dem Verlauf.
Nur für bereits zurückgenommene Importe: Solange ein Import steht, ist der
Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden."""
batch = get_owned(db, ImportBatch, batch_id, user)
if batch.status != ImportStatus.undone:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nur rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen.",
)
db.delete(batch)
db.commit()
+20
View File
@@ -519,3 +519,23 @@ def test_reconciliation_flags_rows_waiting_for_target(admin):
assert rec["difference_cents"] == 9200 assert rec["difference_cents"] == 9200
assert rec["first_mismatch"]["expected_cents"] == 9200 assert rec["first_mismatch"]["expected_cents"] == 9200
assert rec["first_mismatch"]["actual_cents"] == 0 assert rec["first_mismatch"]["actual_cents"] == 0
def test_remove_undone_batch_from_history(admin):
"""Rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen –
laufende nicht, sonst verlöre man die Möglichkeit, sie zurückzunehmen."""
giro = _account(admin, name="Verlauf", currency="EUR")
_account(admin, name="Spar Verlauf", type="sparkonto", currency="EUR",
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
rows = _preview(admin, giro["id"]).json()["rows"]
batch = admin.post("/api/imports/commit",
json={"account_id": giro["id"], "filename": "v.csv", "rows": rows}).json()
# Solange der Import steht, bleibt der Eintrag bestehen.
r = admin.delete(f"/api/imports/batches/{batch['id']}/record")
assert r.status_code == 400
assert any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json())
admin.delete(f"/api/imports/batches/{batch['id']}") # rückgängig machen
assert admin.delete(f"/api/imports/batches/{batch['id']}/record").status_code == 204
assert not any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json())
+1
View File
@@ -242,6 +242,7 @@ export const api = {
importCommit: (body) => request("/imports/commit", { method: "POST", body }), importCommit: (body) => request("/imports/commit", { method: "POST", body }),
importBatches: () => request("/imports/batches"), importBatches: () => request("/imports/batches"),
undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }), undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }),
removeImportBatch: (id) => request(`/imports/batches/${id}/record`, { method: "DELETE" }),
// PDF/Bild-Beleg // PDF/Bild-Beleg
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }), documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
+10
View File
@@ -1077,3 +1077,13 @@ tr.row-dup {
.restore-summary tr:last-child td { .restore-summary tr:last-child td {
border-bottom: none; border-bottom: none;
} }
/* Zweiter Buchen-Knopf unter der Prüfliste – bei vielen Zeilen ist der obere
längst aus dem Bild gescrollt. */
.commit-footer {
display: flex;
justify-content: flex-end;
padding-top: var(--sp-3);
margin-top: var(--sp-2);
border-top: 1px solid var(--border);
}
+3 -1
View File
@@ -340,7 +340,9 @@
"filterValue": "mit dem Wert", "filterValue": "mit dem Wert",
"filterValuePlaceholder": "z. B. Giro", "filterValuePlaceholder": "z. B. Giro",
"filterNone": "— kein Filter —", "filterNone": "— kein Filter —",
"filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt." "filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt.",
"removeEntry": "Eintrag entfernen",
"removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen."
}, },
"password": { "password": {
"show": "Passwort anzeigen", "show": "Passwort anzeigen",
+3 -1
View File
@@ -340,7 +340,9 @@
"filterValue": "with value", "filterValue": "with value",
"filterValuePlaceholder": "e.g. Current", "filterValuePlaceholder": "e.g. Current",
"filterNone": "— no filter —", "filterNone": "— no filter —",
"filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice." "filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice.",
"removeEntry": "Remove entry",
"removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone."
}, },
"password": { "password": {
"show": "Show password", "show": "Show password",
+25 -1
View File
@@ -196,6 +196,15 @@ export default function Import() {
} catch (e) { toast(e.message, "error"); } } catch (e) { toast(e.message, "error"); }
} }
async function entfernen(b) {
if (!(await confirm({
title: t("import.removeEntry"),
message: t("import.removeEntryConfirm", { file: b.filename || "—" }),
danger: true, confirmLabel: t("common.delete"),
}))) return;
try { await api.removeImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); }
}
async function undo(b) { async function undo(b) {
if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return; if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return;
try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); }
@@ -325,7 +334,15 @@ export default function Import() {
<button className="btn sm danger" onClick={() => undo(b)}> <button className="btn sm danger" onClick={() => undo(b)}>
<Icon name="refresh-ccw" size={14} /> {t("import.undo")} <Icon name="refresh-ccw" size={14} /> {t("import.undo")}
</button> </button>
) : <span className="muted small">{t("import.undone")}</span>), ) : (
<span className="nowrap">
<span className="muted small">{t("import.undone")}</span>
<button className="btn sm ghost" title={t("import.removeEntry")}
style={{ marginLeft: 6 }} onClick={() => entfernen(b)}>
<Icon name="trash" size={14} />
</button>
</span>
)),
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
], [t, formatDate, accountById]); ], [t, formatDate, accountById]);
@@ -614,6 +631,13 @@ export default function Import() {
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")} rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")}
columns={reviewColumns} columns={reviewColumns}
/> />
{/* Zweiter Knopf unter der Liste: bei vielen Zeilen muss man zum Buchen
sonst wieder ganz nach oben scrollen. */}
<div className="commit-footer">
<button className="btn primary" onClick={commit} disabled={busy || includeCount === 0}>
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
</button>
</div>
</div> </div>
)} )}