dolibarr.metallzuschlag/class/metallzuschlagapi.class.php
data 8729b5fdb7 Metallzuschlag v1.1 - Kupferzuschlag-Berechnung + Notierungsverlauf
- Sonepar Metal Note API: CU/AL Tageswerte + Monatsdurchschnitte
- Dashboard mit Chart.js Verlaufsdiagramm (30/90/365 Tage)
- Trigger: Kupfergehalt (kg/km) = Aderanzahl × Querschnitt × 8,89
- Trigger: Kupferzuschlag (€/m) auf Einkaufspreisen berechnen
- CU-Logik: Lieferant-eigener Wert oder aktuellster aus History
- Cronjobs: Wöchentlicher API-Abruf + Kupferzuschlag-Neuberechnung
- Extrafields: Lieferantenkarte (CU/AL/Datum/Quelle), Produkt (Aderanzahl/Querschnitt/Kupfergehalt)
- Admin-Seite mit API-URL, Auto-Fetch, Lieferantenübersicht
- Mehrsprachig (de_DE, en_US)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:41:31 +01:00

491 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 metallzuschlag/class/metallzuschlagapi.class.php
* \ingroup metallzuschlag
* \brief API-Client fuer Metallnotierungen + Cronjob-Methode
*/
/**
* Klasse fuer Metallnotiz-API-Abfragen und Cronjob
*/
class MetallzuschlagApi
{
/** @var DoliDB */
public $db;
/** @var string */
public $error = '';
/** @var string[] */
public $errors = array();
/** @var string */
public $output = '';
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Cronjob-Methode: Metallnotierungen abrufen und speichern
*
* @return int 0 bei Erfolg, -1 bei Fehler
*/
public function fetchMetalNotes()
{
global $conf, $langs;
$langs->load('metallzuschlag@metallzuschlag');
dol_syslog("MetallzuschlagApi::fetchMetalNotes - Start", LOG_INFO);
$baseUrl = getDolGlobalString('METALLZUSCHLAG_API_URL', 'https://www.sonepar.de/api/content/metalnote');
$today = date('Ymd');
$firstOfMonth = date('Ym01');
$results = array();
$hasError = false;
// Tageswerte abrufen
$dayData = $this->callApi($baseUrl.'/day?date='.$today);
if ($dayData !== false && !empty($dayData['data'])) {
foreach ($dayData['data'] as $entry) {
$metal = $entry['nes'];
$value = (float)$entry['nku'];
$date = $entry['ndt'];
$res = $this->saveNotation($date, $metal, $value, null, 'sonepar');
if ($res < 0) {
$hasError = true;
} else {
$results[] = $metal.': '.$value.' EUR/100kg ('.$date.')';
}
}
} else {
$this->output .= "Tageswerte: Keine Daten erhalten\n";
$hasError = true;
}
// Monatsdurchschnitt Vormonat abrufen
$monthData = $this->callApi($baseUrl.'/previousmonth?date='.$firstOfMonth);
if ($monthData !== false && !empty($monthData['data'])) {
foreach ($monthData['data'] as $entry) {
$metal = $entry['nes'];
$avgValue = (float)$entry['nku'];
// Monatsdurchschnitt auf den heutigen Tageseintrag aktualisieren
$this->updateMonthAvg(date('Y-m-d'), $metal, $avgValue, 'sonepar');
$results[] = $metal.' Ø Vormonat: '.$avgValue.' EUR/100kg';
}
}
// Lieferanten-Extrafields aktualisieren
$updatedSuppliers = $this->updateSupplierExtrafields($dayData);
if ($updatedSuppliers > 0) {
$results[] = $updatedSuppliers.' Lieferant(en) aktualisiert';
}
if (!empty($results)) {
$this->output = implode("\n", $results);
}
dol_syslog("MetallzuschlagApi::fetchMetalNotes - Fertig: ".implode(', ', $results), LOG_INFO);
return $hasError ? -1 : 0;
}
/**
* API-Aufruf durchfuehren
*
* @param string $url URL zum Abrufen
* @return array|false Dekodierte JSON-Antwort oder false bei Fehler
*/
public function callApi($url)
{
dol_syslog("MetallzuschlagApi::callApi - URL: ".$url, LOG_DEBUG);
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'User-Agent: Dolibarr-Metallzuschlag/1.0',
),
));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || !empty($curlError)) {
$this->error = 'cURL-Fehler: '.$curlError;
dol_syslog("MetallzuschlagApi::callApi - ".$this->error, LOG_ERR);
return false;
}
if ($httpCode !== 200) {
$this->error = 'HTTP '.$httpCode.' von '.$url;
dol_syslog("MetallzuschlagApi::callApi - ".$this->error, LOG_ERR);
return false;
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error = 'JSON-Parse-Fehler: '.json_last_error_msg();
dol_syslog("MetallzuschlagApi::callApi - ".$this->error, LOG_ERR);
return false;
}
if (empty($data['status']) || $data['status']['statuscode'] != 200) {
$this->error = 'API-Fehler: Status '.($data['status']['statuscode'] ?? 'unbekannt');
dol_syslog("MetallzuschlagApi::callApi - ".$this->error, LOG_ERR);
return false;
}
return $data;
}
/**
* Notierung in DB speichern (INSERT oder UPDATE bei Duplikat)
*
* @param string $date Datum (Y-m-d)
* @param string $metal Metall-Kuerzel (CU/AL)
* @param float $value Wert in EUR/100kg
* @param float|null $avg Monatsdurchschnitt (optional)
* @param string $source Quelle
* @return int 1 bei Erfolg, -1 bei Fehler
*/
public function saveNotation($date, $metal, $value, $avg = null, $source = 'sonepar')
{
$sql = "INSERT INTO ".$this->db->prefix()."metallzuschlag_history";
$sql .= " (date_notiz, metal, value, value_month_avg, source)";
$sql .= " VALUES ('".$this->db->escape($date)."',";
$sql .= " '".$this->db->escape($metal)."',";
$sql .= " ".((float)$value).",";
$sql .= " ".($avg !== null ? ((float)$avg) : "NULL").",";
$sql .= " '".$this->db->escape($source)."')";
$sql .= " ON DUPLICATE KEY UPDATE value = ".((float)$value);
if ($avg !== null) {
$sql .= ", value_month_avg = ".((float)$avg);
}
$result = $this->db->query($sql);
if (!$result) {
$this->error = 'DB-Fehler: '.$this->db->lasterror();
dol_syslog("MetallzuschlagApi::saveNotation - ".$this->error, LOG_ERR);
return -1;
}
return 1;
}
/**
* Monatsdurchschnitt auf bestehenden Eintrag aktualisieren
*
* @param string $date Datum
* @param string $metal Metall
* @param float $avg Durchschnittswert
* @param string $source Quelle
* @return int
*/
public function updateMonthAvg($date, $metal, $avg, $source = 'sonepar')
{
$sql = "UPDATE ".$this->db->prefix()."metallzuschlag_history";
$sql .= " SET value_month_avg = ".((float)$avg);
$sql .= " WHERE date_notiz = '".$this->db->escape($date)."'";
$sql .= " AND metal = '".$this->db->escape($metal)."'";
$sql .= " AND source = '".$this->db->escape($source)."'";
return $this->db->query($sql) ? 1 : -1;
}
/**
* Lieferanten-Extrafields mit aktuellen Werten aktualisieren
*
* @param array|false $dayData Tageswerte von der API
* @return int Anzahl aktualisierter Lieferanten
*/
public function updateSupplierExtrafields($dayData)
{
if ($dayData === false || empty($dayData['data'])) {
return 0;
}
// Werte aus API-Antwort extrahieren
$values = array();
foreach ($dayData['data'] as $entry) {
$values[$entry['nes']] = (float)$entry['nku'];
}
$cuValue = isset($values['CU']) ? $values['CU'] : null;
$alValue = isset($values['AL']) ? $values['AL'] : null;
$today = date('Y-m-d');
// Alle Lieferanten mit metallzuschlag_source != '' finden
$sql = "SELECT fk_object, metallzuschlag_source";
$sql .= " FROM ".$this->db->prefix()."societe_extrafields";
$sql .= " WHERE metallzuschlag_source IS NOT NULL";
$sql .= " AND metallzuschlag_source != ''";
$sql .= " AND metallzuschlag_source != 'manuell'";
$resql = $this->db->query($sql);
if (!$resql) {
return 0;
}
$count = 0;
while ($obj = $this->db->fetch_object($resql)) {
$sets = array();
if ($cuValue !== null) {
$sets[] = "metallzuschlag_cu = ".$cuValue;
}
if ($alValue !== null) {
$sets[] = "metallzuschlag_al = ".$alValue;
}
$sets[] = "metallzuschlag_date = '".$this->db->escape($today)."'";
if (!empty($sets)) {
$sqlUpdate = "UPDATE ".$this->db->prefix()."societe_extrafields";
$sqlUpdate .= " SET ".implode(", ", $sets);
$sqlUpdate .= " WHERE fk_object = ".((int)$obj->fk_object);
if ($this->db->query($sqlUpdate)) {
$count++;
}
}
}
return $count;
}
/**
* Aktuelle Notierung aus DB holen
*
* @param string $metal Metall (CU/AL)
* @param string $source Quelle
* @return object|null Objekt mit value, value_month_avg, date_notiz oder null
*/
public function getLatest($metal = 'CU', $source = 'sonepar')
{
$sql = "SELECT date_notiz, metal, value, value_month_avg, source";
$sql .= " FROM ".$this->db->prefix()."metallzuschlag_history";
$sql .= " WHERE metal = '".$this->db->escape($metal)."'";
$sql .= " AND source = '".$this->db->escape($source)."'";
$sql .= " ORDER BY date_notiz DESC LIMIT 1";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
return $this->db->fetch_object($resql);
}
return null;
}
/**
* Historie aus DB holen
*
* @param string $source Quelle
* @param int $limit Anzahl Eintraege
* @return array Array von Objekten
*/
public function getHistory($source = 'sonepar', $limit = 30)
{
$results = array();
$sql = "SELECT date_notiz, metal, value, value_month_avg, source";
$sql .= " FROM ".$this->db->prefix()."metallzuschlag_history";
$sql .= " WHERE source = '".$this->db->escape($source)."'";
$sql .= " ORDER BY date_notiz DESC, metal ASC";
$sql .= " LIMIT ".((int)$limit);
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$results[] = $obj;
}
}
return $results;
}
/**
* Cronjob: Kupferzuschlag auf allen Einkaufspreisen neu berechnen
*
* Fuer alle Produkte mit Kupfergehalt > 0 werden die Einkaufspreise aktualisiert.
* CU-Notiz: Lieferant-eigener Wert (metallzuschlag_cu) oder aktuellster aus History.
*
* @return int 0 bei Erfolg, -1 bei Fehler
*/
public function recalcAllKupferzuschlag()
{
global $langs;
$langs->load('metallzuschlag@metallzuschlag');
dol_syslog("MetallzuschlagApi::recalcAllKupferzuschlag - Start", LOG_INFO);
// Aktuellste CU-Notiz als Fallback
$fallbackCU = 0;
$latestCU = $this->getLatest('CU');
if ($latestCU) {
$fallbackCU = (float) $latestCU->value;
}
if ($fallbackCU <= 0) {
$this->error = 'Keine CU-Notierung vorhanden';
dol_syslog("MetallzuschlagApi::recalcAllKupferzuschlag - ".$this->error, LOG_ERR);
return -1;
}
// Alle Produkte mit kupfergehalt > 0
$sql = "SELECT pe.fk_object, pe.kupfergehalt";
$sql .= " FROM ".$this->db->prefix()."product_extrafields pe";
$sql .= " WHERE pe.kupfergehalt IS NOT NULL AND pe.kupfergehalt > 0";
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = 'DB-Fehler: '.$this->db->lasterror();
return -1;
}
$productCount = 0;
$priceCount = 0;
while ($product = $this->db->fetch_object($resql)) {
$kupfergehalt = (float) $product->kupfergehalt;
$productId = (int) $product->fk_object;
// Alle Einkaufspreise dieses Produkts
$sqlPrices = "SELECT pf.rowid, pf.fk_soc";
$sqlPrices .= " FROM ".$this->db->prefix()."product_fournisseur_price pf";
$sqlPrices .= " WHERE pf.fk_product = ".$productId;
$resPrices = $this->db->query($sqlPrices);
if (!$resPrices) {
continue;
}
while ($price = $this->db->fetch_object($resPrices)) {
// CU-Notiz des Lieferanten oder Fallback
$cuNotiz = $this->getCUForSupplier((int) $price->fk_soc, $fallbackCU);
if ($cuNotiz <= 0) {
continue;
}
// Kupferzuschlag EUR/m = Kupfergehalt (kg/km) × CU (EUR/100kg) / 100.000
$kupferzuschlag = $kupfergehalt * $cuNotiz / 100000;
// UPDATE oder INSERT
$sqlUpd = "UPDATE ".$this->db->prefix()."product_fournisseur_price_extrafields";
$sqlUpd .= " SET kupferzuschlag = ".((float) $kupferzuschlag);
$sqlUpd .= " WHERE fk_object = ".((int) $price->rowid);
$resUpd = $this->db->query($sqlUpd);
if ($resUpd && $this->db->affected_rows($resUpd) > 0) {
$priceCount++;
} else {
$sqlIns = "INSERT INTO ".$this->db->prefix()."product_fournisseur_price_extrafields";
$sqlIns .= " (fk_object, kupferzuschlag) VALUES (".((int) $price->rowid).", ".((float) $kupferzuschlag).")";
if ($this->db->query($sqlIns)) {
$priceCount++;
}
}
}
$productCount++;
}
$this->output = $productCount.' Produkte, '.$priceCount.' Einkaufspreise aktualisiert (CU: '.$fallbackCU.' EUR/100kg)';
dol_syslog("MetallzuschlagApi::recalcAllKupferzuschlag - ".$this->output, LOG_INFO);
return 0;
}
/**
* CU-Notiz fuer einen Lieferanten ermitteln
*
* @param int $socId Lieferanten-ID
* @param float $fallbackCU Fallback-Wert aus History
* @return float CU-Notiz in EUR/100kg
*/
public function getCUForSupplier($socId, $fallbackCU)
{
$sql = "SELECT metallzuschlag_cu";
$sql .= " FROM ".$this->db->prefix()."societe_extrafields";
$sql .= " WHERE fk_object = ".((int) $socId);
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
if (!empty($obj->metallzuschlag_cu) && (float) $obj->metallzuschlag_cu > 0) {
return (float) $obj->metallzuschlag_cu;
}
}
return $fallbackCU;
}
/**
* Verlaufsdaten fuer Chart aufbereiten
*
* @param int $days Zeitraum in Tagen
* @param string $source Quelle
* @return array Array mit 'labels', 'cu', 'al' fuer Chart.js
*/
public function getChartData($days = 90, $source = 'sonepar')
{
$result = array(
'labels' => array(),
'cu' => array(),
'al' => array(),
);
$dateFrom = date('Y-m-d', strtotime('-'.$days.' days'));
// Alle Daten im Zeitraum holen, nach Datum sortiert
$sql = "SELECT date_notiz, metal, value";
$sql .= " FROM ".$this->db->prefix()."metallzuschlag_history";
$sql .= " WHERE source = '".$this->db->escape($source)."'";
$sql .= " AND date_notiz >= '".$this->db->escape($dateFrom)."'";
$sql .= " ORDER BY date_notiz ASC, metal ASC";
$resql = $this->db->query($sql);
if (!$resql) {
return $result;
}
// Daten nach Datum gruppieren
$byDate = array();
while ($obj = $this->db->fetch_object($resql)) {
$date = $obj->date_notiz;
if (!isset($byDate[$date])) {
$byDate[$date] = array('CU' => null, 'AL' => null);
}
$byDate[$date][$obj->metal] = (float) $obj->value;
}
foreach ($byDate as $date => $values) {
$result['labels'][] = $date;
$result['cu'][] = $values['CU'];
$result['al'][] = $values['AL'];
}
return $result;
}
}