dolibarr.preisbot/class/preisbot.class.php
data af137e7d83 fix: Add try-catch to prevent cronjob from hanging
- Wrap updatePrices() in try-catch for fatal errors
- Separate try-catch for GlobalNotify and mail sending
- Prevents SMTP timeouts from blocking the entire cronjob

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:07:48 +01:00

350 lines
10 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 class/preisbot.class.php
* \ingroup preisbot
* \brief PreisBot class for automatic price updates
*/
class PreisBot
{
/**
* @var DoliDB Database handler
*/
public $db;
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Error messages
*/
public $errors = array();
/**
* @var string Output for cronjob
*/
public $output = '';
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Cronjob method: Update product prices based on margin extrafield
*
* @return int 0 if OK, -1 if error
*/
public function updatePrices()
{
global $conf, $user, $langs;
try {
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
$priceSource = getDolGlobalString('PREISBOT_PRICE_SOURCE', 'cheapest');
$priceDirection = getDolGlobalString('PREISBOT_PRICE_DIRECTION', 'up_only');
$minMargin = (float) getDolGlobalString('PREISBOT_MIN_MARGIN', 20);
$updated = 0;
$skipped = 0;
$errors = 0;
$priceChanges = array();
// Hole alle Produkte mit gesetztem Gewinnaufschlag (nur zum Verkauf stehende)
$sql = "SELECT p.rowid, p.ref, p.label, p.price, pe.preisbot_margin";
$sql .= " FROM ".$this->db->prefix()."product as p";
$sql .= " LEFT JOIN ".$this->db->prefix()."product_extrafields as pe ON pe.fk_object = p.rowid";
$sql .= " WHERE pe.preisbot_margin IS NOT NULL";
$sql .= " AND pe.preisbot_margin >= ".((float) $minMargin);
$sql .= " AND p.tosell = 1";
$sql .= " AND p.entity IN (".getEntity('product').")";
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
while ($obj = $this->db->fetch_object($resql)) {
$productId = $obj->rowid;
$currentPrice = (float) $obj->price;
$margin = (float) $obj->preisbot_margin;
// Hole Einkaufspreis basierend auf Einstellung
$buyPrice = $this->getSupplierPrice($productId, $priceSource);
if ($buyPrice <= 0) {
$skipped++;
continue;
}
// Berechne neuen Verkaufspreis
$newPrice = $buyPrice * (1 + ($margin / 100));
$newPrice = round($newPrice, 2);
// Prüfe Preisrichtung
if ($priceDirection === 'up_only' && $newPrice < $currentPrice) {
$skipped++;
continue;
}
// Nur aktualisieren wenn Preis sich ändert
if (abs($newPrice - $currentPrice) < 0.01) {
$skipped++;
continue;
}
// Preis aktualisieren
$product = new Product($this->db);
$product->fetch($productId);
$result = $product->updatePrice(
$newPrice,
$product->price_base_type,
$user,
$product->tva_tx,
$product->price_min,
0,
$product->tva_npr,
0,
0,
array(),
$product->default_vat_code,
'Preisbot'
);
if ($result > 0) {
$updated++;
$diff = $newPrice - $currentPrice;
$diffPercent = $currentPrice > 0 ? round(($diff / $currentPrice) * 100, 2) : 0;
$priceChanges[] = array(
'ref' => $obj->ref,
'label' => $obj->label,
'old_price' => $currentPrice,
'new_price' => $newPrice,
'diff' => $diff,
'diff_percent' => $diffPercent,
'margin' => $margin,
'buy_price' => $buyPrice
);
$this->output .= "Produkt ".$obj->ref.": ".$currentPrice." -> ".$newPrice." EUR (Aufschlag: ".$margin."%)\n";
} else {
$errors++;
$this->errors[] = "Fehler bei Produkt ".$obj->ref.": ".$product->error;
}
}
$this->output .= "\n--- Zusammenfassung ---\n";
$this->output .= "Aktualisiert: ".$updated."\n";
$this->output .= "Übersprungen: ".$skipped."\n";
$this->output .= "Fehler: ".$errors."\n";
// Benachrichtigung und Mail nur wenn es Änderungen gab
if ($updated > 0) {
try {
$this->sendNotification($priceChanges, $updated, $errors);
} catch (Exception $e) {
$this->output .= "Warnung: GlobalNotify fehlgeschlagen: ".$e->getMessage()."\n";
}
try {
$this->sendMailReport($priceChanges, $updated, $skipped, $errors);
} catch (Exception $e) {
$this->output .= "Warnung: Mail-Versand fehlgeschlagen: ".$e->getMessage()."\n";
}
}
return ($errors > 0) ? -1 : 0;
} catch (Exception $e) {
$this->error = 'PreisBot Exception: '.$e->getMessage();
$this->output .= 'FEHLER: '.$e->getMessage()."\n";
return -1;
}
}
/**
* Get supplier price for product
*
* @param int $productId Product ID
* @param string $priceSource Price source (cheapest, newest)
* @return float Supplier unitprice or 0 if not found
*/
private function getSupplierPrice($productId, $priceSource)
{
$orderBy = 'pfp.unitprice ASC'; // cheapest
if ($priceSource === 'newest') {
$orderBy = 'pfp.tms DESC';
}
$sql = "SELECT pfp.unitprice";
$sql .= " FROM ".$this->db->prefix()."product_fournisseur_price as pfp";
$sql .= " WHERE pfp.fk_product = ".((int) $productId);
$sql .= " AND pfp.status = 1"; // Nur aktive Preise
$sql .= " AND pfp.unitprice > 0";
$sql .= " ORDER BY ".$orderBy;
$sql .= " LIMIT 1";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
return (float) $obj->unitprice;
}
return 0;
}
/**
* Send GlobalNotify notification
*
* @param array $priceChanges Array of price changes
* @param int $updated Number of updated products
* @param int $errors Number of errors
*/
private function sendNotification($priceChanges, $updated, $errors)
{
// Prüfe ob GlobalNotify verfügbar ist
$globalNotifyFile = dol_buildpath('/globalnotify/class/globalnotify.class.php', 0);
if (!file_exists($globalNotifyFile) || !isModEnabled('globalnotify')) {
return;
}
require_once $globalNotifyFile;
$title = $updated.' Produktpreise aktualisiert';
$message = "PreisBot hat ".$updated." Verkaufspreise angepasst.\n\n";
// Zeige die ersten 5 Änderungen in der Notification
$count = 0;
foreach ($priceChanges as $change) {
if ($count >= 5) {
$message .= "\n... und ".($updated - 5)." weitere";
break;
}
$diffSign = $change['diff'] >= 0 ? '+' : '';
$message .= $change['ref'].": ".number_format($change['old_price'], 2, ',', '.')." -> ".number_format($change['new_price'], 2, ',', '.')." EUR (".$diffSign.number_format($change['diff'], 2, ',', '.')." EUR)\n";
$count++;
}
if ($errors > 0) {
GlobalNotify::warning('preisbot', $title, $message);
} else {
GlobalNotify::info('preisbot', $title, $message);
}
}
/**
* Send email report with detailed price changes
*
* @param array $priceChanges Array of price changes
* @param int $updated Number of updated products
* @param int $skipped Number of skipped products
* @param int $errors Number of errors
*/
private function sendMailReport($priceChanges, $updated, $skipped, $errors)
{
global $conf, $user;
$mailTo = getDolGlobalString('PREISBOT_MAIL_TO');
if (empty($mailTo)) {
return;
}
require_once DOL_DOCUMENT_ROOT.'/core/class/CMailFile.class.php';
$subject = '[PreisBot] '.$updated.' Produktpreise aktualisiert - '.dol_print_date(dol_now(), 'day');
// HTML Mail erstellen
$body = '<html><head><style>
body { font-family: Arial, sans-serif; }
table { border-collapse: collapse; width: 100%; margin-top: 15px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: right; }
th { background-color: #4CAF50; color: white; }
tr:nth-child(even) { background-color: #f2f2f2; }
td:first-child, td:nth-child(2) { text-align: left; }
.positive { color: green; }
.negative { color: red; }
.summary { background-color: #f5f5f5; padding: 15px; margin-bottom: 20px; border-radius: 5px; }
</style></head><body>';
$body .= '<h2>PreisBot - Preisanpassungen vom '.dol_print_date(dol_now(), 'dayhour').'</h2>';
// Zusammenfassung
$body .= '<div class="summary">';
$body .= '<strong>Zusammenfassung:</strong><br>';
$body .= 'Aktualisiert: <strong>'.$updated.'</strong><br>';
$body .= 'Übersprungen: '.$skipped.'<br>';
if ($errors > 0) {
$body .= '<span style="color:red;">Fehler: '.$errors.'</span><br>';
}
$body .= '</div>';
// Tabelle mit Preisänderungen
$body .= '<h3>Details der Preisänderungen</h3>';
$body .= '<table>';
$body .= '<tr>';
$body .= '<th>Referenz</th>';
$body .= '<th>Bezeichnung</th>';
$body .= '<th>EK-Preis</th>';
$body .= '<th>Aufschlag</th>';
$body .= '<th>Alter VK</th>';
$body .= '<th>Neuer VK</th>';
$body .= '<th>Differenz</th>';
$body .= '<th>%</th>';
$body .= '</tr>';
foreach ($priceChanges as $change) {
$diffClass = $change['diff'] >= 0 ? 'positive' : 'negative';
$diffSign = $change['diff'] >= 0 ? '+' : '';
$body .= '<tr>';
$body .= '<td>'.$change['ref'].'</td>';
$body .= '<td>'.dol_trunc($change['label'], 40).'</td>';
$body .= '<td>'.number_format($change['buy_price'], 2, ',', '.').' &euro;</td>';
$body .= '<td>'.number_format($change['margin'], 1, ',', '.').' %</td>';
$body .= '<td>'.number_format($change['old_price'], 2, ',', '.').' &euro;</td>';
$body .= '<td><strong>'.number_format($change['new_price'], 2, ',', '.').' &euro;</strong></td>';
$body .= '<td class="'.$diffClass.'">'.$diffSign.number_format($change['diff'], 2, ',', '.').' &euro;</td>';
$body .= '<td class="'.$diffClass.'">'.$diffSign.number_format($change['diff_percent'], 1, ',', '.').' %</td>';
$body .= '</tr>';
}
$body .= '</table>';
$body .= '<br><br><small>Diese E-Mail wurde automatisch vom PreisBot-Modul generiert.</small>';
$body .= '</body></html>';
$from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM', $conf->global->MAIN_INFO_SOCIETE_MAIL);
$mail = new CMailFile(
$subject,
$mailTo,
$from,
$body,
array(), // filenames
array(), // mimetypes
array(), // filenames2
'', // cc
'', // bcc
0, // deliveryreceipt
1 // msgishtml
);
$mail->sendfile();
}
}