'Empfänger und Verwendungszweck', 'empfaenger' => 'Nur Empfänger', 'verwendungszweck' => 'Nur Verwendungszweck', ]; public const VERGLEICHE = [ 'enthaelt' => 'enthält', 'beginnt' => 'beginnt mit', 'endet' => 'endet auf', 'regex' => 'regulärer Ausdruck', ]; /** @return list> */ public static function alle(bool $nurAktive = false): array { $sql = 'SELECT r.*, k.pfad AS kategorie_pfad, kl.name AS klassenname, g.name AS gegenkontoname FROM abgleich_regeln r LEFT JOIN kategorien k ON k.id = r.kategorie_id LEFT JOIN klassen kl ON kl.id = r.klasse_id LEFT JOIN konten g ON g.id = r.gegenkonto_id WHERE r.benutzer_id = ?'; if ($nurAktive) { $sql .= ' AND r.aktiv = 1'; } return Database::fetchAll($sql . ' ORDER BY r.prioritaet, r.name', [Mandant::id()]); } public static function find(int $id): ?array { $zeile = Database::fetchOne( 'SELECT * FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()] ); return $zeile === false ? null : $zeile; } /** @param array $daten */ public static function anlegen(array $daten): int { $f = self::normalisiere($daten); Database::execute( 'INSERT INTO abgleich_regeln (benutzer_id, name, feld, vergleich, muster, betrag_von, betrag_bis, kategorie_id, klasse_id, gegenkonto_id, splits, prioritaet, aktiv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $f['name'], $f['feld'], $f['vergleich'], $f['muster'], $f['betrag_von'], $f['betrag_bis'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'], $f['prioritaet'], $f['aktiv'], ] ); return Database::lastInsertId(); } /** @param array $daten */ public static function speichern(int $id, array $daten): void { $f = self::normalisiere($daten); Database::execute( 'UPDATE abgleich_regeln SET name = ?, feld = ?, vergleich = ?, muster = ?, betrag_von = ?, betrag_bis = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?, prioritaet = ?, aktiv = ? WHERE id = ? AND benutzer_id = ?', [ $f['name'], $f['feld'], $f['vergleich'], $f['muster'], $f['betrag_von'], $f['betrag_bis'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'], $f['prioritaet'], $f['aktiv'], $id, Mandant::id(), ] ); } public static function loeschen(int $id): void { Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } /** * Die erste Regel, die auf den Umsatz passt. * * @param array $umsatz * @param list>|null $regeln vorgeladene Regeln, sonst werden sie geholt * @return array|null */ public static function passendeRegel(array $umsatz, ?array $regeln = null): ?array { foreach ($regeln ?? self::alle(true) as $regel) { if (self::passt($regel, $umsatz)) { return $regel; } } return null; } /** * @param array $regel * @param array $umsatz */ public static function passt(array $regel, array $umsatz): bool { $betrag = round((float)($umsatz['betrag'] ?? 0), 2); // Betragsgrenzen vergleichen den Wert mit Vorzeichen: „von −60 bis −40“ // trifft Lastschriften zwischen 40 und 60 Euro. if ($regel['betrag_von'] !== null && $betrag < (float)$regel['betrag_von']) { return false; } if ($regel['betrag_bis'] !== null && $betrag > (float)$regel['betrag_bis']) { return false; } $muster = (string)$regel['muster']; if ($muster === '') { return false; } foreach (self::felderZumVergleich($regel, $umsatz) as $text) { if (self::trifft((string)$regel['vergleich'], $muster, $text)) { return true; } } return false; } /** * Was eine Regel dem Umsatz mitgibt. Splits werden erst hier aus dem * Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf * Rechnungen jeder Höhe. * * @param array $regel * @param array $umsatz * @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list>} */ public static function anwenden(array $regel, array $umsatz): array { $betrag = round((float)($umsatz['betrag'] ?? 0), 2); $splits = []; $schema = json_decode((string)($regel['splits'] ?? ''), true); if (is_array($schema) && $schema !== []) { $summe = 0.0; foreach ($schema as $nummer => $zeile) { $anteil = (float)($zeile['anteil'] ?? 0); $teil = round($betrag * $anteil, 2); // Die letzte Zeile bekommt den Rest, damit die Summe der Splits // auf den Cent zum Buchungsbetrag passt – bei 1/3 zu 2/3 ginge // sonst ein Cent verloren, und die Buchung ließe sich nicht // speichern. if ($nummer === array_key_last($schema)) { $teil = round($betrag - $summe, 2); } $summe += $teil; $splits[] = [ 'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null, 'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null, 'betrag' => $teil, 'notiz' => (string)($zeile['notiz'] ?? ''), ]; } } return [ 'kategorie_id' => $regel['kategorie_id'] === null ? null : (int)$regel['kategorie_id'], 'klasse_id' => $regel['klasse_id'] === null ? null : (int)$regel['klasse_id'], 'gegenkonto_id' => $regel['gegenkonto_id'] === null ? null : (int)$regel['gegenkonto_id'], 'splits' => $splits, ]; } public static function zaehleTreffer(int $id): void { Database::execute('UPDATE abgleich_regeln SET treffer = treffer + 1 WHERE id = ?', [$id]); } /** * Prüft ein Muster, bevor es gespeichert wird. Ein ungültiger regulärer * Ausdruck brächte sonst jeden Abruf zum Stehen. */ public static function musterIstGueltig(string $vergleich, string $muster): bool { if ($vergleich !== 'regex') { return $muster !== ''; } return @preg_match(self::alsRegex($muster), '') !== false; } /** * @param array $regel * @param array $umsatz * @return list */ private static function felderZumVergleich(array $regel, array $umsatz): array { $empfaenger = (string)($umsatz['empfaenger'] ?? ''); $zweck = (string)($umsatz['verwendungszweck'] ?? ''); return match ((string)$regel['feld']) { 'empfaenger' => [$empfaenger], 'verwendungszweck' => [$zweck], default => [$empfaenger, $zweck], }; } private static function trifft(string $vergleich, string $muster, string $text): bool { if ($text === '') { return false; } // Groß- und Kleinschreibung spielt keine Rolle: Banken schreiben denselben // Empfänger mal in Versalien, mal gemischt. $textKlein = mb_strtolower($text); $musterKlein = mb_strtolower($muster); return match ($vergleich) { 'beginnt' => str_starts_with($textKlein, $musterKlein), 'endet' => str_ends_with($textKlein, $musterKlein), 'regex' => @preg_match(self::alsRegex($muster), $text) === 1, default => str_contains($textKlein, $musterKlein), }; } /** Das Muster wird als Ausdruck ohne Trenner erwartet – die setzt erst diese Methode. */ private static function alsRegex(string $muster): string { return '/' . str_replace('/', '\/', $muster) . '/iu'; } /** * @param array $daten * @return array */ private static function normalisiere(array $daten): array { $name = trim((string)($daten['name'] ?? '')); if ($name === '') { throw new \InvalidArgumentException('Die Regel braucht einen Namen.'); } $feld = (string)($daten['feld'] ?? 'beides'); $vergleich = (string)($daten['vergleich'] ?? 'enthaelt'); $muster = trim((string)($daten['muster'] ?? '')); if ($muster === '') { throw new \InvalidArgumentException('Die Regel braucht ein Muster.'); } if (!self::musterIstGueltig($vergleich, $muster)) { throw new \InvalidArgumentException('Das Muster ist kein gültiger regulärer Ausdruck.'); } $grenze = static function (mixed $wert): ?float { return ($wert === null || $wert === '') ? null : round((float)$wert, 2); }; $id = static function (mixed $wert): ?int { return ($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert; }; $splits = $daten['splits'] ?? null; if (is_array($splits)) { $splits = self::splitschema($splits); } elseif (is_string($splits) && trim($splits) !== '') { $splits = self::splitschema((array)json_decode($splits, true)); } else { $splits = null; } return [ 'name' => mb_substr($name, 0, 128), 'feld' => array_key_exists($feld, self::FELDER) ? $feld : 'beides', 'vergleich' => array_key_exists($vergleich, self::VERGLEICHE) ? $vergleich : 'enthaelt', 'muster' => mb_substr($muster, 0, 255), 'betrag_von' => $grenze($daten['betrag_von'] ?? null), 'betrag_bis' => $grenze($daten['betrag_bis'] ?? null), 'kategorie_id' => $id($daten['kategorie_id'] ?? null), 'klasse_id' => $id($daten['klasse_id'] ?? null), 'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null), 'splits' => $splits, 'prioritaet' => (int)($daten['prioritaet'] ?? 100), 'aktiv' => !empty($daten['aktiv']) ? 1 : 0, ]; } /** * Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil * zwischen 0 und 1. Zeilen ohne Kategorie oder ohne Anteil fallen weg. * * @param array $roh */ private static function splitschema(array $roh): ?string { $zeilen = []; foreach ($roh as $zeile) { if (!is_array($zeile)) { continue; } $kategorie = (int)($zeile['kategorie_id'] ?? 0); $anteil = (float)($zeile['anteil'] ?? 0); if ($kategorie === 0 || $anteil <= 0.0) { continue; } $zeilen[] = [ 'kategorie_id' => $kategorie, 'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null, 'anteil' => round(min($anteil, 1.0), 4), 'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255), ]; } return $zeilen === [] ? null : json_encode($zeilen, JSON_UNESCAPED_UNICODE); } }