IDS Connect v2.3 - NetPrice-Korrektur & Produktzuordnung
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 <noreply@anthropic.com>
This commit is contained in:
parent
5f5a389809
commit
cf1b2815fc
5 changed files with 187 additions and 21 deletions
25
CHANGELOG.md
25
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
|
||||
|
|
|
|||
10
ChangeLog.md
10
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
|
||||
|
|
|
|||
|
|
@ -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 '<div class="info">';
|
||||
print '<strong>'.$langs->trans("IdsconnectCartReviewInfo").'</strong><br>';
|
||||
print 'Großhändler: <strong>'.htmlspecialchars($supplier->label ?: '-').'</strong>';
|
||||
print ' | Empfangen: '.dol_print_date($log->date_creation, 'dayhour');
|
||||
print ' | Artikel: <strong>'.count($items).'</strong>';
|
||||
if ($matched_count > 0) {
|
||||
print ' | Produkte erkannt: <strong>'.$matched_count.'/'.count($items).'</strong>';
|
||||
}
|
||||
print '</div>';
|
||||
|
||||
// Artikel-Tabelle
|
||||
|
|
@ -194,7 +226,7 @@ print '<table class="noborder centpercent">';
|
|||
print '<tr class="liste_titre">';
|
||||
print '<th>'.$langs->trans("IdsconnectCartArticleNr").'</th>';
|
||||
print '<th>'.$langs->trans("IdsconnectCartDescription").'</th>';
|
||||
print '<th>'.$langs->trans("IdsconnectCartManufacturer").'</th>';
|
||||
print '<th>Dolibarr-Produkt</th>';
|
||||
print '<th class="right">'.$langs->trans("IdsconnectCartQty").'</th>';
|
||||
print '<th>'.$langs->trans("IdsconnectCartUnit").'</th>';
|
||||
print '<th class="right">'.$langs->trans("IdsconnectCartUnitPrice").'</th>';
|
||||
|
|
@ -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 '<tr class="oddeven">';
|
||||
print '<td><code>'.htmlspecialchars($item['artikelnr']).'</code></td>';
|
||||
print '<td>'.htmlspecialchars($item['bezeichnung']).'</td>';
|
||||
print '<td>'.htmlspecialchars($item['hersteller'] ?: '-').'</td>';
|
||||
print '<td>'.htmlspecialchars($item['bezeichnung']);
|
||||
if (!empty($item['hersteller'])) {
|
||||
print '<br><span class="opacitymedium" style="font-size:0.85em">'.htmlspecialchars($item['hersteller']).'</span>';
|
||||
}
|
||||
print '</td>';
|
||||
// Dolibarr-Produkt
|
||||
print '<td>';
|
||||
if ($match) {
|
||||
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$match['fk_product'].'">';
|
||||
print img_picto('', 'product', 'class="paddingright"');
|
||||
print htmlspecialchars($match['product_ref']).'</a>';
|
||||
print '<br><span class="opacitymedium" style="font-size:0.85em">'.htmlspecialchars($match['product_label']).'</span>';
|
||||
} else {
|
||||
print '<span class="opacitymedium">Freitext</span>';
|
||||
}
|
||||
print '</td>';
|
||||
print '<td class="right">'.($item['menge']).'</td>';
|
||||
print '<td>'.htmlspecialchars($item['einheit']).'</td>';
|
||||
print '<td class="right">'.price($item['einzelpreis']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue