diff --git a/Changes.md b/Changes.md index c67a256..9783cff 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,23 @@ # Änderungen +## Regeln lassen sich auf Buchungen ohne Kategorie anwenden (27.09.2026) + +Der Knopf unter Verwaltung → Regeln hieß „Gegen offene Umsätze prüfen“ und zählte nur, +wie viele noch nicht abgeglichene Umsätze eine Regel träfe – das beantwortet weder, wo im +Kontoblatt schon Lücken liegen, noch schließt es sie. + +- Neu: `Buchung::ohneKategorie()` liefert Buchungen ganz ohne Kategorie – weder direkt + (`kategorie_id`) noch in einer Splitzeile –, ausgenommen Umbuchungen (die haben per + Definition ein Gegenkonto statt einer Kategorie). +- Neu: `Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden()` trägt bei jeder Buchung + ohne Kategorie die erste passende Regel nach (Kategorie, Klasse, Gegenkonto oder + Split) – ohne Treffer bleibt die Buchung unverändert, ein abgeschlossener Monat oder + sonstiger Fehler betrifft nur die eine Buchung und bricht den Lauf nicht ab. +- Der Knopf „Regeln auf Buchungen ohne Kategorie anwenden“ (mit Rückfrage) ersetzt den + reinen Testlauf; die Regel-Trefferzähler zählen mit. +- Test in `tests/16_BankingTest.php` (`buchungOhneKategorieWirdGefunden`, + `regelnKategorisierenBuchungenOhneKategorie`). + ## `update.sh` zieht aus dem Fork (25.09.2026) - Das Update-Skript holt den Code ausdrücklich per `git pull --ff-only origin main`. Vorher diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index a236fcc..edf3217 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -821,29 +821,18 @@ class BankingController extends BaseController } /** - * Wendet die Regeln auf die offenen Umsätze an, ohne sie zu übernehmen – - * damit lässt sich eine neue Regel ausprobieren, bevor sie bucht. + * Trägt bei allen Buchungen ohne Kategorie die erste passende Regel nach. + * Maßgeblich ist die Kategorie, nicht der Abgleichsstatus: Eine bereits + * übernommene Buchung ohne Kategorie soll die Regel genauso finden wie + * ein frisch abgerufener Umsatz. */ public function regelnPruefen(): void { $this->verlangeAnmeldung(); - $regeln = AbgleichRegel::alle(true); - $treffer = 0; - $gesamt = 0; + $ergebnis = Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden(); - foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) { - $gesamt++; - if (AbgleichRegel::passendeRegel($umsatz, $regeln) !== null) { - $treffer++; - } - } - - View::redirect('/banking/regeln', sprintf( - 'Von %d offenen Umsätzen treffen die Regeln auf %d.', - $gesamt, - $treffer - )); + View::redirect('/banking/regeln', Bankabgleich::berichtOhneKategorie($ergebnis)); } // ── Formulardaten ──────────────────────────────────────────────────────── diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index 0f4e3c3..89a4054 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -487,6 +487,35 @@ class Buchung return $anzahlen; } + /** + * Buchungen ganz ohne Kategorie – weder direkt noch in einer Splitzeile. + * Grundlage für den Regel-Testlauf: Er soll zeigen, wie viele Lücken eine + * (neue oder geänderte) Regel schließen würde, nicht wie viele Umsätze + * noch nicht abgeglichen sind – das sind zwei verschiedene Fragen. + * + * Kein Gegenkonto und nicht gesplittet ohne jede kategorisierte Zeile: + * dieselbe Definition wie bei {@see unkategorisierteJeKonto()}, nur als + * Liste statt als Zählung je Konto. + * + * @return list> + */ + public static function ohneKategorie(): array + { + return Database::fetchAll( + 'SELECT b.* FROM buchungen b + WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NULL + AND ( + (b.gesplittet = 0 AND b.kategorie_id IS NULL) + OR (b.gesplittet = 1 AND NOT EXISTS ( + SELECT 1 FROM buchung_splits s + WHERE s.buchung_id = b.id AND s.kategorie_id IS NOT NULL + )) + ) + ORDER BY b.datum', + [Mandant::id()] + ); + } + /** * Die jüngsten Buchungen zu einem Empfängernamen, kontenübergreifend – für * die Adressverwaltung: Wer sich eine Adresse ansieht, will wissen, was diff --git a/src/Services/Bankabgleich.php b/src/Services/Bankabgleich.php index 804e983..2b9eb36 100644 --- a/src/Services/Bankabgleich.php +++ b/src/Services/Bankabgleich.php @@ -6,6 +6,7 @@ namespace Finanzmanager\Services; use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Bankumsatz; +use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Einstellung; /** @@ -128,4 +129,95 @@ class Bankabgleich return $text; } + + /** + * Trägt bei allen Buchungen ohne Kategorie die erste passende Regel nach – + * der Testlauf unter „Regeln“ soll die gefundenen Lücken gleich schließen, + * nicht nur zählen, wie viele Regeln träfen. + * + * Buchungen ohne Treffer bleiben unangetastet, ebenso ein abgeschlossener + * Monat: Der einzelne Fehler landet im Bericht, der Lauf macht mit den + * übrigen Buchungen weiter. + * + * @return array{gesamt: int, kategorisiert: int, fehler: list} + */ + public static function regelnAufBuchungenOhneKategorieAnwenden(): array + { + $regeln = AbgleichRegel::alle(true); + $gesamt = 0; + $kategorisiert = 0; + $fehler = []; + + foreach (Buchung::ohneKategorie() as $buchung) { + $gesamt++; + + $umsatz = [ + 'empfaenger' => $buchung['empfaenger'], + 'verwendungszweck' => $buchung['buchungstext'], + 'betrag' => $buchung['betrag'], + ]; + + $regel = AbgleichRegel::passendeRegel($umsatz, $regeln); + if ($regel === null) { + continue; + } + + $zuordnung = AbgleichRegel::anwenden($regel, $umsatz); + + try { + Buchung::speichern( + (int)$buchung['id'], + [ + 'konto_id' => $buchung['konto_id'], + 'datum' => $buchung['datum'], + 'valuta' => $buchung['valuta'], + 'betrag' => $buchung['betrag'], + 'empfaenger' => $buchung['empfaenger'], + 'buchungstext' => $buchung['buchungstext'], + 'nummer' => $buchung['nummer'], + 'abgeglichen' => $buchung['abgeglichen'], + 'kategorie_id' => $zuordnung['kategorie_id'], + 'klasse_id' => $zuordnung['klasse_id'], + 'gegenkonto_id' => $zuordnung['gegenkonto_id'], + ], + $zuordnung['splits'] + ); + AbgleichRegel::zaehleTreffer((int)$regel['id']); + $kategorisiert++; + } catch (\Throwable $e) { + $fehler[] = sprintf( + '%s, %s: %s', + date('d.m.Y', strtotime((string)$buchung['datum'])), + number_format((float)$buchung['betrag'], 2, ',', '.') . ' €', + $e->getMessage() + ); + DebugLog::schreibe('abgleich', 'Buchung ' . (int)$buchung['id'] . ': ' . $e->getMessage()); + } + } + + return ['gesamt' => $gesamt, 'kategorisiert' => $kategorisiert, 'fehler' => $fehler]; + } + + /** + * Eine Zeile für die Meldung nach dem Anwenden der Regeln. + * + * @param array{gesamt: int, kategorisiert: int, fehler: list} $ergebnis + */ + public static function berichtOhneKategorie(array $ergebnis): string + { + $text = sprintf( + 'Von %d Buchungen ohne Kategorie wurden %d anhand der Regeln kategorisiert.', + $ergebnis['gesamt'], + $ergebnis['kategorisiert'] + ); + + if ($ergebnis['fehler'] !== []) { + $text .= ' Nicht kategorisiert: ' . implode('; ', array_slice($ergebnis['fehler'], 0, 3)); + if (count($ergebnis['fehler']) > 3) { + $text .= sprintf(' (und %d weitere)', count($ergebnis['fehler']) - 3); + } + } + + return $text; + } } diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php index 98a8938..f8a4129 100644 --- a/src/Views/banking/regeln.php +++ b/src/Views/banking/regeln.php @@ -29,7 +29,10 @@ include __DIR__ . '/../layout/kopf.php';
- +
Zum Abgleich
diff --git a/tests/16_BankingTest.php b/tests/16_BankingTest.php index fdb4627..680572d 100644 --- a/tests/16_BankingTest.php +++ b/tests/16_BankingTest.php @@ -10,6 +10,7 @@ use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Konto; +use Finanzmanager\Services\Bankabgleich; use Finanzmanager\Services\Crypto; use Finanzmanager\Services\FinTsVerfahren; @@ -48,6 +49,8 @@ class BankingTest extends TestCase $this->abgleichFindetVorhandeneBuchung(); $this->uebernehmenErzeugtEineBuchung(); $this->regelmitSplitsErzeugtEineSplitbuchung(); + $this->buchungOhneKategorieWirdGefunden(); + $this->regelnKategorisierenBuchungenOhneKategorie(); $this->verwerfenUndZuruecksetzen(); $this->abrufprotokollHaeltDenVerlauf(); } @@ -777,6 +780,109 @@ class BankingTest extends TestCase AbgleichRegel::loeschen($regelId); } + /** + * Der Regel-Testlauf soll Lücken zeigen, die eine Regel schließen könnte – + * nicht den Abgleichsstatus. Deshalb zählt „ohne Kategorie“ unabhängig + * davon, ob eine Buchung aus einem Umsatz entstand oder manuell erfasst + * wurde, und eine Splitbuchung zählt nur, wenn wirklich keine Zeile eine + * Kategorie trägt. + */ + private function buchungOhneKategorieWirdGefunden(): void + { + $ohneKategorie = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -19.99, + 'empfaenger' => 'Unbekannter Laden', + ]); + $mitKategorie = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -30.00, + 'empfaenger' => 'Bekannter Laden', 'kategorie_id' => $this->strom, + ]); + $splitOhneKategorie = Buchung::anlegen( + ['konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -50.00, 'empfaenger' => 'Split ohne'], + [ + ['kategorie_id' => null, 'betrag' => -30.00], + ['kategorie_id' => null, 'betrag' => -20.00], + ] + ); + $splitMitKategorie = Buchung::anlegen( + ['konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -50.00, 'empfaenger' => 'Split mit'], + [ + ['kategorie_id' => $this->strom, 'betrag' => -30.00], + ['kategorie_id' => null, 'betrag' => -20.00], + ] + ); + $umbuchung = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-10', 'betrag' => -40.00, + 'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->sparkonto, + ]); + + $ids = array_column(Buchung::ohneKategorie(), 'id'); + $ids = array_map('intval', $ids); + + $this->assert(in_array($ohneKategorie, $ids, true), 'Buchung ohne jede Kategorie taucht auf'); + $this->assert(in_array($splitOhneKategorie, $ids, true), 'Splitbuchung ohne kategorisierte Zeile taucht auf'); + $this->assert(!in_array($mitKategorie, $ids, true), 'Kategorisierte Buchung fehlt in der Liste'); + $this->assert(!in_array($splitMitKategorie, $ids, true), 'Teilweise kategorisierte Splitbuchung fehlt in der Liste'); + $this->assert(!in_array($umbuchung, $ids, true), 'Eine Umbuchung braucht keine Kategorie'); + } + + /** + * Der Knopf soll die Lücken nicht nur zählen, sondern gleich schließen: + * Jede Buchung ohne Kategorie, auf die eine Regel passt, bekommt deren + * Zuordnung nachgetragen. Ohne Treffer bleibt die Buchung unverändert. + */ + private function regelnKategorisierenBuchungenOhneKategorie(): void + { + $regelId = AbgleichRegel::anlegen([ + 'name' => 'Stadtwerke Nachtrag', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', + 'muster' => 'Stadtwerke Nachtrag', 'kategorie_id' => $this->strom, 'prioritaet' => 1, 'aktiv' => 1, + ]); + $splitRegelId = AbgleichRegel::anlegen([ + 'name' => 'Vermieter Nachtrag geteilt', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', + 'muster' => 'Vermieter Nachtrag', + 'splits' => [ + ['kategorie_id' => $this->strom, 'anteil' => 0.5], + ['kategorie_id' => $this->miete, 'anteil' => 0.5], + ], + 'prioritaet' => 1, 'aktiv' => 1, + ]); + $treffervorher = (int)AbgleichRegel::find($regelId)['treffer']; + + $trifft = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-11', 'betrag' => -55.00, + 'empfaenger' => 'Stadtwerke Nachtrag GmbH', + ]); + $trifftSplit = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-11', 'betrag' => -100.00, + 'empfaenger' => 'Vermieter Nachtrag GmbH', + ]); + $trifftNicht = Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-08-11', 'betrag' => -12.00, + 'empfaenger' => 'Ohne jede Regel', + ]); + + $ergebnis = Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden(); + + $this->assertEquals($this->strom, (int)Buchung::find($trifft)['kategorie_id'], 'Die Kategorie wurde nachgetragen'); + $this->assertNull(Buchung::find($trifftNicht)['kategorie_id'], 'Ohne Regel bleibt die Buchung unverändert'); + + $splitBuchung = Buchung::find($trifftSplit); + $this->assertEquals(1, (int)$splitBuchung['gesplittet'], 'Eine Splitregel macht die Buchung zur Splitbuchung'); + $splits = Buchung::splits($trifftSplit); + $this->assertEquals(2, count($splits), 'Zwei Splitzeilen aus der Regel'); + $this->assertEquals(-50.00, (float)$splits[0]['betrag'], 'Je zur Hälfte aufgeteilt'); + + $luecken = array_map('intval', array_column(Buchung::ohneKategorie(), 'id')); + $this->assert(in_array($trifftNicht, $luecken, true), 'Bleibt ohne Regeltreffer in der Lückenliste'); + $this->assert(!in_array($trifft, $luecken, true), 'Verlässt die Lückenliste, sobald sie kategorisiert ist'); + + $this->assertGreaterThan($treffervorher, (int)AbgleichRegel::find($regelId)['treffer'], 'Der Treffer wird gezählt'); + $this->assert($ergebnis['kategorisiert'] >= 2, 'Mindestens die zwei passenden Buchungen wurden kategorisiert'); + + AbgleichRegel::loeschen($regelId); + AbgleichRegel::loeschen($splitRegelId); + } + private function verwerfenUndZuruecksetzen(): void { $id = $this->umsatzAnlegen([