feat: Konto wird aus der Datei erkannt – ein Kurzbefehl genuegt
Bisher brauchte es je Konto einen eigenen Kurzbefehl, weil account_id Pflicht war. Jetzt ist sie optional: Raiffeisen fuehrt die eigene IBAN in der Datei mit (Treffer gegen die Erkennungs-Texte der Konten), Revolut die Waehrung – zusammen mit der erkannten Bank und dem Import-Verlauf reicht das. Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto verfaelscht zwei Salden auf einmal. Stattdessen nennt die Fehlermeldung die Konten samt Nummern, sodass man sie im Kurzbefehl nachtragen kann. Hat der Schluessel ein Standardkonto, gilt das. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,7 +154,7 @@ MAX_UPLOAD = 5 * 1024 * 1024
|
||||
@router.post("/statement", status_code=status.HTTP_201_CREATED)
|
||||
async def quick_statement(
|
||||
file: UploadFile = File(...),
|
||||
account_id: int = Form(...),
|
||||
account_id: int | None = Form(None),
|
||||
marke: ApiToken = Depends(require_quick_token),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
@@ -165,7 +165,6 @@ async def quick_statement(
|
||||
wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und
|
||||
die Möglichkeit, einzelne Zeilen abzuwählen.
|
||||
"""
|
||||
konto = _konto(db, marke, account_id)
|
||||
daten = await file.read()
|
||||
if not daten:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer")
|
||||
@@ -176,6 +175,33 @@ async def quick_statement(
|
||||
f"{MAX_UPLOAD // 1024 // 1024} MB)",
|
||||
)
|
||||
|
||||
# Ohne ausdrückliche Angabe das Konto aus der Datei ableiten – dann reicht
|
||||
# EIN Kurzbefehl fürs Telefon statt einer je Konto.
|
||||
if account_id is not None:
|
||||
konto = _konto(db, marke, account_id)
|
||||
herkunft = "angegeben"
|
||||
else:
|
||||
user = db.get(User, marke.owner_id)
|
||||
erkannt = csv_import.erkenne_format(daten)
|
||||
gefunden = csv_import.erkenne_konto(db, user, daten, erkannt)
|
||||
if gefunden["account_id"] is None:
|
||||
if marke.default_account_id:
|
||||
konto = _konto(db, marke, marke.default_account_id)
|
||||
herkunft = "Standardkonto des Schlüssels"
|
||||
else:
|
||||
namen = ", ".join(
|
||||
f"{a.name} = {a.id}" for a in db.query(Account).filter(
|
||||
Account.owner_id == marke.owner_id, Account.is_archived.is_(False))
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"{gefunden['grund']}. Bitte account_id im Kurzbefehl "
|
||||
f"angeben. Deine Konten: {namen}"),
|
||||
)
|
||||
else:
|
||||
konto = _konto(db, marke, gefunden["account_id"])
|
||||
herkunft = gefunden["grund"]
|
||||
|
||||
upload = PendingUpload(
|
||||
owner_id=marke.owner_id,
|
||||
account_id=konto.id,
|
||||
@@ -190,5 +216,6 @@ async def quick_statement(
|
||||
"id": upload.id,
|
||||
"konto": konto.name,
|
||||
"size_bytes": upload.size_bytes,
|
||||
"message": f"Empfangen für {konto.name} – zum Prüfen in Certain öffnen",
|
||||
"erkannt_als": herkunft,
|
||||
"message": f"Empfangen für {konto.name} ({herkunft}) – zum Prüfen in Certain öffnen",
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
AccountIdentifier,
|
||||
InboxItem,
|
||||
InboxStatus,
|
||||
AmountMode,
|
||||
@@ -273,6 +274,82 @@ def erkenne_format(data: bytes, encoding: str = "utf-8") -> dict:
|
||||
}
|
||||
|
||||
|
||||
def erkenne_konto(db: Session, user: User, data: bytes, erkannt: dict) -> dict:
|
||||
"""Zu welchem Konto gehört diese Datei?
|
||||
|
||||
Damit reicht EIN Kurzbefehl fürs Telefon statt einer je Konto. Zwei
|
||||
Anhaltspunkte, in dieser Reihenfolge:
|
||||
|
||||
1. Eine Spalte mit der EIGENEN IBAN (Raiffeisen führt sie in Spalte 0).
|
||||
Trifft sie einen Erkennungs-Text eines Kontos, ist die Sache eindeutig.
|
||||
2. Die Währung der Zeilen zusammen mit der erkannten Bank. Revolut liefert
|
||||
je Währung eine eigene Datei; welches Konto gemeint ist, verrät der
|
||||
Import-Verlauf – dorthin ging zuletzt eine Datei derselben Bank.
|
||||
|
||||
Bleibt es mehrdeutig, wird NICHT geraten: Ein Auszug im falschen Konto
|
||||
verfälscht zwei Salden auf einmal.
|
||||
"""
|
||||
if not erkannt.get("ok"):
|
||||
return {"account_id": None, "grund": erkannt.get("grund", "Format unbekannt")}
|
||||
|
||||
config = erkannt["config"]
|
||||
text = decode_bytes(data, config.get("encoding") or "utf-8")
|
||||
zeilen = [r for r in csv.reader(io.StringIO(text), delimiter=config["delimiter"])
|
||||
if any((c or "").strip() for c in r)]
|
||||
if len(zeilen) < 2:
|
||||
return {"account_id": None, "grund": "Zu wenige Zeilen"}
|
||||
proben = zeilen[1:31]
|
||||
|
||||
konten = db.query(Account).filter(Account.owner_id == user.id,
|
||||
Account.is_archived.is_(False)).all()
|
||||
|
||||
# 1. Eigene IBAN in einer Spalte.
|
||||
idents: dict[str, int] = {}
|
||||
for acc_id, wert in db.query(AccountIdentifier.account_id, AccountIdentifier.value_norm).join(
|
||||
Account, Account.id == AccountIdentifier.account_id
|
||||
).filter(Account.owner_id == user.id):
|
||||
if wert:
|
||||
idents[normalize_iban(wert)] = acc_id
|
||||
breite = max(len(r) for r in proben)
|
||||
for i in range(breite):
|
||||
werte = {normalize_iban(r[i]) for r in proben if i < len(r) and r[i].strip()}
|
||||
werte = {w for w in werte if len(w) >= 15}
|
||||
if len(werte) == 1:
|
||||
treffer = idents.get(next(iter(werte)))
|
||||
if treffer:
|
||||
return {"account_id": treffer, "grund": "eigene IBAN in der Datei",
|
||||
"sicher": True}
|
||||
|
||||
# 2. Währung plus zuletzt benutztes Konto derselben Bank.
|
||||
waehrung_i = config["column_map"].get("currency")
|
||||
waehrungen = set()
|
||||
if waehrung_i is not None:
|
||||
waehrungen = {r[waehrung_i].strip().upper()
|
||||
for r in proben if waehrung_i < len(r) and r[waehrung_i].strip()}
|
||||
if len(waehrungen) == 1:
|
||||
w = next(iter(waehrungen))
|
||||
passend = [a for a in konten if a.currency.upper() == w]
|
||||
if len(passend) == 1:
|
||||
return {"account_id": passend[0].id, "grund": f"einziges Konto in {w}", "sicher": True}
|
||||
if passend and erkannt.get("preset"):
|
||||
frueher = {
|
||||
b.account_id
|
||||
for b in db.query(ImportBatch).filter(
|
||||
ImportBatch.owner_id == user.id,
|
||||
ImportBatch.preset == erkannt["preset"],
|
||||
ImportBatch.status == ImportStatus.committed,
|
||||
)
|
||||
}
|
||||
treffer = [a for a in passend if a.id in frueher]
|
||||
if len(treffer) == 1:
|
||||
return {"account_id": treffer[0].id,
|
||||
"grund": f"{w} und zuletzt für „{erkannt['preset']}“ benutzt",
|
||||
"sicher": True}
|
||||
|
||||
return {"account_id": None,
|
||||
"grund": "Das Konto lässt sich aus der Datei nicht sicher ableiten"}
|
||||
|
||||
|
||||
# --- Kleine Parser-Helfer --------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -319,12 +319,10 @@ def test_fehler_am_kurzbefehl_sind_im_feld_message_lesbar(admin):
|
||||
kopf = {"X-Certain-Token": marke["token"]}
|
||||
import io
|
||||
|
||||
# Datei da, aber account_id vergessen: Pflichtfeld fehlt.
|
||||
r = client.post("/api/quick/statement",
|
||||
files={"file": ("a.csv", io.BytesIO(b"Datum;Betrag\n"), "text/csv")},
|
||||
headers=kopf)
|
||||
# Gar keine Datei mitgeschickt: Pflichtfeld fehlt.
|
||||
r = client.post("/api/quick/statement", data={"account_id": str(konto["id"])}, headers=kopf)
|
||||
assert r.status_code == 422
|
||||
assert "account_id" in r.json()["message"]
|
||||
assert "file" in r.json()["message"]
|
||||
assert "Formular" in r.json()["message"]
|
||||
|
||||
# Falsche Marke.
|
||||
@@ -339,3 +337,48 @@ def test_fehler_am_kurzbefehl_sind_im_feld_message_lesbar(admin):
|
||||
data={"account_id": str(konto["id"])}, headers=kopf)
|
||||
assert r.status_code == 400
|
||||
assert "leer" in r.json()["message"].lower()
|
||||
|
||||
|
||||
def test_konto_wird_aus_der_datei_erkannt(admin):
|
||||
"""Ein Kurzbefehl statt einer je Konto: Raiffeisen fuehrt die eigene IBAN
|
||||
in der Datei mit, damit ist die Zuordnung eindeutig."""
|
||||
client = _fremd()
|
||||
iban = "CH43 8080 8007 9160 7106 9"
|
||||
konto = _konto(admin, name="Lohnkonto IBAN", currency="CHF",
|
||||
identifiers=[{"kind": "iban", "value": iban}])
|
||||
# Zweites Konto derselben Waehrung, damit es etwas zu verwechseln gibt.
|
||||
_konto(admin, name="Anderes CHF-Konto", currency="CHF")
|
||||
marke = _marke(admin, None, name="Ohne Standardkonto " + secrets.token_hex(3))
|
||||
import io
|
||||
|
||||
csv_text = (
|
||||
"IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\n"
|
||||
"CH4380808007916071069;2026-01-05 00:00:00.0;LSV Viseca;-1718.7;702.47;2026-01-05 00:00:00.0\n"
|
||||
"CH4380808007916071069;2026-01-06 00:00:00.0;Coop;-42.10;660.37;2026-01-06 00:00:00.0\n"
|
||||
)
|
||||
r = client.post("/api/quick/statement",
|
||||
files={"file": ("raiff.csv", io.BytesIO(csv_text.encode()), "text/csv")},
|
||||
headers={"X-Certain-Token": marke["token"]})
|
||||
assert r.status_code == 201, r.text
|
||||
assert "IBAN" in r.json()["erkannt_als"]
|
||||
|
||||
wartend = [u for u in admin.get("/api/imports/uploads").json()
|
||||
if u["filename"] == "raiff.csv"]
|
||||
assert wartend and wartend[0]["account_id"] == konto["id"]
|
||||
|
||||
|
||||
def test_unklares_konto_nennt_die_moeglichkeiten(admin):
|
||||
"""Raten waere teuer: Ein Auszug im falschen Konto verfaelscht zwei Salden."""
|
||||
client = _fremd()
|
||||
_konto(admin, name="Erstes Konto", currency="CHF")
|
||||
_konto(admin, name="Zweites Konto", currency="CHF")
|
||||
marke = _marke(admin, None, name="Ratlos " + secrets.token_hex(3))
|
||||
import io
|
||||
|
||||
r = client.post("/api/quick/statement",
|
||||
files={"file": ("x.csv", io.BytesIO(
|
||||
b"Datum;Betrag;Text\n01.02.2026;-5,00;Kiosk\n"), "text/csv")},
|
||||
headers={"X-Certain-Token": marke["token"]})
|
||||
assert r.status_code == 400
|
||||
assert "account_id" in r.json()["message"]
|
||||
assert "Erstes Konto" in r.json()["message"]
|
||||
|
||||
Reference in New Issue
Block a user