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:
@@ -24,6 +24,7 @@ from .routers import (
|
||||
budgets,
|
||||
categories,
|
||||
dashboard,
|
||||
imports,
|
||||
rates,
|
||||
settings as settings_router,
|
||||
transactions,
|
||||
@@ -103,6 +104,7 @@ api.include_router(categories.router)
|
||||
api.include_router(transactions.router)
|
||||
api.include_router(budgets.router)
|
||||
api.include_router(dashboard.router)
|
||||
api.include_router(imports.router)
|
||||
api.include_router(rates.router)
|
||||
|
||||
app.include_router(api)
|
||||
|
||||
@@ -84,6 +84,23 @@ class BudgetTreatment(str, enum.Enum):
|
||||
neutral = "neutral"
|
||||
|
||||
|
||||
class AmountMode(str, enum.Enum):
|
||||
"""Wie der Betrag in der CSV steht."""
|
||||
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
|
||||
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
|
||||
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
|
||||
|
||||
|
||||
class MatchField(str, enum.Enum):
|
||||
payee = "payee"
|
||||
purpose = "purpose"
|
||||
|
||||
|
||||
class ImportStatus(str, enum.Enum):
|
||||
committed = "committed"
|
||||
undone = "undone"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -273,6 +290,12 @@ class Transaction(Base):
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
|
||||
import_batch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("import_batches.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
@@ -394,3 +417,72 @@ class DashboardLayout(Base):
|
||||
# JSON-Array: [{i, type, x, y, w, h, props}]
|
||||
layout: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4: CSV-Import (Templates, Batches für Undo, lernende Kategorie-Regeln)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ImportTemplate(Base):
|
||||
__tablename__ = "import_templates"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
delimiter: Mapped[str] = mapped_column(String(2), nullable=False, default=";")
|
||||
decimal_sep: Mapped[str] = mapped_column(String(1), nullable=False, default=",")
|
||||
date_format: Mapped[str] = mapped_column(String(32), nullable=False, default="%d.%m.%Y")
|
||||
encoding: Mapped[str] = mapped_column(String(16), nullable=False, default="utf-8")
|
||||
has_header: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
skip_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
amount_mode: Mapped[AmountMode] = mapped_column(_enum(AmountMode), nullable=False, default=AmountMode.signed)
|
||||
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
|
||||
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
|
||||
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class ImportBatch(Base):
|
||||
__tablename__ = "import_batches"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
template_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[ImportStatus] = mapped_column(_enum(ImportStatus), nullable=False, default=ImportStatus.committed)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class CategorizationRule(Base):
|
||||
"""Lernende Regel: Empfänger/Zweck enthält Muster -> Kategorie (oder Transfer)."""
|
||||
|
||||
__tablename__ = "categorization_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
match_field: Mapped[MatchField] = mapped_column(_enum(MatchField), nullable=False, default=MatchField.payee)
|
||||
pattern: Mapped[str] = mapped_column(String(255), nullable=False) # normalisiert (lowercase)
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"),
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
@@ -6,9 +6,11 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .models import (
|
||||
AccountType,
|
||||
AmountMode,
|
||||
BudgetTreatment,
|
||||
CategoryKind,
|
||||
IdentifierKind,
|
||||
ImportStatus,
|
||||
Role,
|
||||
TxnType,
|
||||
)
|
||||
@@ -283,3 +285,63 @@ class PlannedIncomeOut(BaseModel):
|
||||
target_account_id: int | None
|
||||
planned_cents: int
|
||||
position: int
|
||||
|
||||
|
||||
# --- CSV-Import ------------------------------------------------------------
|
||||
|
||||
|
||||
class ImportTemplateCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
delimiter: str = ";"
|
||||
decimal_sep: str = ","
|
||||
date_format: str = "%d.%m.%Y"
|
||||
encoding: str = "utf-8"
|
||||
has_header: bool = True
|
||||
skip_rows: int = 0
|
||||
amount_mode: AmountMode = AmountMode.signed
|
||||
column_map: dict[str, int | None] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ImportTemplateUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
delimiter: str | None = None
|
||||
decimal_sep: str | None = None
|
||||
date_format: str | None = None
|
||||
encoding: str | None = None
|
||||
has_header: bool | None = None
|
||||
skip_rows: int | None = None
|
||||
amount_mode: AmountMode | None = None
|
||||
column_map: dict[str, int | None] | None = None
|
||||
|
||||
|
||||
class ImportTemplateOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
delimiter: str
|
||||
decimal_sep: str
|
||||
date_format: str
|
||||
encoding: str
|
||||
has_header: bool
|
||||
skip_rows: int
|
||||
amount_mode: AmountMode
|
||||
column_map: dict[str, int | None]
|
||||
|
||||
|
||||
class ImportBatchOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
account_id: int
|
||||
template_id: int | None
|
||||
filename: str
|
||||
row_count: int
|
||||
imported_count: int
|
||||
status: ImportStatus
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ImportCommitIn(BaseModel):
|
||||
account_id: int
|
||||
template_id: int | None = None
|
||||
filename: str = ""
|
||||
rows: list[dict] = Field(default_factory=list)
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""CSV-Import: Datei parsen, Vorschau mit Erkennung (Duplikate, interne Transfers,
|
||||
Auto-Kategorisierung), und die eigentliche Verbuchung inkl. Batch für Undo.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
AccountIdentifier,
|
||||
AmountMode,
|
||||
BudgetTreatment,
|
||||
CategorizationRule,
|
||||
Category,
|
||||
ImportBatch,
|
||||
ImportStatus,
|
||||
ImportTemplate,
|
||||
MatchField,
|
||||
SAVINGS_ACCOUNT_TYPES,
|
||||
Transaction,
|
||||
TxnType,
|
||||
User,
|
||||
)
|
||||
from . import currency
|
||||
|
||||
|
||||
# --- Kleine Parser-Helfer --------------------------------------------------
|
||||
|
||||
|
||||
def normalize_text(s: str | None) -> str:
|
||||
return (s or "").strip().lower()
|
||||
|
||||
|
||||
def normalize_iban(s: str | None) -> str:
|
||||
return "".join(ch for ch in (s or "").upper() if ch.isalnum())
|
||||
|
||||
|
||||
def parse_amount(raw: str | None, decimal_sep: str) -> int | None:
|
||||
"""Betragstext -> Cent (mit Vorzeichen). Erkennt Tausender-Trenner und Klammern."""
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
neg = ("-" in s) or (s.startswith("(") and s.endswith(")"))
|
||||
thousands = "." if decimal_sep == "," else ","
|
||||
s2 = s.replace(thousands, "").replace(decimal_sep, ".")
|
||||
digits = "".join(ch for ch in s2 if ch.isdigit() or ch == ".")
|
||||
if digits in ("", "."):
|
||||
return None
|
||||
try:
|
||||
val = Decimal(digits)
|
||||
except InvalidOperation:
|
||||
return None
|
||||
cents = int((val * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
return -cents if neg else cents
|
||||
|
||||
|
||||
def parse_date(raw: str | None, fmt: str) -> date | None:
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def decode_bytes(data: bytes, encoding: str) -> str:
|
||||
for enc in (encoding, "utf-8-sig", "utf-8", "latin-1"):
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
|
||||
text = decode_bytes(data, template.encoding)
|
||||
delim = (template.delimiter or ";")[0]
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
||||
rows = [r for r in reader if any((c or "").strip() for c in r)]
|
||||
start = template.skip_rows + (1 if template.has_header else 0)
|
||||
return rows[start:]
|
||||
|
||||
|
||||
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
|
||||
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor)."""
|
||||
text = decode_bytes(data, template.encoding)
|
||||
delim = (template.delimiter or ";")[0]
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delim)
|
||||
rows = [r for r in reader if any((c or "").strip() for c in r)]
|
||||
header = None
|
||||
body_start = template.skip_rows
|
||||
if template.has_header and len(rows) > body_start:
|
||||
header = rows[body_start]
|
||||
body_start += 1
|
||||
return {"header": header, "rows": rows[body_start: body_start + limit]}
|
||||
|
||||
|
||||
def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str:
|
||||
key = f"{account_id}|{d}|{amount_cents}|{normalize_text(payee)}"
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# --- Vorschau --------------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_rules(rules: list[CategorizationRule], payee: str, purpose: str) -> int | None:
|
||||
p = normalize_text(payee)
|
||||
z = normalize_text(purpose)
|
||||
for rule in rules: # bereits nach Priorität sortiert
|
||||
hay = p if rule.match_field == MatchField.payee else z
|
||||
if rule.pattern and rule.pattern in hay and rule.category_id:
|
||||
return rule.category_id
|
||||
return None
|
||||
|
||||
|
||||
def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]:
|
||||
cmap = json.loads(template.column_map or "{}")
|
||||
|
||||
def col(field: str) -> int | None:
|
||||
idx = cmap.get(field)
|
||||
return idx if isinstance(idx, int) and idx >= 0 else None
|
||||
|
||||
rows = read_rows(data, template)
|
||||
|
||||
# Identifikatoren aller Konten des Nutzers -> Konto-ID (für Transfer-Erkennung).
|
||||
idents: dict[str, int] = {}
|
||||
for value_norm, acc_id in (
|
||||
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id)
|
||||
.join(Account, Account.id == AccountIdentifier.account_id)
|
||||
.filter(Account.owner_id == user.id)
|
||||
):
|
||||
idents[value_norm] = acc_id
|
||||
|
||||
rules = (
|
||||
db.query(CategorizationRule)
|
||||
.filter(CategorizationRule.owner_id == user.id)
|
||||
.order_by(CategorizationRule.priority.desc(), CategorizationRule.hits.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
existing = {
|
||||
h[0]
|
||||
for h in db.query(Transaction.import_hash).filter(
|
||||
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
|
||||
)
|
||||
}
|
||||
seen_in_file: set[str] = set()
|
||||
|
||||
out: list[dict] = []
|
||||
for i, r in enumerate(rows):
|
||||
def cell(field: str) -> str:
|
||||
c = col(field)
|
||||
return r[c].strip() if c is not None and c < len(r) else ""
|
||||
|
||||
d = parse_date(cell("date"), template.date_format)
|
||||
|
||||
if template.amount_mode == AmountMode.debit_credit:
|
||||
debit = parse_amount(cell("debit"), template.decimal_sep)
|
||||
credit = parse_amount(cell("credit"), template.decimal_sep)
|
||||
amount = (credit or 0) - (abs(debit) if debit else 0)
|
||||
elif template.amount_mode == AmountMode.abs_expense:
|
||||
a = parse_amount(cell("amount"), template.decimal_sep)
|
||||
amount = -abs(a) if a is not None else None
|
||||
else:
|
||||
amount = parse_amount(cell("amount"), template.decimal_sep)
|
||||
|
||||
payee = cell("payee")
|
||||
purpose = cell("purpose")
|
||||
iban = normalize_iban(cell("counterparty_iban"))
|
||||
|
||||
valid = d is not None and amount is not None and amount != 0
|
||||
ttype = "transfer" if False else ("expense" if (amount or 0) < 0 else "income")
|
||||
category_id: int | None = None
|
||||
to_account_id: int | None = None
|
||||
|
||||
other = idents.get(iban) if iban else None
|
||||
if other is not None and other != account.id:
|
||||
ttype = "transfer"
|
||||
to_account_id = other
|
||||
else:
|
||||
category_id = _apply_rules(rules, payee, purpose)
|
||||
|
||||
h = import_hash(account.id, d, amount, payee)
|
||||
is_dup = h in existing or h in seen_in_file
|
||||
seen_in_file.add(h)
|
||||
|
||||
out.append({
|
||||
"index": i,
|
||||
"date": d.isoformat() if d else None,
|
||||
"amount_cents": amount,
|
||||
"payee": payee or None,
|
||||
"purpose": purpose or None,
|
||||
"counterparty_iban": iban or None,
|
||||
"valid": valid,
|
||||
"type": ttype,
|
||||
"category_id": category_id,
|
||||
"to_account_id": to_account_id,
|
||||
"import_hash": h,
|
||||
"is_duplicate": is_dup,
|
||||
"include": valid and not is_dup,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# --- Verbuchen -------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_transaction(db: Session, user: User, account: Account, accounts: dict[int, Account], row: dict, batch_id: int) -> Transaction | None:
|
||||
d = date.fromisoformat(row["date"]) if row.get("date") else None
|
||||
amount = row.get("amount_cents")
|
||||
if d is None or not amount:
|
||||
return None
|
||||
ttype = row.get("type") or ("expense" if amount < 0 else "income")
|
||||
payee = row.get("payee")
|
||||
purpose = row.get("purpose")
|
||||
notes = None
|
||||
|
||||
if ttype == "transfer":
|
||||
other_id = row.get("to_account_id")
|
||||
other = accounts.get(other_id) if other_id else None
|
||||
if other is None:
|
||||
ttype = "expense" if amount < 0 else "income" # Fallback ohne Zielkonto
|
||||
|
||||
if ttype == "transfer":
|
||||
other = accounts[row["to_account_id"]]
|
||||
mag = abs(amount)
|
||||
if amount < 0:
|
||||
from_acc, to_acc = account, other
|
||||
from_amount = mag # in Kontowährung (bekannt aus dem Auszug)
|
||||
else:
|
||||
from_acc, to_acc = other, account
|
||||
# from-Betrag in der Währung des anderen Kontos schätzen.
|
||||
conv = currency.convert(db, mag, account.currency, from_acc.currency, d)
|
||||
from_amount = conv if conv is not None else mag
|
||||
to_amount = mag if amount < 0 else mag
|
||||
# Konsistente to_amount-Berechnung:
|
||||
if from_acc.currency == to_acc.currency:
|
||||
to_amount = from_amount
|
||||
else:
|
||||
conv = currency.convert(db, from_amount, from_acc.currency, to_acc.currency, d)
|
||||
to_amount = conv if conv is not None else from_amount
|
||||
# Sparen/Depot-Ziel budgetieren (überschreibbar über row["budget_treatment"]).
|
||||
treatment = row.get("budget_treatment")
|
||||
budget_cat = row.get("budget_category_id")
|
||||
txn = Transaction(
|
||||
owner_id=user.id, type=TxnType.transfer, date=d,
|
||||
account_id=from_acc.id, to_account_id=to_acc.id,
|
||||
amount_cents=from_amount, to_amount_cents=to_amount,
|
||||
amount_eur_cents=currency.compute_amount_eur_cents(db, from_amount, from_acc.currency, d),
|
||||
budget_treatment=BudgetTreatment(treatment) if treatment in ("budgeted", "neutral") else None,
|
||||
budget_category_id=budget_cat,
|
||||
description=purpose, payee=payee, notes=notes,
|
||||
import_batch_id=batch_id, import_hash=row.get("import_hash"),
|
||||
)
|
||||
return txn
|
||||
|
||||
mag = abs(amount)
|
||||
txn = Transaction(
|
||||
owner_id=user.id,
|
||||
type=TxnType.expense if ttype == "expense" else TxnType.income,
|
||||
date=d, account_id=account.id, amount_cents=mag,
|
||||
amount_eur_cents=currency.compute_amount_eur_cents(db, mag, account.currency, d),
|
||||
category_id=row.get("category_id"),
|
||||
payee=payee, description=purpose, notes=notes,
|
||||
import_batch_id=batch_id, import_hash=row.get("import_hash"),
|
||||
)
|
||||
return txn
|
||||
|
||||
|
||||
def _learn_rule(db: Session, user: User, row: dict) -> None:
|
||||
"""Merkt sich Empfänger -> Kategorie für künftige Importe."""
|
||||
cat = row.get("category_id")
|
||||
payee = row.get("payee")
|
||||
if not cat or not payee or row.get("type") == "transfer":
|
||||
return
|
||||
pattern = normalize_text(payee)[:255]
|
||||
if not pattern:
|
||||
return
|
||||
existing = (
|
||||
db.query(CategorizationRule)
|
||||
.filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.payee,
|
||||
CategorizationRule.pattern == pattern,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is None:
|
||||
db.add(CategorizationRule(
|
||||
owner_id=user.id, match_field=MatchField.payee, pattern=pattern,
|
||||
category_id=cat, priority=1, hits=1,
|
||||
))
|
||||
else:
|
||||
existing.category_id = cat
|
||||
existing.hits += 1
|
||||
|
||||
|
||||
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch:
|
||||
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||
batch = ImportBatch(
|
||||
owner_id=user.id, account_id=account.id, template_id=template_id,
|
||||
filename=(filename or "")[:255], row_count=len(rows),
|
||||
imported_count=0, status=ImportStatus.committed,
|
||||
)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
|
||||
imported = 0
|
||||
for row in rows:
|
||||
if not row.get("include", True):
|
||||
continue
|
||||
txn = _make_transaction(db, user, account, accounts, row, batch.id)
|
||||
if txn is None:
|
||||
continue
|
||||
db.add(txn)
|
||||
_learn_rule(db, user, row)
|
||||
imported += 1
|
||||
|
||||
batch.imported_count = imported
|
||||
db.commit()
|
||||
db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
def undo_batch(db: Session, user: User, batch: ImportBatch) -> int:
|
||||
n = (
|
||||
db.query(Transaction)
|
||||
.filter(Transaction.owner_id == user.id, Transaction.import_batch_id == batch.id)
|
||||
.delete()
|
||||
)
|
||||
batch.status = ImportStatus.undone
|
||||
db.commit()
|
||||
return n
|
||||
@@ -0,0 +1,80 @@
|
||||
"""CSV-Import: Templates, Batches, Kategorisierungs-Regeln, Transaktions-Herkunft.
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-08-19
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"import_templates",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("delimiter", sa.String(length=2), nullable=False, server_default=";"),
|
||||
sa.Column("decimal_sep", sa.String(length=1), nullable=False, server_default=","),
|
||||
sa.Column("date_format", sa.String(length=32), nullable=False, server_default="%d.%m.%Y"),
|
||||
sa.Column("encoding", sa.String(length=16), nullable=False, server_default="utf-8"),
|
||||
sa.Column("has_header", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("skip_rows", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("amount_mode", sa.String(length=32), nullable=False, server_default="signed"),
|
||||
sa.Column("column_map", sa.Text(), nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_import_templates_owner_id", "import_templates", ["owner_id"])
|
||||
|
||||
op.create_table(
|
||||
"import_batches",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("template_id", sa.Integer(), sa.ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("filename", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("row_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("imported_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="committed"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_import_batches_owner_id", "import_batches", ["owner_id"])
|
||||
|
||||
op.create_table(
|
||||
"categorization_rules",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("match_field", sa.String(length=32), nullable=False, server_default="payee"),
|
||||
sa.Column("pattern", sa.String(length=255), nullable=False),
|
||||
sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("priority", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("hits", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"),
|
||||
)
|
||||
op.create_index("ix_categorization_rules_owner_id", "categorization_rules", ["owner_id"])
|
||||
|
||||
# Transaktions-Herkunft (für Undo + Duplikaterkennung).
|
||||
op.add_column("transactions", sa.Column("import_batch_id", sa.Integer(), nullable=True))
|
||||
op.add_column("transactions", sa.Column("import_hash", sa.String(length=64), nullable=True))
|
||||
op.create_index("ix_transactions_import_batch_id", "transactions", ["import_batch_id"])
|
||||
op.create_index("ix_transactions_import_hash", "transactions", ["import_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_transactions_import_hash", table_name="transactions")
|
||||
op.drop_index("ix_transactions_import_batch_id", table_name="transactions")
|
||||
with op.batch_alter_table("transactions") as batch:
|
||||
batch.drop_column("import_hash")
|
||||
batch.drop_column("import_batch_id")
|
||||
op.drop_index("ix_categorization_rules_owner_id", table_name="categorization_rules")
|
||||
op.drop_table("categorization_rules")
|
||||
op.drop_index("ix_import_batches_owner_id", table_name="import_batches")
|
||||
op.drop_table("import_batches")
|
||||
op.drop_index("ix_import_templates_owner_id", table_name="import_templates")
|
||||
op.drop_table("import_templates")
|
||||
@@ -0,0 +1,103 @@
|
||||
import io
|
||||
import json
|
||||
|
||||
|
||||
def _account(admin, **kw):
|
||||
body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}
|
||||
body.update(kw)
|
||||
r = admin.post("/api/accounts", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _leb(admin):
|
||||
for g in admin.get("/api/categories").json():
|
||||
for c in g["categories"]:
|
||||
if c["name"] == "Lebensmittel":
|
||||
return c["id"]
|
||||
raise AssertionError("Lebensmittel fehlt")
|
||||
|
||||
|
||||
CSV = (
|
||||
"Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n"
|
||||
"15.03.2026;-50,00;REWE;Einkauf;\n"
|
||||
"25.03.2026;3.000,00;Arbeitgeber;Gehalt;\n"
|
||||
"15.03.2026;-50,00;REWE;Einkauf;\n" # Duplikat der ersten Zeile
|
||||
"20.03.2026;-200,00;Sparen;Ueberweisung;CH9300762011623852957\n" # interner Transfer
|
||||
)
|
||||
|
||||
COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3, "counterparty_iban": 4})
|
||||
|
||||
|
||||
def _preview(admin, account_id):
|
||||
return admin.post(
|
||||
"/api/imports/preview",
|
||||
files={"file": ("konto.csv", io.BytesIO(CSV.encode("utf-8")), "text/csv")},
|
||||
data={
|
||||
"account_id": str(account_id),
|
||||
"delimiter": ";", "decimal_sep": ",", "date_format": "%d.%m.%Y",
|
||||
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
|
||||
"amount_mode": "signed", "column_map": COLMAP,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_preview_parsing_and_detection(admin):
|
||||
giro = _account(admin, name="Giro Import")
|
||||
# Zweites Konto mit passender IBAN für die Transfer-Erkennung.
|
||||
spar = _account(admin, name="Spar", type="sparkonto", currency="EUR",
|
||||
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
|
||||
|
||||
r = _preview(admin, giro["id"])
|
||||
assert r.status_code == 200, r.text
|
||||
rows = r.json()["rows"]
|
||||
assert len(rows) == 4
|
||||
|
||||
# Zeile 0: Ausgabe 50 EUR
|
||||
assert rows[0]["type"] == "expense"
|
||||
assert rows[0]["amount_cents"] == -5000
|
||||
assert rows[0]["is_duplicate"] is False
|
||||
# Zeile 1: Einnahme 3000 (Tausenderpunkt korrekt)
|
||||
assert rows[1]["type"] == "income"
|
||||
assert rows[1]["amount_cents"] == 300000
|
||||
# Zeile 2: Duplikat der Zeile 0
|
||||
assert rows[2]["is_duplicate"] is True
|
||||
assert rows[2]["include"] is False
|
||||
# Zeile 3: interner Transfer (IBAN passt zu 'spar')
|
||||
assert rows[3]["type"] == "transfer"
|
||||
assert rows[3]["to_account_id"] == spar["id"]
|
||||
|
||||
|
||||
def test_commit_and_undo_and_learning(admin):
|
||||
giro = _account(admin, name="Giro C")
|
||||
leb = _leb(admin)
|
||||
rows = _preview(admin, giro["id"]).json()["rows"]
|
||||
|
||||
# Nutzer kategorisiert die erste (REWE) als Lebensmittel -> soll gelernt werden.
|
||||
for row in rows:
|
||||
if row["payee"] == "REWE" and not row["is_duplicate"]:
|
||||
row["category_id"] = leb
|
||||
|
||||
r = admin.post("/api/imports/commit", json={
|
||||
"account_id": giro["id"], "filename": "konto.csv", "rows": rows,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
batch = r.json()
|
||||
# Duplikat wird übersprungen -> 3 gebucht (Ausgabe, Einnahme, Transfer).
|
||||
assert batch["imported_count"] == 3
|
||||
|
||||
# Saldo: 0 + 3000 - 50 - 200(Transfer raus) = 2750,00
|
||||
bal = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
|
||||
assert bal == 275000, bal
|
||||
|
||||
# Regel gelernt: erneute Vorschau schlägt für REWE Lebensmittel vor.
|
||||
rows2 = _preview(admin, giro["id"]).json()["rows"]
|
||||
rewe = next(r for r in rows2 if r["payee"] == "REWE")
|
||||
assert rewe["category_id"] == leb
|
||||
|
||||
# Undo entfernt die Buchungen dieses Batches wieder.
|
||||
r = admin.delete(f"/api/imports/batches/{batch['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["deleted"] == 3
|
||||
bal2 = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
|
||||
assert bal2 == 0, bal2
|
||||
+2
-2
@@ -23,10 +23,10 @@ kein Rollover in v1.
|
||||
Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf,
|
||||
Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen.
|
||||
|
||||
## Phase 4 – CSV-Import
|
||||
## Phase 4 – CSV-Import ✅
|
||||
Bank-Templates (Trennzeichen/Dezimal/Datum/Spaltenmapping/Vorzeichen), lernende
|
||||
Auto-Kategorisierung, Prüfansicht, Duplikat-Erkennung, interne-Transfer-Erkennung
|
||||
via IBAN.
|
||||
via IBAN, Batch-Undo. Vorgefertigte Bank-Templates folgen aus Nutzer-Beispielen.
|
||||
|
||||
## Phase 5 – PDF/Bild-Import (Stretch)
|
||||
Lokal (pdfplumber/pypdf + Tesseract-OCR), speist dieselbe Prüfansicht.
|
||||
|
||||
@@ -11,6 +11,7 @@ import Accounts from "./pages/Accounts";
|
||||
import Categories from "./pages/Categories";
|
||||
import Transactions from "./pages/Transactions";
|
||||
import Budget from "./pages/Budget";
|
||||
import Import from "./pages/Import";
|
||||
import Users from "./pages/Users";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
@@ -46,6 +47,7 @@ function Sidebar() {
|
||||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
||||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -131,6 +133,7 @@ export default function App() {
|
||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -186,4 +186,15 @@ export const api = {
|
||||
cardBalances: () => request("/dashboard/balances"),
|
||||
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
|
||||
cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`),
|
||||
|
||||
// CSV-Import
|
||||
importTemplates: () => request("/imports/templates"),
|
||||
createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }),
|
||||
updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }),
|
||||
deleteImportTemplate: (id) => request(`/imports/templates/${id}`, { method: "DELETE" }),
|
||||
importRaw: (formData) => request("/imports/raw", { method: "POST", formData }),
|
||||
importPreview: (formData) => request("/imports/preview", { method: "POST", formData }),
|
||||
importCommit: (body) => request("/imports/commit", { method: "POST", body }),
|
||||
importBatches: () => request("/imports/batches"),
|
||||
undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
@@ -526,3 +526,37 @@ tr.budget-total-row td {
|
||||
border-right-color: var(--border-strong);
|
||||
border-bottom-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* ---------- CSV-Import ---------- */
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.raw-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.raw-table th,
|
||||
.raw-table td {
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.raw-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.map-grid label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
tr.row-invalid {
|
||||
opacity: 0.45;
|
||||
}
|
||||
tr.row-dup {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@@ -195,5 +195,52 @@
|
||||
"incomeLabel": "Bezeichnung",
|
||||
"incomeCategory": "Kategorie",
|
||||
"targetAccount": "Zielkonto"
|
||||
},
|
||||
"import": {
|
||||
"title": "CSV-Import",
|
||||
"step1": "1 · Konto & Datei",
|
||||
"step2": "2 · Format",
|
||||
"step3": "3 · Spalten zuordnen",
|
||||
"step4": "4 · Prüfen & buchen",
|
||||
"account": "Zielkonto",
|
||||
"file": "CSV-Datei",
|
||||
"template": "Vorlage",
|
||||
"templateManual": "Manuell einstellen",
|
||||
"delimiter": "Trennzeichen",
|
||||
"decimal": "Dezimaltrenner",
|
||||
"dateFormat": "Datumsformat",
|
||||
"encoding": "Kodierung",
|
||||
"amountMode": "Betragsspalten",
|
||||
"skipRows": "Zeilen überspringen",
|
||||
"hasHeader": "Erste Zeile ist Kopfzeile",
|
||||
"modeSigned": "Ein Betrag (mit Vorzeichen)",
|
||||
"modeDebitCredit": "Getrennte Soll-/Haben-Spalten",
|
||||
"modeAbsExpense": "Ein Betrag (immer Ausgabe)",
|
||||
"readFile": "Datei einlesen",
|
||||
"makePreview": "Vorschau erstellen",
|
||||
"saveTemplate": "Als Vorlage speichern",
|
||||
"templateName": "Vorlagenname",
|
||||
"colDate": "Datum",
|
||||
"colAmount": "Betrag",
|
||||
"colDebit": "Soll (Ausgang)",
|
||||
"colCredit": "Haben (Eingang)",
|
||||
"colPayee": "Empfänger",
|
||||
"colPurpose": "Verwendungszweck",
|
||||
"colIban": "Gegen-IBAN",
|
||||
"makePreviewHint": "Ordne mindestens Datum und Betrag zu.",
|
||||
"commit": "{{n}} Buchungen übernehmen",
|
||||
"imported": "{{n}} Buchungen importiert.",
|
||||
"importedCount": "{{n}} gebucht",
|
||||
"typeCol": "Typ",
|
||||
"payeeCol": "Empfänger / Zweck",
|
||||
"badDate": "Datum?",
|
||||
"duplicate": "Duplikat",
|
||||
"chooseAccount": "Konto wählen",
|
||||
"history": "Import-Verlauf",
|
||||
"undo": "Rückgängig",
|
||||
"undoConfirm": "Alle Buchungen aus {{file}} wieder entfernen?",
|
||||
"undone": "rückgängig gemacht",
|
||||
"needFile": "Bitte zuerst eine CSV-Datei wählen.",
|
||||
"needFileAccount": "Bitte Konto und Datei wählen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,5 +195,52 @@
|
||||
"incomeLabel": "Label",
|
||||
"incomeCategory": "Category",
|
||||
"targetAccount": "Target account"
|
||||
},
|
||||
"import": {
|
||||
"title": "CSV import",
|
||||
"step1": "1 · Account & file",
|
||||
"step2": "2 · Format",
|
||||
"step3": "3 · Map columns",
|
||||
"step4": "4 · Review & import",
|
||||
"account": "Target account",
|
||||
"file": "CSV file",
|
||||
"template": "Template",
|
||||
"templateManual": "Configure manually",
|
||||
"delimiter": "Delimiter",
|
||||
"decimal": "Decimal separator",
|
||||
"dateFormat": "Date format",
|
||||
"encoding": "Encoding",
|
||||
"amountMode": "Amount columns",
|
||||
"skipRows": "Skip rows",
|
||||
"hasHeader": "First row is a header",
|
||||
"modeSigned": "Single amount (signed)",
|
||||
"modeDebitCredit": "Separate debit/credit columns",
|
||||
"modeAbsExpense": "Single amount (always expense)",
|
||||
"readFile": "Read file",
|
||||
"makePreview": "Build preview",
|
||||
"saveTemplate": "Save as template",
|
||||
"templateName": "Template name",
|
||||
"colDate": "Date",
|
||||
"colAmount": "Amount",
|
||||
"colDebit": "Debit (out)",
|
||||
"colCredit": "Credit (in)",
|
||||
"colPayee": "Payee",
|
||||
"colPurpose": "Purpose",
|
||||
"colIban": "Counterparty IBAN",
|
||||
"makePreviewHint": "Map at least date and amount.",
|
||||
"commit": "Import {{n}} transactions",
|
||||
"imported": "Imported {{n}} transactions.",
|
||||
"importedCount": "{{n}} imported",
|
||||
"typeCol": "Type",
|
||||
"payeeCol": "Payee / purpose",
|
||||
"badDate": "Date?",
|
||||
"duplicate": "Duplicate",
|
||||
"chooseAccount": "Choose account",
|
||||
"history": "Import history",
|
||||
"undo": "Undo",
|
||||
"undoConfirm": "Remove all transactions from {{file}}?",
|
||||
"undone": "undone",
|
||||
"needFile": "Please choose a CSV file first.",
|
||||
"needFileAccount": "Please choose an account and a file."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api";
|
||||
import { useSettings } from "../settings";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
import { formatMoney } from "../format";
|
||||
|
||||
const DATE_FORMATS = [
|
||||
{ v: "%d.%m.%Y", l: "TT.MM.JJJJ" },
|
||||
{ v: "%Y-%m-%d", l: "JJJJ-MM-TT" },
|
||||
{ v: "%d/%m/%Y", l: "TT/MM/JJJJ" },
|
||||
{ v: "%m/%d/%Y", l: "MM/TT/JJJJ" },
|
||||
{ v: "%d.%m.%y", l: "TT.MM.JJ" },
|
||||
];
|
||||
|
||||
function defaultConfig() {
|
||||
return {
|
||||
delimiter: ";",
|
||||
decimal_sep: ",",
|
||||
date_format: "%d.%m.%Y",
|
||||
encoding: "utf-8",
|
||||
has_header: true,
|
||||
skip_rows: 0,
|
||||
amount_mode: "signed",
|
||||
column_map: {},
|
||||
};
|
||||
}
|
||||
|
||||
export default function Import() {
|
||||
const { t } = useTranslation();
|
||||
const { formatDate } = useSettings();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [batches, setBatches] = useState([]);
|
||||
|
||||
const [accountId, setAccountId] = useState("");
|
||||
const [file, setFile] = useState(null);
|
||||
const [config, setConfig] = useState(defaultConfig());
|
||||
const [raw, setRaw] = useState(null);
|
||||
const [rows, setRows] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
|
||||
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
|
||||
|
||||
async function loadStamm() {
|
||||
try {
|
||||
const [a, g, tp, b] = await Promise.all([
|
||||
api.listAccounts(), api.listCategories(), api.importTemplates(), api.importBatches(),
|
||||
]);
|
||||
setAccounts(a); setGroups(g); setTemplates(tp); setBatches(b);
|
||||
if (a[0] && !accountId) setAccountId(String(a[0].id));
|
||||
} catch (e) { toast(e.message, "error"); }
|
||||
}
|
||||
useEffect(() => { loadStamm(); }, []);
|
||||
|
||||
function setCfg(patch) { setConfig((c) => ({ ...c, ...patch })); }
|
||||
function setCol(field, value) {
|
||||
setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } }));
|
||||
}
|
||||
|
||||
function applyTemplate(id) {
|
||||
const tpl = templates.find((x) => String(x.id) === String(id));
|
||||
if (!tpl) return;
|
||||
setConfig({
|
||||
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: tpl.column_map || {},
|
||||
});
|
||||
}
|
||||
|
||||
function baseForm() {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("delimiter", config.delimiter);
|
||||
fd.append("encoding", config.encoding);
|
||||
fd.append("has_header", config.has_header ? "true" : "false");
|
||||
fd.append("skip_rows", String(config.skip_rows || 0));
|
||||
return fd;
|
||||
}
|
||||
|
||||
async function loadRaw() {
|
||||
if (!file) { toast(t("import.needFile"), "error"); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
setRaw(await api.importRaw(baseForm()));
|
||||
setRows(null);
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (!file || !accountId) { toast(t("import.needFileAccount"), "error"); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = baseForm();
|
||||
fd.append("account_id", accountId);
|
||||
fd.append("decimal_sep", config.decimal_sep);
|
||||
fd.append("date_format", config.date_format);
|
||||
fd.append("amount_mode", config.amount_mode);
|
||||
fd.append("column_map", JSON.stringify(config.column_map));
|
||||
const res = await api.importPreview(fd);
|
||||
setRows(res.rows.map((r) => ({ ...r })));
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const batch = await api.importCommit({
|
||||
account_id: Number(accountId),
|
||||
template_id: null,
|
||||
filename: file?.name || "",
|
||||
rows,
|
||||
});
|
||||
toast(t("import.imported", { n: batch.imported_count }));
|
||||
setRows(null); setRaw(null); setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
loadStamm();
|
||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
if (!saveName.trim()) { toast(t("import.templateName"), "error"); return; }
|
||||
try {
|
||||
await api.createImportTemplate({ name: saveName.trim(), ...config });
|
||||
setSaveName("");
|
||||
setTemplates(await api.importTemplates());
|
||||
toast(t("settings.saved"));
|
||||
} catch (e) { toast(e.message, "error"); }
|
||||
}
|
||||
|
||||
async function undo(b) {
|
||||
if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return;
|
||||
try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); }
|
||||
}
|
||||
|
||||
const colOptions = useMemo(() => {
|
||||
const n = raw ? Math.max(raw.header?.length || 0, ...(raw.rows || []).map((r) => r.length), 0) : 0;
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
i, label: `${i}${raw?.header?.[i] ? ` · ${raw.header[i]}` : ""}`,
|
||||
}));
|
||||
}, [raw]);
|
||||
|
||||
const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]);
|
||||
const incomeGroups = useMemo(() => groups.filter((g) => g.kind === "income"), [groups]);
|
||||
|
||||
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
|
||||
|
||||
function updateRow(idx, patch) {
|
||||
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
const acc = accountById[Number(accountId)];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("import.title")}</h1>
|
||||
|
||||
{/* Schritt 1: Konto + Datei */}
|
||||
<div className="card">
|
||||
<h2>{t("import.step1")}</h2>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<span>{t("import.account")}</span>
|
||||
<select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("import.file")}</span>
|
||||
<input ref={fileRef} type="file" accept=".csv,text/csv,text/plain"
|
||||
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); }} />
|
||||
</label>
|
||||
</div>
|
||||
{templates.length > 0 && (
|
||||
<label>
|
||||
<span>{t("import.template")}</span>
|
||||
<select defaultValue="" onChange={(e) => applyTemplate(e.target.value)}>
|
||||
<option value="">{t("import.templateManual")}</option>
|
||||
{templates.map((tp) => <option key={tp.id} value={tp.id}>{tp.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schritt 2: Format */}
|
||||
<div className="card">
|
||||
<h2>{t("import.step2")}</h2>
|
||||
<div className="row">
|
||||
<label>
|
||||
<span>{t("import.delimiter")}</span>
|
||||
<select value={config.delimiter} onChange={(e) => setCfg({ delimiter: e.target.value })}>
|
||||
<option value=";">Semikolon ;</option>
|
||||
<option value=",">Komma ,</option>
|
||||
<option value={"\t"}>Tab</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("import.decimal")}</span>
|
||||
<select value={config.decimal_sep} onChange={(e) => setCfg({ decimal_sep: e.target.value })}>
|
||||
<option value=",">Komma (1.234,56)</option>
|
||||
<option value=".">Punkt (1,234.56)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("import.dateFormat")}</span>
|
||||
<select value={config.date_format} onChange={(e) => setCfg({ date_format: e.target.value })}>
|
||||
{DATE_FORMATS.map((f) => <option key={f.v} value={f.v}>{f.l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label>
|
||||
<span>{t("import.encoding")}</span>
|
||||
<select value={config.encoding} onChange={(e) => setCfg({ encoding: e.target.value })}>
|
||||
<option value="utf-8">UTF-8</option>
|
||||
<option value="latin-1">ISO-8859-1 / Latin-1</option>
|
||||
<option value="cp1252">Windows-1252</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("import.amountMode")}</span>
|
||||
<select value={config.amount_mode} onChange={(e) => setCfg({ amount_mode: e.target.value })}>
|
||||
<option value="signed">{t("import.modeSigned")}</option>
|
||||
<option value="debit_credit">{t("import.modeDebitCredit")}</option>
|
||||
<option value="abs_expense">{t("import.modeAbsExpense")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ maxWidth: 160 }}>
|
||||
<span>{t("import.skipRows")}</span>
|
||||
<input type="number" min="0" value={config.skip_rows}
|
||||
onChange={(e) => setCfg({ skip_rows: Math.max(0, Number(e.target.value) || 0) })} />
|
||||
</label>
|
||||
<label className="checkline" style={{ alignSelf: "flex-end", marginBottom: 14 }}>
|
||||
<input type="checkbox" checked={config.has_header}
|
||||
onChange={(e) => setCfg({ has_header: e.target.checked })} />
|
||||
<span>{t("import.hasHeader")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn" onClick={loadRaw} disabled={busy || !file}>
|
||||
<Icon name="search" size={15} /> {t("import.readFile")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Schritt 3: Spaltenzuordnung */}
|
||||
{raw && (
|
||||
<div className="card">
|
||||
<h2>{t("import.step3")}</h2>
|
||||
<div className="table-scroll">
|
||||
<table className="table raw-table">
|
||||
<thead>
|
||||
<tr>{colOptions.map((c) => <th key={c.i}>{c.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{raw.rows.map((r, ri) => (
|
||||
<tr key={ri}>{colOptions.map((c) => <td key={c.i}>{r[c.i] ?? ""}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="map-grid">
|
||||
{[
|
||||
["date", t("import.colDate"), true],
|
||||
...(config.amount_mode === "debit_credit"
|
||||
? [["debit", t("import.colDebit"), true], ["credit", t("import.colCredit"), true]]
|
||||
: [["amount", t("import.colAmount"), true]]),
|
||||
["payee", t("import.colPayee"), false],
|
||||
["purpose", t("import.colPurpose"), false],
|
||||
["counterparty_iban", t("import.colIban"), false],
|
||||
].map(([field, label]) => (
|
||||
<label key={field}>
|
||||
<span>{label}</span>
|
||||
<select value={config.column_map[field] ?? ""} onChange={(e) => setCol(field, e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<button className="btn primary" onClick={loadPreview} disabled={busy}>
|
||||
<Icon name="chevron-right" size={15} /> {t("import.makePreview")}
|
||||
</button>
|
||||
<div className="input-with-button" style={{ maxWidth: 320 }}>
|
||||
<input placeholder={t("import.templateName")} value={saveName} onChange={(e) => setSaveName(e.target.value)} />
|
||||
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schritt 4: Prüfansicht */}
|
||||
{rows && (
|
||||
<div className="card">
|
||||
<div className="page-head">
|
||||
<h2 style={{ margin: 0 }}>{t("import.step4")}</h2>
|
||||
<button className="btn primary" onClick={commit} disabled={busy || includeCount === 0}>
|
||||
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{t("transactions.date")}</th>
|
||||
<th>{t("transactions.amount")}</th>
|
||||
<th>{t("import.typeCol")}</th>
|
||||
<th>{t("import.payeeCol")}</th>
|
||||
<th>{t("transactions.category")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, idx) => (
|
||||
<tr key={idx} className={!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : ""}>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!r.include} disabled={!r.valid}
|
||||
onChange={(e) => updateRow(idx, { include: e.target.checked })} style={{ width: "auto" }} />
|
||||
</td>
|
||||
<td className="nowrap">{r.date ? formatDate(r.date) : <span className="amount neg">{t("import.badDate")}</span>}</td>
|
||||
<td className="nowrap">
|
||||
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
|
||||
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select value={r.type} onChange={(e) => updateRow(idx, { type: e.target.value })} style={{ minWidth: 110 }}>
|
||||
<option value="expense">{t("transactions.expense")}</option>
|
||||
<option value="income">{t("transactions.income")}</option>
|
||||
<option value="transfer">{t("transactions.transfer")}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="ellipsis" style={{ maxWidth: 200 }}>
|
||||
{r.payee || r.purpose || ""}
|
||||
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
|
||||
</td>
|
||||
<td>
|
||||
{r.type === "transfer" ? (
|
||||
<select value={r.to_account_id || ""} onChange={(e) => updateRow(idx, { to_account_id: e.target.value ? Number(e.target.value) : null })} style={{ minWidth: 150 }}>
|
||||
<option value="">{t("import.chooseAccount")}</option>
|
||||
{accounts.filter((a) => a.id !== Number(accountId)).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<select value={r.category_id || ""} onChange={(e) => updateRow(idx, { category_id: e.target.value ? Number(e.target.value) : null })} style={{ minWidth: 150 }}>
|
||||
<option value="">{t("transactions.uncategorized")}</option>
|
||||
{(r.type === "income" ? incomeGroups : expenseGroups).map((g) => (
|
||||
<optgroup key={g.id} label={g.name}>
|
||||
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verlauf */}
|
||||
{batches.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>{t("import.history")}</h2>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{batches.map((b) => (
|
||||
<tr key={b.id} className={b.status === "undone" ? "row-dup" : ""}>
|
||||
<td><strong>{b.filename || "—"}</strong> <span className="muted small">{accountById[b.account_id]?.name}</span></td>
|
||||
<td className="nowrap">{formatDate(b.created_at)}</td>
|
||||
<td className="nowrap">{t("import.importedCount", { n: b.imported_count })}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{b.status === "committed" ? (
|
||||
<button className="btn sm danger" onClick={() => undo(b)}>
|
||||
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
|
||||
</button>
|
||||
) : <span className="muted small">{t("import.undone")}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user