feat: Transfer-Schlagwort mit festem Zielkonto (z. B. REVOLUT -> Konto Revolut)
- Transfer-Regel kann direkt ein Zielkonto tragen (target_account_id); ohne -> weiterhin Auto-Erkennung aus dem Buchungstext (Konto-Texte) - Migration 0010; Endpunkt aktualisiert bestehendes Wort statt Konflikt - Import bevorzugt das feste Zielkonto, sonst _guess_target_account - Konten-Seite: Transfer-Erkennung mit Zielkonto-Auswahl + Anzeige am Chip - Test: festes Zielkonto greift ohne Konto-Texte (75 grün) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -551,6 +551,11 @@ class CategorizationRule(Base):
|
|||||||
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
|
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
|
||||||
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Optionales festes Zielkonto für Transfer-Regeln. Ohne -> Zielkonto wird aus
|
||||||
|
# dem Buchungstext (Konto-Erkennungs-Texte) erraten.
|
||||||
|
target_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
# Art der Regel: "expense" | "income" | "transfer". Erlaubt, dasselbe Wort je
|
# Art der Regel: "expense" | "income" | "transfer". Erlaubt, dasselbe Wort je
|
||||||
# einmal für eine Ausgabe- UND eine Einnahme-Kategorie zu verwenden (das
|
# einmal für eine Ausgabe- UND eine Einnahme-Kategorie zu verwenden (das
|
||||||
# Matching filtert ohnehin nach Buchungsart).
|
# Matching filtert ohnehin nach Buchungsart).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, get_owned
|
from ..deps import get_current_user, get_owned
|
||||||
from ..models import CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
|
from ..models import Account, CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
CategoryCreate,
|
CategoryCreate,
|
||||||
CategoryGroupCreate,
|
CategoryGroupCreate,
|
||||||
@@ -353,7 +353,10 @@ def add_keyword(
|
|||||||
|
|
||||||
|
|
||||||
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
|
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
|
||||||
return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents)
|
return TransferKeywordOut(
|
||||||
|
id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents,
|
||||||
|
target_account_id=rule.target_account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
|
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
|
||||||
@@ -378,15 +381,21 @@ def add_transfer_keyword(
|
|||||||
pattern = normalize_text(payload.word)[:255]
|
pattern = normalize_text(payload.word)[:255]
|
||||||
if not pattern:
|
if not pattern:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||||
|
target_id = None
|
||||||
|
if payload.target_account_id is not None:
|
||||||
|
target_id = get_owned(db, Account, payload.target_account_id, user).id
|
||||||
|
# Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto).
|
||||||
existing = _find_any_rule(db, user, pattern, "transfer")
|
existing = _find_any_rule(db, user, pattern, "transfer")
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if existing.is_transfer and existing.amount_cents == payload.amount_cents:
|
existing.amount_cents = payload.amount_cents
|
||||||
|
existing.target_account_id = target_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
return _transfer_keyword_out(existing)
|
return _transfer_keyword_out(existing)
|
||||||
raise _conflict(db, existing)
|
|
||||||
rule = CategorizationRule(
|
rule = CategorizationRule(
|
||||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||||
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
|
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
|
||||||
kind="transfer", source=RuleSource.manual, priority=10,
|
target_account_id=target_id, kind="transfer", source=RuleSource.manual, priority=10,
|
||||||
)
|
)
|
||||||
db.add(rule)
|
db.add(rule)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -441,12 +441,14 @@ class KeywordOut(BaseModel):
|
|||||||
class TransferKeywordCreate(BaseModel):
|
class TransferKeywordCreate(BaseModel):
|
||||||
word: str = Field(min_length=1, max_length=255)
|
word: str = Field(min_length=1, max_length=255)
|
||||||
amount_cents: int | None = Field(default=None, gt=0)
|
amount_cents: int | None = Field(default=None, gt=0)
|
||||||
|
target_account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TransferKeywordOut(BaseModel):
|
class TransferKeywordOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
word: str
|
word: str
|
||||||
amount_cents: int | None = None
|
amount_cents: int | None = None
|
||||||
|
target_account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class FlaggedKeywordOut(BaseModel):
|
class FlaggedKeywordOut(BaseModel):
|
||||||
|
|||||||
@@ -300,6 +300,10 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
matched_rule_id = rule.id
|
matched_rule_id = rule.id
|
||||||
if rule.is_transfer:
|
if rule.is_transfer:
|
||||||
ttype = "transfer"
|
ttype = "transfer"
|
||||||
|
# Festes Zielkonto der Regel bevorzugen, sonst aus dem Text raten.
|
||||||
|
if rule.target_account_id and rule.target_account_id != account.id:
|
||||||
|
to_account_id = rule.target_account_id
|
||||||
|
else:
|
||||||
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
|
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
|
||||||
else:
|
else:
|
||||||
category_id = rule.category_id
|
category_id = rule.category_id
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Transfer-Regeln: optionales festes Zielkonto.
|
||||||
|
|
||||||
|
Erlaubt, einem Transfer-Schlagwort direkt ein Zielkonto zuzuweisen (z. B.
|
||||||
|
„REVOLUT" -> Konto Revolut). Ohne Zielkonto wird es weiterhin aus dem
|
||||||
|
Buchungstext erraten.
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009
|
||||||
|
Create Date: 2026-08-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0010"
|
||||||
|
down_revision = "0009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.add_column(sa.Column("target_account_id", sa.Integer(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
"fk_rule_target_account", "accounts", ["target_account_id"], ["id"], ondelete="SET NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("categorization_rules") as batch:
|
||||||
|
batch.drop_constraint("fk_rule_target_account", type_="foreignkey")
|
||||||
|
batch.drop_column("target_account_id")
|
||||||
@@ -249,6 +249,27 @@ def test_transfer_rule_and_target_guess(admin):
|
|||||||
assert rows[1]["to_account_id"] is None
|
assert rows[1]["to_account_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_keyword_fixed_target(admin):
|
||||||
|
giro = _account(admin, name="Giro FT")
|
||||||
|
ziel = _account(admin, name="Revolut Konto")
|
||||||
|
# Transfer-Schlagwort „revolut" MIT festem Zielkonto (ohne Konto-Texte).
|
||||||
|
r = admin.post("/api/categories/transfer-keywords",
|
||||||
|
json={"word": "revolut", "target_account_id": ziel["id"]})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert r.json()["target_account_id"] == ziel["id"]
|
||||||
|
|
||||||
|
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-25,00;Einkauf REVOLUT;Karte\n"
|
||||||
|
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||||||
|
assert rows[0]["type"] == "transfer"
|
||||||
|
assert rows[0]["to_account_id"] == ziel["id"]
|
||||||
|
|
||||||
|
# Erneutes Anlegen desselben Worts aktualisiert das Zielkonto (kein Konflikt).
|
||||||
|
r2 = admin.post("/api/categories/transfer-keywords",
|
||||||
|
json={"word": "revolut", "target_account_id": giro["id"]})
|
||||||
|
assert r2.status_code == 201, r2.text
|
||||||
|
assert r2.json()["target_account_id"] == giro["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_same_word_across_kinds_allowed(admin):
|
def test_same_word_across_kinds_allowed(admin):
|
||||||
a, b = _expense_cats(admin, 2)
|
a, b = _expense_cats(admin, 2)
|
||||||
inc = _income_cat(admin)
|
inc = _income_cat(admin)
|
||||||
|
|||||||
@@ -173,8 +173,9 @@
|
|||||||
},
|
},
|
||||||
"transferRules": {
|
"transferRules": {
|
||||||
"title": "Transfer-Erkennung",
|
"title": "Transfer-Erkennung",
|
||||||
"hint": "Buchungen, deren Text eines dieser Wörter enthält, werden als Transfer erkannt. Das Zielkonto wird aus dem Buchungstext über die Erkennungs-Texte je Konto (Konto bearbeiten, Art Text) erraten.",
|
"hint": "Buchungen, deren Text eines dieser Wörter enthält (z. B. „Revolut“), werden als Transfer gesetzt. Das Zielkonto kannst du fest wählen – oder automatisch aus dem Buchungstext erraten lassen (über die Erkennungs-Texte je Konto: Konto bearbeiten, Art „Text“).",
|
||||||
"wordPlaceholder": "Wort (z. B. Übertrag)",
|
"wordPlaceholder": "Wort (z. B. Übertrag)",
|
||||||
|
"autoTarget": "automatisch",
|
||||||
"empty": "Noch keine Transfer-Schlagwörter."
|
"empty": "Noch keine Transfer-Schlagwörter."
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|||||||
@@ -173,8 +173,9 @@
|
|||||||
},
|
},
|
||||||
"transferRules": {
|
"transferRules": {
|
||||||
"title": "Transfer detection",
|
"title": "Transfer detection",
|
||||||
"hint": "Transactions whose text contains one of these words are recognized as a transfer. The target account is guessed from the booking text via each account's detection texts (edit account, kind Text).",
|
"hint": "Transactions whose text contains one of these words (e.g. “Revolut”) are set as a transfer. Pick a fixed target account – or let it be guessed automatically from the booking text (via each account's detection texts: edit account, kind “Text”).",
|
||||||
"wordPlaceholder": "Word (e.g. Transfer)",
|
"wordPlaceholder": "Word (e.g. Transfer)",
|
||||||
|
"autoTarget": "automatic",
|
||||||
"empty": "No transfer keywords yet."
|
"empty": "No transfer keywords yet."
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|||||||
@@ -36,13 +36,15 @@ function emptyForm() {
|
|||||||
|
|
||||||
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
|
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
|
||||||
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
|
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
|
||||||
function TransferKeywords() {
|
function TransferKeywords({ accounts }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [words, setWords] = useState([]);
|
const [words, setWords] = useState([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const accName = (id) => accounts.find((a) => a.id === id)?.name;
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
try { setWords(await api.listTransferKeywords()); }
|
try { setWords(await api.listTransferKeywords()); }
|
||||||
@@ -58,9 +60,11 @@ function TransferKeywords() {
|
|||||||
const body = { word: w };
|
const body = { word: w };
|
||||||
const c = parseAmountToCents(amount);
|
const c = parseAmountToCents(amount);
|
||||||
if (c) body.amount_cents = c;
|
if (c) body.amount_cents = c;
|
||||||
|
if (target) body.target_account_id = Number(target);
|
||||||
await api.addTransferKeyword(body);
|
await api.addTransferKeyword(body);
|
||||||
setInput("");
|
setInput("");
|
||||||
setAmount("");
|
setAmount("");
|
||||||
|
setTarget("");
|
||||||
await laden();
|
await laden();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, "error");
|
toast(err.message, "error");
|
||||||
@@ -82,9 +86,14 @@ function TransferKeywords() {
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
||||||
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
|
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
|
||||||
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
|
style={{ flex: "0 0 110px", minWidth: 0, marginTop: 0 }}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
||||||
|
<select value={target} onChange={(e) => setTarget(e.target.value)}
|
||||||
|
style={{ flex: "0 0 160px", minWidth: 0, marginTop: 0 }}>
|
||||||
|
<option value="">{t("transferRules.autoTarget")}</option>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
|
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
|
||||||
<Icon name="plus" size={15} />
|
<Icon name="plus" size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -97,6 +106,7 @@ function TransferKeywords() {
|
|||||||
<span className="kw-chip" key={w.id}>
|
<span className="kw-chip" key={w.id}>
|
||||||
{w.word}
|
{w.word}
|
||||||
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
|
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
|
||||||
|
<span className="kw-amount">→ {w.target_account_id ? (accName(w.target_account_id) || "?") : t("transferRules.autoTarget")}</span>
|
||||||
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
|
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
|
||||||
<Icon name="close" size={13} />
|
<Icon name="close" size={13} />
|
||||||
</button>
|
</button>
|
||||||
@@ -239,7 +249,7 @@ export default function Accounts() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TransferKeywords />
|
<TransferKeywords accounts={accounts} />
|
||||||
|
|
||||||
{form && (
|
{form && (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
Reference in New Issue
Block a user