feat: Kontoauszug vom Telefon per Teilen-Menue einlesen
Revolut und Raiffeisen lassen die CSV auch am Handy exportieren; nur N26 verlangt die Web-Version. Ein zweiter Kurzbefehl nimmt die Datei aus dem Teilen-Menue entgegen und schickt sie mit dem gewaehlten Konto an /quick/statement. Am Telefon laesst sich keine Spaltenzuordnung vornehmen. Der Endpunkt verwendet deshalb die Einstellung, mit der dieses Konto zuletzt erfolgreich eingelesen wurde – und sagt klar Bescheid, wenn es noch keine gibt, statt mit falschen Spalten zu raten. Gebucht wird sofort: Ein Auszug hat hunderte Zeilen, die einzeln abzunicken waere keine Erleichterung. Sicher ist das durch die Duplikaterkennung und weil der Stapel im Import-Verlauf mit einem Klick zurueckzunehmen ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,9 @@ Auswahl braucht – sie liefert nur Name, Symbol und Farbe, keine Salden.
|
||||
from datetime import date as date_t
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -21,6 +23,9 @@ from ..deps import require_quick_token
|
||||
from ..models import (
|
||||
Account,
|
||||
ApiToken,
|
||||
ImportBatch,
|
||||
ImportStatus,
|
||||
ImportTemplate,
|
||||
InboxItem,
|
||||
InboxSource,
|
||||
InboxStatus,
|
||||
@@ -144,3 +149,82 @@ def quick_booking(
|
||||
message=f"{betrag / 100:.2f} {konto.currency} auf {konto.name}{wohin}",
|
||||
item=InboxItemOut.model_validate(eintrag),
|
||||
)
|
||||
|
||||
|
||||
def _letzte_vorlage(db: Session, konto: Account) -> ImportTemplate | None:
|
||||
"""Die Einstellung, mit der dieses Konto zuletzt erfolgreich eingelesen wurde.
|
||||
|
||||
Am Telefon lässt sich keine Spaltenzuordnung vornehmen. Der Kurzbefehl
|
||||
setzt deshalb voraus, dass die Datei am Rechner schon einmal für dieses
|
||||
Konto eingelesen wurde – dann steht im Import-Verlauf, wie es ging.
|
||||
"""
|
||||
stapel = (
|
||||
db.query(ImportBatch)
|
||||
.filter(ImportBatch.owner_id == konto.owner_id,
|
||||
ImportBatch.account_id == konto.id,
|
||||
ImportBatch.status == ImportStatus.committed)
|
||||
.order_by(ImportBatch.created_at.desc(), ImportBatch.id.desc())
|
||||
.first()
|
||||
)
|
||||
if stapel is None:
|
||||
return None
|
||||
if stapel.template_id:
|
||||
vorlage = db.get(ImportTemplate, stapel.template_id)
|
||||
if vorlage is not None and vorlage.owner_id == konto.owner_id:
|
||||
return vorlage
|
||||
if stapel.preset:
|
||||
for p in csv_import.PRESETS:
|
||||
if p["name"] == stapel.preset:
|
||||
felder = {k: v for k, v in p.items() if k not in ("name", "column_map")}
|
||||
return ImportTemplate(owner_id=konto.owner_id, name="_kurzbefehl",
|
||||
column_map=json.dumps(p["column_map"]), **felder)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/statement", status_code=status.HTTP_201_CREATED)
|
||||
async def quick_statement(
|
||||
file: UploadFile = File(...),
|
||||
account_id: int = Form(...),
|
||||
marke: ApiToken = Depends(require_quick_token),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Kontoauszug vom Telefon aus einlesen (Teilen-Menü).
|
||||
|
||||
Gebucht wird sofort – ein Auszug hat schnell hunderte Zeilen, die einzeln
|
||||
abzunicken wäre keine Erleichterung. Sicher ist das, weil die
|
||||
Duplikaterkennung greift und der ganze Stapel im Import-Verlauf mit einem
|
||||
Klick rückgängig zu machen ist.
|
||||
"""
|
||||
konto = _konto(db, marke, account_id)
|
||||
user = db.get(User, marke.owner_id)
|
||||
vorlage = _letzte_vorlage(db, konto)
|
||||
if vorlage is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(f"Für „{konto.name}“ gibt es noch keine Einstellung. Bitte einen Auszug "
|
||||
"dieses Kontos einmal am Rechner einlesen – danach geht es vom Telefon."),
|
||||
)
|
||||
|
||||
daten = await file.read()
|
||||
try:
|
||||
kandidaten = csv_import.parse_file_to_candidates(daten, vorlage)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Datei nicht lesbar: {e}") from e
|
||||
|
||||
zeilen = csv_import.detect_rows(db, user, konto, kandidaten)
|
||||
zu_buchen = [z for z in zeilen if z.get("include")]
|
||||
stapel = csv_import.commit_import(
|
||||
db, user, konto, vorlage.id if vorlage.id else None,
|
||||
(file.filename or "Vom Telefon")[:255], zu_buchen,
|
||||
preset=None if vorlage.id else vorlage.name,
|
||||
)
|
||||
dubletten = sum(1 for z in zeilen if z.get("is_duplicate"))
|
||||
return {
|
||||
"gelesen": len(zeilen),
|
||||
"gebucht": stapel.imported_count,
|
||||
"duplikate": dubletten,
|
||||
"konto": konto.name,
|
||||
"message": f"{stapel.imported_count} gebucht auf {konto.name}"
|
||||
f" · {dubletten} schon vorhanden",
|
||||
}
|
||||
|
||||
@@ -247,3 +247,61 @@ def test_zwei_gleiche_zahlungen_verbrauchen_je_einen_eintrag(admin):
|
||||
assert gruende.count("inbox") == 2, gruende
|
||||
# Die dritte ist echt und wird gebucht.
|
||||
assert sum(1 for r in v["rows"] if r["include"]) == 1
|
||||
|
||||
|
||||
def test_auszug_vom_telefon_nutzt_die_zuletzt_benutzte_vorlage(admin):
|
||||
"""Am Telefon kann man keine Spalten zuordnen. Also wird die Einstellung
|
||||
wiederverwendet, mit der dieses Konto zuletzt eingelesen wurde."""
|
||||
client = _fremd()
|
||||
konto = _konto(admin, name="Handykonto", currency="CHF")
|
||||
marke = _marke(admin, konto["id"])
|
||||
kopf = {"X-Certain-Token": marke["token"]}
|
||||
import io
|
||||
|
||||
csv_text = KOPF + "01.04.2026;-12,00;Bäckerei;Brot;\n02.04.2026;-8,00;Kiosk;Zeitung;\n"
|
||||
|
||||
# Ohne vorherigen Import am Rechner: klare Ansage statt Rätselraten.
|
||||
r = client.post("/api/quick/statement",
|
||||
files={"file": ("a.csv", io.BytesIO(csv_text.encode()), "text/csv")},
|
||||
data={"account_id": str(konto["id"])}, headers=kopf)
|
||||
assert r.status_code == 400
|
||||
assert "am Rechner" in r.json()["detail"]
|
||||
|
||||
# Einmal am Rechner einlesen legt die Einstellung fest.
|
||||
import json as _json
|
||||
vorlage = admin.post("/api/imports/templates", json={
|
||||
"name": "Handytest " + secrets.token_hex(3), "delimiter": ";", "decimal_sep": ",",
|
||||
"date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": True, "skip_rows": 0,
|
||||
"amount_mode": "signed",
|
||||
"column_map": {"date": 0, "amount": 1, "payee": 2, "purpose": 3},
|
||||
})
|
||||
assert vorlage.status_code == 201, vorlage.text
|
||||
v = _import(admin, konto["id"], csv_text)
|
||||
admin.post("/api/imports/commit", json={
|
||||
"account_id": konto["id"], "filename": "april.csv",
|
||||
"template_id": vorlage.json()["id"],
|
||||
"rows": [x for x in v["rows"] if x["include"]]})
|
||||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 2000
|
||||
|
||||
# Ab jetzt geht es vom Telefon: neue Zeilen kommen an, alte nicht doppelt.
|
||||
mehr = csv_text + "03.04.2026;-5,50;Coop;Milch;\n"
|
||||
r = client.post("/api/quick/statement",
|
||||
files={"file": ("april.csv", io.BytesIO(mehr.encode()), "text/csv")},
|
||||
data={"account_id": str(konto["id"])}, headers=kopf)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["gebucht"] == 1
|
||||
assert r.json()["duplikate"] == 2
|
||||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 2550
|
||||
|
||||
# Und der Stapel steht im Verlauf, also mit einem Klick ruecknehmbar.
|
||||
stapel = admin.get("/api/imports/batches").json()
|
||||
assert any(b["filename"] == "april.csv" and b["imported_count"] == 1 for b in stapel)
|
||||
|
||||
|
||||
def test_auszug_vom_telefon_braucht_eine_gueltige_marke(admin):
|
||||
import io
|
||||
konto = _konto(admin, name="Fremdes Konto")
|
||||
r = _fremd().post("/api/quick/statement",
|
||||
files={"file": ("a.csv", io.BytesIO(b"x"), "text/csv")},
|
||||
data={"account_id": str(konto["id"])})
|
||||
assert r.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user