feat: Import-Vorlage richtet sich nach dem gewaehlten Konto

Beim Kontowechsel musste man die Vorlage jedes Mal neu waehlen – und las
im schlimmsten Fall den Revolut-Auszug mit der Raiffeisen-Vorlage ein
(genau das steht im Verlauf).

Eigene Vorlagen standen schon als template_id im Verlauf; Bank-Vorlagen
sind fest im Code und hinterliessen nichts. Der Verlauf merkt sich deshalb
jetzt auch deren Namen (Migration 0014). Die Oberflaeche waehlt beim
Kontowechsel, womit zuletzt in dieses Konto importiert wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 20:44:50 +02:00
co-authored by Claude Opus 5
parent 78835d1e40
commit 3a44509e77
7 changed files with 79 additions and 5 deletions
+4
View File
@@ -530,6 +530,10 @@ class ImportBatch(Base):
template_id: Mapped[int | None] = mapped_column( template_id: Mapped[int | None] = mapped_column(
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
) )
# Name der Bank-Vorlage (Raiffeisen, N26 …). Eigene Vorlagen stehen in
# template_id; die fest eingebauten haben keine ID und wuerden sonst
# vergessen, sobald der Import durch ist.
preset: Mapped[str | None] = mapped_column(String(64), nullable=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="") filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+2 -1
View File
@@ -142,7 +142,8 @@ async def preview(
@router.post("/commit", response_model=ImportBatchOut) @router.post("/commit", response_model=ImportBatchOut)
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
account = get_owned(db, Account, payload.account_id, user) account = get_owned(db, Account, payload.account_id, user)
return csv_import.commit_import(db, user, account, payload.template_id, payload.filename, payload.rows) return csv_import.commit_import(db, user, account, payload.template_id, payload.filename,
payload.rows, preset=payload.preset)
@router.get("/batches", response_model=list[ImportBatchOut]) @router.get("/batches", response_model=list[ImportBatchOut])
+2
View File
@@ -419,6 +419,7 @@ class ImportBatchOut(BaseModel):
id: int id: int
account_id: int account_id: int
template_id: int | None template_id: int | None
preset: str | None
filename: str filename: str
row_count: int row_count: int
imported_count: int imported_count: int
@@ -429,6 +430,7 @@ class ImportBatchOut(BaseModel):
class ImportCommitIn(BaseModel): class ImportCommitIn(BaseModel):
account_id: int account_id: int
template_id: int | None = None template_id: int | None = None
preset: str | None = None
filename: str = "" filename: str = ""
rows: list[dict] = Field(default_factory=list) rows: list[dict] = Field(default_factory=list)
+3 -2
View File
@@ -698,11 +698,12 @@ def _apply_counterpart_amount(db: Session, user: User, account: Account, row: di
return True return True
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch: def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str,
rows: list[dict], preset: str | None = None) -> ImportBatch:
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
batch = ImportBatch( batch = ImportBatch(
owner_id=user.id, account_id=account.id, template_id=template_id, owner_id=user.id, account_id=account.id, template_id=template_id,
filename=(filename or "")[:255], row_count=len(rows), preset=(preset or None), filename=(filename or "")[:255], row_count=len(rows),
imported_count=0, status=ImportStatus.committed, imported_count=0, status=ImportStatus.committed,
) )
db.add(batch) db.add(batch)
@@ -0,0 +1,29 @@
"""Import-Verlauf merkt sich die verwendete Bank-Vorlage.
Eigene Vorlagen stehen als ``template_id`` im Verlauf, Bank-Vorlagen wie
Raiffeisen oder N26 stecken dagegen fest im Code und hinterliessen bisher
nichts. Damit die Oberflaeche beim Kontowechsel die zuletzt benutzte
Einstellung wieder vorschlagen kann, wird der Name der Bank-Vorlage
mitgeschrieben.
Revision ID: 0014
Revises: 0013
"""
import sqlalchemy as sa
from alembic import op
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("import_batches") as batch:
batch.add_column(sa.Column("preset", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("import_batches") as batch:
batch.drop_column("preset")
+17
View File
@@ -605,3 +605,20 @@ def test_rohvorschau_nennt_die_waehlbaren_filterwerte(admin):
assert produkte[0]["count"] == 3 assert produkte[0]["count"] == 3
# Spalten mit lauter verschiedenen Werten sind keine Gruppen. # Spalten mit lauter verschiedenen Werten sind keine Gruppen.
assert "3" not in daten["filter_values"] assert "3" not in daten["filter_values"]
def test_verlauf_merkt_sich_die_bank_vorlage(admin):
"""Bank-Vorlagen haben keine ID. Ohne diesen Vermerk wüsste die Oberfläche
beim nächsten Import in dasselbe Konto nicht mehr, welches Format passt."""
konto = _account(admin, name="Merkkonto", currency="CHF")
r = _preview(admin, konto["id"])
zeilen = [z for z in r.json()["rows"] if z["valid"]][:1]
gebucht = admin.post("/api/imports/commit", json={
"account_id": konto["id"], "preset": "Raiffeisen (CH)",
"filename": "k.csv", "rows": zeilen,
})
assert gebucht.status_code == 200, gebucht.text
assert gebucht.json()["preset"] == "Raiffeisen (CH)"
verlauf = admin.get("/api/imports/batches").json()
meins = [b for b in verlauf if b["account_id"] == konto["id"]]
assert meins[0]["preset"] == "Raiffeisen (CH)"
+22 -2
View File
@@ -58,6 +58,7 @@ export default function Import() {
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [config, setConfig] = useState(defaultConfig()); const [config, setConfig] = useState(defaultConfig());
const [raw, setRaw] = useState(null); const [raw, setRaw] = useState(null);
const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | ""
const [rows, setRows] = useState(null); const [rows, setRows] = useState(null);
const [recon, setRecon] = useState(null); const [recon, setRecon] = useState(null);
const [docText, setDocText] = useState(""); const [docText, setDocText] = useState("");
@@ -100,6 +101,7 @@ export default function Import() {
} }
// Ein zusammengeführtes Vorlagen-Dropdown: Werte typ-getaggt "preset:i" / "tpl:id". // Ein zusammengeführtes Vorlagen-Dropdown: Werte typ-getaggt "preset:i" / "tpl:id".
function applySource(value) { function applySource(value) {
setQuelle(value || "");
if (!value) return; if (!value) return;
const [kind, id] = value.split(":"); const [kind, id] = value.split(":");
if (kind === "preset") { if (kind === "preset") {
@@ -111,6 +113,22 @@ export default function Import() {
} }
} }
// Beim Kontowechsel die Einstellung vorschlagen, mit der zuletzt in DIESES
// Konto importiert wurde. Ohne das waehlt man jedes Mal neu – und liest im
// schlimmsten Fall den Revolut-Auszug mit der Raiffeisen-Vorlage ein.
useEffect(() => {
if (!accountId || !batches.length) return;
const letzter = batches.find(
(b) => String(b.account_id) === String(accountId) && (b.template_id || b.preset));
if (!letzter) return;
if (letzter.template_id && templates.some((x) => x.id === letzter.template_id)) {
applySource(`tpl:${letzter.template_id}`);
return;
}
const i = presets.findIndex((p) => p.name === letzter.preset);
if (i >= 0) applySource(`preset:${i}`);
}, [accountId, batches, templates, presets]);
function baseForm() { function baseForm() {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
@@ -168,9 +186,11 @@ export default function Import() {
async function commit() { async function commit() {
setBusy(true); setBusy(true);
try { try {
const [art, id] = quelle.split(":");
const batch = await api.importCommit({ const batch = await api.importCommit({
account_id: Number(accountId), account_id: Number(accountId),
template_id: null, template_id: art === "tpl" ? Number(id) : null,
preset: art === "preset" ? presets[Number(id)]?.name || null : null,
filename: file?.name || "", filename: file?.name || "",
rows, rows,
}); });
@@ -409,7 +429,7 @@ export default function Import() {
<div className="row" style={{ display: mode === "csv" ? undefined : "none" }}> <div className="row" style={{ display: mode === "csv" ? undefined : "none" }}>
<label className="grow"> <label className="grow">
<span>{t("import.templateAny")}</span> <span>{t("import.templateAny")}</span>
<select defaultValue="" onChange={(e) => applySource(e.target.value)}> <select value={quelle} onChange={(e) => applySource(e.target.value)}>
<option value="">{t("import.templateManual")}</option> <option value="">{t("import.templateManual")}</option>
{presets.length > 0 && ( {presets.length > 0 && (
<optgroup label={t("import.presetGroup")}> <optgroup label={t("import.presetGroup")}>