Version 2.3: Preisvergleich und theoretischer Lagerbestand

- 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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-24 08:40:11 +01:00
parent ff52c0bece
commit c976123d81
5 changed files with 407 additions and 97 deletions

View file

@ -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 = '<span class="badge '.$badgeClass.' classfortooltip" title="'.dol_escape_htmltag($tooltip).'" style="font-size: 11px;">';
$html .= '<i class="fas '.$icon.'" style="margin-right: 4px;"></i>';
$html .= number_format($qtyStock, 0, ',', '.');
// Differenz berechnen (offene Aufträge/Lieferungen)
$reserved = $physicalStock - $theoreticalStock;
// Erweiterter Tooltip mit Details - HTML Entity &#10; für Zeilenumbruch in title-Attribut
$tooltipFull = $tooltip.'&#10;';
$tooltipFull .= 'Physisch: '.(int)$physicalStock.'&#10;';
$tooltipFull .= 'Theoretisch: '.(int)$theoreticalStock;
if ($reserved != 0) {
$tooltipFull .= '&#10;Differenz: '.($reserved > 0 ? '+' : '').(int)$reserved;
}
// Badge mit zwei Werten: Physisch | Theoretisch
$html = '<span class="badge '.$badgeClass.' classfortooltip" title="'.$tooltipFull.'" style="font-size: 11px; white-space: nowrap;">';
$html .= '<i class="fas '.$icon.'" style="margin-right: 3px;"></i>';
$html .= '<span style="font-weight: bold;">'.(int)$physicalStock.'</span>';
$html .= '<span style="opacity: 0.7; margin: 0 2px;">|</span>';
$html .= '<span style="opacity: 0.8;">'.(int)$theoreticalStock.'</span>';
$html .= '</span>';
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 = '<span class="badge classfortooltip" style="background-color: #28a745; color: #fff; font-size: 9px; padding: 2px 4px; margin-left: 3px;" ';
$html .= 'title="'.dol_escape_htmltag($tooltip).'">';
$html .= dol_escape_htmltag($code);
$html .= '</span>';
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 = '<span class="classfortooltip" title="'.dol_escape_htmltag($tooltip).'" ';
$html .= 'style="display: inline-block; width: 14px; height: 14px; border-radius: 50%; background-color: '.$color.'; ';
$html .= 'text-align: center; line-height: 14px; margin-left: 4px;">';
$html .= '<i class="fas '.$icon.'" style="color: #fff; font-size: 8px;"></i>';
$html .= '</span>';
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 .= '</a>';
// Modal-Container (versteckt)
$html .= '<div id="'.$modalId.'" class="supplier-modal-content" style="display: none;" title="Lieferanten-Shops">';
$html .= '<div id="'.$modalId.'" class="supplier-modal-content" style="display: none;" title="Lieferanten-Vergleich">';
$isFirst = true;
foreach ($suppliers as $supplier) {
$isCurrentSupplier = ($supplier['supplier_id'] == $currentSupplierId);
// Prozentuale Differenz zum günstigsten berechnen
$percentDiff = 0;
if ($minPrice > 0 && $supplier['price'] > $minPrice) {
$percentDiff = round((($supplier['price'] - $minPrice) / $minPrice) * 100, 1);
}
$rowStyle = '';
if ($isCurrentSupplier) {
$rowStyle = 'background-color: #e8f4fd;';
if ($isFirst) {
$rowStyle = 'background-color: #d4edda;'; // Hellgrün für günstigsten
} elseif ($isCurrentSupplier) {
$rowStyle = 'background-color: #e8f4fd;'; // Hellblau für aktuellen
}
$html .= '<div class="supplier-modal-item" style="padding: 10px; border-bottom: 1px solid #eee; '.$rowStyle.'">';
// Lieferanten-Name mit Stern für günstigsten
$html .= '<div style="font-weight: '.($isFirst ? 'bold' : 'normal').'; margin-bottom: 5px;">';
$html .= '<div style="font-weight: '.($isFirst ? 'bold' : 'normal').'; margin-bottom: 5px; display: flex; justify-content: space-between; align-items: center;">';
$html .= '<span>';
if ($isFirst) {
$html .= '<i class="fas fa-star" style="color: gold; margin-right: 5px;" title="Günstigster Lieferant"></i>';
}
$html .= dol_escape_htmltag($supplier['supplier_name']);
if ($isCurrentSupplier) {
$html .= ' <span style="font-size: 10px; color: #666;">(aktueller Lieferant)</span>';
$html .= ' <span style="font-size: 10px; color: #666;">(aktuell)</span>';
}
$html .= '</span>';
// Prozent-Badge rechts
if ($percentDiff > 0) {
$diffColor = $percentDiff > 20 ? '#dc3545' : ($percentDiff > 10 ? '#fd7e14' : '#6c757d');
$html .= '<span style="font-size: 10px; color: '.$diffColor.'; font-weight: bold;">+'.$percentDiff.'%</span>';
}
$html .= '</div>';
// Preis und Mindestmenge
$html .= '<div style="font-size: 12px; color: #666; margin-bottom: 8px;">';
$html .= '<strong>'.number_format($supplier['price'], 2, ',', '.').' EUR</strong>';
$html .= ' &middot; Mindestmenge: '.(int)$supplier['min_qty'].' Stk.';
$html .= '<strong style="font-size: 13px;">'.number_format($supplier['price'], 2, ',', '.').' EUR</strong>';
$html .= ' &middot; ab '.(int)$supplier['min_qty'].' Stk.';
$html .= ' &middot; Art.-Nr: '.dol_escape_htmltag($supplier['ref_fourn']);
$html .= '</div>';
// Shop-Link Button
$html .= '<a href="'.dol_escape_htmltag($supplier['full_url']).'" ';
$html .= 'target="supplier_'.$supplier['supplier_id'].'" ';
$html .= 'class="button small" style="font-size: 11px; padding: 4px 10px;">';
$html .= '<i class="fas fa-external-link-alt" style="margin-right: 5px;"></i>Im Shop öffnen';
$html .= '</a>';
// Shop-Link Button (nur wenn Shop-URL vorhanden)
if (!empty($supplier['full_url'])) {
$html .= '<a href="'.dol_escape_htmltag($supplier['full_url']).'" ';
$html .= 'target="supplier_'.$supplier['supplier_id'].'" ';
$html .= 'class="button small" style="font-size: 11px; padding: 4px 10px;">';
$html .= '<i class="fas fa-external-link-alt" style="margin-right: 5px;"></i>Im Shop öffnen';
$html .= '</a>';
}
$html .= '</div>';
@ -748,18 +954,21 @@ class ActionsSupplierLink3 extends CommonHookActions
$line = $parameters['line'];
// Lagerbestand, Wunschbestand und Mindestbestand abfragen
$sqlStock = "SELECT stock, desiredstock, seuil_stock_alerte
// Nur für Produkte verarbeiten (nicht für Freitext-Zeilen oder Dienstleistungen)
if (empty($line->fk_product)) {
return 0;
}
// Wunschbestand und Mindestbestand abfragen
$sqlStock = "SELECT desiredstock, seuil_stock_alerte
FROM ".MAIN_DB_PREFIX."product
WHERE rowid = ".(int)$line->fk_product;
$resStock = $this->db->query($sqlStock);
$qtyStock = 0;
$desiredQty = 0;
$alertQty = 0;
if ($resStock && $this->db->num_rows($resStock) > 0) {
$objStock = $this->db->fetch_object($resStock);
$qtyStock = (float) $objStock->stock;
$desiredQty = (float) $objStock->desiredstock;
$alertQty = (float) $objStock->seuil_stock_alerte;
}
@ -768,29 +977,37 @@ class ActionsSupplierLink3 extends CommonHookActions
if (!empty($object->socid) && $isSupplierOrder) {
$fk_supplier = $object->socid;
// Alle Lieferanten für dieses Produkt abrufen
$allSuppliers = $this->getAllSuppliersForProduct($line->fk_product);
// Alle Lieferanten für dieses Produkt abrufen (mit Shop-URL)
$allSuppliers = $this->getAllSuppliersForProduct($line->fk_product, true);
// Shop-Icon/Modal generieren
// Shop-Icon/Modal generieren mit Produkt-ID für Preisvergleich
$lineId = !empty($line->id) ? $line->id : uniqid();
$shopLinkHtml = $this->getShopLinkHtml($allSuppliers, $fk_supplier, $line->ref_fourn, $lineId);
$shopLinkHtml = $this->getShopLinkHtml($allSuppliers, $fk_supplier, $line->ref_fourn, $lineId, $line->fk_product);
// Lagerbestand-Badge generieren (4 Zustände: rot/orange/grau/grün)
$stockBadgeHtml = $this->getStockBadgeHtml($qtyStock, $desiredQty, $alertQty);
// Preis-Indikator für diesen Lieferanten (Grün/Orange/Rot)
$priceIndicator = $this->getPriceIndicatorBadge($line->fk_product, $fk_supplier);
// Lagerbestand-Badge generieren (mit virtuellem Bestand)
$stockBadgeHtml = $this->getStockBadgeHtml($line->fk_product, $desiredQty, $alertQty);
// Neue ref_fourn zusammenbauen:
// [Shop-Icon feste Breite] [Lagerbestand feste Breite] [Artikel-Nummer]
// [Shop-Icon] [Preis-Indikator] [Lagerbestand] [Artikel-Nummer]
$newref = '<div style="display: inline-flex; align-items: center;">';
// Shop-Icon mit fester Breite (damit alle untereinander stehen)
// Shop-Icon mit fester Breite
$newref .= '<span class="supplier-shop-col" style="display: inline-block; width: 28px; text-align: center;">';
if (!empty($shopLinkHtml)) {
$newref .= $shopLinkHtml;
}
$newref .= '</span>';
// Lagerbestand-Badge mit fester Breite (rechtsbündig für gleichmäßige Ausrichtung)
$newref .= '<span class="supplier-stock-col" style="display: inline-block; min-width: 55px; text-align: right; margin-right: 8px;">';
// Preis-Indikator (Grün/Orange/Rot Punkt)
if (!empty($priceIndicator)) {
$newref .= '<span style="display: inline-block; width: 18px; text-align: center;">'.$priceIndicator.'</span>';
}
// Lagerbestand-Badge (Virtuell|Physisch)
$newref .= '<span class="supplier-stock-col" style="display: inline-block; min-width: 70px; text-align: right; margin-right: 8px;">';
$newref .= $stockBadgeHtml;
$newref .= '</span>';
@ -806,17 +1023,20 @@ class ActionsSupplierLink3 extends CommonHookActions
// NUR für Produkte, NICHT für Dienstleistungen
if (!empty($line->fk_product) && $line->product_type == 0) {
// Alle Lieferanten für dieses Produkt abrufen
$allSuppliers = $this->getAllSuppliersForProduct($line->fk_product);
// Alle Lieferanten für dieses Produkt abrufen (mit Shop-URL)
$allSuppliers = $this->getAllSuppliersForProduct($line->fk_product, true);
// Shop-Icon/Modal generieren (ohne aktuellen Lieferanten, da Kundenauftrag/Angebot)
// Shop-Icon/Modal generieren mit Produkt-ID für Preisvergleich
$lineId = !empty($line->id) ? $line->id : uniqid();
$shopLinkHtml = $this->getShopLinkHtml($allSuppliers, 0, '', $lineId);
$shopLinkHtml = $this->getShopLinkHtml($allSuppliers, 0, '', $lineId, $line->fk_product);
// Lagerbestand-Badge mit neuem System (4 Zustände: rot/orange/grau/grün)
$stockBadgeHtml = $this->getStockBadgeHtml($qtyStock, $desiredQty, $alertQty);
// Günstigster Lieferant Badge (Kürzel in grün)
$cheapestBadge = $this->getCheapestSupplierBadge($this->getAllSuppliersForProduct($line->fk_product, false));
// Rechtsbündig: [Shop-Icon feste Breite] [Lagerbestand feste Breite]
// Lagerbestand-Badge (mit virtuellem Bestand)
$stockBadgeHtml = $this->getStockBadgeHtml($line->fk_product, $desiredQty, $alertQty);
// Rechtsbündig: [Shop-Icon] [Lieferanten-Badge] [Lagerbestand]
$stockInfo = '<span style="float: right; margin-left: 15px; display: inline-flex; align-items: center;">';
// Shop-Icon mit fester Breite
@ -826,8 +1046,13 @@ class ActionsSupplierLink3 extends CommonHookActions
}
$stockInfo .= '</span>';
// Lagerbestand-Badge mit fester Breite (rechtsbündig für gleichmäßige Ausrichtung)
$stockInfo .= '<span class="supplier-stock-col" style="display: inline-block; min-width: 55px; text-align: right;">';
// Günstigster Lieferant Badge (z.B. "SON" in grün)
if (!empty($cheapestBadge)) {
$stockInfo .= $cheapestBadge;
}
// Lagerbestand-Badge (Virtuell|Physisch)
$stockInfo .= '<span class="supplier-stock-col" style="display: inline-block; min-width: 70px; text-align: right;">';
$stockInfo .= $stockBadgeHtml;
$stockInfo .= '</span>';

View file

@ -76,7 +76,7 @@ class modSupplierLink3 extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@supplierlink3'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '2.2';
$this->version = '2.3';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';

View file

@ -1,6 +1,7 @@
/**
* SupplierLink3 - Replenish page enhancements
* Ersetzt die "Aktueller Lagerbestand"-Spalte mit Badge + Shop-Link
* Version 2.3: Lagerbestand (Physisch|Theoretisch) wie Dolibarr Standard
*/
console.log('SL3: replenish.js loaded');
@ -24,40 +25,58 @@ $(document).ready(function() {
'.badge.badge-success { background-color: #28a745 !important; color: #fff !important; }' +
'.sl3-stock-wrapper { display: inline-flex; align-items: center; justify-content: flex-end; }' +
'.sl3-shop-col { display: inline-block; width: 28px; text-align: center; }' +
'.sl3-badge-col { display: inline-block; min-width: 55px; text-align: right; }' +
'.sl3-supplier-badge { display: inline-block; background-color: #28a745; color: #fff; font-size: 9px; padding: 2px 4px; border-radius: 3px; margin-left: 3px; }' +
'.sl3-badge-col { display: inline-block; min-width: 70px; text-align: right; }' +
'.sl3-modal-item { padding: 10px; border-bottom: 1px solid #eee; }' +
'.sl3-modal-item:hover { background-color: #f5f5f5; }' +
'.sl3-modal-item:last-child { border-bottom: none; }' +
'.sl3-modal-item.cheapest { background-color: #d4edda; }' +
'</style>');
// Modal-Handler
// Modal-Handler mit Preisvergleich
$(document).on('click', '.sl3-modal-trigger', function(e) {
e.preventDefault();
var suppliers = $(this).data('suppliers');
if (!suppliers || suppliers.length === 0) return;
var minPrice = suppliers[0].price;
var content = '';
for (var i = 0; i < suppliers.length; i++) {
var sup = suppliers[i];
var isFirst = (i === 0);
var bgStyle = isFirst ? 'background-color: #e8f4fd;' : '';
var bgClass = isFirst ? 'cheapest' : '';
var star = isFirst ? '<i class="fas fa-star" style="color: gold; margin-right: 5px;"></i>' : '';
content += '<div class="sl3-modal-item" style="' + bgStyle + '">';
content += '<div style="font-weight: ' + (isFirst ? 'bold' : 'normal') + '; margin-bottom: 5px;">' + star + sup.supplier_name + '</div>';
content += '<div style="font-size: 12px; color: #666; margin-bottom: 8px;">';
content += '<strong>' + parseFloat(sup.price).toFixed(2).replace('.', ',') + ' EUR</strong>';
content += ' - Art.-Nr: ' + sup.ref_fourn;
if (sup.min_qty > 1) content += ' (ab ' + sup.min_qty + ' St.)';
// Prozentuale Differenz zum günstigsten
var percentDiff = 0;
var percentHtml = '';
if (minPrice > 0 && sup.price > minPrice) {
percentDiff = ((sup.price - minPrice) / minPrice * 100).toFixed(1);
var diffColor = percentDiff > 20 ? '#dc3545' : (percentDiff > 10 ? '#fd7e14' : '#6c757d');
percentHtml = '<span style="font-size: 10px; color: ' + diffColor + '; font-weight: bold;">+' + percentDiff + '%</span>';
}
content += '<div class="sl3-modal-item ' + bgClass + '">';
content += '<div style="font-weight: ' + (isFirst ? 'bold' : 'normal') + '; margin-bottom: 5px; display: flex; justify-content: space-between; align-items: center;">';
content += '<span>' + star + sup.supplier_name + '</span>';
content += percentHtml;
content += '</div>';
content += '<a href="' + sup.full_url + '" target="supplier_' + sup.supplier_id + '" class="button small">Im Shop</a>';
content += '<div style="font-size: 12px; color: #666; margin-bottom: 8px;">';
content += '<strong style="font-size: 13px;">' + parseFloat(sup.price).toFixed(2).replace('.', ',') + ' EUR</strong>';
content += ' &middot; ab ' + sup.min_qty + ' St.';
content += ' &middot; Art.-Nr: ' + sup.ref_fourn;
content += '</div>';
if (sup.full_url) {
content += '<a href="' + sup.full_url + '" target="supplier_' + sup.supplier_id + '" class="button small">Im Shop öffnen</a>';
}
content += '</div>';
}
$('<div>').html(content).dialog({
modal: true,
title: 'Lieferanten-Shops',
width: 400,
title: 'Lieferanten-Vergleich',
width: 420,
buttons: { 'Schließen': function() { $(this).dialog('close'); } }
});
});
@ -86,7 +105,7 @@ $(document).ready(function() {
console.log('SL3: Column indices found:', colIndex);
// Fallback auf feste Indizes wenn nicht gefunden (für Produkte ohne Service-Spalte)
// Fallback auf feste Indizes wenn nicht gefunden
if (colIndex.stock < 0) colIndex.stock = 5;
if (colIndex.desired < 0) colIndex.desired = 3;
if (colIndex.alert < 0) colIndex.alert = 4;
@ -101,18 +120,15 @@ $(document).ready(function() {
// Produkt-Link finden um die Produkt-ID zu extrahieren
var $productLink = $row.find('td a[href*="product/card.php?id="]').first();
if ($productLink.length === 0) {
// Versuche alternative Selektoren
$productLink = $row.find('td a[href*="product.php?id="]').first();
}
if ($productLink.length === 0) {
console.log('SL3: Row', rowsFound, '- no product link found');
return;
}
var href = $productLink.attr('href');
var match = href.match(/id=(\d+)/);
if (!match) {
console.log('SL3: Row', rowsFound, '- no ID in href:', href);
return;
}
var productId = match[1];
@ -122,31 +138,53 @@ $(document).ready(function() {
var $stockCell = $row.find('td').eq(colIndex.stock);
if ($stockCell.length === 0) return;
// Stock-Wert aus der Zelle lesen
var stock = parseFloat($stockCell.text().trim()) || 0;
// Stock-Wert aus der Zelle lesen (physischer Bestand)
var physicalStock = parseFloat($stockCell.text().trim()) || 0;
// Desired-Stock und Alert-Stock
var desired = parseFloat($row.find('td').eq(colIndex.desired).text().trim()) || 0;
var alert = parseFloat($row.find('td').eq(colIndex.alert).text().trim()) || 0;
// Badge-Klasse bestimmen
// In der Replenish-Liste haben wir nur den physischen Bestand
// Theoretischer Bestand wird hier gleich dem physischen gesetzt
// (Der echte theoretische Bestand wird nur in den Orders angezeigt)
var theoreticalStock = physicalStock;
// Badge-Klasse basierend auf physischem Bestand
var badgeClass = 'badge-success';
if (stock < 1) {
var icon = 'fa-check-circle';
var tooltip = 'Ausreichend verfügbar';
if (physicalStock < 1) {
badgeClass = 'badge-danger';
} else if (alert > 0 && stock <= alert) {
icon = 'fa-times-circle';
tooltip = 'Nicht verfügbar';
} else if (alert > 0 && physicalStock <= alert) {
badgeClass = 'badge-warning';
} else if (desired > 0 && stock < desired) {
icon = 'fa-exclamation-triangle';
tooltip = 'Unter Mindestmenge (' + alert + ')';
} else if (desired > 0 && physicalStock < desired) {
badgeClass = 'badge-secondary';
icon = 'fa-box-open';
tooltip = 'Unter Wunschmenge (' + desired + ')';
}
// Shop-Link erstellen (immer mit Container für feste Breite)
// Shop-Link und günstigster Lieferant ermitteln
var shopIconHtml = '';
var supplierBadgeHtml = '';
var suppliers = productSuppliers[productId];
if (suppliers && suppliers.length > 0) {
// Günstigster Lieferant Badge
var cheapest = suppliers[0];
var shortCode = cheapest.supplier_name.replace(/[^a-zA-Z0-9]/g, '').substring(0, 3).toUpperCase();
supplierBadgeHtml = '<span class="sl3-supplier-badge classfortooltip" title="Günstigster: ' +
cheapest.supplier_name + ' (' + parseFloat(cheapest.price).toFixed(2).replace('.', ',') + ' EUR)">' +
shortCode + '</span>';
if (suppliers.length === 1) {
var sup = suppliers[0];
shopIconHtml = '<a href="' + sup.full_url + '" target="supplier_' + sup.supplier_id + '" ' +
'class="classfortooltip" title="' + sup.supplier_name + '" style="color: #0077b6; font-size: 14px;">' +
shopIconHtml = '<a href="' + cheapest.full_url + '" target="supplier_' + cheapest.supplier_id + '" ' +
'class="classfortooltip" title="' + cheapest.supplier_name + '" style="color: #0077b6; font-size: 14px;">' +
'<i class="' + shopIcon + '"></i></a>';
} else {
shopIconHtml = '<a href="#" class="sl3-modal-trigger" data-suppliers=\'' + JSON.stringify(suppliers) + '\' ' +
@ -155,10 +193,17 @@ $(document).ready(function() {
}
}
// Stock-Zelle ersetzen: inline-flex wie im Kundenauftrag
// Stock-Zelle ersetzen mit neuem Format: Physisch | Theoretisch
var html = '<div class="sl3-stock-wrapper">' +
'<span class="sl3-shop-col">' + shopIconHtml + '</span>' +
'<span class="sl3-badge-col"><span class="badge ' + badgeClass + '">' + Math.floor(stock) + '</span></span>' +
supplierBadgeHtml +
'<span class="sl3-badge-col">' +
'<span class="badge ' + badgeClass + ' classfortooltip" title="' + tooltip + '" style="font-size: 11px; white-space: nowrap;">' +
'<i class="fas ' + icon + '" style="margin-right: 3px;"></i>' +
'<span style="font-weight: bold;">' + Math.floor(physicalStock) + '</span>' +
'<span style="opacity: 0.7; margin: 0 2px;">|</span>' +
'<span style="opacity: 0.8;">' + Math.floor(theoreticalStock) + '</span>' +
'</span></span>' +
'</div>';
$stockCell.html(html).addClass('right').css('text-align', 'right');
});

View file

@ -95,3 +95,23 @@ SL3_CreateSupplierOrder = Lieferantenbestellung erstellen
SL3_SelectSupplier = Lieferant wählen
SL3_SelectProducts = Produkte auswählen
SL3_NoSuppliers = Keine Lieferanten verfügbar
#
# Price comparison (v2.3)
#
SL3_CheapestSupplier = Günstigster Lieferant
SL3_PriceCheapest = Günstigster Preis
SL3_PriceAverage = Durchschnittlicher Preis
SL3_PriceExpensive = Teuerster Preis
SL3_SupplierComparison = Lieferanten-Vergleich
SL3_PercentMore = teurer
#
# Stock display (v2.3)
#
SL3_TheoreticalStock = Theoretischer Bestand
SL3_PhysicalStock = Physischer Bestand
SL3_StockDifference = Differenz (offene Aufträge/Lieferungen)
SL3_StockTooltipPhysical = Physisch
SL3_StockTooltipTheoretical = Theoretisch
SL3_StockTooltipDifference = Differenz

View file

@ -95,3 +95,23 @@ SL3_CreateSupplierOrder = Create Supplier Order
SL3_SelectSupplier = Select supplier
SL3_SelectProducts = Select products
SL3_NoSuppliers = No suppliers available
#
# Price comparison (v2.3)
#
SL3_CheapestSupplier = Cheapest supplier
SL3_PriceCheapest = Cheapest price
SL3_PriceAverage = Average price
SL3_PriceExpensive = Most expensive
SL3_SupplierComparison = Supplier Comparison
SL3_PercentMore = more expensive
#
# Stock display (v2.3)
#
SL3_TheoreticalStock = Theoretical stock
SL3_PhysicalStock = Physical stock
SL3_StockDifference = Difference (open orders/shipments)
SL3_StockTooltipPhysical = Physical
SL3_StockTooltipTheoretical = Theoretical
SL3_StockTooltipDifference = Difference