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:
parent
014a943f78
commit
d9729a0cc5
4 changed files with 293 additions and 4 deletions
|
|
@ -16,6 +16,7 @@
|
||||||
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
||||||
dol_include_once('/bankimport/class/fints.class.php');
|
dol_include_once('/bankimport/class/fints.class.php');
|
||||||
dol_include_once('/bankimport/class/banktransaction.class.php');
|
dol_include_once('/bankimport/class/banktransaction.class.php');
|
||||||
|
dol_include_once('/bankimport/class/bankstatement.class.php');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class BankImportCron
|
* Class BankImportCron
|
||||||
|
|
@ -732,4 +733,275 @@ class BankImportCron
|
||||||
|
|
||||||
return $this->doAutoImport();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,13 +274,27 @@ class modBankImport extends DolibarrModules
|
||||||
'objectname' => 'BankImportCron',
|
'objectname' => 'BankImportCron',
|
||||||
'method' => 'doAutoImport',
|
'method' => 'doAutoImport',
|
||||||
'parameters' => '',
|
'parameters' => '',
|
||||||
'comment' => 'Automatic bank statement import via FinTS',
|
'comment' => 'Automatischer Import von Bankbuchungen via FinTS (wöchentlich)',
|
||||||
'frequency' => 1,
|
'frequency' => 1,
|
||||||
'unitfrequency' => 86400, // Daily
|
'unitfrequency' => 604800, // Wöchentlich
|
||||||
'status' => 0, // Disabled by default
|
'status' => 0, // Standardmässig deaktiviert
|
||||||
'test' => 'isModEnabled("bankimport") && getDolGlobalInt("BANKIMPORT_AUTO_ENABLED")',
|
'test' => 'isModEnabled("bankimport") && getDolGlobalInt("BANKIMPORT_AUTO_ENABLED")',
|
||||||
'priority' => 50,
|
'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 */
|
/* END MODULEBUILDER CRON */
|
||||||
// Example: $this->cronjobs=array(
|
// Example: $this->cronjobs=array(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
AutoImportErrorDesc = Beim letzten automatischen Import am %s ist ein Fehler aufgetreten.
|
||||||
TANConfirmedImportComplete = TAN bestätigt - Import abgeschlossen
|
TANConfirmedImportComplete = TAN bestätigt - Import abgeschlossen
|
||||||
LastFetchWarning = Letzter Abruf am %s - Bitte regelmäßig neue Buchungen abrufen
|
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
|
# Errors
|
||||||
HistoricalDataLimit = Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit.
|
HistoricalDataLimit = Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit.
|
||||||
|
|
|
||||||
|
|
@ -292,3 +292,5 @@ StatementsNotPdfFormat = Bank statements received but not in PDF format
|
||||||
StatementsUsingHKEKA = Using HKEKA (generic statement) instead of HKEKP
|
StatementsUsingHKEKA = Using HKEKA (generic statement) instead of HKEKP
|
||||||
StatementsUsingHKEKP = Using HKEKP (PDF statement)
|
StatementsUsingHKEKP = Using HKEKP (PDF statement)
|
||||||
NeitherHKEKPnorHKEKA = The bank supports neither HKEKP nor HKEKA for electronic statements
|
NeitherHKEKPnorHKEKA = The bank supports neither HKEKP nor HKEKA for electronic statements
|
||||||
|
BankImportAutoFetch = Automatic bank import (transactions)
|
||||||
|
BankImportFetchPdfStatements = Automatic PDF statement retrieval
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue