Files
certain/backend/app/routers/imports.py
T
GuidoandClaude Opus 4.8 4a6f7a0814 feat: Zeilenfilter für Auszüge mit mehreren Pockets + Gebühren-Richtung
Der Revolut-Auszug enthält ALLE Produkte in einer Datei – im CHF-Export stehen
533 Giro-Zeilen und 255 Zeilen des Ersparten, jedes mit eigenem Kontostand-
Verlauf. Ohne Trennung landeten beide auf demselben Konto und die Pocket-
Bewegungen zählten doppelt.

- Import-Vorlage kennt einen Zeilenfilter („nur Zeilen aus Spalte X mit Wert Y"),
  Migration 0012; damit bekommt jedes Pocket sein eigenes Konto
- Gebührenzeilen (Betrag 0.00) können Belastung ODER Erstattung sein – beide
  sehen identisch aus. Die Richtung kommt jetzt aus der Kontostand-Spalte;
  ohne sie bleibt die Belastung der Normalfall
- Tests für beides; 90 grün

Nachgerechnet an den echten Auszügen: Raiffeisen 223.37, Revolut CHF 61.88,
Erspartes 6.71, Revolut EUR 0.68 – alle vier stimmen zeilengenau mit der
Kontostand-Spalte überein.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:01:07 +02:00

163 lines
6.1 KiB
Python

"""CSV-Import: Templates verwalten, Datei-Vorschau, verbuchen, rückgängig machen."""
import json
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, AmountMode, ImportBatch, 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("/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)
@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}