fix: Cron-Job Stabilität und Logging (v3.6)

- Fehlendes require_once für admin.lib.php hinzugefügt
- Dediziertes Cron-Logging unter /documents/importzugferd/logs/
- Shutdown Handler für fatale PHP-Fehler
- Robustere Fehlerbehandlung mit try/catch
- CHANGELOG.md erstellt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-23 10:57:56 +01:00
parent 1e1d6cab20
commit a2c492d833
3 changed files with 182 additions and 33 deletions

47
CHANGELOG.md Normal file
View file

@ -0,0 +1,47 @@
# Changelog
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## [3.6] - 2026-02-23
### Behoben
- **Cron-Job Fix**: Fehlendes `require_once` für `admin.lib.php` hinzugefügt - verhinderte das Speichern des letzten Laufzeitpunkts
- Cron-Job lief in Endlosschleife weil `dolibarr_set_const()` nicht gefunden wurde
### Hinzugefügt
- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/importzugferd/logs/cron_importzugferd.log`
- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie
- **Detailliertes Logging**: Zeigt jeden Schritt des Import-Prozesses (Ordner-Zugriff, PDF-Scan, IMAP-Status)
### Verbessert
- Robustere Fehlerbehandlung mit try/catch für Exceptions und Throwables
- IMAP-Import wird nur ausgeführt wenn tatsächlich konfiguriert
## [3.5] - 2026-02-15
### Hinzugefügt
- Automatischer Cron-Import aus Watch-Folder
- IMAP-Mailbox-Unterstützung für E-Mail-Rechnungen
- Konfigurierbare Import-Frequenz (stündlich, täglich, wöchentlich)
- Archiv- und Fehler-Ordner für verarbeitete Dateien
## [3.0] - 2026-02-01
### Hinzugefügt
- ZUGFeRD/Factur-X PDF-Parsing
- Automatische Lieferanten-Erkennung
- Rechnungsvorschau vor Import
- Datanorm-Integration für Artikelpreise
## [2.0] - 2026-01-15
### Hinzugefügt
- Basis-Import von ZUGFeRD-Rechnungen
- Manuelle Datei-Auswahl
- Integration in Lieferantenrechnungen
## [1.0] - 2026-01-01
### Erste Version
- Grundlegende ZUGFeRD-Erkennung
- XML-Extraktion aus PDF

View file

@ -14,6 +14,7 @@
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
dol_include_once('/importzugferd/class/zugferdparser.class.php');
dol_include_once('/importzugferd/class/zugferdimport.class.php');
@ -60,6 +61,16 @@ class CronImportZugferd
*/
public $error_count = 0;
/**
* @var string Path to cron log file
*/
private $cronLogFile = '';
/**
* @var float Start time of cron execution
*/
private $startTime = 0;
/**
* Constructor
*
@ -67,7 +78,45 @@ class CronImportZugferd
*/
public function __construct($db)
{
global $conf;
$this->db = $db;
// Set up dedicated log file for cron jobs
$logDir = $conf->importzugferd->dir_output.'/logs';
if (!is_dir($logDir)) {
dol_mkdir($logDir);
}
$this->cronLogFile = $logDir.'/cron_importzugferd.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";
@file_put_contents($this->cronLogFile, $logLine, FILE_APPEND | LOCK_EX);
dol_syslog("CronImportZugferd: ".$message, $level === 'ERROR' ? LOG_ERR : ($level === 'WARNING' ? LOG_WARNING : LOG_INFO));
}
/**
* Shutdown handler to catch fatal errors
*/
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->cronLog("========== CRON END (fatal shutdown) ==========");
}
}
/**
@ -127,22 +176,35 @@ class CronImportZugferd
*/
public function runScheduledImport()
{
global $conf, $user, $langs;
global $langs;
// Initialize timing and shutdown handler
$this->startTime = microtime(true);
register_shutdown_function(array($this, 'handleShutdown'));
$langs->load('importzugferd@importzugferd');
$this->cronLog("========== CRON START ==========");
$this->cronLog("PHP Version: ".PHP_VERSION.", Memory Limit: ".ini_get('memory_limit'));
// Reset counters
$this->imported_count = 0;
$this->skipped_count = 0;
$this->error_count = 0;
$this->errors = array();
$folderResult = $this->importFromFolder();
try {
$this->cronLog("Starting folder import...");
$this->importFromFolder();
$this->cronLog("Folder import completed");
// IMAP nur wenn konfiguriert
$mailboxResult = 0;
if (!empty(getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'))) {
$mailboxResult = $this->fetchFromMailbox();
$this->cronLog("Starting IMAP import...");
$this->fetchFromMailbox();
$this->cronLog("IMAP import completed");
} else {
$this->cronLog("IMAP not configured - skipping");
}
// Update last run time
@ -160,7 +222,23 @@ class CronImportZugferd
$this->output .= "\nErrors: " . implode(", ", array_slice($this->errors, 0, 5));
}
$duration = round(microtime(true) - $this->startTime, 2);
$this->cronLog("Completed: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}, duration={$duration}s");
$this->cronLog("========== CRON END (success) ==========");
return ($this->error_count > 0) ? -1 : 0;
} catch (Exception $e) {
$this->error = 'Exception: '.$e->getMessage();
$this->cronLog("EXCEPTION: ".$e->getMessage()."\n".$e->getTraceAsString(), 'ERROR');
$this->cronLog("========== CRON END (exception) ==========");
return -1;
} catch (Throwable $t) {
$this->error = 'Fatal: '.$t->getMessage();
$this->cronLog("FATAL: ".$t->getMessage()."\n".$t->getTraceAsString(), 'ERROR');
$this->cronLog("========== CRON END (fatal) ==========");
return -1;
}
}
/**
@ -170,7 +248,7 @@ class CronImportZugferd
*/
public function importFromFolder()
{
global $conf, $user, $langs;
global $langs;
$langs->load('importzugferd@importzugferd');
@ -179,24 +257,45 @@ class CronImportZugferd
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
$autoCreate = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
$this->cronLog("Watch folder: {$watchFolder}");
// Validate settings
if (empty($watchFolder) || !is_dir($watchFolder)) {
$this->output = 'Watch folder not configured or not accessible';
return 0; // Not an error, just not configured
if (empty($watchFolder)) {
$this->cronLog("Watch folder not configured - skipping");
$this->output = 'Watch folder not configured';
return 0;
}
if (!is_dir($watchFolder)) {
$this->cronLog("Watch folder not accessible: {$watchFolder}", 'WARNING');
$this->output = 'Watch folder not accessible';
return 0;
}
$this->cronLog("Watch folder accessible, scanning for PDFs...");
// Load admin user for import actions
$admin_user = new User($this->db);
$admin_user->fetch(1);
// Find PDF files
$this->cronLog("Running glob for *.pdf...");
$files = glob($watchFolder . '/*.pdf');
$files = array_merge($files, glob($watchFolder . '/*.PDF'));
$this->cronLog("Found ".count($files)." .pdf files");
$this->cronLog("Running glob for *.PDF...");
$filesUpper = glob($watchFolder . '/*.PDF');
$this->cronLog("Found ".count($filesUpper)." .PDF files");
$files = array_merge($files, $filesUpper);
if (empty($files)) {
$this->cronLog("No PDF files found in watch folder");
return 0;
}
$this->cronLog("Total ".count($files)." PDF files to process");
// Ensure archive folder exists if configured
if (!empty($archiveFolder) && !is_dir($archiveFolder)) {
dol_mkdir($archiveFolder);
@ -208,20 +307,22 @@ class CronImportZugferd
}
foreach ($files as $file) {
$this->cronLog("Processing: ".basename($file));
// Use ZugferdImport::importFromFile for consistent handling
$import = new ZugferdImport($this->db);
$result = $import->importFromFile($admin_user, $file, $autoCreate);
$result = $import->importFromFile($admin_user, $file, !empty($autoCreate));
if ($result > 0) {
$this->imported_count++;
dol_syslog("CronImportZugferd: Imported invoice from folder: " . basename($file), LOG_INFO);
$this->cronLog("Imported: ".basename($file)." -> ID {$result}");
// Archive the file
$this->moveFile($file, $archiveFolder, 'imported_');
} elseif ($result == -2) {
// Duplicate - already imported
$this->skipped_count++;
dol_syslog("CronImportZugferd: Skipped duplicate invoice: " . basename($file), LOG_INFO);
$this->cronLog("Skipped (duplicate): ".basename($file));
// Archive duplicates - delete if no archive folder
if (!$this->moveFile($file, $archiveFolder, 'duplicate_')) {
@ -230,7 +331,7 @@ class CronImportZugferd
} else {
$this->error_count++;
$this->errors[] = basename($file) . ': ' . $import->error;
dol_syslog("CronImportZugferd: Error importing: " . basename($file) . " - " . $import->error, LOG_WARNING);
$this->cronLog("Error importing ".basename($file).": ".$import->error, 'ERROR');
// Try error folder first, fall back to archive folder
if (!$this->moveFile($file, $errorFolder, 'error_')) {
@ -240,6 +341,7 @@ class CronImportZugferd
}
}
$this->cronLog("Folder import finished: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}");
return 0;
}

View file

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