dolibarr.idsconnect/class/idsconnect.class.php
data 5f5a389809 IDS Connect v2.2 - Menü-Integration, ADL-Hooks, Admin-Erweiterung
- Menü unter Einkauf > Lieferantenbestellungen statt eigenes Top-Menü
- ADL-Buttons auf Produkt-Lieferantenpreisen per Hook (pricesuppliercard)
- Admin-Seite: Großhändler-Schnellübersicht mit Version-Check
- Dashboard: Shop-öffnen-Button (LI-Action)
- Neue Datei: class/actions_idsconnect.class.php

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:45:15 +01:00

553 lines
18 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;
}
}
}
}
foreach ($itemNodes as $item) {
// Roh-Preise aus XML (können sich auf Preiseinheit beziehen)
$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');
// Preiseinheit normalisieren (0 oder negativ = 1)
if ($price_basis <= 0) {
$price_basis = 1;
}
// Einzelpreis pro Stück berechnen (NetPrice / PriceBasis)
$einzelpreis = ($price_basis != 1) ? $raw_netprice / $price_basis : $raw_netprice;
$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' => (float) $this->getXmlValue($item, array('Qty', 'Menge', 'menge', 'NEW_ITEM-QUANTITY'), '0'),
'einheit' => $this->getXmlValue($item, array('QU', 'Einheit', 'einheit', 'ME', 'NEW_ITEM-UNIT'), 'STK'),
'einzelpreis' => $einzelpreis,
'angebotspreis' => $angebotspreis,
'preiseinheit' => ($price_basis != 1) ? (int) $price_basis : 0,
'raw_netprice' => $raw_netprice,
'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;
}
/**
* 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');
}
}