244 lines
9 KiB
PHP
244 lines
9 KiB
PHP
<?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;
|
||
}
|
||
}
|