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>
This commit is contained in:
@@ -504,6 +504,12 @@ class ImportTemplate(Base):
|
||||
# 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="{}")
|
||||
# Zeilenfilter: nur Zeilen übernehmen, in denen Spalte `filter_column` den
|
||||
# Wert `filter_value` hat. Revolut legt alle Pockets („Giro", „Erspartes" …)
|
||||
# mit je eigenem Saldo-Verlauf in EINE Datei – ohne Filter landete alles auf
|
||||
# demselben Konto und zählte doppelt.
|
||||
filter_column: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
filter_value: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +52,7 @@ def create_template(payload: ImportTemplateCreate, db: Session = Depends(get_db)
|
||||
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()
|
||||
@@ -112,6 +114,8 @@ async def preview(
|
||||
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:
|
||||
@@ -121,6 +125,7 @@ async def preview(
|
||||
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 {
|
||||
|
||||
@@ -371,6 +371,8 @@ class ImportTemplateCreate(BaseModel):
|
||||
skip_rows: int = 0
|
||||
amount_mode: AmountMode = AmountMode.signed
|
||||
column_map: dict[str, int | None] = Field(default_factory=dict)
|
||||
filter_column: int | None = None
|
||||
filter_value: str | None = None
|
||||
|
||||
|
||||
class ImportTemplateUpdate(BaseModel):
|
||||
@@ -383,6 +385,8 @@ class ImportTemplateUpdate(BaseModel):
|
||||
skip_rows: int | None = None
|
||||
amount_mode: AmountMode | None = None
|
||||
column_map: dict[str, int | None] | None = None
|
||||
filter_column: int | None = None
|
||||
filter_value: str | None = None
|
||||
|
||||
|
||||
class ImportTemplateOut(BaseModel):
|
||||
@@ -396,6 +400,8 @@ class ImportTemplateOut(BaseModel):
|
||||
skip_rows: int
|
||||
amount_mode: AmountMode
|
||||
column_map: dict[str, int | None]
|
||||
filter_column: int | None = None
|
||||
filter_value: str | None = None
|
||||
|
||||
|
||||
class ImportBatchOut(BaseModel):
|
||||
|
||||
@@ -144,7 +144,18 @@ def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
|
||||
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:]
|
||||
rows = rows[start:]
|
||||
|
||||
# Zeilenfilter: Revolut legt alle Pockets in eine Datei, jedes mit eigenem
|
||||
# Saldo-Verlauf. Ohne Filter landet alles auf demselben Konto.
|
||||
spalte = template.filter_column
|
||||
wert = (template.filter_value or "").strip().lower()
|
||||
if spalte is not None and wert:
|
||||
rows = [
|
||||
r for r in rows
|
||||
if spalte < len(r) and (r[spalte] or "").strip().lower() == wert
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
|
||||
@@ -306,6 +317,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
||||
return idx if isinstance(idx, int) and idx >= 0 else None
|
||||
|
||||
out: list[dict] = []
|
||||
vorheriger_saldo: int | None = None
|
||||
for r in read_rows(data, template):
|
||||
def cell(field: str) -> str:
|
||||
c = col(field)
|
||||
@@ -322,11 +334,23 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
||||
else:
|
||||
amount = parse_amount(cell("amount"), template.decimal_sep)
|
||||
|
||||
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag – eine Gebühr
|
||||
# ist immer ein Kostenposten, unabhängig vom Vorzeichen in der Datei.
|
||||
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag.
|
||||
fee = parse_amount(cell("fee"), template.decimal_sep)
|
||||
balance = parse_amount(cell("balance"), template.decimal_sep)
|
||||
if amount is not None and fee:
|
||||
amount -= abs(fee)
|
||||
if amount == 0:
|
||||
# Reine Gebührenzeile: Ob belastet oder erstattet („Erstattung für
|
||||
# Transaktion"), steht nicht im Betrag – beide Male 0.00 bei
|
||||
# gleicher Gebühr. Die Saldo-Spalte weiß es; ohne sie ist eine
|
||||
# Belastung der Normalfall.
|
||||
delta = (balance - vorheriger_saldo) if (
|
||||
balance is not None and vorheriger_saldo is not None
|
||||
) else None
|
||||
amount = delta if delta is not None else -abs(fee)
|
||||
else:
|
||||
amount -= abs(fee)
|
||||
if balance is not None:
|
||||
vorheriger_saldo = balance
|
||||
|
||||
out.append({
|
||||
"date": d.isoformat() if d else None,
|
||||
@@ -337,7 +361,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
||||
"state": (cell("state").strip().upper() or None),
|
||||
"row_currency": (cell("currency").strip().upper() or None),
|
||||
# Laufender Kontostand aus der Datei – Grundlage für den Saldo-Abgleich.
|
||||
"balance_cents": parse_amount(cell("balance"), template.decimal_sep),
|
||||
"balance_cents": balance,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
Reference in New Issue
Block a user