* * 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'], ''); } // 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 = ''."\n"; $html .= 'IDS Connect - '.$this->escapeHtml($supplier->label).''."\n"; $html .= ''."\n"; $html .= '

Verbindung zu '.$this->escapeHtml($supplier->label).' wird hergestellt...

'."\n"; $html .= '
'."\n"; $html .= ''."\n"; $html .= ''."\n"; $html .= ''."\n"; $html .= ''."\n"; $html .= ''."\n"; $html .= ''."\n"; // Warenkorb-XML bei WKS if ($action === self::ACTION_WKS && !empty($extra['warenkorb'])) { $html .= ''."\n"; } // Artikelnummer bei Deep-Link if ($action === self::ACTION_ADL && !empty($extra['artikelnr'])) { $html .= ''."\n"; } // Target-Parameter (ab v2.3) if (!empty($extra['target'])) { $html .= ''."\n"; } $html .= ''."\n"; $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 = ''."\n"; $xml .= ''."\n"; $xml .= ' '."\n"; $xml .= ' '.date('Y-m-d').''."\n"; $xml .= ' '."\n"; $xml .= ' Warenkorbsendung'."\n"; $xml .= ' 2.0'."\n"; $xml .= ' '."\n"; $xml .= ' '."\n"; $xml .= ' '."\n"; $xml .= ' Lieferung'."\n"; $xml .= ' EUR'."\n"; $xml .= ' '."\n"; foreach ($lines as $line) { $netprice = (float) ($line['einzelpreis'] ?? 0); $qty = (float) ($line['menge'] ?? 0); $vat = (float) ($line['mwst_satz'] ?? 19); $xml .= ' '."\n"; $xml .= ' '.htmlspecialchars($line['artikelnr'] ?? '', ENT_XML1, 'UTF-8').''."\n"; $xml .= ' '.$qty.''."\n"; $xml .= ' '.htmlspecialchars($line['einheit'] ?? 'PCE', ENT_XML1, 'UTF-8').''."\n"; $xml .= ' '.htmlspecialchars($line['bezeichnung'] ?? '', ENT_XML1, 'UTF-8').''."\n"; $xml .= ' '.$netprice.''."\n"; $xml .= ' 1'."\n"; $xml .= ' '.$vat.''."\n"; $xml .= ' '."\n"; } $xml .= ' '."\n"; $xml .= ''; 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"; $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.status = 1"; $resql = $this->db->query($sql); if ($resql) { while ($obj = $this->db->fetch_object($resql)) { // 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, ); } } 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'); } }