Fix n.n.: Regel-Testlauf trägt Kategorien direkt nach statt nur zu zählen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-27 13:50:17 +02:00
parent 2bf5bae3bd
commit 4bed769810
5 changed files with 173 additions and 35 deletions

View file

@ -1,18 +1,22 @@
# Änderungen # Ä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 Der Knopf unter Verwaltung → Regeln hieß „Gegen offene Umsätze prüfen“ und zählte nur,
gegen noch nicht abgeglichene Umsätze – das beantwortet aber eine andere Frage als die, wie viele noch nicht abgeglichene Umsätze eine Regel träfe – das beantwortet weder, wo im
die beim Anlegen oder Ändern einer Regel interessiert: Wie viele bereits vorhandene Kontoblatt schon Lücken liegen, noch schließt es sie.
Lücken würde diese Regel schließen?
- Neu: `Buchung::ohneKategorie()` liefert Buchungen ganz ohne Kategorie – weder direkt - Neu: `Buchung::ohneKategorie()` liefert Buchungen ganz ohne Kategorie – weder direkt
(`kategorie_id`) noch in einer Splitzeile –, ausgenommen Umbuchungen (die haben per (`kategorie_id`) noch in einer Splitzeile –, ausgenommen Umbuchungen (die haben per
Definition ein Gegenkonto statt einer Kategorie). Definition ein Gegenkonto statt einer Kategorie).
- `BankingController::regelnPruefen()` testet die aktiven Regeln jetzt gegen diese Liste - Neu: `Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden()` trägt bei jeder Buchung
statt gegen `Bankumsatz::liste(['status' => 'offen'])`. ohne Kategorie die erste passende Regel nach (Kategorie, Klasse, Gegenkonto oder
- Test in `tests/16_BankingTest.php` (`buchungOhneKategorieWirdGefunden`). 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) ## `update.sh` zieht aus dem Fork (25.09.2026)

View file

@ -12,7 +12,6 @@ use Finanzmanager\Models\Bankinstitut;
use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse; use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto; use Finanzmanager\Models\Konto;
@ -822,37 +821,18 @@ class BankingController extends BaseController
} }
/** /**
* Wendet die Regeln auf Buchungen ohne Kategorie an, ohne sie zu ändern – * Trägt bei allen Buchungen ohne Kategorie die erste passende Regel nach.
* damit lässt sich eine neue oder geänderte Regel ausprobieren, bevor sie * Maßgeblich ist die Kategorie, nicht der Abgleichsstatus: Eine bereits
* bucht. Maßgeblich ist die Kategorie, nicht der Abgleichsstatus: Eine * übernommene Buchung ohne Kategorie soll die Regel genauso finden wie
* bereits übernommene Buchung ohne Kategorie soll die Regel genauso * ein frisch abgerufener Umsatz.
* finden wie ein frisch abgerufener Umsatz.
*/ */
public function regelnPruefen(): void public function regelnPruefen(): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();
$regeln = AbgleichRegel::alle(true); $ergebnis = Bankabgleich::regelnAufBuchungenOhneKategorieAnwenden();
$treffer = 0;
$gesamt = 0;
foreach (Buchung::ohneKategorie() as $buchung) { View::redirect('/banking/regeln', Bankabgleich::berichtOhneKategorie($ergebnis));
$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
));
} }
// ── Formulardaten ──────────────────────────────────────────────────────── // ── Formulardaten ────────────────────────────────────────────────────────

View file

@ -6,6 +6,7 @@ namespace Finanzmanager\Services;
use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankumsatz; use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
/** /**
@ -128,4 +129,95 @@ class Bankabgleich
return $text; 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<string>}
*/
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<string>} $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;
}
} }

View file

@ -29,7 +29,10 @@ include __DIR__ . '/../layout/kopf.php';
<div class="seitenaktionen"> <div class="seitenaktionen">
<form method="post" action="/banking/regeln/pruefen" class="inline"> <form method="post" action="/banking/regeln/pruefen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf">Gegen Buchungen ohne Kategorie prüfen</button> <button type="submit" class="knopf"
data-bestaetigen="Regeln auf alle Buchungen ohne Kategorie anwenden? Buchungen ohne Treffer bleiben unverändert.">
Regeln auf Buchungen ohne Kategorie anwenden
</button>
</form> </form>
<a href="/banking/umsaetze" class="knopf">Zum Abgleich</a> <a href="/banking/umsaetze" class="knopf">Zum Abgleich</a>
</div> </div>

View file

@ -10,6 +10,7 @@ use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto; use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Bankabgleich;
use Finanzmanager\Services\Crypto; use Finanzmanager\Services\Crypto;
use Finanzmanager\Services\FinTsVerfahren; use Finanzmanager\Services\FinTsVerfahren;
@ -49,6 +50,7 @@ class BankingTest extends TestCase
$this->uebernehmenErzeugtEineBuchung(); $this->uebernehmenErzeugtEineBuchung();
$this->regelmitSplitsErzeugtEineSplitbuchung(); $this->regelmitSplitsErzeugtEineSplitbuchung();
$this->buchungOhneKategorieWirdGefunden(); $this->buchungOhneKategorieWirdGefunden();
$this->regelnKategorisierenBuchungenOhneKategorie();
$this->verwerfenUndZuruecksetzen(); $this->verwerfenUndZuruecksetzen();
$this->abrufprotokollHaeltDenVerlauf(); $this->abrufprotokollHaeltDenVerlauf();
} }
@ -824,6 +826,63 @@ class BankingTest extends TestCase
$this->assert(!in_array($umbuchung, $ids, true), 'Eine Umbuchung braucht keine Kategorie'); $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 private function verwerfenUndZuruecksetzen(): void
{ {
$id = $this->umsatzAnlegen([ $id = $this->umsatzAnlegen([