Finanzmanager/src/Services/Bankabgleich.php
fds2610-bot 42b43090bc Fix n.n.: Umbuchungen finden beide Seiten zueinander
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 13:11:59 +02:00

244 lines
9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
/**
* Der Abgleich am Stück: offene Bankumsätze zu Buchungen machen, ohne dass für
* jeden Umsatz ein Klick fällig wird.
*
* **Zwei Betriebsarten.** Der automatische Lauf nach dem Abruf bucht alles, was
* ein Konto hat – notfalls ohne Kategorie. Das ist der Sinn der Einstellung
* `banking_automatisch_buchen`: Die Umsätze sollen im Kontoblatt stehen, und die
* Kategorie wird dort nachgetragen. Welche Konten das betrifft, zeigt die
* Kontenübersicht (fett gedruckt, siehe `Buchung::unkategorisierteJeKonto()`).
*
* Der Knopf „Alles übernehmen“ in der Vorschlagsliste ist zurückhaltender: Er
* lässt liegen, was keine Regel trifft. Wer den Dialog benutzt, will die
* Zuordnung selbst treffen – dort wäre eine Buchung ohne Kategorie nur Arbeit,
* die man ihm ungefragt aufhalst.
*
* Gemeinsam ist beiden: Passt eine schon erfasste Buchung, wird **verbunden**
* statt neu gebucht. Sonst stünde jede selbst erfasste Zahlung doppelt im
* Kontoblatt.
*/
class Bankabgleich
{
/**
* Arbeitet alle offenen Umsätze ab.
*
* @param bool $ohneKategorie Auch buchen, wenn keine Regel greift?
* @return array{verbunden: int, uebernommen: int, offen: int, fehler: list<string>}
*/
public static function laufenLassen(bool $ohneKategorie): array
{
$regeln = AbgleichRegel::alle(true);
$verbunden = 0;
$uebernommen = 0;
$offen = 0;
$fehler = [];
foreach (Bankumsatz::liste(['status' => 'offen']) as $umsatz) {
// Ohne Konto gibt es kein Kontoblatt, in das die Buchung gehörte.
if ((int)$umsatz['konto_id'] === 0) {
$offen++;
continue;
}
$vorschlag = Bankumsatz::vorschlag($umsatz, $regeln);
if ($vorschlag['art'] === 'verbinden' && $vorschlag['buchung'] !== null) {
// Ein unsicherer Treffer (mehrere Kandidaten ohne IBAN-Bestätigung)
// bleibt für die Vorschlagsliste liegen.
if (!$vorschlag['sicher']) {
$offen++;
continue;
}
try {
Bankumsatz::verbinden((int)$umsatz['id'], (int)$vorschlag['buchung']['id']);
$verbunden++;
} catch (\Throwable $e) {
$offen++;
$fehler[] = sprintf(
'%s, %s: %s',
date('d.m.Y', strtotime((string)$umsatz['buchungstag'])),
number_format((float)$umsatz['betrag'], 2, ',', '.') . ' €',
$e->getMessage()
);
}
continue;
}
if ($vorschlag['zuordnung'] === null && !$ohneKategorie) {
$offen++;
continue;
}
try {
Bankumsatz::uebernehmen((int)$umsatz['id'], $vorschlag['zuordnung'] ?? []);
$uebernommen++;
} catch (\Throwable $e) {
// Ein einzelner Umsatz darf den Lauf nicht abbrechen: Ein
// abgeschlossener Monat oder eine Buchung mit gleichem
// Fingerabdruck betrifft nur ihn. Er bleibt offen und steht
// weiter in der Vorschlagsliste.
$offen++;
$fehler[] = sprintf(
'%s, %s: %s',
date('d.m.Y', strtotime((string)$umsatz['buchungstag'])),
number_format((float)$umsatz['betrag'], 2, ',', '.') . ' €',
$e->getMessage()
);
DebugLog::schreibe('abgleich', 'Umsatz ' . (int)$umsatz['id'] . ': ' . $e->getMessage());
}
}
// Umbuchungen, die beide Seiten schon als eigene Buchung haben.
$zusammen = Bankumsatz::umbuchungenZusammenfuehren();
$verbunden += $zusammen['zusammengefuehrt'];
$fehler = array_merge($fehler, $zusammen['fehler']);
return [
'verbunden' => $verbunden,
'uebernommen' => $uebernommen,
'offen' => $offen,
'fehler' => $fehler,
];
}
/**
* Der Lauf nach einem Abruf – nur, wenn er eingeschaltet ist.
*
* @return array{verbunden: int, uebernommen: int, offen: int, fehler: list<string>}|null
*/
public static function automatisch(): ?array
{
if (!Einstellung::getBool('banking_automatisch_buchen', true)) {
return null;
}
return self::laufenLassen(true);
}
/**
* Eine Zeile für Meldung und Abrufprotokoll.
*
* @param array{verbunden: int, uebernommen: int, offen: int, fehler: list<string>} $ergebnis
*/
public static function bericht(array $ergebnis): string
{
$text = sprintf(
'%d gebucht, %d mit vorhandenen Buchungen verbunden, %d bleiben offen.',
$ergebnis['uebernommen'],
$ergebnis['verbunden'],
$ergebnis['offen']
);
if ($ergebnis['fehler'] !== []) {
$text .= ' Nicht gebucht: ' . implode('; ', array_slice($ergebnis['fehler'], 0, 3));
if (count($ergebnis['fehler']) > 3) {
$text .= sprintf(' (und %d weitere)', count($ergebnis['fehler']) - 3);
}
}
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;
}
}