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:
@@ -32,6 +32,12 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
|
||||
# --- 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])
|
||||
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()
|
||||
|
||||
@@ -30,6 +30,39 @@ from ..models import (
|
||||
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 --------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ export const api = {
|
||||
cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`),
|
||||
|
||||
// CSV-Import
|
||||
importPresets: () => request("/imports/presets"),
|
||||
importTemplates: () => request("/imports/templates"),
|
||||
createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }),
|
||||
updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }),
|
||||
|
||||
@@ -204,7 +204,8 @@
|
||||
"step4": "4 · Prüfen & buchen",
|
||||
"account": "Zielkonto",
|
||||
"file": "CSV-Datei",
|
||||
"template": "Vorlage",
|
||||
"preset": "Bank-Vorlage (vorgefertigt)",
|
||||
"template": "Eigene Vorlage",
|
||||
"templateManual": "Manuell einstellen",
|
||||
"delimiter": "Trennzeichen",
|
||||
"decimal": "Dezimaltrenner",
|
||||
|
||||
@@ -204,7 +204,8 @@
|
||||
"step4": "4 · Review & import",
|
||||
"account": "Target account",
|
||||
"file": "CSV file",
|
||||
"template": "Template",
|
||||
"preset": "Bank preset",
|
||||
"template": "My template",
|
||||
"templateManual": "Configure manually",
|
||||
"delimiter": "Delimiter",
|
||||
"decimal": "Decimal separator",
|
||||
|
||||
+36
-18
@@ -38,6 +38,7 @@ export default function Import() {
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [presets, setPresets] = useState([]);
|
||||
const [batches, setBatches] = useState([]);
|
||||
|
||||
const [accountId, setAccountId] = useState("");
|
||||
@@ -52,10 +53,10 @@ export default function Import() {
|
||||
|
||||
async function loadStamm() {
|
||||
try {
|
||||
const [a, g, tp, b] = await Promise.all([
|
||||
api.listAccounts(), api.listCategories(), api.importTemplates(), api.importBatches(),
|
||||
const [a, g, tp, pr, b] = await Promise.all([
|
||||
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));
|
||||
} 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) } }));
|
||||
}
|
||||
|
||||
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) {
|
||||
const tpl = templates.find((x) => String(x.id) === String(id));
|
||||
if (!tpl) return;
|
||||
setConfig({
|
||||
delimiter: tpl.delimiter, decimal_sep: tpl.decimal_sep, date_format: tpl.date_format,
|
||||
encoding: tpl.encoding, has_header: tpl.has_header, skip_rows: tpl.skip_rows,
|
||||
amount_mode: tpl.amount_mode, column_map: tpl.column_map || {},
|
||||
});
|
||||
if (tpl) applyConfig(tpl);
|
||||
}
|
||||
function applyPreset(idx) {
|
||||
const p = presets[Number(idx)];
|
||||
if (p) applyConfig(p);
|
||||
}
|
||||
|
||||
function baseForm() {
|
||||
@@ -179,15 +186,26 @@ export default function Import() {
|
||||
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); }} />
|
||||
</label>
|
||||
</div>
|
||||
{templates.length > 0 && (
|
||||
<label>
|
||||
<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 className="row">
|
||||
{presets.length > 0 && (
|
||||
<label className="grow">
|
||||
<span>{t("import.preset")}</span>
|
||||
<select defaultValue="" onChange={(e) => applyPreset(e.target.value)}>
|
||||
<option value="">{t("import.templateManual")}</option>
|
||||
{presets.map((p, i) => <option key={i} value={i}>{p.name}</option>)}
|
||||
</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>
|
||||
|
||||
{/* Schritt 2: Format */}
|
||||
|
||||
Reference in New Issue
Block a user