feat: Separater Cronjob für PDF-Kontoauszüge (monatlich)

- 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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-06 07:18:49 +01:00
parent 014a943f78
commit d9729a0cc5
4 changed files with 293 additions and 4 deletions

View file

@ -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;
}
}
}

View file

@ -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(

View file

@ -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.

View file

@ -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