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() {