Beim Kontowechsel musste man die Vorlage jedes Mal neu waehlen – und las im schlimmsten Fall den Revolut-Auszug mit der Raiffeisen-Vorlage ein (genau das steht im Verlauf). Eigene Vorlagen standen schon als template_id im Verlauf; Bank-Vorlagen sind fest im Code und hinterliessen nichts. Der Verlauf merkt sich deshalb jetzt auch deren Namen (Migration 0014). Die Oberflaeche waehlt beim Kontowechsel, womit zuletzt in dieses Konto importiert wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
6.9 KiB
Python
182 lines
6.9 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 ..models import Account, 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("/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()
|