Kritische Bugfixes: - FIX: Produktmatching korrigiert (qty→quantity, status→tosell in SQL) Vorher wurden alle Artikel als Freitext importiert, jetzt korrekte Verknüpfung mit Dolibarr-Produkten über product_fournisseur_price Neue Features: - FEAT: Sonepar NetPrice-Heuristik implementiert Erkennt automatisch ob NetPrice für PriceBasis (IDS-Standard) oder Order-Qty (Sonepar-Variante) ist. 2-Pass-Algorithmus vergleicht beide Interpretationen mit DB-Preis und wählt korrekte aus. Löst +100% Preisabweichungs-Problem bei Sonepar-Warenkörben. - FEAT: Preis-Vergleich in cart_review.php und tab_supplierorder.php Zeigt Abweichungen zwischen Shop-Preisen und gespeicherten Dolibarr-Preisen mit farbiger Markierung (rot >10%, gelb 2-10%, grün ≤2%) Manuelle Preis-Aktualisierung über Checkboxen (keine Automatik) - Admin-Option IDSCONNECT_PRICE_UPDATE_ENABLED mit konfigurierbarem Schwellwert - Übersetzungen für de_DE und en_US erweitert Dateien: - class/idsconnect.class.php: matchProducts() qty→quantity Fix - cart_review.php: Heuristik + Preis-Vergleich UI - tab_supplierorder.php: Preis-Vergleich für Bestellungen - admin/setup.php: Neue Preis-Update-Optionen - CHANGELOG.md: Dokumentation v2.9 und v3.1 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
640 lines
22 KiB
PHP
Executable file
640 lines
22 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 idsconnect/class/idsconnect.class.php
|
|
* \ingroup idsconnect
|
|
* \brief Kern-Klasse für IDS Connect Schnittstelle
|
|
*/
|
|
|
|
require_once DOL_DOCUMENT_ROOT.'/core/lib/security.lib.php';
|
|
dol_include_once('/idsconnect/class/idssupplier.class.php');
|
|
dol_include_once('/idsconnect/class/idslog.class.php');
|
|
|
|
/**
|
|
* IDS Connect Schnittstelle - Formular-Builder, XML-Parser, Callback-Verwaltung
|
|
*/
|
|
class IdsConnect
|
|
{
|
|
/** @var DoliDB */
|
|
private $db;
|
|
|
|
/** @var string Fehler */
|
|
public $error = '';
|
|
/** @var array Fehler */
|
|
public $errors = array();
|
|
|
|
// Erlaubte IDS-Aktionen
|
|
const ACTION_WKE = 'WKE'; // Warenkorb empfangen
|
|
const ACTION_WKS = 'WKS'; // Warenkorb senden
|
|
const ACTION_ADL = 'ADL'; // Artikel Deep-Link
|
|
const ACTION_LI = 'LI'; // Login-Info
|
|
const ACTION_SV = 'SV'; // Schnittstellenversion
|
|
|
|
// Erlaubte IDS-Versionen
|
|
const ALLOWED_VERSIONS = array('1.3', '2.0', '2.1', '2.2', '2.3', '2.5');
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param DoliDB $db Datenbank-Handler
|
|
*/
|
|
public function __construct($db)
|
|
{
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Prüft ob der Testmodus aktiv ist (global ODER pro Großhändler)
|
|
*
|
|
* @param IdsSupplier $supplier Großhändler
|
|
* @return bool true wenn Testmodus aktiv
|
|
*/
|
|
public function isTestMode($supplier)
|
|
{
|
|
// Globaler Testmodus hat Vorrang
|
|
if (getDolGlobalInt('IDSCONNECT_TESTMODE')) {
|
|
return true;
|
|
}
|
|
// Dann Supplier-spezifischer Testmodus
|
|
return !empty($supplier->testmode);
|
|
}
|
|
|
|
/**
|
|
* Generiert einen sicheren Callback-Token für eine Transaktion
|
|
*
|
|
* @param int $supplierId Großhändler-ID
|
|
* @param int $userId Benutzer-ID
|
|
* @return string Token (64 Zeichen hex)
|
|
*/
|
|
public function generateCallbackToken($supplierId, $userId)
|
|
{
|
|
$secret = getDolGlobalString('IDSCONNECT_CALLBACK_SECRET', 'idsconnect_default_secret');
|
|
$timestamp = dol_now();
|
|
$random = bin2hex(random_bytes(16));
|
|
$data = $supplierId.'|'.$userId.'|'.$timestamp.'|'.$random;
|
|
return hash_hmac('sha256', $data, $secret);
|
|
}
|
|
|
|
/**
|
|
* Verifiziert einen Callback-Token
|
|
*
|
|
* @param string $token Der zu prüfende Token
|
|
* @return array|false Log-Eintrag wenn gültig, false wenn ungültig
|
|
*/
|
|
public function verifyCallbackToken($token)
|
|
{
|
|
if (empty($token) || strlen($token) !== 64) {
|
|
return false;
|
|
}
|
|
|
|
// Token nur Hex-Zeichen erlauben
|
|
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
|
return false;
|
|
}
|
|
|
|
// Token in der Datenbank suchen
|
|
$sql = "SELECT rowid FROM ".$this->db->prefix()."idsconnect_log";
|
|
$sql .= " WHERE callback_token = '".$this->db->escape($token)."'";
|
|
$sql .= " AND status = 'pending'";
|
|
// Token max. 2 Stunden gültig
|
|
$sql .= " AND date_creation > '".$this->db->idate(dol_now() - 7200)."'";
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql && $this->db->num_rows($resql) > 0) {
|
|
$obj = $this->db->fetch_object($resql);
|
|
$log = new IdsLog($this->db);
|
|
$log->fetch($obj->rowid);
|
|
return $log;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Baut die Callback-URL (HOOKURL) für den Großhandels-Shop
|
|
*
|
|
* @param string $token Callback-Token
|
|
* @return string Vollständige HOOKURL
|
|
*/
|
|
public function buildHookUrl($token)
|
|
{
|
|
global $dolibarr_main_url_root;
|
|
|
|
// Öffentliche URL verwenden falls konfiguriert (für Reverse-Proxy-Setups)
|
|
$public_url = getDolGlobalString('IDSCONNECT_PUBLIC_URL');
|
|
if (!empty($public_url)) {
|
|
$urlroot = rtrim($public_url, '/');
|
|
} else {
|
|
$urlroot = $dolibarr_main_url_root;
|
|
// Protokoll sicherstellen
|
|
if (!empty($urlroot) && strpos($urlroot, '://') === false) {
|
|
$urlroot = 'http://'.$urlroot;
|
|
}
|
|
// Sicherstellen dass HTTPS verwendet wird
|
|
if (strpos($urlroot, 'http://') === 0 && !empty($_SERVER['HTTPS'])) {
|
|
$urlroot = 'https://'.substr($urlroot, 7);
|
|
}
|
|
}
|
|
|
|
return $urlroot.'/custom/idsconnect/callback.php?token='.urlencode($token);
|
|
}
|
|
|
|
/**
|
|
* Generiert das HTML-Formular für den IDS Connect Aufruf
|
|
*
|
|
* @param IdsSupplier $supplier Großhändler
|
|
* @param string $action IDS-Action (WKE, WKS, ADL etc.)
|
|
* @param User $user Aktueller Benutzer
|
|
* @param array $extra Zusätzliche Parameter (z.B. 'warenkorb' XML, 'artikelnr' für ADL)
|
|
* @return array Array mit 'html' und 'log_id', oder false bei Fehler
|
|
*/
|
|
public function buildLaunchForm($supplier, $action, $user, $extra = array())
|
|
{
|
|
// Validierung: Action prüfen
|
|
$allowed_actions = array(self::ACTION_WKE, self::ACTION_WKS, self::ACTION_ADL, self::ACTION_LI, self::ACTION_SV);
|
|
if (!in_array($action, $allowed_actions)) {
|
|
$this->error = 'Ungültige IDS-Action: '.$action;
|
|
return false;
|
|
}
|
|
|
|
// Validierung: Version prüfen
|
|
if (!in_array($supplier->ids_version, self::ALLOWED_VERSIONS)) {
|
|
$this->error = 'Ungültige IDS-Version: '.$supplier->ids_version;
|
|
return false;
|
|
}
|
|
|
|
// Validierung: URL prüfen
|
|
if (!filter_var($supplier->ids_url, FILTER_VALIDATE_URL)) {
|
|
$this->error = 'Ungültige Shop-URL: '.$supplier->ids_url;
|
|
return false;
|
|
}
|
|
|
|
// Testmodus prüfen
|
|
$testmode = $this->isTestMode($supplier);
|
|
|
|
// Callback-Token generieren
|
|
$token = $this->generateCallbackToken($supplier->id, $user->id);
|
|
|
|
// HOOKURL bauen
|
|
$hookurl = $this->buildHookUrl($token);
|
|
|
|
// Log-Eintrag erstellen
|
|
$log = new IdsLog($this->db);
|
|
$log->fk_supplier = $supplier->id;
|
|
$log->fk_user = $user->id;
|
|
$log->action_type = $action;
|
|
$log->direction = 'OUT';
|
|
$log->callback_token = $token;
|
|
$log->status = 'pending';
|
|
$log->ip_address = getUserRemoteIP();
|
|
|
|
// Im Testmodus: Mock-Server URL verwenden
|
|
$target_url = $supplier->ids_url;
|
|
if ($testmode) {
|
|
global $dolibarr_main_url_root;
|
|
$mock_base = $dolibarr_main_url_root;
|
|
if (!empty($mock_base) && strpos($mock_base, '://') === false) {
|
|
$mock_base = 'http://'.$mock_base;
|
|
}
|
|
$target_url = $mock_base.'/custom/idsconnect/mockserver.php';
|
|
}
|
|
|
|
// Herkunfts-URL des Users speichern (damit Callback-Links zur richtigen Dolibarr-Instanz zeigen)
|
|
$user_base_url = '';
|
|
if (!empty($_SERVER['HTTP_HOST'])) {
|
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
$user_base_url = $scheme.'://'.$_SERVER['HTTP_HOST'];
|
|
}
|
|
|
|
$request_data = array(
|
|
'action' => $action,
|
|
'version' => $supplier->ids_version,
|
|
'customer_no' => $supplier->ids_customer_no,
|
|
'username' => $supplier->ids_username,
|
|
'supplier_ref' => $supplier->ref,
|
|
'supplier_label' => $supplier->label,
|
|
'testmode' => $testmode ? 1 : 0,
|
|
'target_url' => $target_url,
|
|
'hookurl' => $hookurl,
|
|
'shop_url' => $supplier->ids_url,
|
|
'user_base_url' => $user_base_url,
|
|
);
|
|
|
|
// Warenkorb-XML bei WKS
|
|
if ($action === self::ACTION_WKS && !empty($extra['warenkorb'])) {
|
|
$log->cart_xml = $extra['warenkorb'];
|
|
$request_data['has_cart'] = true;
|
|
$request_data['cart_lines'] = substr_count($extra['warenkorb'], '<OrderItem>');
|
|
}
|
|
|
|
// ADL-Parameter
|
|
if ($action === self::ACTION_ADL && !empty($extra['artikelnr'])) {
|
|
$request_data['artikelnr'] = $extra['artikelnr'];
|
|
}
|
|
|
|
$log->request_data = json_encode($request_data);
|
|
$log_id = $log->create($user);
|
|
|
|
if ($log_id < 0) {
|
|
$this->error = 'Fehler beim Erstellen des Log-Eintrags: '.$log->error;
|
|
dol_syslog("IDS Connect buildLaunchForm: Log-Eintrag fehlgeschlagen: ".$log->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
|
|
dol_syslog("IDS Connect Launch: Action=".$action." Supplier=".$supplier->ref." Target=".$target_url." Testmode=".($testmode ? 'JA' : 'NEIN')." LogID=".$log_id, LOG_INFO);
|
|
|
|
// HTML-Formular generieren
|
|
$html = '<!DOCTYPE html>'."\n";
|
|
$html .= '<html><head><meta charset="UTF-8"><title>IDS Connect - '.$this->escapeHtml($supplier->label).'</title></head>'."\n";
|
|
$html .= '<body onload="document.forms[\'idsform\'].submit();">'."\n";
|
|
$html .= '<p>Verbindung zu '.$this->escapeHtml($supplier->label).' wird hergestellt...</p>'."\n";
|
|
$html .= '<form id="idsform" name="idsform" action="'.$this->escapeHtml($target_url).'" method="post" enctype="multipart/form-data">'."\n";
|
|
$html .= '<input type="hidden" name="kndnr" value="'.$this->escapeHtml($supplier->ids_customer_no).'">'."\n";
|
|
$html .= '<input type="hidden" name="name_kunde" value="'.$this->escapeHtml($supplier->ids_username).'">'."\n";
|
|
$html .= '<input type="hidden" name="pw_kunde" value="'.$this->escapeHtml($supplier->ids_password).'">'."\n";
|
|
$html .= '<input type="hidden" name="version" value="'.$this->escapeHtml($supplier->ids_version).'">'."\n";
|
|
$html .= '<input type="hidden" name="action" value="'.$this->escapeHtml($action).'">'."\n";
|
|
$html .= '<input type="hidden" name="hookurl" value="'.$this->escapeHtml($hookurl).'">'."\n";
|
|
|
|
// Warenkorb-XML bei WKS
|
|
if ($action === self::ACTION_WKS && !empty($extra['warenkorb'])) {
|
|
$html .= '<input type="hidden" name="warenkorb" value="'.$this->escapeHtml($extra['warenkorb']).'">'."\n";
|
|
}
|
|
|
|
// Artikelnummer bei Deep-Link
|
|
if ($action === self::ACTION_ADL && !empty($extra['artikelnr'])) {
|
|
$html .= '<input type="hidden" name="artikelnr" value="'.$this->escapeHtml($extra['artikelnr']).'">'."\n";
|
|
}
|
|
|
|
// Target-Parameter (ab v2.3)
|
|
if (!empty($extra['target'])) {
|
|
$html .= '<input type="hidden" name="target" value="'.$this->escapeHtml($extra['target']).'">'."\n";
|
|
}
|
|
|
|
$html .= '<noscript><input type="submit" value="Weiter zum Shop"></noscript>'."\n";
|
|
$html .= '</form></body></html>';
|
|
|
|
return array(
|
|
'html' => $html,
|
|
'log_id' => $log_id,
|
|
'testmode' => $testmode,
|
|
'token' => $token,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verarbeitet den empfangenen Warenkorb-XML vom Großhandel
|
|
*
|
|
* @param string $xml_string Empfangenes XML
|
|
* @return array|false Geparste Artikel-Liste oder false bei Fehler
|
|
*/
|
|
public function parseCartXml($xml_string)
|
|
{
|
|
if (empty($xml_string)) {
|
|
$this->error = 'Leerer Warenkorb empfangen';
|
|
return false;
|
|
}
|
|
|
|
// XXE-Schutz: Externe Entities deaktivieren
|
|
$previousValue = libxml_disable_entity_loader(true);
|
|
libxml_use_internal_errors(true);
|
|
|
|
$xml = simplexml_load_string($xml_string, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT);
|
|
|
|
libxml_disable_entity_loader($previousValue);
|
|
|
|
if ($xml === false) {
|
|
$errors = libxml_get_errors();
|
|
$this->error = 'XML-Parse-Fehler: ';
|
|
foreach ($errors as $xmlerror) {
|
|
$this->error .= trim($xmlerror->message).' ';
|
|
}
|
|
libxml_clear_errors();
|
|
return false;
|
|
}
|
|
|
|
// Debug: Root-Element und Kinder loggen
|
|
$root_name = $xml->getName();
|
|
$children = array();
|
|
foreach ($xml->children() as $child) {
|
|
$children[] = $child->getName();
|
|
}
|
|
dol_syslog("IDS Connect parseCartXml: Root='".$root_name."' Kinder=[".implode(', ', $children)."] XML-Länge=".strlen($xml_string), LOG_DEBUG);
|
|
|
|
$items = array();
|
|
|
|
// Verschiedene mögliche XML-Strukturen unterstützen
|
|
$itemNodes = array();
|
|
|
|
// Format 1: GAEB-ähnlich mit BoQBody
|
|
if (isset($xml->Award->BoQBody->BoQCtgy)) {
|
|
foreach ($xml->Award->BoQBody->BoQCtgy as $category) {
|
|
if (isset($category->BoQBody->Itemlist->Item)) {
|
|
foreach ($category->BoQBody->Itemlist->Item as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format 2: Einfache Item-Liste
|
|
if (isset($xml->Item)) {
|
|
foreach ($xml->Item as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
|
|
// Format 3: Artikel-Liste
|
|
if (isset($xml->Artikel)) {
|
|
foreach ($xml->Artikel as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
|
|
// Format 4: NEW_ITEM (OCI-ähnlich)
|
|
if (isset($xml->NEW_ITEM)) {
|
|
foreach ($xml->NEW_ITEM as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
|
|
// Format 5: IDS Connect 2.5 - Warenkorb/Order/OrderItem
|
|
if (isset($xml->Order)) {
|
|
foreach ($xml->Order as $order) {
|
|
if (isset($order->OrderItem)) {
|
|
foreach ($order->OrderItem as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Format 6: Namespace-Variante - Namespaces strippen und nochmal versuchen
|
|
if (empty($itemNodes) && (strpos($xml_string, 'xmlns') !== false || strpos($xml_string, ':') !== false)) {
|
|
dol_syslog("IDS Connect parseCartXml: Keine Items gefunden, versuche Namespace-Stripping", LOG_DEBUG);
|
|
// Alle Namespace-Deklarationen und Prefixe entfernen
|
|
$clean_xml = preg_replace('/\s+xmlns(:[a-zA-Z0-9]+)?="[^"]*"/', '', $xml_string);
|
|
$clean_xml = preg_replace('/<([\/]?)([a-zA-Z0-9]+):/', '<$1', $clean_xml);
|
|
|
|
libxml_use_internal_errors(true);
|
|
$xml_clean = simplexml_load_string($clean_xml, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT);
|
|
libxml_clear_errors();
|
|
|
|
if ($xml_clean !== false) {
|
|
$root_clean = $xml_clean->getName();
|
|
$children_clean = array();
|
|
foreach ($xml_clean->children() as $child) {
|
|
$children_clean[] = $child->getName();
|
|
}
|
|
dol_syslog("IDS Connect parseCartXml: Nach NS-Strip: Root='".$root_clean."' Kinder=[".implode(', ', $children_clean)."]", LOG_DEBUG);
|
|
|
|
// Alle Format-Checks nochmal auf das bereinigte XML
|
|
if (isset($xml_clean->Order)) {
|
|
foreach ($xml_clean->Order as $order) {
|
|
if (isset($order->OrderItem)) {
|
|
foreach ($order->OrderItem as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (empty($itemNodes) && isset($xml_clean->Artikel)) {
|
|
foreach ($xml_clean->Artikel as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
if (empty($itemNodes) && isset($xml_clean->Item)) {
|
|
foreach ($xml_clean->Item as $item) {
|
|
$itemNodes[] = $item;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Format-Erkennung: NetPrice = Stückpreis (IDS-Standard) oder Zeilengesamtpreis (z.B. Sonepar)?
|
|
// Heuristik: Bei Positionen mit Menge > 1 prüfen ob NetPrice > OfferPrice.
|
|
// Wenn ja, ist NetPrice der Zeilengesamtpreis (weil ein Netto-Stückpreis unter dem Listenpreis liegt).
|
|
// ============================================================
|
|
$total_votes = 0;
|
|
$standard_votes = 0;
|
|
foreach ($itemNodes as $item) {
|
|
$chk_qty = (float) $this->getXmlValue($item, array('Qty', 'Menge', 'menge', 'NEW_ITEM-QUANTITY'), '0');
|
|
$chk_net = (float) $this->getXmlValue($item, array('NetPrice', 'UP', 'Einzelpreis', 'einzelpreis', 'EP', 'NEW_ITEM-PRICE'), '0');
|
|
$chk_offer = (float) $this->getXmlValue($item, array('OfferPrice'), '0');
|
|
if ($chk_qty > 1 && $chk_offer > 0 && $chk_net > 0) {
|
|
if ($chk_net > $chk_offer) {
|
|
$total_votes++;
|
|
} else {
|
|
$standard_votes++;
|
|
}
|
|
}
|
|
}
|
|
$netprice_is_line_total = ($total_votes > 0 && $total_votes > $standard_votes);
|
|
if ($netprice_is_line_total) {
|
|
dol_syslog("IDS Connect parseCartXml: NetPrice als Zeilengesamtpreis erkannt (Votes: total=".$total_votes." standard=".$standard_votes.")", LOG_INFO);
|
|
}
|
|
|
|
foreach ($itemNodes as $item) {
|
|
// Roh-Preise aus XML
|
|
$raw_netprice = (float) $this->getXmlValue($item, array('NetPrice', 'UP', 'Einzelpreis', 'einzelpreis', 'EP', 'NEW_ITEM-PRICE'), '0');
|
|
$raw_offerprice = (float) $this->getXmlValue($item, array('OfferPrice'), '0');
|
|
$price_basis = (float) $this->getXmlValue($item, array('PriceBasis', 'PE', 'Preiseinheit', 'PriceUnit', 'NEW_ITEM-PRICEUNIT'), '1');
|
|
$mwst = (float) $this->getXmlValue($item, array('VAT', 'MwSt', 'Mehrwertsteuer'), '0');
|
|
$menge = (float) $this->getXmlValue($item, array('Qty', 'Menge', 'menge', 'NEW_ITEM-QUANTITY'), '0');
|
|
|
|
// Preiseinheit normalisieren (0 oder negativ = 1)
|
|
if ($price_basis <= 0) {
|
|
$price_basis = 1;
|
|
}
|
|
|
|
if ($netprice_is_line_total && $menge > 0) {
|
|
// Sonepar-Format: NetPrice = Gesamtpreis der Zeile → durch Menge teilen
|
|
$einzelpreis = $raw_netprice / $menge;
|
|
$gesamtpreis = $raw_netprice;
|
|
} else {
|
|
// IDS-Standard: NetPrice = Preis pro PriceBasis
|
|
$einzelpreis = ($price_basis != 1) ? $raw_netprice / $price_basis : $raw_netprice;
|
|
$gesamtpreis = 0;
|
|
}
|
|
// Angebotspreis (Listenpreis) immer pro PriceBasis-Einheit umrechnen
|
|
$angebotspreis = ($price_basis != 1 && $raw_offerprice > 0) ? $raw_offerprice / $price_basis : $raw_offerprice;
|
|
|
|
$parsed = array(
|
|
'artikelnr' => $this->getXmlValue($item, array('ArtNo', 'RNoPart', 'Artikelnummer', 'artikelnr', 'ArtNr', 'NEW_ITEM-VENDORMAT')),
|
|
'bezeichnung' => $this->getXmlValue($item, array('Kurztext', 'Description', 'Bezeichnung', 'bezeichnung', 'Bez', 'NEW_ITEM-DESCRIPTION')),
|
|
'langtext' => $this->getXmlValue($item, array('Langtext')),
|
|
'menge' => $menge,
|
|
'einheit' => $this->getXmlValue($item, array('QU', 'Einheit', 'einheit', 'ME', 'NEW_ITEM-UNIT'), 'STK'),
|
|
'einzelpreis' => $einzelpreis,
|
|
'angebotspreis' => $angebotspreis,
|
|
'preiseinheit' => ($price_basis != 1 && !$netprice_is_line_total) ? (int) $price_basis : 0,
|
|
'raw_netprice' => $raw_netprice,
|
|
'gesamtpreis' => $gesamtpreis ?: (float) $this->getXmlValue($item, array('GR', 'Gesamtpreis', 'gesamtpreis', 'GP'), '0'),
|
|
'mwst_satz' => $mwst,
|
|
'ean' => $this->getXmlValue($item, array('EAN', 'ean', 'GTIN')),
|
|
'hersteller' => $this->getXmlValue($item, array('ManufacturerID', 'Manufacturer', 'Hersteller', 'hersteller')),
|
|
'herstellernr' => $this->getXmlValue($item, array('ManufacturerAID', 'Herstellernummer', 'herstellernr')),
|
|
'hinweis' => $this->getXmlValue($item, array('Hinweis')),
|
|
);
|
|
|
|
// Gesamtpreis berechnen wenn nicht vorhanden
|
|
if ($parsed['gesamtpreis'] == 0 && $parsed['menge'] > 0 && $parsed['einzelpreis'] > 0) {
|
|
$parsed['gesamtpreis'] = $parsed['menge'] * $parsed['einzelpreis'];
|
|
}
|
|
|
|
// Bezeichnung aus Langtext übernehmen wenn Kurztext leer
|
|
if (empty($parsed['bezeichnung']) && !empty($parsed['langtext'])) {
|
|
$parsed['bezeichnung'] = $parsed['langtext'];
|
|
}
|
|
|
|
if (!empty($parsed['artikelnr']) || !empty($parsed['bezeichnung'])) {
|
|
$items[] = $parsed;
|
|
}
|
|
}
|
|
|
|
if (empty($items)) {
|
|
$this->error = 'Keine Artikel im Warenkorb gefunden (Root: '.$root_name.', Kinder: '.implode(',', $children).')';
|
|
return false;
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Erstellt ein Warenkorb-XML aus Dolibarr-Bestellpositionen (IDS Connect 2.0 Format)
|
|
*
|
|
* @param array $lines Array mit Bestellpositionen
|
|
* @return string XML-String
|
|
*/
|
|
public function buildCartXml($lines)
|
|
{
|
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
|
|
$xml .= '<Warenkorb xmlns="http://www.itek.de/Shop-Anbindung/Warenkorb/">'."\n";
|
|
$xml .= ' <WarenkorbInfo>'."\n";
|
|
$xml .= ' <Date>'.date('Y-m-d').'</Date>'."\n";
|
|
$xml .= ' <Time>'.date('H:i:s').'</Time>'."\n";
|
|
$xml .= ' <RueckgabeKZ>Warenkorbsendung</RueckgabeKZ>'."\n";
|
|
$xml .= ' <Version>2.0</Version>'."\n";
|
|
$xml .= ' </WarenkorbInfo>'."\n";
|
|
$xml .= ' <Order>'."\n";
|
|
$xml .= ' <OrderInfo>'."\n";
|
|
$xml .= ' <ModeOfShipment>Lieferung</ModeOfShipment>'."\n";
|
|
$xml .= ' <Cur>EUR</Cur>'."\n";
|
|
$xml .= ' </OrderInfo>'."\n";
|
|
|
|
foreach ($lines as $line) {
|
|
$netprice = (float) ($line['einzelpreis'] ?? 0);
|
|
$qty = (float) ($line['menge'] ?? 0);
|
|
$vat = (float) ($line['mwst_satz'] ?? 19);
|
|
|
|
$xml .= ' <OrderItem>'."\n";
|
|
$xml .= ' <ArtNo>'.htmlspecialchars($line['artikelnr'] ?? '', ENT_XML1, 'UTF-8').'</ArtNo>'."\n";
|
|
$xml .= ' <Qty>'.$qty.'</Qty>'."\n";
|
|
$xml .= ' <QU>'.htmlspecialchars($line['einheit'] ?? 'PCE', ENT_XML1, 'UTF-8').'</QU>'."\n";
|
|
$xml .= ' <Kurztext>'.htmlspecialchars($line['bezeichnung'] ?? '', ENT_XML1, 'UTF-8').'</Kurztext>'."\n";
|
|
$xml .= ' <NetPrice>'.$netprice.'</NetPrice>'."\n";
|
|
$xml .= ' <PriceBasis>1</PriceBasis>'."\n";
|
|
$xml .= ' <VAT>'.$vat.'</VAT>'."\n";
|
|
$xml .= ' </OrderItem>'."\n";
|
|
}
|
|
|
|
$xml .= ' </Order>'."\n";
|
|
$xml .= '</Warenkorb>';
|
|
|
|
return $xml;
|
|
}
|
|
|
|
/**
|
|
* Sucht Dolibarr-Produkte anhand der Lieferantenreferenzen (Batch-Abfrage)
|
|
*
|
|
* @param array $ref_suppliers Array mit Lieferanten-Artikelnummern
|
|
* @param int $fk_soc Dolibarr-Lieferanten-ID (societe)
|
|
* @return array Assoziatives Array ref_fourn => {fk_product, fk_prod_fourn_price, product_ref, product_label}
|
|
*/
|
|
public function matchProducts($ref_suppliers, $fk_soc)
|
|
{
|
|
$matches = array();
|
|
if (empty($ref_suppliers) || empty($fk_soc)) {
|
|
return $matches;
|
|
}
|
|
|
|
$in_list = array();
|
|
foreach ($ref_suppliers as $ref) {
|
|
if (!empty($ref)) {
|
|
$in_list[] = "'".$this->db->escape($ref)."'";
|
|
}
|
|
}
|
|
if (empty($in_list)) {
|
|
return $matches;
|
|
}
|
|
|
|
$sql = "SELECT pfp.fk_product, pfp.rowid as fk_prod_fourn_price, pfp.ref_fourn, p.ref, p.label, pfp.price, pfp.quantity";
|
|
$sql .= " FROM ".$this->db->prefix()."product_fournisseur_price pfp";
|
|
$sql .= " JOIN ".$this->db->prefix()."product p ON p.rowid = pfp.fk_product";
|
|
$sql .= " WHERE pfp.fk_soc = ".((int) $fk_soc);
|
|
$sql .= " AND pfp.ref_fourn IN (".implode(',', $in_list).")";
|
|
$sql .= " AND p.tosell = 1"; // Nur Produkte die verkauft werden können
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
while ($obj = $this->db->fetch_object($resql)) {
|
|
// Stückpreis berechnen (price / quantity = Preis pro Einheit)
|
|
$qty = (float) $obj->quantity;
|
|
if ($qty <= 0) $qty = 1; // Fallback
|
|
$unit_price = (float) $obj->price / $qty;
|
|
|
|
// Bei Duplikaten: neuester Eintrag gewinnt
|
|
$matches[$obj->ref_fourn] = array(
|
|
'fk_product' => (int) $obj->fk_product,
|
|
'fk_prod_fourn_price' => (int) $obj->fk_prod_fourn_price,
|
|
'product_ref' => $obj->ref,
|
|
'product_label' => $obj->label,
|
|
'stored_price' => $unit_price, // Stückpreis statt Gesamtpreis
|
|
'debug_price' => (float) $obj->price, // Rohdaten für Debug
|
|
'debug_qty' => $qty,
|
|
);
|
|
}
|
|
}
|
|
return $matches;
|
|
}
|
|
|
|
/**
|
|
* XML-Wert aus verschiedenen möglichen Feldnamen extrahieren
|
|
*
|
|
* @param SimpleXMLElement $node XML-Knoten
|
|
* @param array $names Mögliche Feldnamen
|
|
* @param string $default Standardwert
|
|
* @return string Gefundener Wert
|
|
*/
|
|
private function getXmlValue($node, $names, $default = '')
|
|
{
|
|
foreach ($names as $name) {
|
|
if (isset($node->{$name})) {
|
|
return (string) $node->{$name};
|
|
}
|
|
// Auch als Attribut prüfen
|
|
if (isset($node[$name])) {
|
|
return (string) $node[$name];
|
|
}
|
|
}
|
|
return $default;
|
|
}
|
|
|
|
/**
|
|
* HTML-Escaping für sichere Ausgabe
|
|
*
|
|
* @param string $str Eingabe-String
|
|
* @return string Escaped String
|
|
*/
|
|
private function escapeHtml($str)
|
|
{
|
|
return htmlspecialchars($str, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
}
|