feat: Kabel-Kupferzuschlag-Diagramm auf Dashboard

- Neues Diagramm zeigt Kupferzuschlag-Verlauf pro Kabel
- Kabel-Auswahl mit Checkboxen (alle oder einzelne)
- Modus-Auswahl: EUR/m oder Gesamtbetrag (mit Mindestmenge)
- API: getProductsWithKupfergehalt(), getCableChartData()
- Version auf 1.3 erhöht

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-24 10:46:11 +01:00
parent 2e2cb5b710
commit b90f13da34
10 changed files with 313 additions and 1 deletions

0
.gitignore vendored Normal file → Executable file
View file

View file

@ -1,5 +1,14 @@
# Changelog - Metallzuschlag Modul
## 1.3 (2026-02-24)
### Neu
- Dashboard: Kabel-Kupferzuschlag-Diagramm mit zeitgenauem Verlauf
- Kabel-Auswahl: Einzelne oder alle Kabel ein-/ausblenden
- Modus-Auswahl: EUR/m (Stueckpreis) oder Gesamtbetrag (mit Mindestmenge)
- API: getProductsWithKupfergehalt() - alle Kabel mit Kupfergehalt
- API: getCableChartData() - Kupferzuschlag-Verlauf berechnen
## 1.2 (2026-02-24)
### Geaendert

134
class/metallzuschlagapi.class.php Normal file → Executable file
View file

@ -489,4 +489,138 @@ class MetallzuschlagApi
return $result;
}
/**
* Alle Produkte mit Kupfergehalt > 0 holen
*
* @return array Array von Objekten mit id, ref, label, kupfergehalt
*/
public function getProductsWithKupfergehalt()
{
$results = array();
$sql = "SELECT p.rowid, p.ref, p.label, pe.kupfergehalt";
$sql .= " FROM ".$this->db->prefix()."product p";
$sql .= " INNER JOIN ".$this->db->prefix()."product_extrafields pe ON pe.fk_object = p.rowid";
$sql .= " WHERE pe.kupfergehalt IS NOT NULL AND pe.kupfergehalt > 0";
$sql .= " ORDER BY p.ref ASC";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$results[] = $obj;
}
}
return $results;
}
/**
* Kupferzuschlag-Verlauf fuer Kabel berechnen
*
* @param int $days Zeitraum in Tagen
* @param array $productIds Produkt-IDs (leer = alle mit Kupfergehalt)
* @param string $mode 'per_meter' oder 'total' (mit Mindestmenge)
* @param string $source Quelle fuer CU-Notierungen
* @return array ['labels' => [...], 'products' => [id => ['label' => name, 'data' => [...]]]]
*/
public function getCableChartData($days = 90, $productIds = array(), $mode = 'per_meter', $source = 'sonepar')
{
$result = array(
'labels' => array(),
'products' => array(),
);
// CU-Notierungen im Zeitraum holen
$dateFrom = date('Y-m-d', strtotime('-'.$days.' days'));
$sql = "SELECT date_notiz, value";
$sql .= " FROM ".$this->db->prefix()."metallzuschlag_history";
$sql .= " WHERE metal = 'CU'";
$sql .= " AND source = '".$this->db->escape($source)."'";
$sql .= " AND date_notiz >= '".$this->db->escape($dateFrom)."'";
$sql .= " ORDER BY date_notiz ASC";
$resql = $this->db->query($sql);
if (!$resql) {
return $result;
}
$cuByDate = array();
while ($obj = $this->db->fetch_object($resql)) {
$cuByDate[$obj->date_notiz] = (float) $obj->value;
$result['labels'][] = $obj->date_notiz;
}
if (empty($result['labels'])) {
return $result;
}
// Produkte mit Kupfergehalt holen
$sqlProd = "SELECT p.rowid, p.ref, p.label, pe.kupfergehalt";
$sqlProd .= " FROM ".$this->db->prefix()."product p";
$sqlProd .= " INNER JOIN ".$this->db->prefix()."product_extrafields pe ON pe.fk_object = p.rowid";
$sqlProd .= " WHERE pe.kupfergehalt IS NOT NULL AND pe.kupfergehalt > 0";
if (!empty($productIds)) {
$sqlProd .= " AND p.rowid IN (".implode(',', array_map('intval', $productIds)).")";
}
$sqlProd .= " ORDER BY p.ref ASC";
$resProd = $this->db->query($sqlProd);
if (!$resProd) {
return $result;
}
while ($product = $this->db->fetch_object($resProd)) {
$kupfergehalt = (float) $product->kupfergehalt;
$productId = (int) $product->rowid;
// Mindestmenge ermitteln (kleinste quantity aus Einkaufspreisen)
$quantity = 1;
if ($mode === 'total') {
$sqlQty = "SELECT MIN(quantity) as min_qty";
$sqlQty .= " FROM ".$this->db->prefix()."product_fournisseur_price";
$sqlQty .= " WHERE fk_product = ".$productId;
$sqlQty .= " AND quantity > 0";
$resQty = $this->db->query($sqlQty);
if ($resQty && $this->db->num_rows($resQty) > 0) {
$objQty = $this->db->fetch_object($resQty);
if ($objQty->min_qty > 0) {
$quantity = (float) $objQty->min_qty;
}
}
}
// Kupferzuschlag fuer jeden Tag berechnen
$data = array();
foreach ($result['labels'] as $date) {
$cuNotiz = isset($cuByDate[$date]) ? $cuByDate[$date] : 0;
if ($cuNotiz > 0) {
// Kupferzuschlag = Kupfergehalt × CU / 100.000 × Menge
$data[] = round($kupfergehalt * $cuNotiz / 100000 * $quantity, 2);
} else {
$data[] = null;
}
}
$label = $product->ref;
if (!empty($product->label)) {
$label .= ' - '.$product->label;
}
if ($mode === 'total' && $quantity > 1) {
$label .= ' ('.$quantity.'m)';
}
$result['products'][$productId] = array(
'label' => $label,
'data' => $data,
'kupfergehalt' => $kupfergehalt,
);
}
return $result;
}
}

View file

@ -49,7 +49,7 @@ class modMetallzuschlag extends DolibarrModules
$this->descriptionlong = "MetallzuschlagDescriptionLong";
$this->editor_name = 'Alles Watt laeuft';
$this->editor_url = '';
$this->version = '1.2';
$this->version = '1.3';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
$this->picto = 'fa-coins';

9
langs/de_DE/metallzuschlag.lang Normal file → Executable file
View file

@ -80,3 +80,12 @@ MetallzuschlagChartAL = Aluminium (AL) €/100kg
MetallzuschlagLast30Days = Letzte 30 Tage
MetallzuschlagLast90Days = Letzte 90 Tage
MetallzuschlagLast365Days = Letzte 365 Tage
# Kabel-Diagramm
MetallzuschlagCableChart = Kupferzuschlag Kabel
MetallzuschlagSelectCables = Kabel auswählen
MetallzuschlagAllCables = Alle Kabel
MetallzuschlagPerMeter = EUR/m
MetallzuschlagTotalAmount = Gesamtbetrag
MetallzuschlagPerMeterEUR = Kupferzuschlag (EUR/m)
MetallzuschlagTotalAmountEUR = Kupferzuschlag (EUR)

View file

@ -79,3 +79,12 @@ MetallzuschlagChartAL = Aluminum (AL) €/100kg
MetallzuschlagLast30Days = Last 30 days
MetallzuschlagLast90Days = Last 90 days
MetallzuschlagLast365Days = Last 365 days
# Cable chart
MetallzuschlagCableChart = Cable copper surcharge
MetallzuschlagSelectCables = Select cables
MetallzuschlagAllCables = All cables
MetallzuschlagPerMeter = EUR/m
MetallzuschlagTotalAmount = Total amount
MetallzuschlagPerMeterEUR = Copper surcharge (EUR/m)
MetallzuschlagTotalAmountEUR = Copper surcharge (EUR)

View file

@ -285,6 +285,157 @@ if (!empty($chartData['labels'])) {
print '</script>';
}
// Kabel-Diagramm: Kupferzuschlag pro Kabel
$cablesWithKupfer = $api->getProductsWithKupfergehalt();
if (!empty($cablesWithKupfer) && !empty($chartData['labels'])) {
print '<br>';
// Parameter auslesen
$cableIds = GETPOST('cable_ids', 'array');
$cableMode = GETPOST('cable_mode', 'alpha') ?: 'per_meter';
// URL-Basis fuer Links
$baseUrl = $_SERVER["PHP_SELF"].'?chart_days='.$chartDays;
// Kabel-Auswahl und Modus-Buttons
$cableButtons = '';
// Modus-Buttons
$modePerMeter = ($cableMode == 'per_meter') ? ' butActionActive' : '';
$modeTotal = ($cableMode == 'total') ? ' butActionActive' : '';
$cableButtons .= '<a class="butAction'.$modePerMeter.' small" href="'.$baseUrl.'&cable_mode=per_meter'.(!empty($cableIds) ? '&cable_ids[]='.implode('&cable_ids[]=', $cableIds) : '').'">'.$langs->trans("MetallzuschlagPerMeter").'</a> ';
$cableButtons .= '<a class="butAction'.$modeTotal.' small" href="'.$baseUrl.'&cable_mode=total'.(!empty($cableIds) ? '&cable_ids[]='.implode('&cable_ids[]=', $cableIds) : '').'">'.$langs->trans("MetallzuschlagTotalAmount").'</a>';
print load_fiche_titre($langs->trans("MetallzuschlagCableChart"), $cableButtons, '');
// Kabel-Auswahl (Checkboxen)
print '<div class="div-table-responsive-no-min" style="margin-bottom: 15px;">';
print '<form method="get" action="'.$_SERVER["PHP_SELF"].'">';
print '<input type="hidden" name="chart_days" value="'.$chartDays.'">';
print '<input type="hidden" name="cable_mode" value="'.$cableMode.'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="4">'.$langs->trans("MetallzuschlagSelectCables").'</td>';
print '</tr>';
print '<tr class="oddeven">';
$colCount = 0;
foreach ($cablesWithKupfer as $cable) {
$checked = (empty($cableIds) || in_array($cable->rowid, $cableIds)) ? ' checked' : '';
print '<td style="width: 25%;">';
print '<label>';
print '<input type="checkbox" name="cable_ids[]" value="'.$cable->rowid.'"'.$checked.'> ';
print dol_escape_htmltag($cable->ref);
if (!empty($cable->label)) {
print ' <span class="opacitymedium">('.dol_trunc($cable->label, 30).')</span>';
}
print '</label>';
print '</td>';
$colCount++;
if ($colCount % 4 == 0) {
print '</tr><tr class="oddeven">';
}
}
// Leere Zellen auffuellen
while ($colCount % 4 != 0) {
print '<td></td>';
$colCount++;
}
print '</tr>';
print '<tr class="oddeven">';
print '<td colspan="4">';
print '<button type="submit" class="butAction small">'.$langs->trans("Refresh").'</button> ';
print '<a class="butAction small" href="'.$baseUrl.'&cable_mode='.$cableMode.'">'.$langs->trans("MetallzuschlagAllCables").'</a>';
print '</td>';
print '</tr>';
print '</table>';
print '</form>';
print '</div>';
// Kabel-Chart-Daten holen
$cableChartData = $api->getCableChartData($chartDays, $cableIds, $cableMode);
if (!empty($cableChartData['products'])) {
print '<div style="max-width: 100%; height: 400px;">';
print '<canvas id="cableChart"></canvas>';
print '</div>';
// Farben fuer Kabel (verschiedene Farben)
$colors = array(
'#e6194B', '#3cb44b', '#ffe119', '#4363d8', '#f58231',
'#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4',
'#469990', '#dcbeff', '#9A6324', '#fffac8', '#800000',
);
// Datasets fuer Chart.js vorbereiten
$jsLabels = json_encode($cableChartData['labels']);
$datasets = array();
$colorIndex = 0;
foreach ($cableChartData['products'] as $productId => $productData) {
$color = $colors[$colorIndex % count($colors)];
$datasets[] = array(
'label' => $productData['label'],
'data' => $productData['data'],
'borderColor' => $color,
'backgroundColor' => 'transparent',
'borderWidth' => 2,
'tension' => 0.3,
'spanGaps' => true,
);
$colorIndex++;
}
$jsDatasets = json_encode($datasets);
$yAxisLabel = ($cableMode == 'total') ? $langs->trans("MetallzuschlagTotalAmountEUR") : $langs->trans("MetallzuschlagPerMeterEUR");
print '<script>';
print 'document.addEventListener("DOMContentLoaded", function() {';
print ' var ctx = document.getElementById("cableChart").getContext("2d");';
print ' new Chart(ctx, {';
print ' type: "line",';
print ' data: {';
print ' labels: '.$jsLabels.',';
print ' datasets: '.$jsDatasets;
print ' },';
print ' options: {';
print ' responsive: true,';
print ' maintainAspectRatio: false,';
print ' interaction: { mode: "index", intersect: false },';
print ' plugins: {';
print ' tooltip: {';
print ' callbacks: {';
print ' label: function(ctx) {';
print ' return ctx.dataset.label + ": " + (ctx.parsed.y !== null ? ctx.parsed.y.toFixed(2) : "-") + " €";';
print ' }';
print ' }';
print ' }';
print ' },';
print ' scales: {';
print ' x: {';
print ' ticks: { maxTicksLimit: 12 }';
print ' },';
print ' y: {';
print ' type: "linear",';
print ' position: "left",';
print ' title: { display: true, text: "'.dol_escape_js($yAxisLabel).'" },';
print ' ticks: { callback: function(v) { return v.toFixed(2) + " €"; } }';
print ' }';
print ' }';
print ' }';
print ' });';
print '});';
print '</script>';
} else {
print '<div class="opacitymedium">'.$langs->trans("MetallzuschlagNoData").'</div>';
}
}
// Verlaufstabelle
print '<br>';
print load_fiche_titre($langs->trans("MetallzuschlagHistory"), '', '');

0
sql/llx_metallzuschlag_history.key.sql Normal file → Executable file
View file

0
sql/llx_metallzuschlag_history.sql Normal file → Executable file
View file