dolibarr.idsconnect/cart_review.php
data f1e5a47674 IDS Connect v3.1 - Preis-Vergleich & Produktmatching-Fix
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>
2026-03-12 12:27:37 +01:00

475 lines
17 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/cart_review.php
* \ingroup idsconnect
* \brief Empfangenen Warenkorb prüfen und als Lieferantenbestellung übernehmen
*/
// Dolibarr laden
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.commande.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
dol_include_once('/idsconnect/class/idslog.class.php');
dol_include_once('/idsconnect/class/idssupplier.class.php');
dol_include_once('/idsconnect/class/idsconnect.class.php');
dol_include_once('/idsconnect/lib/idsconnect.lib.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
$langs->loadLangs(array("idsconnect@idsconnect", "orders", "bills"));
if (!$user->hasRight('idsconnect', 'use')) {
accessforbidden();
}
$log_id = GETPOSTINT('log_id');
$action = GETPOST('action', 'aZ09');
// Log laden
$log = new IdsLog($db);
if ($log_id > 0) {
$log->fetch($log_id);
}
// Großhändler laden
$supplier = new IdsSupplier($db);
if ($log->fk_supplier > 0) {
$supplier->fetch($log->fk_supplier);
}
// Artikel immer aus dem XML neu parsen (nutzt aktuelle Preis-Logik)
$idsconnect = new IdsConnect($db);
$items = array();
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'];
}
}
// 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);
}
}
// HEURISTIK: Sonepar sendet NetPrice manchmal für Qty statt PriceBasis
// Korrigiere einzelpreis wenn NetPrice/Qty näher am DB-Preis liegt
if (!empty($items) && !empty($product_matches)) {
foreach ($items as &$item) {
$match = $product_matches[$item['artikelnr']] ?? null;
if ($match && !empty($match['stored_price']) && !empty($item['raw_netprice']) && $item['menge'] > 0) {
// Zwei Kandidaten für Stückpreis
$price_from_pricebasis = $item['einzelpreis']; // NetPrice / PriceBasis
$price_from_qty = (float) $item['raw_netprice'] / (float) $item['menge']; // NetPrice / Qty
// Welcher ist näher am DB-Preis?
$diff_pricebasis = abs($price_from_pricebasis - (float) $match['stored_price']);
$diff_qty = abs($price_from_qty - (float) $match['stored_price']);
// Wenn Qty-basiert deutlich näher ist (mindestens 30% Unterschied), nutze das
if ($diff_qty < $diff_pricebasis * 0.7) {
$item['einzelpreis'] = $price_from_qty;
$item['preiseinheit'] = $item['menge']; // Korrektur: NetPrice gilt für Qty, nicht PriceBasis
$item['_price_heuristic'] = 'qty-based'; // Debug-Flag
} else {
$item['_price_heuristic'] = 'pricebasis-standard'; // Debug-Flag
}
}
}
unset($item); // Referenz aufheben
}
/*
* Actions
*/
// Lieferantenbestellung erstellen
if ($action == 'create_order' && $user->hasRight('fournisseur', 'commande', 'creer')) {
if (!verifCond(GETPOST('token', 'alpha') == newToken())) {
accessforbidden('Bad CSRF token');
}
if ($supplier->fk_soc > 0 && !empty($items)) {
$order = new CommandeFournisseur($db);
$order->socid = $supplier->fk_soc;
$order->note_private = 'Erstellt via IDS Connect aus Warenkorb vom '.dol_print_date($log->date_creation, 'dayhour').' ('.$supplier->label.')';
$db->begin();
$order_id = $order->create($user);
if ($order_id > 0) {
$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)
$item['menge'], // qty
$vat_rate, // txtva
0, // txlocaltax1
0, // txlocaltax2
$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
);
if ($result < 0) {
$line_errors++;
}
}
if ($line_errors == 0) {
$db->commit();
// Log aktualisieren
$log->updateStatus('success', $log->response_data);
$log->updateCart($log->cart_xml, $order_id);
setEventMessages($langs->trans("IdsconnectCartImported"), null, 'mesgs');
header('Location: '.DOL_URL_ROOT.'/fourn/commande/card.php?id='.$order_id);
exit;
} else {
$db->rollback();
setEventMessages('Fehler beim Erstellen der Bestellpositionen', null, 'errors');
}
} else {
$db->rollback();
setEventMessages('Fehler beim Erstellen der Bestellung: '.$order->error, null, 'errors');
}
} else {
if ($supplier->fk_soc <= 0) {
setEventMessages('Großhändler hat keinen verknüpften Dolibarr-Lieferanten. Bitte zuerst in der Großhändler-Konfiguration zuweisen.', null, 'errors');
}
}
}
// Manuelle Preis-Aktualisierung
if ($action == 'update_prices' && $user->hasRight('produit', 'creer')) {
if (!verifCond(GETPOST('token', 'alpha') == newToken())) {
accessforbidden('Bad CSRF token');
}
if (getDolGlobalInt('IDSCONNECT_PRICE_UPDATE_ENABLED')) {
$threshold = (float) getDolGlobalString('IDSCONNECT_PRICE_UPDATE_THRESHOLD', '5');
$update_prices = GETPOST('update_price', 'array'); // Array von rowids die aktualisiert werden sollen
$updated_count = 0;
if (!empty($update_prices) && !empty($items)) {
foreach ($items as $item) {
if (!empty($item['artikelnr']) && isset($product_matches[$item['artikelnr']])) {
$match = $product_matches[$item['artikelnr']];
$rowid = $match['fk_prod_fourn_price'];
// Prüfen ob diese Position aktualisiert werden soll
if (in_array($rowid, $update_prices)) {
// Mindestmengen-Preis (Basis-Preis) updaten, nicht Stückpreis
$old_basis_price = (float) $match['debug_price'];
$new_basis_price = !empty($item['raw_netprice']) ? (float) $item['raw_netprice'] : ($item['einzelpreis'] * (!empty($item['preiseinheit']) ? $item['preiseinheit'] : 1));
if ($old_basis_price > 0 && $new_basis_price > 0) {
$sql = "UPDATE ".$db->prefix()."product_fournisseur_price";
$sql .= " SET price = ".((float) $new_basis_price).", tms = NOW()";
$sql .= " WHERE rowid = ".((int) $rowid);
if ($db->query($sql)) {
$updated_count++;
dol_syslog("IDS Connect: Preis für ".$item['artikelnr']." manuell aktualisiert (Basis-Preis): ".$old_basis_price."".$new_basis_price." (".sprintf('%+.1f%%', (($new_basis_price - $old_basis_price) / $old_basis_price) * 100).")", LOG_INFO);
}
}
}
}
}
if ($updated_count > 0) {
setEventMessages($updated_count.' '.$langs->trans("IdsconnectPricesUpdated"), null, 'mesgs');
} else {
setEventMessages($langs->trans("IdsconnectNoPricesSelected"), null, 'warnings');
}
} else {
setEventMessages($langs->trans("IdsconnectNoPricesSelected"), null, 'warnings');
}
}
}
/*
* View
*/
$form = new Form($db);
llxHeader('', $langs->trans("IdsconnectCartReview"), '', '', 0, 0, '', '', '', 'mod-idsconnect page-cart_review');
print load_fiche_titre($langs->trans("IdsconnectCartReviewTitle"), '', 'fa-shopping-cart');
idsconnectShowTestModeBanner();
if (empty($items)) {
print '<div class="warning">'.$langs->trans("IdsconnectCartEmpty").'</div>';
print '<br><a href="'.DOL_URL_ROOT.'/custom/idsconnect/idsconnectindex.php" class="butAction">'.$langs->trans("Back").'</a>';
llxFooter();
$db->close();
exit;
}
// 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>';
} else {
print ' | <span style="color:orange;">⚠ Keine Produkte erkannt</span>';
}
print '</div>';
// DEBUG: Produkt-Matching
if ($user->admin && $matched_count == 0) {
print '<div class="warning">';
print '<strong>DEBUG: Produkt-Matching</strong><br>';
print 'Sonepar fk_soc: <strong>'.$supplier->fk_soc.'</strong><br>';
print 'Gesuchte Artikelnummern: <strong>';
$search_refs = array();
foreach ($items as $item) {
if (!empty($item['artikelnr'])) {
$search_refs[] = $item['artikelnr'];
}
}
print implode(', ', $search_refs).'</strong><br>';
print 'Gefundene Matches: <strong>'.count($product_matches).'</strong><br>';
// Prüfen ob Artikel in DB existieren
if ($supplier->fk_soc > 0 && !empty($search_refs)) {
$sql = "SELECT ref_fourn, fk_product FROM ".$db->prefix()."product_fournisseur_price";
$sql .= " WHERE fk_soc = ".((int) $supplier->fk_soc);
$sql .= " LIMIT 5";
$resql = $db->query($sql);
if ($resql) {
$count = $db->num_rows($resql);
print 'Artikel für diesen Lieferanten in DB: <strong>'.$count.'+ Einträge</strong><br>';
if ($count > 0) {
print 'Beispiele: ';
while ($obj = $db->fetch_object($resql)) {
print $obj->ref_fourn.', ';
}
}
}
}
print '</div>';
}
// Formular für Preis-Updates
$has_price_changes = false;
$threshold = (float) getDolGlobalString('IDSCONNECT_PRICE_UPDATE_THRESHOLD', '5');
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'?log_id='.$log->id.'" name="price_update_form">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="update_prices">';
// Artikel-Tabelle
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("IdsconnectCartArticleNr").'</th>';
print '<th>'.$langs->trans("IdsconnectCartDescription").'</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>';
print '<th class="right">'.$langs->trans("IdsconnectPriceDeviation").'</th>';
print '<th class="right">'.$langs->trans("IdsconnectCartTotalPrice").'</th>';
print '</tr>';
$total = 0;
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']);
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']);
// Preiseinheit-Info anzeigen wenn vorhanden (z.B. "Preis/100")
if (!empty($item['preiseinheit']) && $item['preiseinheit'] > 1) {
print '<br><span class="opacitymedium" style="font-size:0.85em">'.price($item['raw_netprice']).' / '.$item['preiseinheit'].' Stk</span>';
}
print '</td>';
// Preis-Abweichung
print '<td class="right">';
if ($match && !empty($match['stored_price']) && $match['stored_price'] > 0) {
// Stückpreis-Vergleich (einzelpreis bereits korrekt berechnet)
$shop_unit_price = (float) $item['einzelpreis'];
$db_unit_price = (float) $match['stored_price'];
// DEBUG für Admin: Zeige Rohdaten zur Nachvollziehbarkeit
if ($user->admin) {
print '<div style="font-size:0.7em; color:#666; text-align:left;">';
print 'Shop: '.price($item['raw_netprice']).' / '.$item['preiseinheit'].' Stk = '.price($shop_unit_price).'/Stk<br>';
print 'DB: '.price($match['debug_price']).' / '.$match['debug_qty'].' Stk = '.price($db_unit_price).'/Stk<br>';
print '</div>';
}
$deviation = (($shop_unit_price - $db_unit_price) / $db_unit_price) * 100;
$abs_deviation = abs($deviation);
// Farbcode: Grün ≤2%, Gelb 2-10%, Rot >10%
if ($abs_deviation <= 2) {
$color = '#28a745'; // Grün
} elseif ($abs_deviation <= 10) {
$color = '#ffc107'; // Gelb
} else {
$color = '#dc3545'; // Rot
}
print '<span style="color:'.$color.'; font-weight:bold;">'.sprintf('%+.1f%%', $deviation).'</span>';
print '<br><span class="opacitymedium" style="font-size:0.85em">DB: '.price($db_unit_price).'/Stk</span>';
// Checkbox für Preis-Update (nur bei Abweichung über Schwellwert UND wenn Feature aktiviert)
if ($abs_deviation > $threshold && getDolGlobalInt('IDSCONNECT_PRICE_UPDATE_ENABLED') && $user->hasRight('produit', 'creer')) {
$has_price_changes = true;
print '<br><input type="checkbox" name="update_price[]" value="'.$match['fk_prod_fourn_price'].'" id="price_'.$match['fk_prod_fourn_price'].'">';
print ' <label for="price_'.$match['fk_prod_fourn_price'].'" style="font-size:0.85em; cursor:pointer;">'.$langs->trans("IdsconnectUpdatePrice").'</label>';
}
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '<td class="right">'.price($line_total).'</td>';
print '</tr>';
}
// Summe
print '<tr class="liste_total">';
print '<td colspan="7" class="right"><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="right"><strong>'.price($total).'</strong></td>';
print '</tr>';
print '</table>';
print '</form>';
// Aktionsbuttons
print '<div class="tabsAction">';
// Preis-Update-Button (nur wenn es Änderungen gibt)
if ($has_price_changes) {
print '<a class="butAction" href="javascript:document.price_update_form.submit();">'.$langs->trans("IdsconnectUpdateSelectedPrices").'</a>';
}
if ($user->hasRight('fournisseur', 'commande', 'creer') && $supplier->fk_soc > 0) {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?log_id='.$log->id.'&action=create_order&token='.newToken().'">'.$langs->trans("IdsconnectCartCreateOrder").'</a>';
} elseif ($supplier->fk_soc <= 0) {
print '<span class="butActionRefused" title="Kein Dolibarr-Lieferant verknüpft">'.$langs->trans("IdsconnectCartCreateOrder").'</span>';
}
print '<a class="butAction" href="'.DOL_URL_ROOT.'/custom/idsconnect/idsconnectindex.php">'.$langs->trans("Back").'</a>';
print '</div>';
// XML-Details (aufklappbar)
if (!empty($log->cart_xml)) {
print '<br>';
print '<details>';
print '<summary class="opacitymedium" style="cursor:pointer;">XML-Rohdaten anzeigen</summary>';
print '<div class="idsconnect-log-detail">';
print htmlspecialchars($log->cart_xml);
print '</div>';
print '</details>';
}
llxFooter();
$db->close();