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 -------------------------------------------------------------
|
# --- 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()
|
||||||
|
|||||||
@@ -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 --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+28
-10
@@ -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,8 +186,18 @@ 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>
|
||||||
|
<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 && (
|
{templates.length > 0 && (
|
||||||
<label>
|
<label className="grow">
|
||||||
<span>{t("import.template")}</span>
|
<span>{t("import.template")}</span>
|
||||||
<select defaultValue="" onChange={(e) => applyTemplate(e.target.value)}>
|
<select defaultValue="" onChange={(e) => applyTemplate(e.target.value)}>
|
||||||
<option value="">{t("import.templateManual")}</option>
|
<option value="">{t("import.templateManual")}</option>
|
||||||
@@ -189,6 +206,7 @@ export default function Import() {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Schritt 2: Format */}
|
{/* Schritt 2: Format */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user