From c976123d815e7755514c5bbe5062e922e67286f0 Mon Sep 17 00:00:00 2001 From: data Date: Tue, 24 Feb 2026 08:40:11 +0100 Subject: [PATCH] Version 2.3: Preisvergleich und theoretischer Lagerbestand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preisvergleich zwischen Lieferanten mit prozentualem Unterschied - Günstigster-Lieferant-Badge (grünes Kürzel) in Kundenaufträgen - Preis-Indikator (Grün/Orange/Rot) in Lieferantenbestellungen - Lagerbestand-Anzeige: Physisch | Theoretisch (Dolibarr-Standard) - Tooltip mit HTML Entity für Zeilenumbrüche korrigiert - Verwendet Dolibarrs native stock_theorique Berechnung Co-Authored-By: Claude Opus 4.5 --- class/actions_supplierlink3.class.php | 361 +++++++++++++++++++----- core/modules/modSupplierLink3.class.php | 2 +- js/replenish.js | 101 +++++-- langs/de_DE/supplierlink3.lang | 20 ++ langs/en_US/supplierlink3.lang | 20 ++ 5 files changed, 407 insertions(+), 97 deletions(-) diff --git a/class/actions_supplierlink3.class.php b/class/actions_supplierlink3.class.php index 6c5db0a..5d34499 100755 --- a/class/actions_supplierlink3.class.php +++ b/class/actions_supplierlink3.class.php @@ -455,12 +455,13 @@ class ActionsSupplierLink3 extends CommonHookActions /* Add other hook methods here... */ /** - * Holt alle Lieferanten mit Shop-URL für ein Produkt + * Holt ALLE Lieferanten für ein Produkt (auch ohne Shop-URL für Preisvergleich) * * @param int $fk_product Produkt-ID + * @param bool $onlyWithShopUrl Nur Lieferanten mit Shop-URL zurückgeben * @return array Array mit Lieferanten-Daten, sortiert nach Preis aufsteigend */ - private function getAllSuppliersForProduct($fk_product) + private function getAllSuppliersForProduct($fk_product, $onlyWithShopUrl = true) { $suppliers = array(); @@ -470,26 +471,42 @@ class ActionsSupplierLink3 extends CommonHookActions pfp.price, pfp.quantity as min_qty, s.nom as supplier_name, + s.name_alias as supplier_alias, se.shop_url FROM ".MAIN_DB_PREFIX."product_fournisseur_price pfp INNER JOIN ".MAIN_DB_PREFIX."societe s ON s.rowid = pfp.fk_soc LEFT JOIN ".MAIN_DB_PREFIX."societe_extrafields se ON se.fk_object = pfp.fk_soc - WHERE pfp.fk_product = ".(int)$fk_product." - AND se.shop_url IS NOT NULL - AND se.shop_url != '' - ORDER BY pfp.price ASC"; + WHERE pfp.fk_product = ".(int)$fk_product; + + if ($onlyWithShopUrl) { + $sql .= " AND se.shop_url IS NOT NULL AND se.shop_url != ''"; + } + + $sql .= " ORDER BY pfp.price ASC"; $resql = $this->db->query($sql); if ($resql) { + $position = 0; + $totalSuppliers = $this->db->num_rows($resql); while ($obj = $this->db->fetch_object($resql)) { + $position++; + // Kürzel: Erste 3 Zeichen des Alias oder Namens (Großbuchstaben) + $shortName = !empty($obj->supplier_alias) ? $obj->supplier_alias : $obj->supplier_name; + $shortCode = strtoupper(substr(preg_replace('/[^a-zA-Z0-9]/', '', $shortName), 0, 3)); + $suppliers[] = array( 'supplier_id' => $obj->supplier_id, 'ref_fourn' => $obj->ref_fourn, - 'price' => $obj->price, + 'price' => (float)$obj->price, 'min_qty' => $obj->min_qty, 'supplier_name' => $obj->supplier_name, - 'shop_url' => rtrim($obj->shop_url, '/'), - 'full_url' => rtrim($obj->shop_url, '/') . '/' . $obj->ref_fourn + 'supplier_code' => $shortCode, + 'shop_url' => $obj->shop_url ? rtrim($obj->shop_url, '/') : '', + 'full_url' => $obj->shop_url ? rtrim($obj->shop_url, '/') . '/' . $obj->ref_fourn : '', + 'position' => $position, + 'total_suppliers' => $totalSuppliers, + 'is_cheapest' => ($position == 1), + 'is_most_expensive' => ($position == $totalSuppliers && $totalSuppliers > 1) ); } } @@ -498,46 +515,211 @@ class ActionsSupplierLink3 extends CommonHookActions } /** - * Generiert HTML-Badge für Lagerbestand + * Holt Lagerbestand-Daten aus Dolibarr (Physisch + Theoretisch) + * Verwendet Dolibarrs native stock_theorique Berechnung * - * 4 Zustände: - * - ROT: stock <= 0 (Nicht verfügbar) - * - ORANGE: stock < seuil_stock_alerte (Unter Mindestmenge) - * - GRAU: stock < desiredstock (Unter Wunschmenge) - * - GRÜN: stock >= desiredstock (Ausreichend) + * @param int $fk_product Produkt-ID + * @return array Array mit 'physical', 'theoretical' + */ + private function getStockData($fk_product) + { + require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + + $result = array( + 'physical' => 0, + 'theoretical' => 0 + ); + + $product = new Product($this->db); + if ($product->fetch($fk_product) > 0) { + // Physischer Bestand (stock_reel) + $result['physical'] = (float)$product->stock_reel; + + // Theoretischer Bestand - Dolibarrs native Berechnung laden + $product->load_stock(); + $result['theoretical'] = (float)$product->stock_theorique; + } + + return $result; + } + + /** + * Ermittelt die Preisposition eines Lieferanten für ein Produkt * - * @param float $qtyStock Aktueller Lagerbestand + * @param int $fk_product Produkt-ID + * @param int $supplier_id Lieferanten-ID + * @return array Array mit 'position', 'total', 'price_class' (cheapest/average/expensive) + */ + private function getSupplierPricePosition($fk_product, $supplier_id) + { + $result = array( + 'position' => 0, + 'total' => 0, + 'price_class' => 'unknown', + 'price' => 0, + 'min_price' => 0, + 'max_price' => 0, + 'percent_diff' => 0 + ); + + // Alle Lieferanten für dieses Produkt (auch ohne Shop-URL) + $suppliers = $this->getAllSuppliersForProduct($fk_product, false); + + if (empty($suppliers)) { + return $result; + } + + $result['total'] = count($suppliers); + $result['min_price'] = $suppliers[0]['price']; + $result['max_price'] = $suppliers[count($suppliers) - 1]['price']; + + foreach ($suppliers as $sup) { + if ($sup['supplier_id'] == $supplier_id) { + $result['position'] = $sup['position']; + $result['price'] = $sup['price']; + + // Prozentuale Differenz zum günstigsten + if ($result['min_price'] > 0 && $sup['price'] > $result['min_price']) { + $result['percent_diff'] = round((($sup['price'] - $result['min_price']) / $result['min_price']) * 100, 1); + } + + // Preisklasse bestimmen + if ($sup['is_cheapest']) { + $result['price_class'] = 'cheapest'; + } elseif ($sup['is_most_expensive']) { + $result['price_class'] = 'expensive'; + } else { + $result['price_class'] = 'average'; + } + break; + } + } + + return $result; + } + + /** + * Generiert HTML-Badge für Lagerbestand mit Physisch|Theoretisch Anzeige + * + * Format: [Physisch | Theoretisch] - Links physisch, rechts theoretisch (Dolibarr-Standard) + * Farbe basiert auf theoretischem Bestand + * + * @param int $fk_product Produkt-ID * @param float $desiredQty Wunschbestand * @param float $alertQty Mindestbestand (seuil_stock_alerte) * @return string HTML-Badge */ - private function getStockBadgeHtml($qtyStock, $desiredQty, $alertQty = 0) + private function getStockBadgeHtml($fk_product, $desiredQty, $alertQty = 0) { - if ($qtyStock <= 0) { - // ROT: Nicht verfügbar (0 oder negativ) + // Lagerbestand aus Dolibarr holen (verwendet stock_theorique) + $stockData = $this->getStockData($fk_product); + $physicalStock = $stockData['physical']; + $theoreticalStock = $stockData['theoretical']; + + // Farbe basiert auf theoretischem Bestand (verfügbar) + if ($theoreticalStock <= 0) { $badgeClass = 'badge-danger'; $icon = 'fa-times-circle'; - $tooltip = 'Nicht auf Lager'; - } elseif ($alertQty > 0 && $qtyStock < $alertQty) { - // ORANGE: Unter Mindestmenge (Alarm-Schwelle) + $tooltip = 'Nicht verfügbar'; + } elseif ($alertQty > 0 && $theoreticalStock < $alertQty) { $badgeClass = 'badge-warning'; $icon = 'fa-exclamation-triangle'; $tooltip = 'Unter Mindestmenge ('.(int)$alertQty.')'; - } elseif ($desiredQty > 0 && $qtyStock < $desiredQty) { - // GRAU: Unter Wunschmenge + } elseif ($desiredQty > 0 && $theoreticalStock < $desiredQty) { $badgeClass = 'badge-secondary'; $icon = 'fa-box-open'; $tooltip = 'Unter Wunschmenge ('.(int)$desiredQty.')'; } else { - // GRÜN: Ausreichend auf Lager $badgeClass = 'badge-success'; $icon = 'fa-check-circle'; - $tooltip = 'Ausreichend auf Lager'; + $tooltip = 'Ausreichend verfügbar'; } - $html = ''; - $html .= ''; - $html .= number_format($qtyStock, 0, ',', '.'); + // Differenz berechnen (offene Aufträge/Lieferungen) + $reserved = $physicalStock - $theoreticalStock; + + // Erweiterter Tooltip mit Details - HTML Entity für Zeilenumbruch in title-Attribut + $tooltipFull = $tooltip.' '; + $tooltipFull .= 'Physisch: '.(int)$physicalStock.' '; + $tooltipFull .= 'Theoretisch: '.(int)$theoreticalStock; + if ($reserved != 0) { + $tooltipFull .= ' Differenz: '.($reserved > 0 ? '+' : '').(int)$reserved; + } + + // Badge mit zwei Werten: Physisch | Theoretisch + $html = ''; + $html .= ''; + $html .= ''.(int)$physicalStock.''; + $html .= '|'; + $html .= ''.(int)$theoreticalStock.''; + $html .= ''; + + return $html; + } + + /** + * Generiert Lieferanten-Badge für Kundenauftrag/Angebot + * Zeigt Kürzel des günstigsten Lieferanten in grünem Badge + * + * @param array $suppliers Lieferanten-Array (sortiert nach Preis) + * @return string HTML für Lieferanten-Badge + */ + private function getCheapestSupplierBadge($suppliers) + { + if (empty($suppliers)) { + return ''; + } + + $cheapest = $suppliers[0]; + $code = $cheapest['supplier_code']; + $tooltip = 'Günstigster: '.$cheapest['supplier_name'].' ('.number_format($cheapest['price'], 2, ',', '.').' EUR)'; + + $html = ''; + $html .= dol_escape_htmltag($code); + $html .= ''; + + return $html; + } + + /** + * Generiert Preis-Indikator-Badge für Lieferantenbestellung + * Grün = günstigster, Orange = Durchschnitt, Rot = teuerster + * + * @param int $fk_product Produkt-ID + * @param int $supplier_id Lieferanten-ID der Bestellung + * @return string HTML für Preis-Indikator + */ + private function getPriceIndicatorBadge($fk_product, $supplier_id) + { + $pricePos = $this->getSupplierPricePosition($fk_product, $supplier_id); + + if ($pricePos['total'] <= 1) { + // Nur ein Lieferant - kein Vergleich möglich + return ''; + } + + switch ($pricePos['price_class']) { + case 'cheapest': + $color = '#28a745'; // Grün + $icon = 'fa-check'; + $tooltip = 'Günstigster Lieferant'; + break; + case 'expensive': + $color = '#dc3545'; // Rot + $icon = 'fa-arrow-up'; + $tooltip = 'Teuerster Lieferant (+'.$pricePos['percent_diff'].'%)'; + break; + default: // average + $color = '#fd7e14'; // Orange + $icon = 'fa-minus'; + $tooltip = 'Mittelpreis (+'.$pricePos['percent_diff'].'%)'; + } + + $html = ''; $html .= ''; return $html; @@ -545,19 +727,25 @@ class ActionsSupplierLink3 extends CommonHookActions /** * Generiert Shop-Link HTML (Icon oder Modal-Trigger für mehrere Lieferanten) + * Mit Preisvergleich und prozentualer Differenz * - * @param array $suppliers Array der Lieferanten - * @param int $currentSupplierId Aktueller Lieferant der Bestellung + * @param array $suppliers Array der Lieferanten (mit Shop-URL) + * @param int $currentSupplierId Aktueller Lieferant der Bestellung (0 für Kundenauftrag) * @param string $refFourn Lieferanten-Artikelnummer * @param int $lineId Zeilen-ID für eindeutige Modal-ID + * @param int $fk_product Produkt-ID für vollständigen Preisvergleich * @return string HTML für Shop-Link */ - private function getShopLinkHtml($suppliers, $currentSupplierId, $refFourn, $lineId) + private function getShopLinkHtml($suppliers, $currentSupplierId, $refFourn, $lineId, $fk_product = 0) { if (empty($suppliers)) { return ''; } + // Alle Lieferanten für Preisvergleich holen (auch ohne Shop-URL) + $allSuppliers = $fk_product ? $this->getAllSuppliersForProduct($fk_product, false) : $suppliers; + $minPrice = !empty($allSuppliers) ? $allSuppliers[0]['price'] : 0; + // Fall 1: Nur ein Lieferant mit Shop-URL - direkter Link if (count($suppliers) == 1) { $supplier = $suppliers[0]; @@ -584,43 +772,61 @@ class ActionsSupplierLink3 extends CommonHookActions $html .= ''; // Modal-Container (versteckt) - $html .= '