Fix n.n.: Regel-Testlauf prüft gegen Buchungen ohne Kategorie statt gegen offene Umsätze

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-27 13:42:36 +02:00
parent 9ae2dfea40
commit 2bf5bae3bd
5 changed files with 104 additions and 5 deletions

View file

@ -1,5 +1,19 @@
# Änderungen # Änderungen
## Regel-Testlauf prüft gegen Buchungen ohne Kategorie (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?
- 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`).
## `update.sh` zieht aus dem Fork (25.09.2026) ## `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 - Das Update-Skript holt den Code ausdrücklich per `git pull --ff-only origin main`. Vorher

View file

@ -12,6 +12,7 @@ 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;
@ -821,8 +822,11 @@ class BankingController extends BaseController
} }
/** /**
* Wendet die Regeln auf die offenen Umsätze an, ohne sie zu übernehmen – * Wendet die Regeln auf Buchungen ohne Kategorie an, ohne sie zu ändern –
* damit lässt sich eine neue Regel ausprobieren, bevor sie bucht. * 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.
*/ */
public function regelnPruefen(): void public function regelnPruefen(): void
{ {
@ -832,15 +836,20 @@ class BankingController extends BaseController
$treffer = 0; $treffer = 0;
$gesamt = 0; $gesamt = 0;
foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) { foreach (Buchung::ohneKategorie() as $buchung) {
$gesamt++; $gesamt++;
$umsatz = [
'empfaenger' => $buchung['empfaenger'],
'verwendungszweck' => $buchung['buchungstext'],
'betrag' => $buchung['betrag'],
];
if (AbgleichRegel::passendeRegel($umsatz, $regeln) !== null) { if (AbgleichRegel::passendeRegel($umsatz, $regeln) !== null) {
$treffer++; $treffer++;
} }
} }
View::redirect('/banking/regeln', sprintf( View::redirect('/banking/regeln', sprintf(
'Von %d offenen Umsätzen treffen die Regeln auf %d.', 'Von %d Buchungen ohne Kategorie treffen die Regeln auf %d.',
$gesamt, $gesamt,
$treffer $treffer
)); ));

View file

@ -487,6 +487,35 @@ class Buchung
return $anzahlen; 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 jüngsten Buchungen zu einem Empfängernamen, kontenübergreifend – für
* die Adressverwaltung: Wer sich eine Adresse ansieht, will wissen, was * die Adressverwaltung: Wer sich eine Adresse ansieht, will wissen, was

View file

@ -29,7 +29,7 @@ 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 offene Umsätze prüfen</button> <button type="submit" class="knopf">Gegen Buchungen ohne Kategorie prüfen</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

@ -48,6 +48,7 @@ class BankingTest extends TestCase
$this->abgleichFindetVorhandeneBuchung(); $this->abgleichFindetVorhandeneBuchung();
$this->uebernehmenErzeugtEineBuchung(); $this->uebernehmenErzeugtEineBuchung();
$this->regelmitSplitsErzeugtEineSplitbuchung(); $this->regelmitSplitsErzeugtEineSplitbuchung();
$this->buchungOhneKategorieWirdGefunden();
$this->verwerfenUndZuruecksetzen(); $this->verwerfenUndZuruecksetzen();
$this->abrufprotokollHaeltDenVerlauf(); $this->abrufprotokollHaeltDenVerlauf();
} }
@ -777,6 +778,52 @@ class BankingTest extends TestCase
AbgleichRegel::loeschen($regelId); 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');
}
private function verwerfenUndZuruecksetzen(): void private function verwerfenUndZuruecksetzen(): void
{ {
$id = $this->umsatzAnlegen([ $id = $this->umsatzAnlegen([