Compare commits

...

2 commits

Author SHA1 Message Date
745fc68fc9 feat: Kabel-Preislogik für verschiedene Lieferanten-Formate (v3.8)
- Neue zentrale Funktion calculateCablePricing() für einheitliche Preislogik
- Unterschiedliche Lieferanten-Formate: Sonepar (price_unit=1, Ring im Namen) vs Kluxen/Witte (price_unit=100)
- Ringgröße-Erkennung: Ri100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m"
- Cross-Catalog-Suche nur noch über EAN (verhindert Fehlzuordnungen)
- EAN-Auto-Update aus ZUGFeRD mit automatischer Barcode-Typ-Erkennung (EAN8/13/UPC-A)
- Neues Extrafield "produktpreis" für Materialpreis ohne Kupferzuschlag
- Kupfergehalt-Berechnung: Aderanzahl × Querschnitt × 8.9
- Division durch Null abgesichert
- Besseres Error-Handling für Extrafields

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 13:43:26 +01:00
8b0d1830a3 feat: GlobalNotify Integration für Import-Benachrichtigungen
- Helper-Funktion notify() für sichere GlobalNotify-Nutzung
- Benachrichtigung bei importierten Rechnungen (zur Prüfung)
- Warnung bei Import-Fehlern
- Fehler-Benachrichtigung bei IMAP-Verbindungsproblemen
- Sofortige Benachrichtigung bei Exception/Fatal
- countPendingInvoices() für Draft-Rechnungen-Zählung
- Fallback auf dol_syslog wenn GlobalNotify nicht verfügbar
- Version 3.7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 11:37:33 +01:00
10 changed files with 938 additions and 256 deletions

32
CHANGELOG.md Normal file → Executable file
View file

@ -2,6 +2,38 @@
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## [3.8] - 2026-02-25
### Hinzugefügt
- **Kabel-Preisberechnung**: Zentrale Funktion `calculateCablePricing()` für einheitliche Preislogik
- **Kupfergehalt-Berechnung**: Automatische Berechnung aus Aderanzahl × Querschnitt × 8.9
- **Ringgröße-Erkennung**: Unterstützt Ri100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m"
- **Extrafield "produktpreis"**: Speichert reinen Materialpreis ohne Kupferzuschlag (nur Kabel)
- **EAN-Auto-Update**: Barcodes aus ZUGFeRD-Rechnungen werden automatisch in Lieferantenpreise übernommen
### Verbessert
- **Lieferanten-Formate**: Korrekte Unterscheidung zwischen Sonepar (price_unit=1, Ring im Namen) und Kluxen/Witte (price_unit=100)
- **Cross-Catalog-Suche**: Nur noch über EAN, nicht mehr über Artikelnummern (verhindert Fehlzuordnungen)
- **EAN-Barcode-Typ**: Automatische Erkennung (EAN8, EAN13, UPC-A) statt hardcoded EAN13
- **Error-Handling**: Besseres Logging bei Extrafield-Fehlern
### Behoben
- Division durch Null bei Preisberechnung abgesichert
- Mindestbestellmenge und Verpackungseinheit werden von existierenden Lieferantenpreisen übernommen
## [3.7] - 2026-02-23
### Hinzugefügt
- **GlobalNotify Integration**: Benachrichtigungen über das zentrale GlobalNotify-Modul
- Import-Fehler: Warnung bei fehlgeschlagenen Importen
- Rechnungen zur Prüfung: Aktion wenn neue Rechnungen warten
- IMAP-Fehler: Warnung wenn E-Mail Postfach nicht erreichbar
- Exception/Fatal: Sofortige Benachrichtigung bei Abstürzen
- **Helper-Funktion**: `notify()` für sichere GlobalNotify-Nutzung mit Fallback
### Hinweis
GlobalNotify ist optional. Ohne das Modul werden Benachrichtigungen ins Dolibarr-Log geschrieben.
## [3.6] - 2026-02-23
### Behoben

View file

@ -102,23 +102,29 @@ Available in:
## Version History
### 1.1
- New persistent import workflow with database storage
- Manual product assignment via dropdown
- Product removal/reassignment
- Status "Pending" for imports requiring manual intervention
- Pending imports overview on upload page
- UN/ECE unit code translation (C62 → Stk., MTR → m, etc.)
- Batch import from folder or IMAP mailbox
- IMAP connection test with folder selection
- Product template feature (duplicate existing product)
See [CHANGELOG.md](CHANGELOG.md) for detailed version history.
### 3.8 (Current)
- Improved cable pricing for different supplier formats (Sonepar vs Kluxen/Witte)
- Automatic ring size detection from product names (Ri100, Tr500, etc.)
- EAN auto-update from ZUGFeRD invoices with automatic barcode type detection
- New extrafield "produktpreis" for material price without copper surcharge
- Cross-catalog search now EAN-only (prevents mismatches)
### 3.7
- GlobalNotify integration for import notifications
### 3.6
- Cron job stability fixes and dedicated logging
### 3.5
- Automatic cron import from watch folder and IMAP
### 3.0
- Datanorm integration for article prices
### 1.0
- Initial release
- Basic ZUGFeRD/Factur-X import
- Automatic product matching
- Supplier detection
- Duplicate detection
## License

View file

@ -316,6 +316,32 @@ class ActionsImportZugferd
$processed_line['product_ref'] = $product->ref;
$processed_line['product_label'] = $product->label;
}
// Update supplier price with EAN from invoice if empty
$invoiceEan = !empty($line['product']['global_id']) ? trim($line['product']['global_id']) : '';
$supplierRef = !empty($line['product']['seller_id']) ? $line['product']['seller_id'] : '';
if (!empty($invoiceEan) && !empty($supplierRef) && ctype_digit($invoiceEan)) {
// Barcode-Typ basierend auf Länge bestimmen
$eanLen = strlen($invoiceEan);
if ($eanLen == 13) {
$barcodeType = 2; // EAN13
} elseif ($eanLen == 8) {
$barcodeType = 1; // EAN8
} elseif ($eanLen == 12) {
$barcodeType = 3; // UPC-A
} else {
$barcodeType = 0; // Unbekannt
}
$sqlEan = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlEan .= " SET barcode = '" . $this->db->escape($invoiceEan) . "'";
$sqlEan .= ", fk_barcode_type = " . (int)$barcodeType;
$sqlEan .= " WHERE fk_product = " . (int)$match['fk_product'];
$sqlEan .= " AND fk_soc = " . (int)$supplier_id;
$sqlEan .= " AND ref_fourn = '" . $this->db->escape($supplierRef) . "'";
$sqlEan .= " AND (barcode IS NULL OR barcode = '')";
$this->db->query($sqlEan);
}
} else {
$processed_line['needs_creation'] = true;
}

View file

@ -71,6 +71,57 @@ class CronImportZugferd
*/
private $startTime = 0;
/**
* Send notification via GlobalNotify (if available)
*
* @param string $type 'error', 'warning', 'info', 'action'
* @param string $title Title
* @param string $message Message
* @param string $actionUrl URL for action button
* @param string $actionLabel Label for action button
* @return bool True if sent via GlobalNotify
*/
protected function notify($type, $title, $message, $actionUrl = '', $actionLabel = '')
{
if (!isModEnabled('globalnotify')) {
dol_syslog("ImportZugferd [{$type}]: {$title} - {$message}", LOG_WARNING);
return false;
}
$classFile = dol_buildpath('/globalnotify/class/globalnotify.class.php', 0);
if (!file_exists($classFile)) {
dol_syslog("ImportZugferd [{$type}]: {$title} - {$message}", LOG_WARNING);
return false;
}
require_once $classFile;
if (!class_exists('GlobalNotify')) {
dol_syslog("ImportZugferd [{$type}]: {$title} - {$message}", LOG_WARNING);
return false;
}
try {
switch ($type) {
case 'error':
GlobalNotify::error('importzugferd', $title, $message, $actionUrl, $actionLabel);
break;
case 'warning':
GlobalNotify::warning('importzugferd', $title, $message, $actionUrl, $actionLabel);
break;
case 'action':
GlobalNotify::actionRequired('importzugferd', $title, $message, $actionUrl, $actionLabel ?: 'Aktion erforderlich');
break;
default:
GlobalNotify::info('importzugferd', $title, $message, $actionUrl, $actionLabel);
}
return true;
} catch (Exception $e) {
dol_syslog("GlobalNotify error: ".$e->getMessage(), LOG_ERR);
return false;
}
}
/**
* Constructor
*
@ -226,17 +277,34 @@ class CronImportZugferd
$this->cronLog("Completed: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}, duration={$duration}s");
$this->cronLog("========== CRON END (success) ==========");
// Send GlobalNotify notifications
$this->sendImportNotifications();
return ($this->error_count > 0) ? -1 : 0;
} catch (Exception $e) {
$this->error = 'Exception: '.$e->getMessage();
$this->cronLog("EXCEPTION: ".$e->getMessage()."\n".$e->getTraceAsString(), 'ERROR');
$this->cronLog("========== CRON END (exception) ==========");
$this->notify(
'error',
'ZUGFeRD Import fehlgeschlagen',
'Exception: '.$e->getMessage(),
dol_buildpath('/importzugferd/admin/setup.php', 1),
'Einstellungen prüfen'
);
return -1;
} catch (Throwable $t) {
$this->error = 'Fatal: '.$t->getMessage();
$this->cronLog("FATAL: ".$t->getMessage()."\n".$t->getTraceAsString(), 'ERROR');
$this->cronLog("========== CRON END (fatal) ==========");
$this->notify(
'error',
'ZUGFeRD Import Absturz',
'Fatal: '.$t->getMessage(),
dol_buildpath('/importzugferd/admin/setup.php', 1),
'Einstellungen prüfen'
);
return -1;
}
}
@ -645,4 +713,79 @@ class CronImportZugferd
dol_syslog("CronImportZugferd: Keeping error file in watch folder: " . $file, LOG_INFO);
return true;
}
/**
* Send notifications based on import results
*
* @return void
*/
protected function sendImportNotifications()
{
// Check for errors
if ($this->error_count > 0) {
$errorSummary = count($this->errors) > 0 ? implode(', ', array_slice($this->errors, 0, 3)) : 'Siehe Log';
$this->notify(
'warning',
$this->error_count.' ZUGFeRD Import-Fehler',
$errorSummary,
dol_buildpath('/importzugferd/list.php?status=error', 1),
'Fehler anzeigen'
);
}
// Check for imported invoices that need review
if ($this->imported_count > 0) {
// Count pending invoices (drafts needing approval)
$pendingCount = $this->countPendingInvoices();
if ($pendingCount > 0) {
$this->notify(
'action',
$this->imported_count.' ZUGFeRD Rechnungen importiert',
"{$pendingCount} Lieferantenrechnungen warten auf Prüfung und Freigabe",
dol_buildpath('/fourn/facture/list.php?search_status=0', 1),
'Rechnungen prüfen'
);
} else {
// All auto-created and validated
$this->notify(
'info',
$this->imported_count.' ZUGFeRD Rechnungen importiert',
'Alle Rechnungen wurden erfolgreich verarbeitet',
dol_buildpath('/fourn/facture/list.php', 1),
'Anzeigen'
);
}
}
// IMAP connection issues
if (strpos($this->error, 'IMAP connection failed') !== false) {
$this->notify(
'error',
'IMAP Verbindung fehlgeschlagen',
'E-Mail Postfach für ZUGFeRD-Import nicht erreichbar',
dol_buildpath('/importzugferd/admin/setup.php', 1),
'IMAP prüfen'
);
}
}
/**
* Count pending (draft) supplier invoices
*
* @return int Number of draft supplier invoices
*/
protected function countPendingInvoices()
{
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_fourn";
$sql .= " WHERE fk_statut = 0"; // Draft status
$sql .= " AND entity IN (".getEntity('facture_fourn').")";
$resql = $this->db->query($sql);
if ($resql) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->cnt;
}
return 0;
}
}

View file

@ -520,9 +520,22 @@ class Datanorm extends CommonObject
if ($result > 0) {
$results[] = $this->toArray();
$foundIds[$this->id] = true;
// Store EAN and manufacturer_ref for cross-catalog search
// Store EAN from Datanorm
$foundEan = $this->ean;
$foundManufacturerRef = $this->manufacturer_ref;
// If Datanorm has no EAN, try to get it from supplier price (barcode field)
if (empty($foundEan)) {
$sqlEan = "SELECT barcode FROM " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlEan .= " WHERE fk_soc = " . (int)$fk_soc;
$sqlEan .= " AND ref_fourn = '" . $this->db->escape($article_number) . "'";
$sqlEan .= " AND barcode IS NOT NULL AND barcode != ''";
$sqlEan .= " LIMIT 1";
$resEan = $this->db->query($sqlEan);
if ($resEan && $this->db->num_rows($resEan) > 0) {
$objEan = $this->db->fetch_object($resEan);
$foundEan = $objEan->barcode;
}
}
// If not searching all catalogs, return immediately
if (!$searchAll) {
@ -531,24 +544,15 @@ class Datanorm extends CommonObject
}
}
// If searchAll is enabled and we found article with EAN/manufacturer_ref,
// search other catalogs using these identifiers (cross-catalog search)
if ($searchAll && $fk_soc > 0 && (!empty($foundEan) || !empty($foundManufacturerRef))) {
// If searchAll is enabled and we found article with EAN,
// search other catalogs using EAN ONLY (cross-catalog search)
// Note: Artikelnummern-Vergleich macht keinen Sinn über Kataloge hinweg
if ($searchAll && $fk_soc > 0 && !empty($foundEan)) {
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, discount_group, product_group, matchcode";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE (";
$conditions = array();
if (!empty($foundEan)) {
$conditions[] = "ean = '" . $this->db->escape($foundEan) . "'";
}
if (!empty($foundManufacturerRef)) {
$conditions[] = "manufacturer_ref = '" . $this->db->escape($foundManufacturerRef) . "'";
}
$sql .= implode(' OR ', $conditions) . ")";
$sql .= " WHERE ean = '" . $this->db->escape($foundEan) . "'";
$sql .= " AND active = 1";
$sql .= " AND entity = " . (int) $conf->entity;
$sql .= " AND fk_soc != " . (int) $fk_soc; // Exclude already found supplier
@ -588,30 +592,18 @@ class Datanorm extends CommonObject
}
}
// Fallback: Search by partial match on article_number, ean, or manufacturer_ref
// Fallback: Search by EXACT article number match for the specified supplier only
// No LIKE search - cross-catalog comparisons only work via EAN
if ($fk_soc > 0 && empty($results)) {
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, discount_group, product_group, matchcode";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE (article_number LIKE '" . $this->db->escape($article_number) . "%'";
$sql .= " OR ean = '" . $this->db->escape($article_number) . "'";
$sql .= " OR manufacturer_ref LIKE '" . $this->db->escape($article_number) . "%')";
$sql .= " WHERE article_number = '" . $this->db->escape($article_number) . "'";
$sql .= " AND fk_soc = " . (int) $fk_soc;
$sql .= " AND active = 1";
$sql .= " AND entity = " . (int) $conf->entity;
if ($fk_soc > 0 && !$searchAll) {
$sql .= " AND fk_soc = " . (int) $fk_soc;
}
// ORDER BY clause
if ($fk_soc > 0 && $searchAll) {
// Order by matching supplier first, then by price
$sql .= " ORDER BY CASE WHEN fk_soc = " . (int) $fk_soc . " THEN 0 ELSE 1 END, price ASC";
} else {
$sql .= " ORDER BY article_number";
}
$sql .= " LIMIT " . (int) $limit;
$sql .= " LIMIT 1";
$resql = $this->db->query($sql);
if ($resql) {
@ -638,6 +630,7 @@ class Datanorm extends CommonObject
}
$this->db->free($resql);
}
}
return $results;
}

View file

@ -624,6 +624,32 @@ class ZugferdImport extends CommonObject
if (!empty($match) && $match['fk_product'] > 0) {
$fk_product = $match['fk_product'];
$match_method = $match['method'];
// Update supplier price with EAN from invoice if empty
$invoiceEan = !empty($line_data['product']['global_id']) ? trim($line_data['product']['global_id']) : '';
$supplierRef = !empty($line_data['product']['seller_id']) ? $line_data['product']['seller_id'] : '';
if (!empty($invoiceEan) && !empty($supplierRef) && ctype_digit($invoiceEan)) {
// Barcode-Typ basierend auf Länge bestimmen
$eanLen = strlen($invoiceEan);
if ($eanLen == 13) {
$barcodeType = 2; // EAN13
} elseif ($eanLen == 8) {
$barcodeType = 1; // EAN8
} elseif ($eanLen == 12) {
$barcodeType = 3; // UPC-A
} else {
$barcodeType = 0; // Unbekannt
}
$sqlEan = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlEan .= " SET barcode = '" . $this->db->escape($invoiceEan) . "'";
$sqlEan .= ", fk_barcode_type = " . (int)$barcodeType;
$sqlEan .= " WHERE fk_product = " . (int)$fk_product;
$sqlEan .= " AND fk_soc = " . (int)$supplier_id;
$sqlEan .= " AND ref_fourn = '" . $this->db->escape($supplierRef) . "'";
$sqlEan .= " AND (barcode IS NULL OR barcode = '')";
$this->db->query($sqlEan);
}
}
}

View file

@ -76,7 +76,7 @@ class modImportZugferd extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@importzugferd'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '3.6';
$this->version = '3.8';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -616,6 +616,28 @@ class modImportZugferd extends DolibarrModules
'isModEnabled("importzugferd")' // enabled condition
);
// Add extrafield for product price without copper surcharge (only for cables)
$extrafields->addExtraField(
'produktpreis', // attribute code
'Produktpreis', // label (translation key)
'price', // type (price field)
115, // position
'24,8', // size
'product_fournisseur_price', // element type
0, // unique
0, // required
'', // default value
'', // param
1, // always editable
'', // permission
1, // list (show in list)
0, // printable
'', // totalizable
'', // langfile
'importzugferd@importzugferd', // module
'isModEnabled("importzugferd")' // enabled condition
);
// Add extrafield for price unit (Preiseinheit) on supplier prices
$extrafields->addExtraField(
'preiseinheit', // attribute code

File diff suppressed because it is too large Load diff

View file

@ -451,6 +451,8 @@ LabelChange = Namensänderung
#
Kupferzuschlag = Kupferzuschlag
KupferzuschlagHelp = Metallzuschlag pro Einheit (wird aus Rechnungen extrahiert)
Produktpreis = Produktpreis
ProduktpreisHelp = Reiner Materialpreis ohne Kupferzuschlag (nur bei Kabeln)
Preiseinheit = Preiseinheit
PreiseinheitHelp = Anzahl Einheiten pro Preis (z.B. 100 = Preis pro 100 Stück)
Warengruppe = Warengruppe
@ -489,3 +491,7 @@ MoreExpensiveBy = %s%% teurer
RefreshProductListHelp = Produktlisten neu laden (nach Anlage neuer Produkte)
SelectAll = Alle auswählen
DeselectAll = Keine auswählen
# UI Buttons
ExpandAll = Alle aufklappen
CollapseAll = Alle zuklappen

View file

@ -389,6 +389,8 @@ NotifyEmail = Recipient email
#
Kupferzuschlag = Copper Surcharge
KupferzuschlagHelp = Metal surcharge per unit (extracted from invoices)
Produktpreis = Material Price
ProduktpreisHelp = Material price without copper surcharge (cables only)
Preiseinheit = Price Unit
PreiseinheitHelp = Number of units per price (e.g. 100 = price per 100 pieces)
Warengruppe = Product Group
@ -420,3 +422,7 @@ MoreExpensiveBy = %s%% more expensive
RefreshProductListHelp = Refresh product lists (after creating new products)
SelectAll = Select all
DeselectAll = Deselect all
# UI Buttons
ExpandAll = Expand all
CollapseAll = Collapse all