From d9729a0cc5d73bc407228c3d3c3b5c4e2e75d1bc Mon Sep 17 00:00:00 2001 From: data Date: Fri, 6 Mar 2026 07:18:49 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Separater=20Cronjob=20f=C3=BCr=20PDF-Ko?= =?UTF-8?q?ntoausz=C3=BCge=20(monatlich)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doFetchPdfStatements() Methode in BankImportCron hinzugefügt - Automatischer PDF-Abruf per FinTS (HKEKP/HKEKA), Deduplizierung, Parsing, Reconciliation - Bestehender Umsatz-Cronjob auf wöchentlich umgestellt - Neuer Kontoauszug-Cronjob monatlich (30 Tage) - Übersetzungen DE+EN ergänzt Co-Authored-By: Claude Opus 4.6 --- class/bankimportcron.class.php | 272 +++++++++++++++++++++++++++ core/modules/modBankImport.class.php | 20 +- langs/de_DE/bankimport.lang | 3 +- langs/en_US/bankimport.lang | 2 + 4 files changed, 293 insertions(+), 4 deletions(-) diff --git a/class/bankimportcron.class.php b/class/bankimportcron.class.php index 9c1dd98..69a2933 100755 --- a/class/bankimportcron.class.php +++ b/class/bankimportcron.class.php @@ -16,6 +16,7 @@ require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; dol_include_once('/bankimport/class/fints.class.php'); dol_include_once('/bankimport/class/banktransaction.class.php'); +dol_include_once('/bankimport/class/bankstatement.class.php'); /** * Class BankImportCron @@ -732,4 +733,275 @@ class BankImportCron return $this->doAutoImport(); } + + /** + * PDF-Kontoauszuege automatisch von der Bank abrufen (HKEKA/HKEKP) + * Eigene geplante Aufgabe, monatlich empfohlen. + * + * @return int 0 if OK, < 0 if error + */ + public function doFetchPdfStatements() + { + global $conf, $langs, $user; + + $this->startTime = microtime(true); + register_shutdown_function(array($this, 'handleShutdown')); + $langs->load('bankimport@bankimport'); + + $this->cronLog("========== CRON PDF-KONTOAUSZUEGE START =========="); + $this->recordCronStatus('started', 'PDF-Kontoauszug-Abruf gestartet'); + + // Pruefe Pause-Status (gleicher Mechanismus wie Umsaetze) + $pausedUntil = getDolGlobalInt('BANKIMPORT_CRON_PAUSED_UNTIL'); + if ($pausedUntil > 0 && $pausedUntil > time()) { + $pauseReason = getDolGlobalString('BANKIMPORT_CRON_PAUSE_REASON'); + $this->output = "Cron pausiert: {$pauseReason}"; + $this->cronLog("Cron pausiert bis ".date('Y-m-d H:i:s', $pausedUntil), 'WARNING'); + return 0; + } + + // FinTS initialisieren + $fints = new BankImportFinTS($this->db); + + if (!$fints->isConfigured()) { + $this->error = 'FinTS nicht konfiguriert'; + $this->cronLog("FinTS nicht konfiguriert", 'ERROR'); + return -1; + } + + if (!$fints->isLibraryAvailable()) { + $this->error = 'FinTS-Bibliothek nicht gefunden'; + $this->cronLog("FinTS-Bibliothek nicht gefunden", 'ERROR'); + return -1; + } + + try { + // Session wiederherstellen oder neu einloggen + $storedState = getDolGlobalString('BANKIMPORT_CRON_STATE'); + $needLogin = true; + + if (!empty($storedState)) { + $this->cronLog("Versuche gespeicherte Session wiederherzustellen"); + $restoreResult = $fints->restore($storedState); + if ($restoreResult >= 0) { + $needLogin = false; + $this->cronLog("Session wiederhergestellt"); + } else { + $this->cronLog("Session abgelaufen, neuer Login noetig", 'WARNING'); + dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity); + } + } + + if ($needLogin) { + $this->cronLog("Bank-Login..."); + $loginResult = $fints->login(); + + if ($loginResult < 0) { + $this->error = 'Login fehlgeschlagen: '.$fints->error; + $this->cronLog("Login fehlgeschlagen: ".$fints->error, 'ERROR'); + $this->incrementFailCount(); + return -1; + } + + if ($loginResult == 0) { + $this->output = 'TAN erforderlich - Kontoauszuege manuell abrufen'; + $this->cronLog("TAN erforderlich, kann nicht automatisch fortfahren", 'WARNING'); + $this->setNotification('tan_required'); + return 0; + } + + $this->cronLog("Login erfolgreich"); + } + + // Kontoauszuege abrufen + $this->cronLog("Rufe PDF-Kontoauszuege ab..."); + $this->recordCronStatus('running', 'Rufe Kontoauszuege ab'); + + $result = $fints->fetchBankStatements(); + + if ($result === 0) { + $this->output = 'TAN erforderlich'; + $this->cronLog("TAN erforderlich fuer Kontoauszuege", 'WARNING'); + $this->setNotification('tan_required'); + return 0; + } + + if ($result === false || (is_array($result) && !empty($result['error']))) { + $this->error = 'Abruf fehlgeschlagen: '.$fints->error; + $this->cronLog("Abruf fehlgeschlagen: ".$fints->error, 'ERROR'); + $this->incrementFailCount(); + return -1; + } + + if (!is_array($result) || empty($result['pdfs'])) { + $this->output = 'Keine neuen Kontoauszuege verfuegbar'; + $this->cronLog("Keine PDFs erhalten"); + $fints->close(); + $this->resetFailCount(); + $this->recordCronStatus('completed', 'Keine neuen Kontoauszuege'); + return 0; + } + + // System-User fuer DB-Operationen + $importUser = new User($this->db); + $importUser->fetch(1); + + // PDFs deduplizieren + $seenHashes = array(); + $uniquePdfs = array(); + foreach ($result['pdfs'] as $pdfData) { + $hash = md5($pdfData); + if (!isset($seenHashes[$hash])) { + $seenHashes[$hash] = true; + $uniquePdfs[] = $pdfData; + } else { + $this->cronLog("Duplikat-PDF uebersprungen (Hash=".substr($hash, 0, 8)."...)"); + } + } + + $pdfCount = count($uniquePdfs); + $savedCount = 0; + $skippedCount = 0; + $errorCount = 0; + $dir = BankImportStatement::getStorageDir(); + + $this->cronLog("{$pdfCount} eindeutige PDFs erhalten, verarbeite..."); + $this->recordCronStatus('running', "{$pdfCount} PDFs verarbeiten"); + + foreach ($uniquePdfs as $idx => $pdfData) { + // PDF in Temp-Datei fuer Metadaten-Extraktion + $tmpFile = tempnam(sys_get_temp_dir(), 'fints_stmt_'); + file_put_contents($tmpFile, $pdfData); + + $parsed = BankImportStatement::parsePdfMetadata($tmpFile); + + $stmt = new BankImportStatement($this->db); + + if ($parsed && !empty($parsed['statement_number'])) { + $stmt->iban = $parsed['iban'] ?: ($result['iban'] ?? ''); + $stmt->statement_number = $parsed['statement_number']; + $stmt->statement_year = $parsed['statement_year']; + $stmt->statement_date = $parsed['statement_date']; + $stmt->date_from = $parsed['date_from']; + $stmt->date_to = $parsed['date_to']; + $stmt->opening_balance = $parsed['opening_balance']; + $stmt->closing_balance = $parsed['closing_balance']; + } elseif ($parsed && !empty($parsed['iban'])) { + // Saldenmitteilung (IBAN aber keine Auszugsnummer) - ueberspringe + $this->cronLog("PDF ".($idx+1)." uebersprungen (Saldenmitteilung ohne Auszugsnummer)"); + $skippedCount++; + @unlink($tmpFile); + continue; + } else { + // Keinerlei Metadaten - Fallback + $stmt->iban = $result['iban'] ?? ''; + $stmt->statement_number = (string) ($idx + 1); + $stmt->statement_year = (int) date('Y'); + } + + // Duplikat-Pruefung in DB + if ($stmt->statement_number && $stmt->exists()) { + $this->cronLog("Kontoauszug ".$stmt->statement_number."/".$stmt->statement_year." existiert bereits"); + $skippedCount++; + @unlink($tmpFile); + continue; + } + + $stmt->import_key = 'fints_cron_'.date('YmdHis').'_pdf'; + + // Dateiname generieren + if ($parsed) { + $newFilename = BankImportStatement::generateFilename($parsed); + } else { + $newFilename = sprintf('Kontoauszug_FinTS_Cron_%d_%03d.pdf', $stmt->statement_year, $idx + 1); + } + + $stmt->filepath = $dir.'/'.$newFilename; + if (file_exists($stmt->filepath)) { + $newFilename = pathinfo($newFilename, PATHINFO_FILENAME).'_'.date('His').'.pdf'; + $stmt->filepath = $dir.'/'.$newFilename; + } + $stmt->filename = $newFilename; + + // PDF von Temp nach Ziel + if (!rename($tmpFile, $stmt->filepath)) { + copy($tmpFile, $stmt->filepath); + @unlink($tmpFile); + } + $stmt->filesize = filesize($stmt->filepath); + + // In DB speichern + $dbResult = $stmt->create($importUser); + + if ($dbResult > 0) { + $this->cronLog("Kontoauszug ".$stmt->statement_number."/".$stmt->statement_year." gespeichert (ID=".$dbResult.")"); + + // FinTS-Transaktionen verknuepfen + $stmt->linkTransactions(); + + // PDF-Buchungszeilen parsen und speichern + $pdfLines = $stmt->parsePdfTransactions(); + if (!empty($pdfLines)) { + $saveResult = $stmt->saveStatementLines($pdfLines); + $this->cronLog(" ".(is_int($saveResult) && $saveResult > 0 ? $saveResult : 0)." Buchungszeilen gespeichert"); + } + + // PDF ins Dolibarr Bank-Verzeichnis kopieren + Reconcile + $bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID'); + if ($bankAccountId > 0) { + $stmt->copyToDolibarrStatementDir($bankAccountId); + $reconciled = $stmt->reconcileBankEntries($importUser, $bankAccountId); + $this->cronLog(" ".$reconciled." Bankbuchungen abgeglichen"); + } + + $savedCount++; + } else { + $this->cronLog("FEHLER beim Speichern: ".$stmt->error, 'ERROR'); + $errorCount++; + if (file_exists($stmt->filepath)) { + @unlink($stmt->filepath); + } + } + } + + // Session speichern + $state = $fints->persist(); + if (!empty($state)) { + dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity); + } + + $fints->close(); + $this->resetFailCount(); + $this->clearNotification(); + + // Ergebnis + $this->output = "{$savedCount} Kontoauszuege gespeichert"; + if ($skippedCount > 0) { + $this->output .= ", {$skippedCount} uebersprungen"; + } + if ($errorCount > 0) { + $this->output .= ", {$errorCount} Fehler"; + } + + $duration = round(microtime(true) - $this->startTime, 2); + $this->cronLog("Ergebnis: {$savedCount} gespeichert, {$skippedCount} uebersprungen, {$errorCount} Fehler ({$duration}s)"); + $this->recordCronStatus('completed', $this->output); + $this->cronLog("========== CRON PDF-KONTOAUSZUEGE ENDE =========="); + + return $errorCount > 0 ? -1 : 0; + + } catch (Exception $e) { + $this->error = 'Exception: '.$e->getMessage(); + $this->cronLog("EXCEPTION: ".$e->getMessage(), 'ERROR'); + $this->recordCronStatus('error', $this->error); + $this->incrementFailCount(); + return -1; + } catch (Throwable $t) { + $this->error = 'Fatal: '.$t->getMessage(); + $this->cronLog("FATAL: ".$t->getMessage(), 'ERROR'); + $this->recordCronStatus('error', $this->error); + $this->incrementFailCount(); + return -1; + } + } } diff --git a/core/modules/modBankImport.class.php b/core/modules/modBankImport.class.php index 8c4aa20..056f4b1 100755 --- a/core/modules/modBankImport.class.php +++ b/core/modules/modBankImport.class.php @@ -274,13 +274,27 @@ class modBankImport extends DolibarrModules 'objectname' => 'BankImportCron', 'method' => 'doAutoImport', 'parameters' => '', - 'comment' => 'Automatic bank statement import via FinTS', + 'comment' => 'Automatischer Import von Bankbuchungen via FinTS (wöchentlich)', 'frequency' => 1, - 'unitfrequency' => 86400, // Daily - 'status' => 0, // Disabled by default + 'unitfrequency' => 604800, // Wöchentlich + 'status' => 0, // Standardmässig deaktiviert 'test' => 'isModEnabled("bankimport") && getDolGlobalInt("BANKIMPORT_AUTO_ENABLED")', 'priority' => 50, ), + 1 => array( + 'label' => 'BankImportFetchPdfStatements', + 'jobtype' => 'method', + 'class' => '/bankimport/class/bankimportcron.class.php', + 'objectname' => 'BankImportCron', + 'method' => 'doFetchPdfStatements', + 'parameters' => '', + 'comment' => 'Automatischer Abruf von PDF-Kontoauszügen via FinTS (monatlich)', + 'frequency' => 1, + 'unitfrequency' => 2592000, // Monatlich (30 Tage) + 'status' => 0, // Standardmässig deaktiviert + 'test' => 'isModEnabled("bankimport") && getDolGlobalInt("BANKIMPORT_AUTO_ENABLED")', + 'priority' => 60, + ), ); /* END MODULEBUILDER CRON */ // Example: $this->cronjobs=array( diff --git a/langs/de_DE/bankimport.lang b/langs/de_DE/bankimport.lang index f9de815..ed05318 100755 --- a/langs/de_DE/bankimport.lang +++ b/langs/de_DE/bankimport.lang @@ -157,7 +157,8 @@ AutoImportTANRequiredDesc = Der automatische Import benötigt eine TAN-Bestätig AutoImportErrorDesc = Beim letzten automatischen Import am %s ist ein Fehler aufgetreten. TANConfirmedImportComplete = TAN bestätigt - Import abgeschlossen LastFetchWarning = Letzter Abruf am %s - Bitte regelmäßig neue Buchungen abrufen -BankImportAutoFetch = Automatischer Bankimport +BankImportAutoFetch = Automatischer Bankimport (Umsätze) +BankImportFetchPdfStatements = Automatischer Kontoauszug-Abruf (PDF) # Errors HistoricalDataLimit = Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit. diff --git a/langs/en_US/bankimport.lang b/langs/en_US/bankimport.lang index 6be3788..cdf52e5 100755 --- a/langs/en_US/bankimport.lang +++ b/langs/en_US/bankimport.lang @@ -292,3 +292,5 @@ StatementsNotPdfFormat = Bank statements received but not in PDF format StatementsUsingHKEKA = Using HKEKA (generic statement) instead of HKEKP StatementsUsingHKEKP = Using HKEKP (PDF statement) NeitherHKEKPnorHKEKA = The bank supports neither HKEKP nor HKEKA for electronic statements +BankImportAutoFetch = Automatic bank import (transactions) +BankImportFetchPdfStatements = Automatic PDF statement retrieval