From cf1b2815fcb377e79556acf1a2b1b85f5432555a Mon Sep 17 00:00:00 2001 From: data Date: Tue, 24 Feb 2026 19:26:20 +0100 Subject: [PATCH] IDS Connect v2.3 - NetPrice-Korrektur & Produktzuordnung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonepar sendet NetPrice als Zeilengesamtpreis statt Stückpreis - automatische Format-Erkennung per Voting-Heuristik. Warenkorbübernahme verknüpft jetzt Artikel mit Dolibarr-Produkten über Lieferanten-Artikelnummern. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 25 ++++++++ ChangeLog.md | 10 +++ cart_review.php | 80 +++++++++++++++++++----- class/idsconnect.class.php | 91 ++++++++++++++++++++++++++-- core/modules/modIdsconnect.class.php | 2 +- 5 files changed, 187 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5fc82..2d81edc 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ --- +## v2.3 - NetPrice-Korrektur & Produktzuordnung (24.02.2026) + +### Preisberechnung korrigiert (NetPrice-Format) +- **Problem**: Sonepar sendet `NetPrice` als **Zeilengesamtpreis** (nicht als Stückpreis wie im IDS-Standard) + - Beispiel: 20 Stk Steckdose, NetPrice=72.00 → korrekt 3.60€/Stk, war fälschlich 72€/Stk → 1.440€ statt 72€ +- **Lösung**: Automatische Format-Erkennung per Voting-Heuristik in `parseCartXml()`: + - Erster Durchlauf: Prüft alle Artikel ob `NetPrice > OfferPrice` bei `Qty > 1` + - Ein rabattierter Stückpreis kann nie über dem Listenpreis liegen → muss Zeilensumme sein + - Mehrheitsentscheidung bestimmt das Format für den gesamten Warenkorb +- **Rückwärtskompatibel**: Ohne OfferPrice (Mock-Server, andere Großhändler) → IDS-Standard wird beibehalten +- **PriceBasis-Anzeige**: Wird bei erkanntem Zeilensummen-Format unterdrückt + +### Produktzuordnung bei Warenkorbübernahme +- **Problem**: Alle Artikel wurden als Freitext-Positionen in die Bestellung übernommen, nicht als Dolibarr-Produkte +- **Neue Methode**: `matchProducts()` in `idsconnect.class.php` - Batch-Lookup der Lieferanten-Artikelnummern gegen `llx_product_fournisseur_price` +- **cart_review.php** nutzt jetzt `fk_product` und `fk_prod_fourn_price` beim Erstellen der Bestellpositionen +- **Anzeige**: Neue Spalte "Dolibarr-Produkt" in der Warenkorbprüfung mit Link zum Produkt oder "Freitext" +- **Info-Banner**: Zeigt "Produkte erkannt: X/Y" als Übersicht + +### XML-Parsing Priorität +- `cart_review.php` parst Warenkorb jetzt immer frisch aus `cart_xml` (statt aus `response_data`) +- Damit greifen Bugfixes im Parser auch für bereits gespeicherte Warenkörbe + +--- + ## v2.2 - Menü-Integration, ADL-Hooks & Admin-Erweiterung (19.02.2026) ### Menü unter Einkauf/Lieferantenbestellungen diff --git a/ChangeLog.md b/ChangeLog.md index 4e91320..e024a13 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,15 @@ # CHANGELOG MODULE IDSCONNECT FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) +## 2.3 (24.02.2026) +- NetPrice-Format-Erkennung: Sonepar sendet Zeilengesamtpreis statt Stückpreis +- Produktzuordnung: Lieferanten-Artikelnummern werden gegen Dolibarr-Produkte gematcht +- cart_review.php parst XML immer frisch (Bugfixes greifen rückwirkend) + +## 2.2 (19.02.2026) +- Menü-Integration unter Einkauf/Lieferantenbestellungen +- ADL-Buttons auf Produkt-Lieferantenpreisen (Hook) +- Admin-Seite mit Großhändler-Übersicht + ## 2.1 (18.02.2026) - WKS-Flow live getestet mit Sonepar (Warenkorb senden) - buildCartXml auf IDS Connect 2.0 Format umgestellt diff --git a/cart_review.php b/cart_review.php index 87b57c1..6f5a5d8 100755 --- a/cart_review.php +++ b/cart_review.php @@ -77,21 +77,34 @@ if ($log->fk_supplier > 0) { $supplier->fetch($log->fk_supplier); } -// Artikel aus dem Log parsen +// Artikel immer aus dem XML neu parsen (nutzt aktuelle Preis-Logik) +$idsconnect = new IdsConnect($db); $items = array(); -if (!empty($log->response_data)) { +if (!empty($log->cart_xml)) { + $items = $idsconnect->parseCartXml($log->cart_xml); + if ($items === false) { + $items = array(); + } +} +// Fallback: aus response_data wenn kein XML vorhanden +if (empty($items) && !empty($log->response_data)) { $response = json_decode($log->response_data, true); if (!empty($response['items'])) { $items = $response['items']; } } -// Falls keine Artikel im Response, aus dem XML parsen -if (empty($items) && !empty($log->cart_xml)) { - $idsconnect = new IdsConnect($db); - $items = $idsconnect->parseCartXml($log->cart_xml); - if ($items === false) { - $items = array(); +// Produktzuordnung: Lieferantenreferenzen gegen Dolibarr-Produkte matchen +$product_matches = array(); +if (!empty($items) && $supplier->fk_soc > 0) { + $ref_list = array(); + foreach ($items as $item) { + if (!empty($item['artikelnr'])) { + $ref_list[] = $item['artikelnr']; + } + } + if (!empty($ref_list)) { + $product_matches = $idsconnect->matchProducts($ref_list, $supplier->fk_soc); } } @@ -118,15 +131,25 @@ if ($action == 'create_order' && $user->hasRight('fournisseur', 'commande', 'cre $line_errors = 0; foreach ($items as $item) { $vat_rate = !empty($item['mwst_satz']) ? $item['mwst_satz'] : 19; + + // Produkt-Match prüfen + $fk_product = 0; + $fk_prod_fourn_price = 0; + if (!empty($item['artikelnr']) && isset($product_matches[$item['artikelnr']])) { + $match = $product_matches[$item['artikelnr']]; + $fk_product = $match['fk_product']; + $fk_prod_fourn_price = $match['fk_prod_fourn_price']; + } + $result = $order->addline( $item['bezeichnung'], // desc - $item['einzelpreis'], // pu_ht (Stückpreis, schon durch PE geteilt) + $item['einzelpreis'], // pu_ht (Stückpreis) $item['menge'], // qty $vat_rate, // txtva 0, // txlocaltax1 0, // txlocaltax2 - 0, // fk_product - 0, // fk_prod_fourn_price + $fk_product, // fk_product (Dolibarr-Produkt oder 0) + $fk_prod_fourn_price, // fk_prod_fourn_price $item['artikelnr'], // ref_supplier 0, // remise_percent 'HT' // price_base_type @@ -182,11 +205,20 @@ if (empty($items)) { } // Info-Banner +$matched_count = 0; +foreach ($items as $item) { + if (!empty($item['artikelnr']) && isset($product_matches[$item['artikelnr']])) { + $matched_count++; + } +} print '
'; print ''.$langs->trans("IdsconnectCartReviewInfo").'
'; print 'Großhändler: '.htmlspecialchars($supplier->label ?: '-').''; print ' | Empfangen: '.dol_print_date($log->date_creation, 'dayhour'); print ' | Artikel: '.count($items).''; +if ($matched_count > 0) { + print ' | Produkte erkannt: '.$matched_count.'/'.count($items).''; +} print '
'; // Artikel-Tabelle @@ -194,7 +226,7 @@ print ''; print ''; print ''; print ''; -print ''; +print ''; print ''; print ''; print ''; @@ -206,10 +238,30 @@ foreach ($items as $item) { $line_total = $item['gesamtpreis'] ?: ($item['menge'] * $item['einzelpreis']); $total += $line_total; + // Produkt-Match für Anzeige + $match = null; + if (!empty($item['artikelnr']) && isset($product_matches[$item['artikelnr']])) { + $match = $product_matches[$item['artikelnr']]; + } + print ''; print ''; - print ''; - print ''; + print ''; + // Dolibarr-Produkt + print ''; print ''; print ''; print '
'.$langs->trans("IdsconnectCartArticleNr").''.$langs->trans("IdsconnectCartDescription").''.$langs->trans("IdsconnectCartManufacturer").'Dolibarr-Produkt'.$langs->trans("IdsconnectCartQty").''.$langs->trans("IdsconnectCartUnit").''.$langs->trans("IdsconnectCartUnitPrice").'
'.htmlspecialchars($item['artikelnr']).''.htmlspecialchars($item['bezeichnung']).''.htmlspecialchars($item['hersteller'] ?: '-').''.htmlspecialchars($item['bezeichnung']); + if (!empty($item['hersteller'])) { + print '
'.htmlspecialchars($item['hersteller']).''; + } + print '
'; + if ($match) { + print ''; + print img_picto('', 'product', 'class="paddingright"'); + print htmlspecialchars($match['product_ref']).''; + print '
'.htmlspecialchars($match['product_label']).''; + } else { + print 'Freitext'; + } + print '
'.($item['menge']).''.htmlspecialchars($item['einheit']).''.price($item['einzelpreis']); diff --git a/class/idsconnect.class.php b/class/idsconnect.class.php index 6a72eea..01fb06a 100755 --- a/class/idsconnect.class.php +++ b/class/idsconnect.class.php @@ -417,33 +417,66 @@ class IdsConnect } } + // ============================================================ + // 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) { - // Roh-Preise aus XML (können sich auf Preiseinheit beziehen) + $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; } - // Einzelpreis pro Stück berechnen (NetPrice / PriceBasis) - $einzelpreis = ($price_basis != 1) ? $raw_netprice / $price_basis : $raw_netprice; + 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' => (float) $this->getXmlValue($item, array('Qty', 'Menge', 'menge', 'NEW_ITEM-QUANTITY'), '0'), + 'menge' => $menge, '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, + 'preiseinheit' => ($price_basis != 1 && !$netprice_is_line_total) ? (int) $price_basis : 0, 'raw_netprice' => $raw_netprice, - 'gesamtpreis' => (float) $this->getXmlValue($item, array('GR', 'Gesamtpreis', 'gesamtpreis', 'GP'), '0'), + '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')), @@ -518,6 +551,52 @@ class IdsConnect 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 * diff --git a/core/modules/modIdsconnect.class.php b/core/modules/modIdsconnect.class.php index 20f8f28..0745501 100755 --- a/core/modules/modIdsconnect.class.php +++ b/core/modules/modIdsconnect.class.php @@ -59,7 +59,7 @@ class modIdsconnect extends DolibarrModules $this->editor_name = 'Alles Watt laeuft'; $this->editor_url = ''; - $this->version = '1.8'; + $this->version = '2.3'; $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);