From d97e08e913d12cc09464548b38a2c3d63c995cf5 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 08:02:18 +0200 Subject: [PATCH] fix: Datumsspalten mit Uhrzeit werden auch ohne passendes Format erkannt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/services/csv_import.py | 23 ++++++++++++++++++++++- backend/tests/test_import.py | 21 +++++++++++++++++++++ web/src/pages/Import.jsx | 6 ++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index d888609..e1fdacd 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -99,13 +99,34 @@ def parse_amount(raw: str | None, decimal_sep: str) -> int | 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() if not s: return None try: return datetime.strptime(s, fmt).date() except ValueError: - return None + 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 def decode_bytes(data: bytes, encoding: str) -> str: diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 4d6e894..72fe015 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -268,3 +268,24 @@ def test_reconciliation_detects_wrong_amount(admin): assert mm["payee"] == "Premium Abo-Gebühr" assert mm["expected_cents"] == -1099 # laut Kontostand-Spalte 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 diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index a8cfc83..a8ff8c7 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -9,12 +9,18 @@ import CategorySelect from "../components/CategorySelect"; import DataTable from "../components/DataTable"; 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 = [ { v: "%d.%m.%Y", l: "TT.MM.JJJJ" }, { v: "%Y-%m-%d", l: "JJJJ-MM-TT" }, { v: "%d/%m/%Y", l: "TT/MM/JJJJ" }, { v: "%m/%d/%Y", l: "MM/TT/JJJJ" }, { 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() {