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:
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user