fix: Datumsspalten mit Uhrzeit werden auch ohne passendes Format erkannt
Der Revolut-Export führt "2026-01-06 23:11:14" in der Datumsspalte. Wählte man
im Dropdown JJJJ-MM-TT, scheiterte das Parsen an der Uhrzeit – jede Zeile hatte
kein Datum und galt als ungültig ("137 Zeilen erkannt, 0 werden gebucht"),
obwohl die Auswahl richtig aussah.
- parse_date schneidet eine überzählige Uhrzeit ab und kommt auch damit klar,
wenn das Format eine Uhrzeit erwartet, der Wert aber keine hat (ISO-T ebenso)
- Dropdown um die Varianten mit Uhrzeit ergänzt, damit die Bank-Vorlagen ihren
echten Eintrag zeigen statt eines leeren Feldes
- Test: reines Datumsformat auf Datei mit Uhrzeit -> alle Zeilen erkannt, 84 grün
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -99,12 +99,33 @@ def parse_amount(raw: str | None, decimal_sep: str) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_date(raw: str | None, fmt: str) -> date | None:
|
def parse_date(raw: str | None, fmt: str) -> date | None:
|
||||||
|
"""Datumstext -> Datum. Nachsichtig gegenüber einer Uhrzeit, die im Format
|
||||||
|
fehlt oder umgekehrt: Banken exportieren dieselbe Spalte mal als „2026-01-06",
|
||||||
|
mal als „2026-01-06 23:11:14" (auch mit Sekundenbruchteilen). Sonst müsste man
|
||||||
|
im Dropdown für jede Variante ein eigenes Format anbieten."""
|
||||||
s = str(raw or "").strip()
|
s = str(raw or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(s, fmt).date()
|
return datetime.strptime(s, fmt).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
hat_zeit_im_format = any(x in fmt for x in ("%H", "%M", "%S", "%f"))
|
||||||
|
# Wert bringt eine Uhrzeit mit, das Format kennt keine -> nur den Datumsteil.
|
||||||
|
if not hat_zeit_im_format and (" " in s or "T" in s):
|
||||||
|
kopf = s.replace("T", " ").split(" ", 1)[0]
|
||||||
|
try:
|
||||||
|
return datetime.strptime(kopf, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Format erwartet eine Uhrzeit, der Wert hat keine -> Format kürzen.
|
||||||
|
if hat_zeit_im_format:
|
||||||
|
nur_datum = fmt.replace("T", " ").split(" ", 1)[0]
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.replace("T", " ").split(" ", 1)[0], nur_datum).date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,24 @@ def test_reconciliation_detects_wrong_amount(admin):
|
|||||||
assert mm["payee"] == "Premium Abo-Gebühr"
|
assert mm["payee"] == "Premium Abo-Gebühr"
|
||||||
assert mm["expected_cents"] == -1099 # laut Kontostand-Spalte
|
assert mm["expected_cents"] == -1099 # laut Kontostand-Spalte
|
||||||
assert mm["actual_cents"] == 0 # ohne Gebührenspalte erkannt
|
assert mm["actual_cents"] == 0 # ohne Gebührenspalte erkannt
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_format_tolerates_time(admin):
|
||||||
|
"""Wählt man ein reines Datumsformat, obwohl die Spalte eine Uhrzeit enthält,
|
||||||
|
werden die Zeilen trotzdem erkannt (früher: alles ungültig)."""
|
||||||
|
acc = _account(admin, name="Datum Zeit", currency="CHF")
|
||||||
|
nur_datum = json.dumps({
|
||||||
|
"date": 3, "amount": 5, "payee": 4, "fee": 6, "currency": 7, "state": 8, "balance": 9,
|
||||||
|
})
|
||||||
|
res = admin.post(
|
||||||
|
"/api/imports/preview",
|
||||||
|
files={"file": ("r.csv", io.BytesIO(REVOLUT_DE_CSV.encode("utf-8")), "text/csv")},
|
||||||
|
data={
|
||||||
|
"account_id": str(acc["id"]), "delimiter": ",", "decimal_sep": ".",
|
||||||
|
"date_format": "%Y-%m-%d", # ohne Uhrzeit, Datei hat aber welche
|
||||||
|
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
|
||||||
|
"amount_mode": "signed", "column_map": nur_datum,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert all(r["date"] for r in res["rows"]), "Datum trotz Uhrzeit erkannt"
|
||||||
|
assert res["reconciliation"]["ok"] is True
|
||||||
|
|||||||
@@ -9,12 +9,18 @@ import CategorySelect from "../components/CategorySelect";
|
|||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
import { formatMoney } from "../format";
|
import { formatMoney } from "../format";
|
||||||
|
|
||||||
|
// Eine Uhrzeit im Wert ist unkritisch – parse_date im Backend schneidet sie ab.
|
||||||
|
// Die Varianten mit Uhrzeit stehen trotzdem hier, damit die Bank-Vorlagen im
|
||||||
|
// Dropdown ihren echten Eintrag zeigen statt eines leeren Feldes.
|
||||||
const DATE_FORMATS = [
|
const DATE_FORMATS = [
|
||||||
{ v: "%d.%m.%Y", l: "TT.MM.JJJJ" },
|
{ v: "%d.%m.%Y", l: "TT.MM.JJJJ" },
|
||||||
{ v: "%Y-%m-%d", l: "JJJJ-MM-TT" },
|
{ v: "%Y-%m-%d", l: "JJJJ-MM-TT" },
|
||||||
{ v: "%d/%m/%Y", l: "TT/MM/JJJJ" },
|
{ v: "%d/%m/%Y", l: "TT/MM/JJJJ" },
|
||||||
{ v: "%m/%d/%Y", l: "MM/TT/JJJJ" },
|
{ v: "%m/%d/%Y", l: "MM/TT/JJJJ" },
|
||||||
{ v: "%d.%m.%y", l: "TT.MM.JJ" },
|
{ v: "%d.%m.%y", l: "TT.MM.JJ" },
|
||||||
|
{ v: "%Y-%m-%d %H:%M:%S", l: "JJJJ-MM-TT SS:MM:SS" },
|
||||||
|
{ v: "%Y-%m-%d %H:%M:%S.%f", l: "JJJJ-MM-TT SS:MM:SS.s" },
|
||||||
|
{ v: "%d.%m.%Y %H:%M:%S", l: "TT.MM.JJJJ SS:MM:SS" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function defaultConfig() {
|
function defaultConfig() {
|
||||||
|
|||||||
Reference in New Issue
Block a user