feat: Robustes Cron-System mit Monitoring (v2.7)

- Dediziertes Cron-Logging unter /documents/bankimport/logs/
- Shutdown Handler für fatale PHP-Fehler
- Pause-Mechanismus nach 3 Fehlern (verhindert Bank-Sperrung)
- Auth-Fehler-Erkennung für Authentifizierungsprobleme
- Neue Admin-Seite: Cron-Monitor (Status, Logs, Pause/Resume)
- CHANGELOG aktualisiert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-23 10:58:06 +01:00
parent 5264a2e91e
commit f340ba2da5
18 changed files with 764 additions and 24 deletions

26
CHANGELOG.md Normal file → Executable file
View file

@ -2,6 +2,32 @@
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## [2.7] - 2026-02-23
### Hinzugefügt
- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/bankimport/logs/cron_bankimport.log`
- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie
- **Pause-Mechanismus**: Cron pausiert automatisch nach 3 aufeinanderfolgenden Fehlern (60 Min)
- **Auth-Fehler-Erkennung**: Erkennt Bank-Authentifizierungsfehler und pausiert um Kontosperrung zu vermeiden
- **Cron-Monitor Admin-Seite**: Neue Seite unter Admin > BankImport > Cron-Monitor zeigt Status, Logs und ermöglicht Pause/Resume
### Verbessert
- Robustere Fehlerbehandlung mit try/catch für alle Operationen
- Detailliertes Logging mit Zeitstempeln und Elapsed-Time
- Fehler-Zähler verhindert wiederholte fehlgeschlagene Versuche
## [2.6] - 2026-02-20
### Hinzugefügt
- **Multi-Rechnungszahlungen**: Eine Bankbuchung kann jetzt mit mehreren Rechnungen verknüpft werden (Sammelzahlungen)
- **Zahlungsverknüpfung aufheben**: Falsche Zuordnungen können über "Verknüpfung aufheben" korrigiert werden
- **Detailansicht Verknüpfungen**: In der Buchungsdetailansicht werden verknüpfte Zahlungen, Rechnungen und Bank-Einträge angezeigt
- **Bezahlte Rechnungen verknüpfen**: Bereits bezahlte Rechnungen können mit Bankbuchungen verknüpft werden (für nachträgliche Bank-Zuordnung)
### Verbessert
- Bessere Anzeige von Multi-Invoice-Matches im Zahlungsabgleich
- Flexible Rechnungsauswahl per Checkbox bei Sammelzahlungen
## [1.7] - 2026-02-20
### Hinzugefügt

347
admin/cronmonitor.php Normal file
View file

@ -0,0 +1,347 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file bankimport/admin/cronmonitor.php
* \ingroup bankimport
* \brief Cron job monitoring and log viewer
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
// Libraries
require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php";
require_once '../lib/bankimport.lib.php';
dol_include_once('/bankimport/class/bankimportcron.class.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
// Translations
$langs->loadLangs(array("admin", "bankimport@bankimport", "cron"));
// Parameters
$action = GETPOST('action', 'aZ09');
$lines = GETPOSTINT('lines') ?: 100;
// Access control
if (!$user->admin) {
accessforbidden();
}
/*
* Actions
*/
if ($action == 'unpause') {
$cron = new BankImportCron($db);
$cron->unpauseCron();
setEventMessages("Cron-Pause aufgehoben", null, 'mesgs');
header("Location: ".$_SERVER["PHP_SELF"]);
exit;
}
if ($action == 'clearfailcount') {
dolibarr_del_const($db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity);
setEventMessages("Fehlerzähler zurückgesetzt", null, 'mesgs');
header("Location: ".$_SERVER["PHP_SELF"]);
exit;
}
if ($action == 'resetcronjob') {
// Reset the cron job in llx_cronjob table
$sql = "UPDATE ".MAIN_DB_PREFIX."cronjob SET processing = 0, datenextrun = NOW() WHERE label = 'BankImportAutoFetch' AND processing = 1";
$resql = $db->query($sql);
if ($resql && $db->affected_rows($resql) > 0) {
setEventMessages("Hängenden Cron-Job zurückgesetzt", null, 'mesgs');
} else {
setEventMessages("Kein hängender Job gefunden oder Fehler", null, 'warnings');
}
header("Location: ".$_SERVER["PHP_SELF"]);
exit;
}
/*
* View
*/
$page_name = "Cron Monitor - BankImport";
llxHeader('', $page_name, '', '', 0, 0, '', '', '', 'mod-bankimport page-admin-cronmonitor');
// Admin Tabs
$head = bankimportAdminPrepareHead();
print dol_get_fiche_head($head, 'cronmonitor', 'BankImport', -1, 'bank');
// Get cron status
$cronStatus = BankImportCron::getCronStatus();
// Get Dolibarr cron job info
$sql = "SELECT rowid, label, datenextrun, datelastrun, datelastresult, processing, lastoutput, lastresult, status
FROM ".MAIN_DB_PREFIX."cronjob
WHERE label = 'BankImportAutoFetch'";
$resql = $db->query($sql);
$cronJob = $resql ? $db->fetch_object($resql) : null;
print '<div class="div-table-responsive-no-min">';
// Status Overview
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="2">Cron Status Übersicht</th></tr>';
// Enabled/Disabled
print '<tr class="oddeven"><td width="300">Auto-Import aktiviert</td><td>';
if ($cronStatus['enabled']) {
print '<span class="badge badge-status4">Aktiviert</span>';
} else {
print '<span class="badge badge-status8">Deaktiviert</span>';
}
print '</td></tr>';
// Paused status
print '<tr class="oddeven"><td>Pause-Status</td><td>';
if ($cronStatus['paused']) {
print '<span class="badge badge-status1" style="background-color: #ff9800;">PAUSIERT</span> ';
print 'bis '.dol_print_date($cronStatus['paused_until'], 'dayhour');
print '<br><strong>Grund:</strong> '.dol_escape_htmltag($cronStatus['pause_reason']);
print '<br><a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=unpause">Pause aufheben</a>';
} else {
print '<span class="badge badge-status4">Aktiv</span>';
}
print '</td></tr>';
// Failure count
print '<tr class="oddeven"><td>Fehlversuche in Folge</td><td>';
$failCount = $cronStatus['fail_count'];
if ($failCount >= 3) {
print '<span class="badge badge-status8">'.$failCount.'</span> ';
print '<span class="warning">(bei 3+ wird automatisch pausiert)</span> ';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=clearfailcount">Zurücksetzen</a>';
} elseif ($failCount > 0) {
print '<span class="badge badge-status1">'.$failCount.'</span>';
} else {
print '<span class="badge badge-status4">0</span>';
}
print '</td></tr>';
// Last run status
print '<tr class="oddeven"><td>Letzter Lauf</td><td>';
if (!empty($cronStatus['last_run'])) {
$lastRun = $cronStatus['last_run'];
$statusClass = 'badge-status4';
if ($lastRun['status'] == 'error') $statusClass = 'badge-status8';
elseif ($lastRun['status'] == 'paused') $statusClass = 'badge-status1';
elseif ($lastRun['status'] == 'running') $statusClass = 'badge-status6';
print '<span class="badge '.$statusClass.'">'.strtoupper($lastRun['status']).'</span> ';
print dol_print_date($lastRun['timestamp'], 'dayhour');
if (!empty($lastRun['duration'])) {
print ' (Dauer: '.$lastRun['duration'].'s)';
}
if (!empty($lastRun['message'])) {
print '<br><small>'.dol_escape_htmltag($lastRun['message']).'</small>';
}
} else {
print '<span class="opacitymedium">Keine Daten</span>';
}
print '</td></tr>';
// Notification
print '<tr class="oddeven"><td>Aktuelle Benachrichtigung</td><td>';
if (!empty($cronStatus['notification'])) {
$notif = $cronStatus['notification'];
$notifLabels = array(
'tan_required' => 'TAN erforderlich',
'login_error' => 'Login-Fehler',
'fetch_error' => 'Abruf-Fehler',
'config_error' => 'Konfigurationsfehler',
'session_expired' => 'Session abgelaufen',
'error' => 'Allgemeiner Fehler',
'paused' => 'Pausiert'
);
$label = $notifLabels[$notif['type']] ?? $notif['type'];
print '<span class="badge badge-status1" style="background-color: #ff9800;">'.$label.'</span>';
if (!empty($notif['date'])) {
print ' seit '.dol_print_date($notif['date'], 'dayhour');
}
} else {
print '<span class="badge badge-status4">Keine</span>';
}
print '</td></tr>';
print '</table>';
// Dolibarr Cron Job Status
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="2">Dolibarr Cron-Job Status</th></tr>';
if ($cronJob) {
print '<tr class="oddeven"><td width="300">Job Status</td><td>';
if ($cronJob->processing) {
print '<span class="badge badge-status1" style="background-color: #ff9800;">LÄUFT / HÄNGT</span> ';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=resetcronjob">Job zurücksetzen</a>';
} elseif ($cronJob->status == 1) {
print '<span class="badge badge-status4">Bereit</span>';
} else {
print '<span class="badge badge-status5">Deaktiviert</span>';
}
print '</td></tr>';
print '<tr class="oddeven"><td>Nächster geplanter Lauf</td><td>'.dol_print_date($db->jdate($cronJob->datenextrun), 'dayhour').'</td></tr>';
print '<tr class="oddeven"><td>Letzter Lauf (Start)</td><td>'.dol_print_date($db->jdate($cronJob->datelastrun), 'dayhour').'</td></tr>';
print '<tr class="oddeven"><td>Letzter Lauf (Ende)</td><td>'.dol_print_date($db->jdate($cronJob->datelastresult), 'dayhour').'</td></tr>';
print '<tr class="oddeven"><td>Letztes Ergebnis</td><td>';
if ($cronJob->lastresult === '0' || $cronJob->lastresult === 0) {
print '<span class="badge badge-status4">OK</span>';
} elseif (!empty($cronJob->lastresult)) {
print '<span class="badge badge-status8">Fehler: '.$cronJob->lastresult.'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td></tr>';
print '<tr class="oddeven"><td>Letzte Ausgabe</td><td><pre style="margin:0; white-space: pre-wrap;">'.dol_escape_htmltag($cronJob->lastoutput ?: '-').'</pre></td></tr>';
} else {
print '<tr class="oddeven"><td colspan="2" class="center opacitymedium">Cron-Job nicht gefunden</td></tr>';
}
print '</table>';
// Log file viewer
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>Cron Log-Datei</th>';
print '<th class="right">';
print '<form method="get" style="display:inline">';
print 'Zeilen: <input type="number" name="lines" value="'.$lines.'" style="width:60px"> ';
print '<input type="submit" class="button small" value="Aktualisieren">';
print '</form>';
print '</th>';
print '</tr>';
$logFile = $conf->bankimport->dir_output.'/logs/cron_bankimport.log';
print '<tr class="oddeven"><td colspan="2">';
if (file_exists($logFile)) {
$fileSize = filesize($logFile);
print '<small>Datei: '.$logFile.' ('.dol_print_size($fileSize, 1).')</small>';
print '<pre style="background:#1e1e1e; color:#d4d4d4; padding:10px; max-height:500px; overflow:auto; font-size:12px; margin-top:5px;">';
// Read last N lines efficiently
$logContent = '';
$fp = fopen($logFile, 'r');
if ($fp) {
$buffer = array();
while (!feof($fp)) {
$line = fgets($fp);
if ($line !== false) {
$buffer[] = $line;
if (count($buffer) > $lines) {
array_shift($buffer);
}
}
}
fclose($fp);
$logContent = implode('', $buffer);
}
// Syntax highlighting for log
$logContent = htmlspecialchars($logContent);
$logContent = preg_replace('/\[ERROR\]/', '<span style="color:#f44336">[ERROR]</span>', $logContent);
$logContent = preg_replace('/\[WARNING\]/', '<span style="color:#ff9800">[WARNING]</span>', $logContent);
$logContent = preg_replace('/\[INFO\]/', '<span style="color:#4caf50">[INFO]</span>', $logContent);
$logContent = preg_replace('/\[DEBUG\]/', '<span style="color:#9e9e9e">[DEBUG]</span>', $logContent);
$logContent = preg_replace('/========== CRON (START|END.*) ==========/', '<span style="color:#2196f3;font-weight:bold">========== CRON $1 ==========</span>', $logContent);
print $logContent;
print '</pre>';
} else {
print '<span class="opacitymedium">Log-Datei existiert noch nicht: '.$logFile.'</span>';
}
print '</td></tr>';
print '</table>';
// All cron jobs overview
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>Alle Modul Cron-Jobs</th>';
print '<th>Status</th>';
print '<th>Nächster Lauf</th>';
print '<th>Letzter Lauf</th>';
print '<th>Processing</th>';
print '</tr>';
$sql = "SELECT rowid, label, datenextrun, datelastrun, processing, status
FROM ".MAIN_DB_PREFIX."cronjob
WHERE label LIKE '%Import%' OR label LIKE '%Bank%' OR label LIKE '%Zugferd%' OR label LIKE '%Dump%'
ORDER BY label";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
print '<tr class="oddeven">';
print '<td>'.$obj->label.'</td>';
print '<td>';
if ($obj->status == 1) {
print '<span class="badge badge-status4">Aktiv</span>';
} else {
print '<span class="badge badge-status5">Inaktiv</span>';
}
print '</td>';
print '<td>'.dol_print_date($db->jdate($obj->datenextrun), 'dayhour').'</td>';
print '<td>'.dol_print_date($db->jdate($obj->datelastrun), 'dayhour').'</td>';
print '<td>';
if ($obj->processing) {
print '<span class="badge badge-status1" style="background-color:#ff9800">HÄNGT</span>';
} else {
print '<span class="badge badge-status4">OK</span>';
}
print '</td>';
print '</tr>';
}
}
print '</table>';
print dol_get_fiche_end();
llxFooter();
$db->close();

BIN
bin/module_bankimport-1.6.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-1.7.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-1.8.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-1.9.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.0.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.1.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.2.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.3.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.4.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.5.zip Executable file

Binary file not shown.

BIN
bin/module_bankimport-2.6.zip Executable file

Binary file not shown.

View file

@ -43,6 +43,16 @@ class BankImportCron
*/
public $output = '';
/**
* @var string Path to cron log file
*/
private $cronLogFile = '';
/**
* @var float Start time of cron execution
*/
private $startTime = 0;
/**
* Constructor
*
@ -50,7 +60,63 @@ class BankImportCron
*/
public function __construct($db)
{
global $conf;
$this->db = $db;
// Set up dedicated log file for cron jobs
$logDir = $conf->bankimport->dir_output.'/logs';
if (!is_dir($logDir)) {
dol_mkdir($logDir);
}
$this->cronLogFile = $logDir.'/cron_bankimport.log';
}
/**
* Write to dedicated cron log file
*
* @param string $message Log message
* @param string $level Log level (INFO, WARNING, ERROR, DEBUG)
* @return void
*/
private function cronLog($message, $level = 'INFO')
{
$timestamp = date('Y-m-d H:i:s');
$elapsed = $this->startTime > 0 ? round(microtime(true) - $this->startTime, 2).'s' : '0s';
$logLine = "[{$timestamp}] [{$level}] [{$elapsed}] {$message}\n";
// Write to dedicated log file
@file_put_contents($this->cronLogFile, $logLine, FILE_APPEND | LOCK_EX);
// Also log to Dolibarr system log
$dolLevel = LOG_INFO;
if ($level === 'ERROR') $dolLevel = LOG_ERR;
elseif ($level === 'WARNING') $dolLevel = LOG_WARNING;
elseif ($level === 'DEBUG') $dolLevel = LOG_DEBUG;
dol_syslog("BankImportCron: ".$message, $dolLevel);
}
/**
* Record cron execution status to database for monitoring
*
* @param string $status Status (started, running, completed, error)
* @param string $message Status message
* @return void
*/
private function recordCronStatus($status, $message = '')
{
global $conf;
$statusData = array(
'status' => $status,
'message' => $message,
'timestamp' => time(),
'duration' => $this->startTime > 0 ? round(microtime(true) - $this->startTime, 2) : 0,
'memory' => memory_get_usage(true),
'peak_memory' => memory_get_peak_usage(true)
);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATUS', json_encode($statusData), 'chaine', 0, '', $conf->entity);
}
/**
@ -63,33 +129,79 @@ class BankImportCron
{
global $conf, $langs, $user;
// Initialize timing
$this->startTime = microtime(true);
// Register shutdown function to catch fatal errors
register_shutdown_function(array($this, 'handleShutdown'));
$langs->load('bankimport@bankimport');
dol_syslog("BankImportCron::doAutoImport - Starting automatic import", LOG_INFO);
$this->cronLog("========== CRON START ==========");
$this->cronLog("PHP Version: ".PHP_VERSION.", Memory Limit: ".ini_get('memory_limit').", Max Execution Time: ".ini_get('max_execution_time'));
$this->recordCronStatus('started', 'Cron job started');
// Check if automatic import is enabled
if (!getDolGlobalInt('BANKIMPORT_AUTO_ENABLED')) {
$this->output = $langs->trans('AutoImportDisabled');
dol_syslog("BankImportCron::doAutoImport - Auto import is disabled", LOG_INFO);
$this->cronLog("Auto import is disabled - exiting");
$this->recordCronStatus('completed', 'Auto import disabled');
return 0;
}
// CHECK: Is cron paused due to errors? (to prevent bank account lockout)
$pausedUntil = getDolGlobalInt('BANKIMPORT_CRON_PAUSED_UNTIL');
if ($pausedUntil > 0 && $pausedUntil > time()) {
$pauseReason = getDolGlobalString('BANKIMPORT_CRON_PAUSE_REASON');
$remainingMinutes = ceil(($pausedUntil - time()) / 60);
$this->output = "Cron pausiert für {$remainingMinutes} Minuten: {$pauseReason}";
$this->cronLog("Cron is PAUSED until ".date('Y-m-d H:i:s', $pausedUntil)." - Reason: {$pauseReason}", 'WARNING');
$this->recordCronStatus('paused', "Paused: {$pauseReason}");
return 0;
}
// Clear pause if expired
if ($pausedUntil > 0 && $pausedUntil <= time()) {
$this->cronLog("Pause expired - resuming normal operation");
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSED_UNTIL', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSE_REASON', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity);
}
// Check consecutive failure count
$failCount = getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT');
if ($failCount >= 3) {
// After 3 consecutive failures, pause for increasing time
$pauseMinutes = min(60 * 24, 15 * pow(2, $failCount - 3)); // 15min, 30min, 1h, 2h, ... max 24h
$this->pauseCron("Zu viele Fehlversuche ({$failCount}x) - Bitte manuell prüfen", $pauseMinutes);
$this->output = "Automatisch pausiert nach {$failCount} Fehlversuchen";
$this->cronLog("Auto-paused after {$failCount} failures for {$pauseMinutes} minutes", 'WARNING');
return 0;
}
$this->cronLog("Initializing FinTS library");
$this->recordCronStatus('running', 'Initializing FinTS');
// Initialize FinTS
$fints = new BankImportFinTS($this->db);
if (!$fints->isConfigured()) {
$this->error = $langs->trans('AutoImportNotConfigured');
dol_syslog("BankImportCron::doAutoImport - FinTS not configured", LOG_WARNING);
$this->cronLog("FinTS not configured", 'WARNING');
$this->setNotification('config_error');
$this->recordCronStatus('error', 'FinTS not configured');
return -1;
}
if (!$fints->isLibraryAvailable()) {
$this->error = $langs->trans('FinTSLibraryNotFound');
dol_syslog("BankImportCron::doAutoImport - Library not found", LOG_ERR);
$this->cronLog("FinTS library not found", 'ERROR');
$this->recordCronStatus('error', 'FinTS library not found');
return -1;
}
$this->cronLog("FinTS library loaded successfully");
// Check for stored session state (from previous successful TAN)
$storedState = getDolGlobalString('BANKIMPORT_CRON_STATE');
$sessionRestored = false;
@ -97,39 +209,53 @@ class BankImportCron
try {
// Try to restore previous session
if (!empty($storedState)) {
dol_syslog("BankImportCron::doAutoImport - Attempting to restore previous session (state length: ".strlen($storedState).")", LOG_INFO);
$this->cronLog("Attempting to restore previous session (state length: ".strlen($storedState).")");
$this->recordCronStatus('running', 'Restoring session');
$restoreResult = $fints->restore($storedState);
if ($restoreResult < 0) {
// Session expired, need fresh login
dol_syslog("BankImportCron::doAutoImport - Session restore failed: ".$fints->error.", trying fresh login", LOG_WARNING);
$this->cronLog("Session restore failed: ".$fints->error.", trying fresh login", 'WARNING');
$storedState = '';
// Clear stale session
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity);
} else {
dol_syslog("BankImportCron::doAutoImport - Session restored successfully", LOG_INFO);
$this->cronLog("Session restored successfully");
$sessionRestored = true;
}
} else {
dol_syslog("BankImportCron::doAutoImport - No stored session found", LOG_INFO);
$this->cronLog("No stored session found");
}
// If no stored session or restore failed, try fresh login
if (empty($storedState)) {
dol_syslog("BankImportCron::doAutoImport - Attempting fresh login", LOG_INFO);
$this->cronLog("Attempting fresh login to bank");
$this->recordCronStatus('running', 'Logging in to bank');
$loginResult = $fints->login();
if ($loginResult < 0) {
$this->error = $langs->trans('LoginFailed').': '.$fints->error;
dol_syslog("BankImportCron::doAutoImport - Login failed: ".$fints->error, LOG_ERR);
$this->cronLog("Login failed: ".$fints->error, 'ERROR');
$this->setNotification('login_error');
$this->recordCronStatus('error', 'Login failed: '.$fints->error);
// Increment failure count and potentially pause to prevent lockout
$this->incrementFailCount();
// Check if this is a critical auth error that should pause immediately
if ($this->isAuthError($fints->error)) {
$this->pauseCron("Bank-Login fehlgeschlagen - Zugangsdaten prüfen!", 60); // Pause 1 hour
$this->cronLog("CRITICAL: Auth error detected - pausing cron for 1 hour to prevent lockout", 'ERROR');
}
return -1;
}
if ($loginResult == 0) {
// TAN required - can't proceed automatically
$this->output = $langs->trans('TANRequired');
dol_syslog("BankImportCron::doAutoImport - TAN required for login, cannot proceed automatically", LOG_WARNING);
$this->cronLog("TAN required for login - cannot proceed automatically", 'WARNING');
$this->setNotification('tan_required');
$this->recordCronStatus('completed', 'TAN required - waiting for user');
// Store the state so user can complete TAN manually
$state = $fints->persist();
@ -137,10 +263,11 @@ class BankImportCron
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity);
}
$this->cronLog("========== CRON END (TAN required) ==========");
return 0; // Not an error, just can't proceed
}
dol_syslog("BankImportCron::doAutoImport - Fresh login successful", LOG_INFO);
$this->cronLog("Fresh login successful");
}
// Login successful or session restored - fetch statements
@ -148,7 +275,8 @@ class BankImportCron
$dateFrom = strtotime("-{$daysToFetch} days");
$dateTo = time();
dol_syslog("BankImportCron::doAutoImport - Fetching statements from ".date('Y-m-d', $dateFrom)." to ".date('Y-m-d', $dateTo)." ({$daysToFetch} days)", LOG_INFO);
$this->cronLog("Fetching statements from ".date('Y-m-d', $dateFrom)." to ".date('Y-m-d', $dateTo)." ({$daysToFetch} days)");
$this->recordCronStatus('running', 'Fetching bank statements');
$result = $fints->fetchStatements($dateFrom, $dateTo);
@ -157,16 +285,17 @@ class BankImportCron
$txCount = count($result['transactions'] ?? array());
$hasBalance = !empty($result['balance']);
$isPartial = !empty($result['partial']);
dol_syslog("BankImportCron::doAutoImport - fetchStatements returned array: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no'), LOG_INFO);
$this->cronLog("fetchStatements returned: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no'));
} else {
dol_syslog("BankImportCron::doAutoImport - fetchStatements returned: ".var_export($result, true), LOG_INFO);
$this->cronLog("fetchStatements returned: ".var_export($result, true));
}
if ($result === 0) {
// TAN required for statements
$this->output = $langs->trans('TANRequired');
dol_syslog("BankImportCron::doAutoImport - TAN required for statements", LOG_WARNING);
$this->cronLog("TAN required for statements", 'WARNING');
$this->setNotification('tan_required');
$this->recordCronStatus('completed', 'TAN required for statements');
// Store state for manual completion
$state = $fints->persist();
@ -174,13 +303,15 @@ class BankImportCron
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity);
}
$this->cronLog("========== CRON END (TAN required) ==========");
return 0;
}
if ($result < 0) {
$this->error = $langs->trans('FetchFailed').': '.$fints->error;
dol_syslog("BankImportCron::doAutoImport - Fetch failed: ".$fints->error, LOG_ERR);
$this->cronLog("Fetch failed: ".$fints->error, 'ERROR');
$this->setNotification('fetch_error');
$this->recordCronStatus('error', 'Fetch failed: '.$fints->error);
return -1;
}
@ -190,12 +321,12 @@ class BankImportCron
$importedCount = 0;
$skippedCount = 0;
dol_syslog("BankImportCron::doAutoImport - Bank returned {$fetchedCount} transactions", LOG_INFO);
$this->cronLog("Bank returned {$fetchedCount} transactions");
// If restored session returned 0 transactions, the session might be stale
// Try fresh login as fallback
if ($fetchedCount == 0 && $sessionRestored) {
dol_syslog("BankImportCron::doAutoImport - Restored session returned 0 transactions, clearing stale session", LOG_WARNING);
$this->cronLog("Restored session returned 0 transactions - session might be stale", 'WARNING');
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity);
// Note: We don't retry here because a fresh login would require TAN
@ -203,11 +334,15 @@ class BankImportCron
$this->output = $langs->trans('AutoImportNoTransactions').' (Session abgelaufen - nächster Lauf erfordert TAN)';
$this->setNotification('session_expired');
$this->recordCronStatus('completed', 'Session expired - no transactions');
$this->cronLog("========== CRON END (session expired) ==========");
return 0;
}
if (!empty($transactions)) {
$this->cronLog("Starting import of {$fetchedCount} transactions");
$this->recordCronStatus('running', "Importing {$fetchedCount} transactions");
// Get a system user for the import
$importUser = new User($this->db);
$importUser->fetch(1); // Admin user
@ -221,7 +356,7 @@ class BankImportCron
$importedCount = $importResult['imported'] ?? 0;
$skippedCount = $importResult['skipped'] ?? 0;
dol_syslog("BankImportCron::doAutoImport - Import result: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO);
$this->cronLog("Import result: imported={$importedCount}, skipped={$skippedCount}");
}
// Update last fetch info
@ -232,6 +367,7 @@ class BankImportCron
$state = $fints->persist();
if (!empty($state)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity);
$this->cronLog("Session state saved for next run");
}
// Clear any pending state
@ -251,20 +387,199 @@ class BankImportCron
$this->output = $langs->trans('AutoImportNoTransactions');
}
dol_syslog("BankImportCron::doAutoImport - Completed: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO);
// Reset failure count on success
$this->resetFailCount();
$duration = round(microtime(true) - $this->startTime, 2);
$this->cronLog("Completed successfully: imported={$importedCount}, skipped={$skippedCount}, duration={$duration}s");
$this->recordCronStatus('completed', "Success: imported={$importedCount}, skipped={$skippedCount}");
$this->cronLog("========== CRON END (success) ==========");
return 0;
} catch (Exception $e) {
$this->error = 'Exception: '.$e->getMessage();
dol_syslog("BankImportCron::doAutoImport - Exception: ".$e->getMessage(), LOG_ERR);
$this->cronLog("EXCEPTION: ".$e->getMessage()."\nStack trace:\n".$e->getTraceAsString(), 'ERROR');
$this->setNotification('error');
$this->recordCronStatus('error', 'Exception: '.$e->getMessage());
$this->incrementFailCount();
$this->cronLog("========== CRON END (exception) ==========");
return -1;
} catch (Throwable $t) {
$this->error = 'Fatal error: '.$t->getMessage();
$this->cronLog("FATAL ERROR: ".$t->getMessage()."\nStack trace:\n".$t->getTraceAsString(), 'ERROR');
$this->setNotification('error');
$this->recordCronStatus('error', 'Fatal: '.$t->getMessage());
$this->incrementFailCount();
$this->cronLog("========== CRON END (fatal error) ==========");
return -1;
}
}
/**
* Pause cron execution for specified minutes
*
* @param string $reason Reason for pausing
* @param int $minutes Minutes to pause
* @return void
*/
private function pauseCron($reason, $minutes = 60)
{
global $conf;
$pauseUntil = time() + ($minutes * 60);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PAUSED_UNTIL', $pauseUntil, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PAUSE_REASON', $reason, 'chaine', 0, '', $conf->entity);
$this->cronLog("CRON PAUSED until ".date('Y-m-d H:i:s', $pauseUntil)." - Reason: {$reason}", 'WARNING');
$this->setNotification('paused');
}
/**
* Increment consecutive failure count
*
* @return int New failure count
*/
private function incrementFailCount()
{
global $conf;
$count = getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT') + 1;
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $count, 'chaine', 0, '', $conf->entity);
$this->cronLog("Failure count incremented to {$count}");
return $count;
}
/**
* Reset failure count (on success)
*
* @return void
*/
private function resetFailCount()
{
global $conf;
$oldCount = getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT');
if ($oldCount > 0) {
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity);
$this->cronLog("Failure count reset (was {$oldCount})");
}
}
/**
* Check if error message indicates an authentication error
* These errors should pause immediately to prevent account lockout
*
* @param string $error Error message
* @return bool True if this is an auth-related error
*/
private function isAuthError($error)
{
$authKeywords = array(
'authentication',
'authentifizierung',
'passwort',
'password',
'pin',
'credentials',
'zugangsdaten',
'gesperrt',
'locked',
'blocked',
'invalid user',
'ungültiger benutzer',
'zugang verweigert',
'access denied',
'not authorized',
'nicht autorisiert'
);
$errorLower = strtolower($error);
foreach ($authKeywords as $keyword) {
if (strpos($errorLower, $keyword) !== false) {
return true;
}
}
return false;
}
/**
* Manually unpause the cron (called from admin interface)
*
* @return bool Success
*/
public function unpauseCron()
{
global $conf;
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSED_UNTIL', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PAUSE_REASON', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_FAIL_COUNT', $conf->entity);
$this->clearNotification();
$this->cronLog("Cron manually unpaused by admin");
return true;
}
/**
* Get current cron status for display
*
* @return array Status information
*/
public static function getCronStatus()
{
global $conf;
$status = array(
'enabled' => getDolGlobalInt('BANKIMPORT_AUTO_ENABLED') > 0,
'paused' => false,
'paused_until' => 0,
'pause_reason' => '',
'fail_count' => getDolGlobalInt('BANKIMPORT_CRON_FAIL_COUNT'),
'last_run' => null,
'notification' => null
);
// Check if paused
$pausedUntil = getDolGlobalInt('BANKIMPORT_CRON_PAUSED_UNTIL');
if ($pausedUntil > 0 && $pausedUntil > time()) {
$status['paused'] = true;
$status['paused_until'] = $pausedUntil;
$status['pause_reason'] = getDolGlobalString('BANKIMPORT_CRON_PAUSE_REASON');
}
// Get last run status
$lastStatus = getDolGlobalString('BANKIMPORT_CRON_STATUS');
if (!empty($lastStatus)) {
$status['last_run'] = json_decode($lastStatus, true);
}
// Get notification
$status['notification'] = self::getNotification();
return $status;
}
/**
* Shutdown handler to catch fatal errors
* Called automatically by PHP when script ends
*
* @return void
*/
public function handleShutdown()
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE))) {
$message = "FATAL SHUTDOWN: {$error['message']} in {$error['file']}:{$error['line']}";
$this->cronLog($message, 'ERROR');
$this->recordCronStatus('error', $message);
$this->cronLog("========== CRON END (fatal shutdown) ==========");
}
}
/**
* Set notification flag for admin users
* Also sends to GlobalNotify if available
*
* @param string $type Notification type (tan_required, error, etc.)
* @return void
@ -275,6 +590,47 @@ class BankImportCron
dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION', $type, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', time(), 'chaine', 0, '', $conf->entity);
// Send to GlobalNotify if module is enabled
if (isModEnabled('globalnotify')) {
dol_include_once('/globalnotify/class/globalnotify.class.php');
if (class_exists('GlobalNotify')) {
$messages = array(
'tan_required' => array('TAN erforderlich', 'Bank-Login erfordert TAN-Bestätigung', 'action'),
'login_error' => array('Login-Fehler', 'Bank-Login fehlgeschlagen - Zugangsdaten prüfen', 'error'),
'fetch_error' => array('Abruf-Fehler', 'Kontoauszüge konnten nicht abgerufen werden', 'error'),
'config_error' => array('Konfigurationsfehler', 'FinTS ist nicht korrekt konfiguriert', 'error'),
'session_expired' => array('Session abgelaufen', 'Bank-Session ist abgelaufen, neuer Login erforderlich', 'warning'),
'paused' => array('Cron pausiert', 'BankImport Cron wurde automatisch pausiert', 'warning'),
'error' => array('Allgemeiner Fehler', 'Ein Fehler ist aufgetreten', 'error'),
);
if (isset($messages[$type])) {
$msg = $messages[$type];
$actionUrl = dol_buildpath('/bankimport/admin/cronmonitor.php', 1);
$actionLabel = 'Details anzeigen';
if ($type == 'tan_required') {
$actionUrl = dol_buildpath('/bankimport/fetch.php', 1);
$actionLabel = 'TAN eingeben';
}
$notify = new GlobalNotify($this->db);
$notifyType = $msg[2] == 'action' ? GlobalNotify::TYPE_ACTION :
($msg[2] == 'error' ? GlobalNotify::TYPE_ERROR : GlobalNotify::TYPE_WARNING);
$notify->addNotification(
'bankimport',
$notifyType,
$msg[0],
$msg[1],
$actionUrl,
$actionLabel,
$msg[2] == 'error' ? 10 : ($msg[2] == 'action' ? 9 : 7)
);
}
}
}
}
/**

View file

@ -76,7 +76,7 @@ class modBankImport extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@bankimport'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '2.6';
$this->version = '2.7';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';

View file

@ -44,6 +44,17 @@ function bankimportAdminPrepareHead()
$head[$h][2] = 'settings';
$h++;
$head[$h][0] = dol_buildpath("/bankimport/admin/cronmonitor.php", 1);
$head[$h][1] = $langs->trans("CronMonitor");
// Add warning badge if there's a problem
dol_include_once('/bankimport/class/bankimportcron.class.php');
$cronStatus = BankImportCron::getCronStatus();
if ($cronStatus['paused'] || $cronStatus['fail_count'] >= 3 || !empty($cronStatus['notification'])) {
$head[$h][1] .= ' <span class="badge badge-warning">!</span>';
}
$head[$h][2] = 'cronmonitor';
$h++;
/*
$head[$h][0] = dol_buildpath("/bankimport/admin/myobject_extrafields.php", 1);
$head[$h][1] = $langs->trans("ExtraFields");

0
repair.php Normal file → Executable file
View file

0
sql/update_1.7.sql Normal file → Executable file
View file