diff --git a/Changes.md b/Changes.md index 44684a7..9783cff 100644 --- a/Changes.md +++ b/Changes.md @@ -1,18 +1,22 @@ # Änderungen -## Regel-Testlauf prüft gegen Buchungen ohne Kategorie (27.09.2026) +## Regeln lassen sich auf Buchungen ohne Kategorie anwenden (27.09.2026) -Der Knopf „Gegen Buchungen ohne Kategorie prüfen“ unter Verwaltung → Regeln lief bisher -gegen noch nicht abgeglichene Umsätze – das beantwortet aber eine andere Frage als die, -die beim Anlegen oder Ändern einer Regel interessiert: Wie viele bereits vorhandene -Lücken würde diese Regel schließen? +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). -- `BankingController::regelnPruefen()` testet die aktiven Regeln jetzt gegen diese Liste - statt gegen `Bankumsatz::liste(['status' => 'offen'])`. -- Test in `tests/16_BankingTest.php` (`buchungOhneKategorieWirdGefunden`). +- 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) diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index 66cbb66..edf3217 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -12,7 +12,6 @@ use Finanzmanager\Models\Bankinstitut; use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankzugang; -use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Klasse; use Finanzmanager\Models\Konto; @@ -822,37 +821,18 @@ class BankingController extends BaseController } /** - * Wendet die Regeln auf Buchungen ohne Kategorie an, ohne sie zu ändern – - * damit lässt sich eine neue oder geänderte Regel ausprobieren, bevor sie - * bucht. Maßgeblich ist die Kategorie, nicht der Abgleichsstatus: Eine - * bereits übernommene Buchung ohne Kategorie soll die Regel genauso - * finden wie ein frisch abgerufener Umsatz. + * 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 (Buchung::ohneKategorie() as $buchung) { - $gesamt++; - $umsatz = [ - 'empfaenger' => $buchung['empfaenger'], - 'verwendungszweck' => $buchung['buchungstext'], - 'betrag' => $buchung['betrag'], - ]; - if (AbgleichRegel::passendeRegel($umsatz, $regeln) !== null) { - $treffer++; - } - } - - View::redirect('/banking/regeln', sprintf( - 'Von %d Buchungen ohne Kategorie treffen die Regeln auf %d.', - $gesamt, - $treffer - )); + View::redirect('/banking/regeln', Bankabgleich::berichtOhneKategorie($ergebnis)); } // ── Formulardaten ──────────────────────────────────────────────────────── 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 cf9930a..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 4c01552..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; @@ -49,6 +50,7 @@ class BankingTest extends TestCase $this->uebernehmenErzeugtEineBuchung(); $this->regelmitSplitsErzeugtEineSplitbuchung(); $this->buchungOhneKategorieWirdGefunden(); + $this->regelnKategorisierenBuchungenOhneKategorie(); $this->verwerfenUndZuruecksetzen(); $this->abrufprotokollHaeltDenVerlauf(); } @@ -824,6 +826,63 @@ class BankingTest extends TestCase $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([