Auf einem Kartenauszug ist +6,00 eine AUSGABE und -10,00 die Zahlung der Rechnung. Certain kannte diese Leserichtung nicht. Der scheinbare Ausweg "Ein Betrag (immer Ausgabe)" machte auch die Rechnungszahlung und jede Gutschrift zur Ausgabe. Neuer Betragsmodus "signed_inverted". Er dreht Betrag UND Saldospalte: Ein Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so – die Schuld steigt. Wer nur den Betrag dreht, bekommt bei jeder einzelnen Zeile eine Abweichung gemeldet, und die einzige Kontrollinstanz des Imports wird wertlos. Dazu gibt es jetzt einen Test. Waehlen muss den Modus niemand: /imports/detect erkennt jetzt auch das KONTO – erkenne_konto konnte das laengst, benutzt hat es nur der Telefon-Kurzbefehl. Ist das erkannte Konto eine Kreditkarte, wird die Leserichtung mitgesetzt. Eng gefasst: nur bei einer Betragsspalte, nur aus "signed" heraus, nur bei sicher erkanntem Konto. Und nie still – in der Pruefansicht steht, dass umgekehrt gelesen wird, und wo man es aendert. Drei Dinge, die dabei auffielen: - Der Abgleich rechnete mit dem Vorzeichen, gebucht wird aber nach Art (abs(Betrag) + Ausgabe/Einnahme). Wer eine Zeile umstellte, pruefte eine Buchung, die so nie entsteht. - Dieselbe Luecke in der Vorschau: Die Betragsspalte faerbte nach dem Rohvorzeichen. Ein Umstellen aenderte sichtbar nichts. - Auf Kartenauszuegen heisst die Empfaengerspalte "Haendler", und viele fuehren die eigene Kontonummer in jeder Zeile mit. Die gewann die Spaltenwahl ueber die Textlaenge – heraus kam eine Liste, in der bei jeder Buchung dasselbe stand. Neu ausserdem eine Summenzeile je Richtung und eine Sammelaktion: Ist eine Datei durchgehend verkehrt herum gelesen, sind alle Zeilen betroffen, und bei 150 Positionen ist Einzelkorrektur keine Moeglichkeit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""CSV-Import: Templates verwalten, Datei-Vorschau, verbuchen, rückgängig machen."""
|
||
|
||
import json
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, get_owned
|
||
from fastapi.responses import Response
|
||
|
||
from ..models import (PendingUpload, Account, AccountType, AmountMode, ImportBatch,
|
||
ImportStatus, ImportTemplate, User)
|
||
from ..schemas import (
|
||
ImportBatchOut,
|
||
ImportCommitIn,
|
||
ImportTemplateCreate,
|
||
ImportTemplateOut,
|
||
ImportTemplateUpdate,
|
||
)
|
||
from ..services import csv_import
|
||
|
||
router = APIRouter(prefix="/imports", tags=["imports"])
|
||
|
||
|
||
def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
|
||
return ImportTemplateOut(
|
||
id=tpl.id, name=tpl.name, delimiter=tpl.delimiter, decimal_sep=tpl.decimal_sep,
|
||
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
|
||
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
|
||
column_map=json.loads(tpl.column_map or "{}"),
|
||
filter_column=tpl.filter_column, filter_value=tpl.filter_value,
|
||
)
|
||
|
||
|
||
# --- Templates -------------------------------------------------------------
|
||
|
||
|
||
@router.get("/presets")
|
||
def list_presets(_: User = Depends(get_current_user)) -> list[dict]:
|
||
"""Vorgefertigte Bank-Vorlagen (füllen die Format-/Spalten-Einstellungen)."""
|
||
return csv_import.PRESETS
|
||
|
||
|
||
@router.get("/templates", response_model=list[ImportTemplateOut])
|
||
def list_templates(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
rows = db.query(ImportTemplate).filter(ImportTemplate.owner_id == user.id).order_by(ImportTemplate.name).all()
|
||
return [_to_out(t) for t in rows]
|
||
|
||
|
||
@router.post("/templates", response_model=ImportTemplateOut, status_code=status.HTTP_201_CREATED)
|
||
def create_template(payload: ImportTemplateCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
tpl = ImportTemplate(
|
||
owner_id=user.id, name=payload.name.strip(), delimiter=payload.delimiter,
|
||
decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding,
|
||
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
|
||
column_map=json.dumps(payload.column_map),
|
||
filter_column=payload.filter_column, filter_value=payload.filter_value,
|
||
)
|
||
db.add(tpl)
|
||
db.commit()
|
||
db.refresh(tpl)
|
||
return _to_out(tpl)
|
||
|
||
|
||
@router.patch("/templates/{template_id}", response_model=ImportTemplateOut)
|
||
def update_template(template_id: int, payload: ImportTemplateUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
tpl = get_owned(db, ImportTemplate, template_id, user)
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if "column_map" in data and data["column_map"] is not None:
|
||
tpl.column_map = json.dumps(data.pop("column_map"))
|
||
for key, value in data.items():
|
||
if value is not None:
|
||
setattr(tpl, key, value)
|
||
db.commit()
|
||
db.refresh(tpl)
|
||
return _to_out(tpl)
|
||
|
||
|
||
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_template(template_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
tpl = get_owned(db, ImportTemplate, template_id, user)
|
||
db.delete(tpl)
|
||
db.commit()
|
||
|
||
|
||
# --- Datei-Vorschau --------------------------------------------------------
|
||
|
||
|
||
def _transient(user_id: int, **fields) -> ImportTemplate:
|
||
return ImportTemplate(owner_id=user_id, name="_transient", **fields)
|
||
|
||
|
||
@router.post("/detect")
|
||
async def detect_format(
|
||
file: UploadFile = File(...),
|
||
encoding: str = Form("utf-8"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
"""Format UND Konto der Datei erkennen.
|
||
|
||
Das Konto gehoerte von Anfang an hierher: Es ist die gefaehrlichste
|
||
Entscheidung des ganzen Imports – ein Auszug im falschen Konto verfaelscht
|
||
zwei Salden auf einmal – und ``erkenne_konto`` konnte sie laengst. Benutzt
|
||
hat die Funktion bisher nur der Telefon-Kurzbefehl.
|
||
|
||
``erkenne_konto`` raet nicht: Bleibt es mehrdeutig, kommt ``None`` zurueck
|
||
und der Nutzer waehlt wie bisher.
|
||
"""
|
||
daten = await file.read()
|
||
erkannt = csv_import.erkenne_format(daten, encoding)
|
||
|
||
konto = csv_import.erkenne_konto(db, user, daten, erkannt)
|
||
erkannt["konto"] = konto
|
||
|
||
acc_id = konto.get("account_id")
|
||
if acc_id:
|
||
acc = db.get(Account, acc_id)
|
||
if acc is not None and acc.owner_id == user.id:
|
||
_kreditkarten_regel(erkannt, acc)
|
||
return erkannt
|
||
|
||
|
||
def _kreditkarten_regel(erkannt: dict, acc: Account) -> None:
|
||
"""Bei einer Kreditkarte ist + eine Ausgabe.
|
||
|
||
Die Regel steht serverseitig und an EINER Stelle. Im Frontend gebaut
|
||
zerbraeche sie an der Reihenfolge: ``setAccountId`` und die Formaterkennung
|
||
laufen dort im selben Durchgang, die Regel laese also noch den alten
|
||
Zustand.
|
||
|
||
Eng gefasst, mit Absicht:
|
||
|
||
- Nur ``signed``. Bei getrennten Soll-/Haben-Spalten benennen die Spalten
|
||
die Richtung selbst, und ``abs_expense`` wurde bewusst gewaehlt.
|
||
- Nur mit einer Betragsspalte.
|
||
- Nur, wenn das Konto sicher erkannt wurde – sonst raet man zweimal.
|
||
|
||
Umgeschaltet, nicht erzwungen: Der Modus steht unter „Format" und laesst
|
||
sich aendern. Und in der Pruefansicht steht, dass umgekehrt gelesen wird –
|
||
eine stille Umkehr waere schlimmer als gar keine.
|
||
"""
|
||
if acc.type != AccountType.kreditkarte or not erkannt.get("ok"):
|
||
return
|
||
config = erkannt.get("config") or {}
|
||
if config.get("amount_mode") != AmountMode.signed.value:
|
||
return
|
||
if (config.get("column_map") or {}).get("amount") is None:
|
||
return
|
||
config["amount_mode"] = AmountMode.signed_inverted.value
|
||
erkannt["kreditkarte"] = True
|
||
erkannt["begruendung"] = (
|
||
(erkannt.get("begruendung") or "")
|
||
+ f" · Kreditkarte „{acc.name}“: + im Auszug = Ausgabe"
|
||
).strip(" ·")
|
||
|
||
|
||
@router.post("/raw")
|
||
async def raw_preview(
|
||
file: UploadFile = File(...),
|
||
delimiter: str = Form(";"),
|
||
encoding: str = Form("utf-8"),
|
||
has_header: bool = Form(True),
|
||
skip_rows: int = Form(0),
|
||
_: User = Depends(get_current_user),
|
||
) -> dict:
|
||
data = await file.read()
|
||
tpl = _transient(0, delimiter=delimiter, encoding=encoding, has_header=has_header, skip_rows=skip_rows)
|
||
return csv_import.preview_raw(data, tpl)
|
||
|
||
|
||
@router.post("/preview")
|
||
async def preview(
|
||
file: UploadFile = File(...),
|
||
account_id: int = Form(...),
|
||
delimiter: str = Form(";"),
|
||
decimal_sep: str = Form(","),
|
||
date_format: str = Form("%d.%m.%Y"),
|
||
encoding: str = Form("utf-8"),
|
||
has_header: bool = Form(True),
|
||
skip_rows: int = Form(0),
|
||
amount_mode: str = Form("signed"),
|
||
column_map: str = Form("{}"),
|
||
filter_column: int | None = Form(None),
|
||
filter_value: str | None = Form(None),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
account = get_owned(db, Account, account_id, user)
|
||
data = await file.read()
|
||
tpl = _transient(
|
||
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
|
||
encoding=encoding, has_header=has_header, skip_rows=skip_rows,
|
||
amount_mode=AmountMode(amount_mode), column_map=column_map,
|
||
filter_column=filter_column, filter_value=filter_value,
|
||
)
|
||
rows = csv_import.build_preview(db, user, account, tpl, data)
|
||
return {
|
||
"rows": rows,
|
||
"filename": file.filename or "",
|
||
# Abgleich gegen die Saldo-Spalte der Datei (falls zugeordnet).
|
||
"reconciliation": csv_import.reconcile(rows),
|
||
}
|
||
|
||
|
||
# --- Verbuchen / Undo ------------------------------------------------------
|
||
|
||
|
||
@router.post("/commit", response_model=ImportBatchOut)
|
||
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
account = get_owned(db, Account, payload.account_id, user)
|
||
return csv_import.commit_import(db, user, account, payload.template_id, payload.filename,
|
||
payload.rows, preset=payload.preset)
|
||
|
||
|
||
@router.get("/batches", response_model=list[ImportBatchOut])
|
||
def list_batches(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
return (
|
||
db.query(ImportBatch)
|
||
.filter(ImportBatch.owner_id == user.id)
|
||
.order_by(ImportBatch.created_at.desc(), ImportBatch.id.desc())
|
||
.all()
|
||
)
|
||
|
||
|
||
@router.delete("/batches/{batch_id}")
|
||
def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
batch = get_owned(db, ImportBatch, batch_id, user)
|
||
deleted = csv_import.undo_batch(db, user, batch)
|
||
return {"deleted": deleted}
|
||
|
||
|
||
@router.delete("/batches/{batch_id}/record", status_code=status.HTTP_204_NO_CONTENT)
|
||
def remove_batch_record(
|
||
batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
"""Entfernt einen rückgängig gemachten Eintrag aus dem Verlauf.
|
||
|
||
Nur für bereits zurückgenommene Importe: Solange ein Import steht, ist der
|
||
Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden."""
|
||
batch = get_owned(db, ImportBatch, batch_id, user)
|
||
if batch.status != ImportStatus.undone:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Nur rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen.",
|
||
)
|
||
db.delete(batch)
|
||
db.commit()
|
||
|
||
|
||
# --- Vom Telefon abgelieferte Dateien ---------------------------------------
|
||
|
||
|
||
@router.get("/uploads")
|
||
def list_uploads(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
|
||
"""Wartende Dateien. Der Inhalt bleibt hier draussen – nur die Eckdaten."""
|
||
zeilen = (
|
||
db.query(PendingUpload)
|
||
.filter(PendingUpload.owner_id == user.id)
|
||
.order_by(PendingUpload.created_at.desc(), PendingUpload.id.desc())
|
||
.all()
|
||
)
|
||
return [
|
||
{"id": u.id, "filename": u.filename, "account_id": u.account_id,
|
||
"size_bytes": u.size_bytes, "created_at": u.created_at.isoformat() if u.created_at else None}
|
||
for u in zeilen
|
||
]
|
||
|
||
|
||
@router.get("/uploads/{upload_id}/content")
|
||
def upload_content(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""Die Datei selbst – damit die Import-Ansicht sie wie eine lokale behandelt."""
|
||
upload = get_owned(db, PendingUpload, upload_id, user)
|
||
return Response(
|
||
content=upload.content,
|
||
media_type="text/csv",
|
||
headers={"Content-Disposition": f'attachment; filename="{upload.filename}"'},
|
||
)
|
||
|
||
|
||
@router.delete("/uploads/{upload_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_upload(upload_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
db.delete(get_owned(db, PendingUpload, upload_id, user))
|
||
db.commit()
|