dolibarr.preisbot/class/preisbot.class.php
data 5cd7e48041 fix: Pass notrigger=1 to updatePrice to prevent SMTP hang
Dolibarr fires internal triggers on price changes which can attempt
to send emails via SMTP. This causes the cronjob to hang indefinitely
when the SMTP connection blocks. notrigger=1 skips these internal
triggers since PreisBot sends its own notifications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:31:28 +01:00

361 lines
11 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;
// Sicherheits-Timeout: Max 5 Minuten für den gesamten Job
@set_time_limit(300);
try {
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
// User-Kontext sicherstellen (Cronjob hat manchmal keinen User)
if (empty($user) || !is_object($user) || empty($user->id)) {
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
$user = new User($this->db);
$user->fetch(1); // Admin-User
}
$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',
1 // notrigger=1: Keine internen Dolibarr-Trigger (verhindert SMTP-Hänger)
);
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', getDolGlobalString('MAIN_INFO_SOCIETE_MAIL', 'noreply@example.com'));
$mail = new CMailFile(
$subject,
$mailTo,
$from,
$body,
array(), // filenames
array(), // mimetypes
array(), // filenames2
'', // cc
'', // bcc
0, // deliveryreceipt
1 // msgishtml
);
$mail->sendfile();
}
}