feat: Saldo-Abgleich beim CSV-Import gegen die Kontostand-Spalte
Bisher merkte man einen Importfehler erst am falschen Endsaldo, ohne Hinweis auf die Ursache. Enthält der Auszug eine laufende Kontostand-Spalte, gleicht der Import jetzt jede Zeile dagegen ab: Die Spalte sagt, wie sich der Saldo tatsächlich verändert hat, unsere Erkennung muss dazu passen. - neue Spaltenzuordnung "Kontostand (laufend)"; Presets Raiffeisen/Revolut ergänzt - Prüfansicht zeigt: stimmt überein ODER Differenz + erste abweichende Zeile (Datum, Empfänger, Betrag laut Bank vs. erkannt) - meldet zusätzlich den Anfangssaldo laut Datei – häufigste Fehlerquelle - rechnet mit dem tatsächlich gebuchten Betrag, damit auch nicht gebuchte Zeilen auffallen (z. B. nicht zugeordnete Gebührenspalte); Fremdwährungs-Zeilen gehören zu einem anderen Saldo-Verlauf und bleiben außen vor - erkennt absteigend sortierte Auszüge - Tests: stimmiger Auszug (ok) und fehlende Gebührenspalte (zeigt genau die Zeile). 83 grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -329,7 +329,12 @@
|
||||
"colState": "Status",
|
||||
"extraColsHint": "Gebühr wird vom Betrag abgezogen. Zeilen mit Status ungleich COMPLETED (z. B. storniert) und Zeilen in einer anderen Währung als das Zielkonto werden nicht gebucht.",
|
||||
"skipState": "storniert",
|
||||
"skipCurrency": "andere Währung"
|
||||
"skipCurrency": "andere Währung",
|
||||
"colBalance": "Kontostand (laufend)",
|
||||
"reconOk": "Saldo-Abgleich stimmt: alle {{n}} Schritte passen zur Kontostand-Spalte der Datei.",
|
||||
"reconDiff": "Saldo-Abgleich weicht ab. Laut Datei: {{file}} · errechnet: {{app}} · Differenz: {{diff}}.",
|
||||
"reconFirst": "Erste Abweichung am {{date}} bei „{{payee}}“: Bank {{expected}}, erkannt {{actual}}.",
|
||||
"reconOpening": "Anfangssaldo laut Datei: {{value}} (trage ihn beim Konto ein, damit der Stand stimmt)."
|
||||
},
|
||||
"password": {
|
||||
"show": "Passwort anzeigen",
|
||||
|
||||
@@ -329,7 +329,12 @@
|
||||
"colState": "State",
|
||||
"extraColsHint": "The fee is deducted from the amount. Rows whose state is not COMPLETED (e.g. reverted) and rows in a currency other than the target account are not imported.",
|
||||
"skipState": "reverted",
|
||||
"skipCurrency": "other currency"
|
||||
"skipCurrency": "other currency",
|
||||
"colBalance": "Balance (running)",
|
||||
"reconOk": "Balance check passes: all {{n}} steps match the file's balance column.",
|
||||
"reconDiff": "Balance check failed. Per file: {{file}} · computed: {{app}} · difference: {{diff}}.",
|
||||
"reconFirst": "First mismatch on {{date}} at “{{payee}}”: bank {{expected}}, detected {{actual}}.",
|
||||
"reconOpening": "Opening balance per file: {{value}} (enter it on the account so the balance matches)."
|
||||
},
|
||||
"password": {
|
||||
"show": "Show password",
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function Import() {
|
||||
const [config, setConfig] = useState(defaultConfig());
|
||||
const [raw, setRaw] = useState(null);
|
||||
const [rows, setRows] = useState(null);
|
||||
const [recon, setRecon] = useState(null);
|
||||
const [docText, setDocText] = useState("");
|
||||
const [docPages, setDocPages] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -114,7 +115,7 @@ export default function Import() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setRaw(await api.importRaw(baseForm()));
|
||||
setRows(null);
|
||||
setRows(null); setRecon(null);
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function Import() {
|
||||
fd.append("column_map", JSON.stringify(config.column_map));
|
||||
const res = await api.importPreview(fd);
|
||||
setRows(res.rows.map((r) => ({ ...r })));
|
||||
setRecon(res.reconciliation || null);
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
@@ -142,6 +144,7 @@ export default function Import() {
|
||||
fd.append("account_id", accountId);
|
||||
const res = await api.documentPreview(fd);
|
||||
setRows(res.rows.map((r) => ({ ...r })));
|
||||
setRecon(null);
|
||||
setDocText(res.text || "");
|
||||
setDocPages(res.pages || []);
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
@@ -157,7 +160,7 @@ export default function Import() {
|
||||
rows,
|
||||
});
|
||||
toast(t("import.imported", { n: batch.imported_count }));
|
||||
setRows(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]);
|
||||
setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
loadStamm();
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
@@ -495,6 +498,7 @@ export default function Import() {
|
||||
["fee", t("import.colFee")],
|
||||
["currency", t("import.colCurrency")],
|
||||
["state", t("import.colState")],
|
||||
["balance", t("import.colBalance")],
|
||||
].map(([field, label]) => (
|
||||
<label key={field}>
|
||||
<span>{label}</span>
|
||||
@@ -537,6 +541,41 @@ export default function Import() {
|
||||
<span>{t("import.noneValid")}</span>
|
||||
</div>
|
||||
)}
|
||||
{recon?.available && (
|
||||
<div className={`alert ${recon.ok ? "ok" : "error"}`} style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<Icon name={recon.ok ? "check" : "alert"} size={16} />
|
||||
<div>
|
||||
{recon.ok ? (
|
||||
<span>{t("import.reconOk", { n: recon.checked })}</span>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
{t("import.reconDiff", {
|
||||
file: formatMoney(recon.final_balance_cents, acc?.currency || "EUR"),
|
||||
app: formatMoney(recon.computed_final_cents, acc?.currency || "EUR"),
|
||||
diff: formatMoney(recon.difference_cents, acc?.currency || "EUR"),
|
||||
})}
|
||||
</div>
|
||||
{recon.first_mismatch && (
|
||||
<div className="muted small">
|
||||
{t("import.reconFirst", {
|
||||
date: recon.first_mismatch.date ? formatDate(recon.first_mismatch.date) : "?",
|
||||
payee: recon.first_mismatch.payee || "—",
|
||||
expected: formatMoney(recon.first_mismatch.expected_cents, acc?.currency || "EUR"),
|
||||
actual: formatMoney(recon.first_mismatch.actual_cents, acc?.currency || "EUR"),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="muted small">
|
||||
{t("import.reconOpening", {
|
||||
value: formatMoney(recon.opening_balance_cents, acc?.currency || "EUR"),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
id="import-review"
|
||||
rows={rows}
|
||||
|
||||
Reference in New Issue
Block a user