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>
This commit is contained in:
Eduard Wisch 2026-03-12 12:27:37 +01:00
parent cf1b2815fc
commit f1e5a47674
8 changed files with 421 additions and 23 deletions

View file

@ -9,28 +9,46 @@
---
## v2.3 - NetPrice-Korrektur & Produktzuordnung (24.02.2026)
## v2.9 - Preis-Vergleich & Kritische Bugfixes (12.03.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
### 🐛 KRITISCHER BUGFIX: SQL-Spaltenname korrigiert
- **Problem**: `matchProducts()` hat SQL-Fehler produziert wegen falscher Spalte `pfp.qty` (existiert nicht!)
- **Fix**: Alle `pfp.qty``pfp.quantity` ersetzt in:
- [class/idsconnect.class.php:578](class/idsconnect.class.php#L578) - matchProducts() SELECT Query
- [class/idsconnect.class.php:589](class/idsconnect.class.php#L589) - $obj->qty → $obj->quantity
- [tab_supplierorder.php:154,177](tab_supplierorder.php#L154) - Preis-Vergleich Queries
- **Auswirkung**: Vorher wurden ALLE Artikel als Freitext importiert (kein Produkt-Matching!)
- **Test**: Artikel 0486597 wird jetzt korrekt als COK-WAG-2273-202-2F erkannt ✅
### 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
### 🐛 BUGFIX: Produkt-Matching erweitert
- **Fix**: `p.status = 1``p.tosell = 1` in matchProducts() und tab_supplierorder.php
- **Grund**: Dolibarr verwendet `tosell` für verkaufbare Produkte, nicht `status`
### 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
### ✨ Preis-Vergleich in Warenkorb-Import (cart_review.php)
- **Basis-Price-Vergleich**: Shop-Preis (z.B. 12,88€/100 Stk) vs. DB-Preis (12,88€/100 Stk)
- **Vorteil**: Keine Rundungsfehler mehr durch Stückpreis-Umrechnung
- **Berechnung**:
- `shop_basis_price = raw_netprice` (vom Shop empfangen)
- `db_basis_price = debug_price` (aus DB geladen)
- Deviation = ((shop - db) / db) × 100
- **DEBUG-Ausgabe** für Admins: Zeigt Shop-Preis, DB-Preis, Einzelpreis zur Kontrolle
- **Farbcodierung**:
- Grün: ≤2% Abweichung
- Gelb: 2-10% Abweichung
- Rot: >10% Abweichung
### ✨ Preis-Vergleich in Lieferantenbestellungen (tab_supplierorder.php)
- **Admin-Option**: `IDSCONNECT_PRICE_UPDATE_ENABLED` (Standard: AUS)
- **Schwellwert**: `IDSCONNECT_PRICE_UPDATE_THRESHOLD` (Standard: 5%)
- **Anzeige**: Tabelle mit allen Positionen, die vom Schwellwert abweichen
- **Freitext-Warnung**: Zeigt an, wenn Artikel nicht mit Dolibarr-Produkten verknüpft sind
- **Keine Auto-Updates**: Nur Anzeige, keine automatische Preis-Aktualisierung
### 🔧 Technische Details
- **matchProducts()**: Lädt jetzt `pfp.quantity`, `pfp.price` korrekt
- **Stückpreis-Berechnung**: `unit_price = price / quantity` (mit Fallback qty=1)
- **Debug-Felder**: `debug_price` und `debug_qty` für Basis-Price-Vergleich
- **Sprachdateien**: Neue Keys für Preis-Verwaltung (de_DE)
---

View file

@ -91,6 +91,9 @@ if ($action == 'update' && $user->admin) {
$pin_hash = password_hash($new_pin, PASSWORD_DEFAULT);
dolibarr_set_const($db, 'IDSCONNECT_WKS_PIN', $pin_hash, 'chaine', 0, '', $conf->entity);
}
// Preis-Update (manuell)
dolibarr_set_const($db, 'IDSCONNECT_PRICE_UPDATE_ENABLED', GETPOSTINT('IDSCONNECT_PRICE_UPDATE_ENABLED'), 'chaine', 0, '', $conf->entity);
dolibarr_set_const($db, 'IDSCONNECT_PRICE_UPDATE_THRESHOLD', GETPOST('IDSCONNECT_PRICE_UPDATE_THRESHOLD', 'alphanohtml'), 'chaine', 0, '', $conf->entity);
if (!$error) {
setEventMessages($langs->trans("SetupSaved"), null, 'mesgs');
@ -153,6 +156,27 @@ print '<td>';
print $form->selectyesno('IDSCONNECT_LOG_ENABLED', getDolGlobalInt('IDSCONNECT_LOG_ENABLED', 1), 1);
print '</td></tr>';
// Überschrift: Preis-Verwaltung
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("IdsconnectPriceManagement").'</td>';
print '</tr>';
// Preis-Update aktivieren
print '<tr class="oddeven">';
print '<td>'.$langs->trans("IdsconnectPriceUpdateEnabled").'</td>';
print '<td>';
print $form->selectyesno('IDSCONNECT_PRICE_UPDATE_ENABLED', getDolGlobalInt('IDSCONNECT_PRICE_UPDATE_ENABLED', 0), 1);
print ' <span class="opacitymedium">'.$langs->trans("IdsconnectPriceUpdateEnabledHelp").'</span>';
print '</td></tr>';
// Preis-Update Schwellwert
print '<tr class="oddeven">';
print '<td>'.$langs->trans("IdsconnectPriceUpdateThreshold").'</td>';
print '<td>';
print '<input type="number" name="IDSCONNECT_PRICE_UPDATE_THRESHOLD" value="'.getDolGlobalString('IDSCONNECT_PRICE_UPDATE_THRESHOLD', '5').'" min="0" max="100" step="0.1" class="width100">';
print ' % <span class="opacitymedium">'.$langs->trans("IdsconnectPriceUpdateThresholdHelp").'</span>';
print '</td></tr>';
// Überschrift: WKS-Sicherheit
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("IdsconnectWksSecuritySettings").'</td>';

View file

@ -108,6 +108,33 @@ if (!empty($items) && $supplier->fk_soc > 0) {
}
}
// 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
@ -183,6 +210,53 @@ if ($action == 'create_order' && $user->hasRight('fournisseur', 'commande', 'cre
}
}
// 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
@ -218,9 +292,54 @@ 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">';
@ -230,6 +349,7 @@ 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>';
@ -270,21 +390,66 @@ foreach ($items as $item) {
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="6" class="right"><strong>'.$langs->trans("Total").'</strong></td>';
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) {

View file

@ -575,22 +575,30 @@ class IdsConnect
return $matches;
}
$sql = "SELECT pfp.fk_product, pfp.rowid as fk_prod_fourn_price, pfp.ref_fourn, p.ref, p.label";
$sql = "SELECT pfp.fk_product, pfp.rowid as fk_prod_fourn_price, pfp.ref_fourn, p.ref, p.label, pfp.price, pfp.quantity";
$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";
$sql .= " AND p.tosell = 1"; // Nur Produkte die verkauft werden können
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
// Stückpreis berechnen (price / quantity = Preis pro Einheit)
$qty = (float) $obj->quantity;
if ($qty <= 0) $qty = 1; // Fallback
$unit_price = (float) $obj->price / $qty;
// 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,
'stored_price' => $unit_price, // Stückpreis statt Gesamtpreis
'debug_price' => (float) $obj->price, // Rohdaten für Debug
'debug_qty' => $qty,
);
}
}

View file

@ -59,7 +59,7 @@ class modIdsconnect extends DolibarrModules
$this->editor_name = 'Alles Watt laeuft';
$this->editor_url = '';
$this->version = '2.3';
$this->version = '3.1';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);

View file

@ -149,6 +149,20 @@ IdsconnectTestModeActive = TESTMODUS AKTIV
IdsconnectTestModeInfo = Alle Verbindungen gehen zum lokalen Mock-Server. Keine echten Bestellungen möglich.
IdsconnectLiveModeWarning = LIVE-MODUS - Verbindungen gehen zum echten Großhandel!
#
# Preis-Verwaltung
#
IdsconnectPriceManagement = Preis-Verwaltung
IdsconnectPriceDeviation = Preis-Abweichung
IdsconnectPriceUpdateEnabled = Preis-Update bei WKE-Import aktivieren
IdsconnectPriceUpdateEnabledHelp = Zeigt Checkboxen bei Preisabweichungen im Warenkorb an. Sie können dann manuell auswählen, welche Preise aktualisiert werden sollen
IdsconnectPriceUpdateThreshold = Abweichungs-Schwellwert für Preis-Update
IdsconnectPriceUpdateThresholdHelp = Checkboxen werden nur angezeigt wenn die Abweichung diesen Prozentsatz übersteigt (Standard: 5%)
IdsconnectPricesUpdated = Preis(e) erfolgreich aktualisiert
IdsconnectNoPricesSelected = Keine Preise zur Aktualisierung ausgewählt
IdsconnectUpdatePrice = Preis aktualisieren
IdsconnectUpdateSelectedPrices = Ausgewählte Preise aktualisieren
#
# Berechtigungen
#

View file

@ -149,6 +149,20 @@ IdsconnectTestModeActive = TEST MODE ACTIVE
IdsconnectTestModeInfo = All connections go to the local mock server. No real orders possible.
IdsconnectLiveModeWarning = LIVE MODE - Connections go to the real wholesale shop!
#
# Price Management
#
IdsconnectPriceManagement = Price Management
IdsconnectPriceDeviation = Price Deviation
IdsconnectPriceUpdateEnabled = Enable price update on WKE import
IdsconnectPriceUpdateEnabledHelp = Shows checkboxes for price deviations in the cart. You can then manually select which prices should be updated
IdsconnectPriceUpdateThreshold = Deviation threshold for price update
IdsconnectPriceUpdateThresholdHelp = Checkboxes are only shown if the deviation exceeds this percentage (default: 5%)
IdsconnectPricesUpdated = price(s) successfully updated
IdsconnectNoPricesSelected = No prices selected for update
IdsconnectUpdatePrice = Update price
IdsconnectUpdateSelectedPrices = Update selected prices
#
# Permissions
#

View file

@ -141,6 +141,161 @@ if (!empty($matchingSuppliers)) {
print '</div>';
}
// Preis-Vergleich für Bestellpositionen
if (!empty($matchingSuppliers) && count($object->lines) > 0 && getDolGlobalInt('IDSCONNECT_PRICE_UPDATE_ENABLED')) {
print '<br><br>';
print '<h3>'.$langs->trans("IdsconnectPriceManagement").'</h3>';
// Aktuelle Preise aus product_fournisseur_price laden
$price_data = array();
foreach ($object->lines as $line) {
if ($line->fk_product > 0) {
// Produkt ist verknüpft → Preis aus product_fournisseur_price holen
$sql = "SELECT pfp.price, pfp.quantity, pfp.ref_fourn";
$sql .= " FROM ".$db->prefix()."product_fournisseur_price pfp";
$sql .= " WHERE pfp.fk_product = ".((int) $line->fk_product);
$sql .= " AND pfp.fk_soc = ".((int) $object->socid);
$sql .= " ORDER BY pfp.tms DESC LIMIT 1"; // Neuesten Preis
$resql = $db->query($sql);
if ($resql && $obj = $db->fetch_object($resql)) {
// Stückpreis berechnen (price / qty)
$price_qty = (float) $obj->quantity;
if ($price_qty <= 0) $price_qty = 1;
$unit_price = (float) $obj->price / $price_qty;
$price_data[$line->id] = array(
'current_price' => $unit_price, // Stückpreis statt Gesamtpreis
'ref_fourn' => $obj->ref_fourn,
'order_price' => (float) $line->subprice,
'qty' => $line->qty,
'fk_product' => $line->fk_product
);
}
} elseif (!empty($line->ref_supplier)) {
// Freitext → Preis über ref_supplier suchen
$sql = "SELECT pfp.price, pfp.quantity, pfp.fk_product, p.ref, p.label";
$sql .= " FROM ".$db->prefix()."product_fournisseur_price pfp";
$sql .= " JOIN ".$db->prefix()."product p ON p.rowid = pfp.fk_product";
$sql .= " WHERE pfp.ref_fourn = '".$db->escape($line->ref_supplier)."'";
$sql .= " AND pfp.fk_soc = ".((int) $object->socid);
$sql .= " AND p.tosell = 1";
$sql .= " ORDER BY pfp.tms DESC LIMIT 1";
$resql = $db->query($sql);
if ($resql && $obj = $db->fetch_object($resql)) {
// Stückpreis berechnen (price / qty)
$price_qty = (float) $obj->quantity;
if ($price_qty <= 0) $price_qty = 1;
$unit_price = (float) $obj->price / $price_qty;
$price_data[$line->id] = array(
'current_price' => $unit_price, // Stückpreis statt Gesamtpreis
'ref_fourn' => $line->ref_supplier,
'order_price' => (float) $line->subprice,
'qty' => $line->qty,
'fk_product' => (int) $obj->fk_product,
'product_ref' => $obj->ref,
'product_label' => $obj->label,
'is_freetext' => true
);
}
}
}
// Nur Positionen mit Abweichungen anzeigen
$threshold = (float) getDolGlobalString('IDSCONNECT_PRICE_UPDATE_THRESHOLD', '5');
$deviations = array();
foreach ($price_data as $line_id => $data) {
if ($data['current_price'] > 0 && $data['order_price'] > 0) {
$deviation = (($data['order_price'] - $data['current_price']) / $data['current_price']) * 100;
if (abs($deviation) > $threshold) {
$deviations[$line_id] = array_merge($data, array('deviation' => $deviation));
}
}
}
if (!empty($deviations)) {
print '<div class="info">';
print '<strong>'.count($deviations).' Position(en) mit Preisabweichung > '.$threshold.'%</strong>';
print '</div>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("IdsconnectCartArticleNr").'</th>';
print '<th>'.$langs->trans("IdsconnectCartDescription").'</th>';
print '<th class="right">'.$langs->trans("Qty").'</th>';
print '<th class="right">'.$langs->trans("IdsconnectCartUnitPrice").' (Bestellung)</th>';
print '<th class="right">'.$langs->trans("IdsconnectCartUnitPrice").' (Aktuell)</th>';
print '<th class="right">'.$langs->trans("IdsconnectPriceDeviation").'</th>';
print '<th></th>';
print '</tr>';
foreach ($deviations as $line_id => $data) {
// Zeile finden
$line = null;
foreach ($object->lines as $l) {
if ($l->id == $line_id) {
$line = $l;
break;
}
}
if (!$line) continue;
print '<tr class="oddeven">';
print '<td>'.$data['ref_fourn'].'</td>';
print '<td>';
if (!empty($data['is_freetext'])) {
print '<span class="badge badge-warning" title="Freitext-Position">!</span> ';
}
print $line->desc;
if (!empty($data['product_ref'])) {
print '<br><span class="opacitymedium">'.$data['product_ref'].' - '.$data['product_label'].'</span>';
}
print '</td>';
print '<td class="right">'.$line->qty.'</td>';
print '<td class="right">'.price($data['order_price']).'</td>';
print '<td class="right">'.price($data['current_price']).'</td>';
print '<td class="right">';
$abs_deviation = abs($data['deviation']);
if ($abs_deviation <= 2) {
$color = '#28a745';
} elseif ($abs_deviation <= 10) {
$color = '#ffc107';
} else {
$color = '#dc3545';
}
print '<span style="color:'.$color.'; font-weight:bold;">'.sprintf('%+.1f%%', $data['deviation']).'</span>';
print '</td>';
print '<td class="right">';
if (!empty($data['is_freetext']) && $user->hasRight('produit', 'creer')) {
print '<a class="butActionRefused" href="#" title="Position ist Freitext - kann nicht automatisch aktualisiert werden">Freitext</a>';
}
print '</td>';
print '</tr>';
}
print '</table>';
if (!empty($deviations)) {
$has_freetext = false;
foreach ($deviations as $data) {
if (!empty($data['is_freetext'])) {
$has_freetext = true;
break;
}
}
if ($has_freetext) {
print '<br><div class="warning">';
print '<strong>Hinweis:</strong> Freitext-Positionen können nicht automatisch aktualisiert werden. ';
print 'Bitte verknüpfen Sie diese Artikel zuerst mit Dolibarr-Produkten.';
print '</div>';
}
}
} else {
print '<div class="opacitymedium">Keine signifikanten Preisabweichungen gefunden (Schwellwert: '.$threshold.'%)</div>';
}
}
print dol_get_fiche_end();
llxFooter();