dolibarr.bankimport/class/bankimportcron.class.php
data d9729a0cc5 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>
2026-03-06 07:18:49 +01:00

1007 lines
32 KiB
PHP
Executable file

<?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/class/bankimportcron.class.php
* \ingroup bankimport
* \brief Cron job class for automatic bank statement import
*/
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
* Handles automatic bank statement import via scheduled task
*/
class BankImportCron
{
/**
* @var DoliDB Database handler
*/
public $db;
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Error messages
*/
public $errors = array();
/**
* @var string Output message for cron log
*/
public $output = '';
/**
* @var string Path to cron log file
*/
private $cronLogFile = '';
/**
* @var float Start time of cron execution
*/
private $startTime = 0;
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
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);
}
/**
* Execute the automatic import cron job
* Called by Dolibarr's scheduled task system
*
* @return int 0 if OK, < 0 if error
*/
public function doAutoImport()
{
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');
$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');
$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');
$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');
$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;
try {
// Try to restore previous session
if (!empty($storedState)) {
$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
$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 {
$this->cronLog("Session restored successfully");
$sessionRestored = true;
}
} else {
$this->cronLog("No stored session found");
}
// If no stored session or restore failed, try fresh login
if (empty($storedState)) {
$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;
$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');
$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();
if (!empty($state)) {
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
}
$this->cronLog("Fresh login successful");
}
// Login successful or session restored - fetch statements
$daysToFetch = getDolGlobalInt('BANKIMPORT_AUTO_DAYS') ?: 30;
$dateFrom = strtotime("-{$daysToFetch} days");
$dateTo = time();
$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);
// Log what we got back
if (is_array($result)) {
$txCount = count($result['transactions'] ?? array());
$hasBalance = !empty($result['balance']);
$isPartial = !empty($result['partial']);
$this->cronLog("fetchStatements returned: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no'));
} else {
$this->cronLog("fetchStatements returned: ".var_export($result, true));
}
if ($result === 0) {
// TAN required for statements
$this->output = $langs->trans('TANRequired');
$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();
if (!empty($state)) {
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;
$this->cronLog("Fetch failed: ".$fints->error, 'ERROR');
$this->setNotification('fetch_error');
$this->recordCronStatus('error', 'Fetch failed: '.$fints->error);
return -1;
}
// Success - import transactions
$transactions = $result['transactions'] ?? array();
$fetchedCount = count($transactions);
$importedCount = 0;
$skippedCount = 0;
$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) {
$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
// Just mark that the session was stale
$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
$iban = $fints->getIban();
// Use the importFromFinTS method for correct field mapping
$transImporter = new BankImportTransaction($this->db);
$importResult = $transImporter->importFromFinTS($transactions, $iban, $importUser);
$importedCount = $importResult['imported'] ?? 0;
$skippedCount = $importResult['skipped'] ?? 0;
$this->cronLog("Import result: imported={$importedCount}, skipped={$skippedCount}");
}
// Update last fetch info
dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH', time(), 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH_COUNT', $importedCount, 'chaine', 0, '', $conf->entity);
// Store session for next run (might avoid TAN)
$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
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity);
// Clear notification flag on success
$this->clearNotification();
$fints->close();
if ($importedCount > 0) {
$this->output = $langs->trans('AutoImportSuccess', $importedCount);
} elseif ($skippedCount > 0) {
$this->output = $langs->trans('AutoImportNoTransactions').' ('.$skippedCount.' bereits vorhanden)';
} else {
$this->output = $langs->trans('AutoImportNoTransactions');
}
// 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();
$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
*/
private function setNotification($type)
{
global $conf;
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)
);
}
}
}
}
/**
* Clear notification flag
*
* @return void
*/
private function clearNotification()
{
global $conf;
dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', $conf->entity);
}
/**
* Check if there's a pending notification
*
* @return array|null Notification info or null
*/
public static function getNotification()
{
$type = getDolGlobalString('BANKIMPORT_NOTIFICATION');
$date = getDolGlobalInt('BANKIMPORT_NOTIFICATION_DATE');
if (empty($type)) {
return null;
}
return array(
'type' => $type,
'date' => $date
);
}
/**
* Resume a pending TAN action (called from web interface)
*
* @return int 1 if TAN confirmed and import done, 0 if still waiting, -1 if error
*/
public function resumePendingAction()
{
global $conf, $langs, $user;
$pendingState = getDolGlobalString('BANKIMPORT_CRON_PENDING_STATE');
$pendingAction = getDolGlobalString('BANKIMPORT_CRON_PENDING_ACTION');
if (empty($pendingState) || empty($pendingAction)) {
$this->error = 'No pending action';
return -1;
}
$fints = new BankImportFinTS($this->db);
$restoreResult = $fints->restore($pendingState);
if ($restoreResult < 0) {
$this->error = $fints->error;
// Clear expired state
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity);
return -1;
}
$action = @unserialize($pendingAction);
if ($action === false) {
$this->error = 'Could not restore pending action';
return -1;
}
$fints->setPendingAction($action);
// Check if TAN was confirmed
$checkResult = $fints->checkDecoupledTan();
if ($checkResult == 0) {
// Still waiting
// Update state
$newState = $fints->persist();
if (!empty($newState)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $newState, 'chaine', 0, '', $conf->entity);
}
return 0;
}
if ($checkResult < 0) {
$this->error = $fints->error;
return -1;
}
// TAN confirmed - now run the import
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity);
// Store the confirmed session and run import
$state = $fints->persist();
if (!empty($state)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity);
}
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;
}
}
}