fix: Abruf ueber schon eingelesene Monate buchte alles doppelt
"Aelteres nachholen" greift 90 Tage zurueck – mitten in Monate, die schon per CSV eingelesen sind. Genau davor schuetzt sonst das Startdatum, und der Knopf haengt es aus. Der Duplikatschutz faengt das nicht: Sein Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders als im Auszug. Ergebnis: jede Zahlung des Zeitraums zweimal. Das war mein Fehler – ich habe den Knopf gebaut UND empfohlen, ohne die Sicherung zu ersetzen, die er aushebelt. Der Abruf prueft jetzt selbst: Gibt es auf dem Konto schon eine Buchung gleicher Richtung und gleichen Betrags im Umkreis von drei Tagen, die NICHT von der Bank kam, ist der Umsatz dieselbe Bewegung und wird uebersprungen. Jede vorhandene Buchung erklaert dabei hoechstens einen Umsatz – sonst verschluckte eine Kaffeezahlung alle weiteren desselben Betrags. Die Zahl der uebersprungenen steht in der Rueckmeldung. Fuer den bereits angerichteten Schaden gibt es scripts/raeume_doppelte_bankumsaetze.py: Es findet Bank-Umsaetze, die eine vorhandene Buchung wiederholen, und entfernt IMMER die von der Bank – die eigene traegt die Kategorie des Nutzers. Sie wird verworfen, nicht geloescht, damit kein spaeterer Abruf sie wiederholt. Ohne --wirklich passiert nichts; mit --wirklich wird zuerst eine Kopie der Datenbank angelegt. An nachgestellten Daten geprueft: Aus -95.80 wird -48.40, die eigenen Buchungen samt Kategorie bleiben, der echte neue Umsatz bleibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ from ..models import (
|
|||||||
InboxSource,
|
InboxSource,
|
||||||
InboxStatus,
|
InboxStatus,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TxnType,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from . import csv_import, eingang, geheimnis
|
from . import csv_import, eingang, geheimnis
|
||||||
@@ -524,6 +525,51 @@ def _startdatum(db: Session, user: User, konto: Account,
|
|||||||
return min(heute, max(frueheste, letzte + timedelta(days=1)))
|
return min(heute, max(frueheste, letzte + timedelta(days=1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _fremde_buchungen(db: Session, user: User, konto: Account,
|
||||||
|
seit: date) -> dict[tuple[str, int], list]:
|
||||||
|
"""Buchungen des Kontos, die NICHT von der Bank kamen, nach Art und Betrag.
|
||||||
|
|
||||||
|
Ein Abruf ueber einen laengeren Zeitraum greift in Monate zurueck, die
|
||||||
|
schon per CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein
|
||||||
|
Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders
|
||||||
|
als im Auszug. Ohne diese Pruefung stuende die Zahlung zweimal da.
|
||||||
|
"""
|
||||||
|
aus_bank = {
|
||||||
|
txn_id
|
||||||
|
for (txn_id,) in db.query(InboxItem.transaction_id).filter(
|
||||||
|
InboxItem.owner_id == user.id,
|
||||||
|
InboxItem.source == InboxSource.bank,
|
||||||
|
InboxItem.transaction_id.isnot(None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
je_schluessel: dict[tuple[str, int], list] = {}
|
||||||
|
for txn in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.account_id == konto.id,
|
||||||
|
Transaction.type != TxnType.transfer,
|
||||||
|
Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE),
|
||||||
|
):
|
||||||
|
if txn.id in aus_bank:
|
||||||
|
continue
|
||||||
|
je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn)
|
||||||
|
return je_schluessel
|
||||||
|
|
||||||
|
|
||||||
|
def _findet_fremde(fremde: dict, verbraucht: set[int], cents: int, tag: date):
|
||||||
|
"""Passende eigene Buchung – jede erklaert hoechstens EINEN Bank-Umsatz.
|
||||||
|
|
||||||
|
Ohne die Menge verschluckte eine Kaffeezahlung alle weiteren desselben
|
||||||
|
Betrags in der Umgebung.
|
||||||
|
"""
|
||||||
|
art = "expense" if cents < 0 else "income"
|
||||||
|
for txn in fremde.get((art, abs(cents)), []):
|
||||||
|
if txn.id in verbraucht:
|
||||||
|
continue
|
||||||
|
if abs((txn.date - tag).days) <= csv_import.INBOX_TAGE:
|
||||||
|
return txn
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
||||||
nur_link_id: int | None = None, tage: int | None = None) -> dict:
|
nur_link_id: int | None = None, tage: int | None = None) -> dict:
|
||||||
"""Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen.
|
"""Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen.
|
||||||
@@ -543,7 +589,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
|||||||
|
|
||||||
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
|
||||||
geholt = neu = gebucht = vorgemerkt = 0
|
geholt = neu = gebucht = vorgemerkt = doppelt = 0
|
||||||
ab: date | None = None
|
ab: date | None = None
|
||||||
for verknuepfung in verknuepfungen:
|
for verknuepfung in verknuepfungen:
|
||||||
konto = konten.get(verknuepfung.account_id)
|
konto = konten.get(verknuepfung.account_id)
|
||||||
@@ -553,8 +599,12 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
|||||||
# ganz neu.
|
# ganz neu.
|
||||||
seit = _startdatum(db, user, konto, verknuepfung, tage)
|
seit = _startdatum(db, user, konto, verknuepfung, tage)
|
||||||
ab = seit if ab is None else min(ab, seit)
|
ab = seit if ab is None else min(ab, seit)
|
||||||
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege.
|
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege ...
|
||||||
verbraucht: set[int] = set()
|
verbraucht: set[int] = set()
|
||||||
|
# ... und eine fuer schon vorhandene, nicht von der Bank stammende
|
||||||
|
# Buchungen. Beide muessen ueber alle Zeilen des Kontos halten.
|
||||||
|
fremde = _fremde_buchungen(db, user, konto, seit)
|
||||||
|
fremde_verbraucht: set[int] = set()
|
||||||
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
|
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
|
||||||
geholt += 1
|
geholt += 1
|
||||||
zustand = _ist_gebucht(umsatz)
|
zustand = _ist_gebucht(umsatz)
|
||||||
@@ -605,6 +655,14 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
|||||||
if zeile.get("is_duplicate"):
|
if zeile.get("is_duplicate"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Steht die Zahlung schon da, weil sie frueher per Datei oder von
|
||||||
|
# Hand erfasst wurde? Dann ist der Bank-Umsatz dieselbe Bewegung.
|
||||||
|
schon_da = _findet_fremde(fremde, fremde_verbraucht, cents, tag)
|
||||||
|
if schon_da is not None:
|
||||||
|
fremde_verbraucht.add(schon_da.id)
|
||||||
|
doppelt += 1
|
||||||
|
continue
|
||||||
|
|
||||||
waehrung = ((umsatz.get("transaction_amount") or {}).get("currency")
|
waehrung = ((umsatz.get("transaction_amount") or {}).get("currency")
|
||||||
or konto.currency)
|
or konto.currency)
|
||||||
kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung)
|
kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung)
|
||||||
@@ -648,6 +706,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
|
|||||||
return {
|
return {
|
||||||
"uebertraege": verknuepft,
|
"uebertraege": verknuepft,
|
||||||
"geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt,
|
"geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt,
|
||||||
|
"doppelt": doppelt,
|
||||||
"saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False,
|
"saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Findet Bank-Umsätze, die eine schon vorhandene Buchung wiederholen.
|
||||||
|
|
||||||
|
Ein Abruf über einen längeren Zeitraum greift in Monate zurück, die schon per
|
||||||
|
CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein Schlüssel
|
||||||
|
enthält den Empfängertext, und der lautet bei der Bank anders als im Auszug.
|
||||||
|
Die Zahlung steht danach zweimal da.
|
||||||
|
|
||||||
|
Gesucht wird deshalb unscharf: gleiches Konto, gleiche Richtung, gleicher
|
||||||
|
Betrag, Datum ±3 Tage. Jede vorhandene Buchung kann dabei nur EINE
|
||||||
|
Bank-Buchung erklären – sonst verschluckt eine Kaffeezahlung alle anderen
|
||||||
|
desselben Betrags.
|
||||||
|
|
||||||
|
Weggeräumt wird immer die von der BANK, nie deine eigene: Deine trägt die
|
||||||
|
Kategorie, die du vergeben hast. Sie wird nicht gelöscht, sondern verworfen –
|
||||||
|
so holt sie kein späterer Abruf wieder herein.
|
||||||
|
|
||||||
|
Das Skript ändert ohne ``--wirklich`` nichts. Mit ``--wirklich`` legt es zuerst
|
||||||
|
eine Kopie der Datenbank an.
|
||||||
|
|
||||||
|
Aufruf: raeume_doppelte_bankumsaetze.py [pfad-zur-db] [--wirklich]
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
TAGE = 3
|
||||||
|
|
||||||
|
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
WIRKLICH = "--wirklich" in sys.argv
|
||||||
|
DB = argumente[0] if argumente else "/var/lib/certain-finance/app.db"
|
||||||
|
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def geld(cents):
|
||||||
|
return f"{cents / 100:,.2f}".replace(",", "'")
|
||||||
|
|
||||||
|
|
||||||
|
konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")}
|
||||||
|
|
||||||
|
# Welche Buchungen kamen von der Bank? Nur die kommen als Dublette in Frage.
|
||||||
|
von_bank = {
|
||||||
|
r["transaction_id"]: r["id"]
|
||||||
|
for r in con.execute("""
|
||||||
|
SELECT id, transaction_id FROM inbox_items
|
||||||
|
WHERE source = 'bank' AND transaction_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
alle = list(con.execute("""
|
||||||
|
SELECT id, account_id, type, date, amount_cents, payee, description, category_id
|
||||||
|
FROM transactions
|
||||||
|
WHERE type != 'transfer'
|
||||||
|
ORDER BY date, id
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Eigene Buchungen je (Konto, Richtung, Betrag) – daraus wird gleich geschöpft.
|
||||||
|
eigene: dict[tuple, list] = {}
|
||||||
|
for t in alle:
|
||||||
|
if t["id"] in von_bank:
|
||||||
|
continue
|
||||||
|
eigene.setdefault((t["account_id"], t["type"], t["amount_cents"]), []).append(t)
|
||||||
|
|
||||||
|
verbraucht: set[int] = set()
|
||||||
|
funde = []
|
||||||
|
for t in alle:
|
||||||
|
if t["id"] not in von_bank:
|
||||||
|
continue
|
||||||
|
schluessel = (t["account_id"], t["type"], t["amount_cents"])
|
||||||
|
tag = date.fromisoformat(t["date"])
|
||||||
|
passend = None
|
||||||
|
for kandidat in eigene.get(schluessel, []):
|
||||||
|
if kandidat["id"] in verbraucht:
|
||||||
|
continue
|
||||||
|
if abs((date.fromisoformat(kandidat["date"]) - tag).days) <= TAGE:
|
||||||
|
passend = kandidat
|
||||||
|
break
|
||||||
|
if passend is not None:
|
||||||
|
verbraucht.add(passend["id"])
|
||||||
|
funde.append((t, passend))
|
||||||
|
|
||||||
|
if not funde:
|
||||||
|
print("Nichts gefunden – kein Bank-Umsatz wiederholt eine vorhandene Buchung.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"{len(funde)} Bank-Umsätze wiederholen eine vorhandene Buchung.\n")
|
||||||
|
je_konto: dict[int, int] = {}
|
||||||
|
for bank, eigen in funde:
|
||||||
|
konto = konten.get(bank["account_id"], {"name": "?", "currency": ""})
|
||||||
|
zeichen = -1 if bank["type"] == "expense" else 1
|
||||||
|
print(f" {bank['date']} {konto['name']:<22} {geld(bank['amount_cents']):>10} "
|
||||||
|
f"{(bank['payee'] or bank['description'] or '')[:38]}")
|
||||||
|
print(f" wiederholt #{eigen['id']} vom {eigen['date']}: "
|
||||||
|
f"{(eigen['payee'] or eigen['description'] or '')[:38]}")
|
||||||
|
je_konto[bank["account_id"]] = je_konto.get(bank["account_id"], 0) + zeichen * bank["amount_cents"]
|
||||||
|
|
||||||
|
print("\nDie Konten liegen dadurch daneben:")
|
||||||
|
for konto_id, cents in sorted(je_konto.items()):
|
||||||
|
konto = konten.get(konto_id, {"name": "?", "currency": ""})
|
||||||
|
richtung = "zu niedrig" if cents < 0 else "zu hoch"
|
||||||
|
print(f" {konto['name']:<24} {geld(abs(cents))} {konto['currency']} {richtung}")
|
||||||
|
|
||||||
|
if not WIRKLICH:
|
||||||
|
print(f"\nNur angesehen, nichts geändert. Zum Ausführen:\n"
|
||||||
|
f" {sys.argv[0]} {DB} --wirklich")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
kopie = f"{DB}.vor-aufraeumen-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
|
with sqlite3.connect(kopie) as ziel:
|
||||||
|
con.backup(ziel)
|
||||||
|
print(f"\nKopie der Datenbank: {kopie}")
|
||||||
|
|
||||||
|
for bank, _ in funde:
|
||||||
|
# Verwerfen statt loeschen: Der Eintrag bleibt als Sperre stehen, damit
|
||||||
|
# kein spaeterer Abruf denselben Umsatz noch einmal hereinholt.
|
||||||
|
con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL
|
||||||
|
WHERE id = ?""", (von_bank[bank["id"]],))
|
||||||
|
con.execute("DELETE FROM transactions WHERE id = ?", (bank["id"],))
|
||||||
|
con.commit()
|
||||||
|
print(f"{len(funde)} doppelte Bank-Buchungen entfernt.\n"
|
||||||
|
"Sieh dir jetzt die Kontenseite an – dort muss bei den angebundenen\n"
|
||||||
|
"Konten 'Saldo stimmt mit der Bank überein' stehen.")
|
||||||
@@ -1085,3 +1085,47 @@ def test_startdatum_liegt_nie_in_der_zukunft(admin, fake_bank):
|
|||||||
assert gegenstelle.date_from <= heute.isoformat(), \
|
assert gegenstelle.date_from <= heute.isoformat(), \
|
||||||
f"date_from liegt in der Zukunft: {gegenstelle.date_from}"
|
f"date_from liegt in der Zukunft: {gegenstelle.date_from}"
|
||||||
assert r.json()["ab"] == heute.isoformat()
|
assert r.json()["ab"] == heute.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_abruf_ueber_schon_eingelesene_monate_bucht_nicht_doppelt(admin, fake_bank):
|
||||||
|
"""'Aelteres nachholen' greift in Monate zurueck, die schon per CSV
|
||||||
|
eingelesen sind. Der Schluessel des Duplikatschutzes enthaelt den
|
||||||
|
Empfaengertext, und der lautet bei der Bank anders – ohne eigene Pruefung
|
||||||
|
stuende die Zahlung zweimal da."""
|
||||||
|
_app(admin)
|
||||||
|
vor_wochen = date.today() - timedelta(days=30)
|
||||||
|
uid = f"acc-{secrets.token_hex(4)}"
|
||||||
|
fake_bank(
|
||||||
|
konten=[{"uid": uid, "currency": "CHF"}],
|
||||||
|
umsaetze=[
|
||||||
|
{"entry_reference": f"tx-{secrets.token_hex(4)}",
|
||||||
|
"booking_date": vor_wochen.isoformat(),
|
||||||
|
"transaction_amount": {"amount": "35.00", "currency": "CHF"},
|
||||||
|
"credit_debit_indicator": "DBIT",
|
||||||
|
"creditor": {"name": "Coop 6141"}},
|
||||||
|
# Und ein Umsatz, den es noch NICHT gibt – der muss ankommen.
|
||||||
|
{"entry_reference": f"tx-{secrets.token_hex(4)}",
|
||||||
|
"booking_date": vor_wochen.isoformat(),
|
||||||
|
"transaction_amount": {"amount": "7.77", "currency": "CHF"},
|
||||||
|
"credit_debit_indicator": "DBIT",
|
||||||
|
"creditor": {"name": "Neu und echt"}},
|
||||||
|
])
|
||||||
|
state = admin.post("/api/bank/connect",
|
||||||
|
json={"aspsp_name": f"Ueberlapp-{secrets.token_hex(3)}",
|
||||||
|
"country": "CH"}).json()["state"]
|
||||||
|
admin.post(f"/api/bank/callback?state={state}&code=guter-code")
|
||||||
|
link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0]
|
||||||
|
konto = _konto(admin, name=f"Ueberlapp {secrets.token_hex(3)}", currency="CHF")
|
||||||
|
admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}")
|
||||||
|
|
||||||
|
# So sieht der schon eingelesene Auszug aus – anderer Text, ein Tag daneben.
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": (vor_wochen + timedelta(days=1)).isoformat(),
|
||||||
|
"account_id": konto["id"], "amount_cents": 3500,
|
||||||
|
"payee": "COOP-6141 KILLWANGEN"})
|
||||||
|
|
||||||
|
r = admin.post(f"/api/bank/links/{link['id']}/sync", params={"tage": 90}).json()
|
||||||
|
assert r["doppelt"] == 1, r
|
||||||
|
assert r["neu"] == 1, "der echte Umsatz muss ankommen"
|
||||||
|
# 35.00 einmal (aus der CSV) plus 7.77 neu – nicht 35.00 zweimal.
|
||||||
|
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -4277
|
||||||
|
|||||||
@@ -651,7 +651,8 @@
|
|||||||
"pollUnit": "Minuten (0 = aus)",
|
"pollUnit": "Minuten (0 = aus)",
|
||||||
"pollHint": "Certain holt die Umsätze dann von selbst und bucht sie. 0 schaltet das ab; weniger als 15 Minuten lassen die Banken nicht zu.",
|
"pollHint": "Certain holt die Umsätze dann von selbst und bucht sie. 0 schaltet das ab; weniger als 15 Minuten lassen die Banken nicht zu.",
|
||||||
"pollSaved": "Takt gespeichert.",
|
"pollSaved": "Takt gespeichert.",
|
||||||
"syncOlder": "Älteres nachholen"
|
"syncOlder": "Älteres nachholen",
|
||||||
|
"syncedDuplicate": "{{n}} standen schon da (aus Datei oder von Hand) und wurden übersprungen."
|
||||||
},
|
},
|
||||||
"bankReturn": {
|
"bankReturn": {
|
||||||
"title": "Rückkehr von der Bank",
|
"title": "Rückkehr von der Bank",
|
||||||
|
|||||||
@@ -651,7 +651,8 @@
|
|||||||
"pollUnit": "minutes (0 = off)",
|
"pollUnit": "minutes (0 = off)",
|
||||||
"pollHint": "Certain then fetches and books transactions on its own. 0 turns this off; banks do not allow less than 15 minutes.",
|
"pollHint": "Certain then fetches and books transactions on its own. 0 turns this off; banks do not allow less than 15 minutes.",
|
||||||
"pollSaved": "Interval saved.",
|
"pollSaved": "Interval saved.",
|
||||||
"syncOlder": "Catch up on older"
|
"syncOlder": "Catch up on older",
|
||||||
|
"syncedDuplicate": "{{n}} were already there (from a file or entered by hand) and were skipped."
|
||||||
},
|
},
|
||||||
"bankReturn": {
|
"bankReturn": {
|
||||||
"title": "Back from your bank",
|
"title": "Back from your bank",
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export default function Bankanbindung() {
|
|||||||
// weiss, warum.
|
// weiss, warum.
|
||||||
const teile = [t("bank.synced", { gebucht: r.gebucht, ab: formatDate(r.ab) })];
|
const teile = [t("bank.synced", { gebucht: r.gebucht, ab: formatDate(r.ab) })];
|
||||||
if (r.vorgemerkt > 0) teile.push(t("bank.syncedPending", { n: r.vorgemerkt }));
|
if (r.vorgemerkt > 0) teile.push(t("bank.syncedPending", { n: r.vorgemerkt }));
|
||||||
|
// Uebersprungene nicht verschweigen: Sonst sieht ein Abruf ueber schon
|
||||||
|
// eingelesene Monate aus, als haette er nichts gefunden.
|
||||||
|
if (r.doppelt > 0) teile.push(t("bank.syncedDuplicate", { n: r.doppelt }));
|
||||||
if (r.uebertraege > 0) teile.push(t("bank.syncedTransfers", { n: r.uebertraege }));
|
if (r.uebertraege > 0) teile.push(t("bank.syncedTransfers", { n: r.uebertraege }));
|
||||||
return teile.join(" ");
|
return teile.join(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user