Merge pull request 'Fix n.n.: Regel-Testlauf prüft gegen Buchungen ohne Kategorie' (#78) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/78
This commit is contained in:
fds2610 2026-09-27 13:52:58 +02:00
commit 101e52dbd8
6 changed files with 255 additions and 18 deletions

View file

@ -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

View file

@ -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 ────────────────────────────────────────────────────────

View file

@ -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<array<string,mixed>>
*/
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

View file

@ -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<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">
<form method="post" action="/banking/regeln/pruefen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf">Gegen offene Umsätze 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>
<a href="/banking/umsaetze" class="knopf">Zum Abgleich</a>
</div>

View file

@ -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([