From 3a44509e7704c7c9e55b2ddf01dd13377a26ab73 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 20:44:50 +0200 Subject: [PATCH] feat: Import-Vorlage richtet sich nach dem gewaehlten Konto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/models.py | 4 +++ backend/app/routers/imports.py | 3 +- backend/app/schemas.py | 2 ++ backend/app/services/csv_import.py | 5 ++-- .../versions/0014_import_batch_preset.py | 29 +++++++++++++++++++ backend/tests/test_import.py | 17 +++++++++++ web/src/pages/Import.jsx | 24 +++++++++++++-- 7 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/0014_import_batch_preset.py diff --git a/backend/app/models.py b/backend/app/models.py index dc44314..a076775 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -530,6 +530,10 @@ class ImportBatch(Base): template_id: Mapped[int | None] = mapped_column( 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="") row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/routers/imports.py b/backend/app/routers/imports.py index ab5a4f5..bcf9e6f 100644 --- a/backend/app/routers/imports.py +++ b/backend/app/routers/imports.py @@ -142,7 +142,8 @@ async def preview( @router.post("/commit", response_model=ImportBatchOut) def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_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]) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 70a0efb..c4364d2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -419,6 +419,7 @@ class ImportBatchOut(BaseModel): id: int account_id: int template_id: int | None + preset: str | None filename: str row_count: int imported_count: int @@ -429,6 +430,7 @@ class ImportBatchOut(BaseModel): class ImportCommitIn(BaseModel): account_id: int template_id: int | None = None + preset: str | None = None filename: str = "" rows: list[dict] = Field(default_factory=list) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index e6812b6..a420d00 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -698,11 +698,12 @@ def _apply_counterpart_amount(db: Session, user: User, account: Account, row: di 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)} batch = ImportBatch( 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, ) db.add(batch) diff --git a/backend/migrations/versions/0014_import_batch_preset.py b/backend/migrations/versions/0014_import_batch_preset.py new file mode 100644 index 0000000..cb7e6f3 --- /dev/null +++ b/backend/migrations/versions/0014_import_batch_preset.py @@ -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") diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 620b944..628c914 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -605,3 +605,20 @@ def test_rohvorschau_nennt_die_waehlbaren_filterwerte(admin): assert produkte[0]["count"] == 3 # Spalten mit lauter verschiedenen Werten sind keine Gruppen. 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)" diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index a822382..c112f88 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -58,6 +58,7 @@ export default function Import() { const [file, setFile] = useState(null); const [config, setConfig] = useState(defaultConfig()); const [raw, setRaw] = useState(null); + const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | "" const [rows, setRows] = useState(null); const [recon, setRecon] = useState(null); const [docText, setDocText] = useState(""); @@ -100,6 +101,7 @@ export default function Import() { } // Ein zusammengeführtes Vorlagen-Dropdown: Werte typ-getaggt "preset:i" / "tpl:id". function applySource(value) { + setQuelle(value || ""); if (!value) return; const [kind, id] = value.split(":"); 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() { const fd = new FormData(); fd.append("file", file); @@ -168,9 +186,11 @@ export default function Import() { async function commit() { setBusy(true); try { + const [art, id] = quelle.split(":"); const batch = await api.importCommit({ 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 || "", rows, }); @@ -409,7 +429,7 @@ export default function Import() {