feat: Phase 4 – CSV-Import (Templates, Prüfansicht, Duplikat/Transfer-Erkennung, Undo)
Backend: - Modelle + Migration 0005: import_templates, import_batches, categorization_rules; transactions um import_batch_id + import_hash erweitert. - services/csv_import: Datei parsen (Trennzeichen, Dezimal-/Datumsformat, Kodierung, Header/Skip, Vorzeichen vs. Soll/Haben), Vorschau mit Duplikat-Erkennung (Hash), interner-Transfer-Erkennung via IBAN, lernender Auto-Kategorisierung; Buchen mit Batch und Undo. - routers/imports: Templates (CRUD), Roh-Vorschau, Prüf-Vorschau, Buchen, Verlauf, Rückgängig. pytest: Parsing, Duplikate, Transfer, Buchen, Undo, Lernen. Frontend: - Import-Seite: Konto+Datei, Format/Spaltenzuordnung (mit Roh-Vorschau), editierbare Prüfansicht (Kategorie/Typ/Zielkonto, Duplikate markiert), Buchen, Import-Verlauf mit Undo; Vorlagen speichern; i18n DE/EN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""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 "{}"),
|
||||
)
|
||||
|
||||
|
||||
# --- Templates -------------------------------------------------------------
|
||||
|
||||
|
||||
@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),
|
||||
)
|
||||
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("{}"),
|
||||
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,
|
||||
)
|
||||
rows = csv_import.build_preview(db, user, account, tpl, data)
|
||||
return {"rows": rows, "filename": file.filename or ""}
|
||||
|
||||
|
||||
# --- 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}
|
||||
Reference in New Issue
Block a user