feat: vorgefertigte Import-Presets für Raiffeisen (CH) und Revolut

- services/csv_import: PRESETS-Liste (aus echten Beispiel-Exporten des Nutzers),
  GET /imports/presets.
- Frontend: Preset-Auswahl im Import füllt Format/Spalten automatisch.
Beide Presets per TestClient gegen echte Zeilenformate verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 20:14:39 +02:00
co-authored by Claude Opus 4.8
parent 1381967aae
commit ddf4f1f5ed
6 changed files with 80 additions and 20 deletions
+6
View File
@@ -32,6 +32,12 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
# --- Templates ------------------------------------------------------------- # --- Templates -------------------------------------------------------------
@router.get("/presets")
def list_presets(_: User = Depends(get_current_user)) -> list[dict]:
"""Vorgefertigte Bank-Vorlagen (füllen die Format-/Spalten-Einstellungen)."""
return csv_import.PRESETS
@router.get("/templates", response_model=list[ImportTemplateOut]) @router.get("/templates", response_model=list[ImportTemplateOut])
def list_templates(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_templates(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
rows = db.query(ImportTemplate).filter(ImportTemplate.owner_id == user.id).order_by(ImportTemplate.name).all() rows = db.query(ImportTemplate).filter(ImportTemplate.owner_id == user.id).order_by(ImportTemplate.name).all()
+33
View File
@@ -30,6 +30,39 @@ from ..models import (
from . import currency from . import currency
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
PRESETS: list[dict] = [
{
"name": "Raiffeisen (CH)",
"delimiter": ";",
"decimal_sep": ".",
"date_format": "%Y-%m-%d %H:%M:%S.%f",
"encoding": "utf-8",
"has_header": False,
"skip_rows": 0,
"amount_mode": "signed",
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
"column_map": {"date": 1, "amount": 3, "payee": 2,
"purpose": None, "counterparty_iban": None},
},
{
"name": "Revolut",
"delimiter": ",",
"decimal_sep": ".",
"date_format": "%Y-%m-%d %H:%M:%S",
"encoding": "utf-8",
"has_header": True,
"skip_rows": 0,
"amount_mode": "signed",
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
"column_map": {"date": 3, "amount": 5, "payee": 4,
"purpose": None, "counterparty_iban": None},
},
]
# --- Kleine Parser-Helfer -------------------------------------------------- # --- Kleine Parser-Helfer --------------------------------------------------
+1
View File
@@ -188,6 +188,7 @@ export const api = {
cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`), cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`),
// CSV-Import // CSV-Import
importPresets: () => request("/imports/presets"),
importTemplates: () => request("/imports/templates"), importTemplates: () => request("/imports/templates"),
createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }), createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }),
updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }), updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }),
+2 -1
View File
@@ -204,7 +204,8 @@
"step4": "4 · Prüfen & buchen", "step4": "4 · Prüfen & buchen",
"account": "Zielkonto", "account": "Zielkonto",
"file": "CSV-Datei", "file": "CSV-Datei",
"template": "Vorlage", "preset": "Bank-Vorlage (vorgefertigt)",
"template": "Eigene Vorlage",
"templateManual": "Manuell einstellen", "templateManual": "Manuell einstellen",
"delimiter": "Trennzeichen", "delimiter": "Trennzeichen",
"decimal": "Dezimaltrenner", "decimal": "Dezimaltrenner",
+2 -1
View File
@@ -204,7 +204,8 @@
"step4": "4 · Review & import", "step4": "4 · Review & import",
"account": "Target account", "account": "Target account",
"file": "CSV file", "file": "CSV file",
"template": "Template", "preset": "Bank preset",
"template": "My template",
"templateManual": "Configure manually", "templateManual": "Configure manually",
"delimiter": "Delimiter", "delimiter": "Delimiter",
"decimal": "Decimal separator", "decimal": "Decimal separator",
+36 -18
View File
@@ -38,6 +38,7 @@ export default function Import() {
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [presets, setPresets] = useState([]);
const [batches, setBatches] = useState([]); const [batches, setBatches] = useState([]);
const [accountId, setAccountId] = useState(""); const [accountId, setAccountId] = useState("");
@@ -52,10 +53,10 @@ export default function Import() {
async function loadStamm() { async function loadStamm() {
try { try {
const [a, g, tp, b] = await Promise.all([ const [a, g, tp, pr, b] = await Promise.all([
api.listAccounts(), api.listCategories(), api.importTemplates(), api.importBatches(), api.listAccounts(), api.listCategories(), api.importTemplates(), api.importPresets(), api.importBatches(),
]); ]);
setAccounts(a); setGroups(g); setTemplates(tp); setBatches(b); setAccounts(a); setGroups(g); setTemplates(tp); setPresets(pr); setBatches(b);
if (a[0] && !accountId) setAccountId(String(a[0].id)); if (a[0] && !accountId) setAccountId(String(a[0].id));
} catch (e) { toast(e.message, "error"); } } catch (e) { toast(e.message, "error"); }
} }
@@ -66,14 +67,20 @@ export default function Import() {
setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } })); setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } }));
} }
function applyConfig(src) {
setConfig({
delimiter: src.delimiter, decimal_sep: src.decimal_sep, date_format: src.date_format,
encoding: src.encoding, has_header: src.has_header, skip_rows: src.skip_rows,
amount_mode: src.amount_mode, column_map: src.column_map || {},
});
}
function applyTemplate(id) { function applyTemplate(id) {
const tpl = templates.find((x) => String(x.id) === String(id)); const tpl = templates.find((x) => String(x.id) === String(id));
if (!tpl) return; if (tpl) applyConfig(tpl);
setConfig({ }
delimiter: tpl.delimiter, decimal_sep: tpl.decimal_sep, date_format: tpl.date_format, function applyPreset(idx) {
encoding: tpl.encoding, has_header: tpl.has_header, skip_rows: tpl.skip_rows, const p = presets[Number(idx)];
amount_mode: tpl.amount_mode, column_map: tpl.column_map || {}, if (p) applyConfig(p);
});
} }
function baseForm() { function baseForm() {
@@ -179,15 +186,26 @@ export default function Import() {
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); }} /> onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); }} />
</label> </label>
</div> </div>
{templates.length > 0 && ( <div className="row">
<label> {presets.length > 0 && (
<span>{t("import.template")}</span> <label className="grow">
<select defaultValue="" onChange={(e) => applyTemplate(e.target.value)}> <span>{t("import.preset")}</span>
<option value="">{t("import.templateManual")}</option> <select defaultValue="" onChange={(e) => applyPreset(e.target.value)}>
{templates.map((tp) => <option key={tp.id} value={tp.id}>{tp.name}</option>)} <option value="">{t("import.templateManual")}</option>
</select> {presets.map((p, i) => <option key={i} value={i}>{p.name}</option>)}
</label> </select>
)} </label>
)}
{templates.length > 0 && (
<label className="grow">
<span>{t("import.template")}</span>
<select defaultValue="" onChange={(e) => applyTemplate(e.target.value)}>
<option value="">{t("import.templateManual")}</option>
{templates.map((tp) => <option key={tp.id} value={tp.id}>{tp.name}</option>)}
</select>
</label>
)}
</div>
</div> </div>
{/* Schritt 2: Format */} {/* Schritt 2: Format */}