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>
This commit is contained in:
Eduard Wisch 2026-02-25 13:43:26 +01:00
parent 8b0d1830a3
commit 745fc68fc9
9 changed files with 782 additions and 256 deletions

19
CHANGELOG.md Normal file → Executable file
View file

@ -2,6 +2,25 @@
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

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

@ -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,55 +592,44 @@ class Datanorm extends CommonObject
}
}
// Fallback: Search by partial match on article_number, ean, or manufacturer_ref
$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 .= " AND active = 1";
$sql .= " AND entity = " . (int) $conf->entity;
if ($fk_soc > 0 && !$searchAll) {
// 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 = '" . $this->db->escape($article_number) . "'";
$sql .= " AND fk_soc = " . (int) $fk_soc;
}
$sql .= " AND active = 1";
$sql .= " AND entity = " . (int) $conf->entity;
$sql .= " LIMIT 1";
// 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;
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
if (!isset($foundIds[$obj->rowid])) {
$results[] = array(
'id' => $obj->rowid,
'fk_soc' => $obj->fk_soc,
'article_number' => $obj->article_number,
'short_text1' => $obj->short_text1,
'short_text2' => $obj->short_text2,
'ean' => $obj->ean,
'manufacturer_ref' => $obj->manufacturer_ref,
'manufacturer_name' => $obj->manufacturer_name,
'unit_code' => $obj->unit_code,
'price' => $obj->price,
'price_unit' => $obj->price_unit,
'discount_group' => $obj->discount_group,
'product_group' => $obj->product_group,
'matchcode' => $obj->matchcode,
);
$foundIds[$obj->rowid] = true;
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
if (!isset($foundIds[$obj->rowid])) {
$results[] = array(
'id' => $obj->rowid,
'fk_soc' => $obj->fk_soc,
'article_number' => $obj->article_number,
'short_text1' => $obj->short_text1,
'short_text2' => $obj->short_text2,
'ean' => $obj->ean,
'manufacturer_ref' => $obj->manufacturer_ref,
'manufacturer_name' => $obj->manufacturer_name,
'unit_code' => $obj->unit_code,
'price' => $obj->price,
'price_unit' => $obj->price_unit,
'discount_group' => $obj->discount_group,
'product_group' => $obj->product_group,
'matchcode' => $obj->matchcode,
);
$foundIds[$obj->rowid] = true;
}
}
$this->db->free($resql);
}
$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.7';
$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