Fehler beseitigt, Massenimport eingefügt Datenorm korrigiert.

This commit is contained in:
Eduard Wisch 2026-02-01 16:54:43 +01:00
parent e420698a58
commit 244e41c353
18 changed files with 21093 additions and 133 deletions

View file

@ -5,7 +5,12 @@
"Bash(python3:*)",
"Bash(xmllint:*)",
"Bash(php -r:*)",
"Bash(chmod:*)"
"Bash(chmod:*)",
"Bash(cut:*)",
"Bash(grep:*)",
"Bash(mysql:*)",
"Bash(ls:*)",
"Bash(php:*)"
]
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,27 @@
# CHANGELOG MODULE IMPORTZUGFERD FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
## 2.1
### Bugfixes
- Rechnungsimport: Preise wurden falsch als Brutto (TTC) statt Netto (HT) behandelt - korrigierte Parameterreihenfolge in addline()
- Datanorm Massenaktualisierung: Lieferantenauswahl ging nach Aktionen verloren - Redirects hinzugefuegt
- Datanorm Massenaktualisierung: "Alle Aenderungen uebernehmen" Button war nicht sichtbar ohne Suchergebnisse
- Datanorm Massenaktualisierung: Filter-Auswahl (Preis/Beschreibung/Bezeichnung) wurde bei "Alle hinzufuegen" ignoriert
- ProductFournisseur::update_buyprice erwartet Societe-Objekt, nicht Integer-ID
### Verbesserungen
- Bestaetungsdialog fuer Massenaktionen verwendet jetzt Dolibarr jQuery UI Dialog statt JavaScript confirm()
- Manuelles Metallzuschlag-Eingabefeld entfernt (nicht mehr benoetigt - Kupferzuschlag wird aus ZUGFeRD XML extrahiert)
- Ausstehende Aenderungen werden immer angezeigt wenn vorhanden, unabhaengig von Suchergebnissen
## 2.0
- Datanorm 4.0/5.0 Katalog-Import
- Kupferzuschlag-Extraktion aus ZUGFeRD XML (AllowanceCharge)
- Automatischer Preisvergleich zwischen Datanorm und aktuellen Einkaufspreisen
- Massenaktualisierung von Produktpreisen und Beschreibungen
- Aenderungsprotokoll fuer Preisanpassungen
## 1.0
Initial version

View file

@ -264,8 +264,15 @@ class ActionsImportZugferd
public function processLineItems($lines, $supplier_id)
{
$processed = array();
$last_product_index = -1;
foreach ($lines as $idx => $line) {
// Check if this is a metal surcharge line
$is_surcharge = $this->isMetalSurchargeLine($line);
// Get copper surcharge directly from parsed line data (if available)
$copper_surcharge_per_unit = isset($line['copper_surcharge_per_unit']) ? $line['copper_surcharge_per_unit'] : null;
foreach ($lines as $line) {
$processed_line = array(
'line_id' => $line['line_id'],
'supplier_ref' => $line['product']['seller_id'],
@ -285,10 +292,14 @@ class ActionsImportZugferd
'product_label' => '',
'match_method' => '',
'needs_creation' => false,
'is_metal_surcharge' => $is_surcharge,
'metal_surcharge' => $copper_surcharge_per_unit ?: 0, // From parsed XML or will be filled from surcharge lines
'copper_surcharge_raw' => isset($line['copper_surcharge']) ? $line['copper_surcharge'] : null,
'copper_surcharge_basis_qty' => isset($line['copper_surcharge_basis_qty']) ? $line['copper_surcharge_basis_qty'] : null,
);
// Try to find product
if ($supplier_id > 0) {
if ($supplier_id > 0 && !$is_surcharge) {
$match = $this->mapping->findProduct($supplier_id, $line['product']);
if ($match['fk_product'] > 0) {
$processed_line['fk_product'] = $match['fk_product'];
@ -303,11 +314,43 @@ class ActionsImportZugferd
} else {
$processed_line['needs_creation'] = true;
}
} else {
} elseif (!$is_surcharge) {
$processed_line['needs_creation'] = true;
}
$processed[] = $processed_line;
$current_index = count($processed) - 1;
// If this is a metal surcharge line, associate it with the previous product
// Only use this fallback if the product line doesn't already have copper_surcharge from XML
if ($is_surcharge && $last_product_index >= 0) {
// Only apply if the previous product doesn't already have a copper surcharge from XML
if (empty($processed[$last_product_index]['metal_surcharge'])) {
// Calculate surcharge per unit based on the product's quantity
$product_qty = $processed[$last_product_index]['quantity'];
if ($product_qty > 0) {
$surcharge_per_unit = $line['line_total'] / $product_qty;
$processed[$last_product_index]['metal_surcharge'] = $surcharge_per_unit;
dol_syslog("Metal surcharge from separate line: " . $line['line_total'] . " EUR for " . $product_qty . " units = " . $surcharge_per_unit . " EUR/unit", LOG_INFO);
}
}
// Copy product info to surcharge line for reference
$processed_line['fk_product'] = $processed[$last_product_index]['fk_product'];
$processed_line['associated_product_ref'] = $processed[$last_product_index]['product_ref'];
$processed[$current_index] = $processed_line;
}
// Log if copper surcharge was extracted from XML
if ($copper_surcharge_per_unit > 0) {
dol_syslog("Copper surcharge from XML: " . $copper_surcharge_per_unit . " EUR/unit for " . $line['product']['name'], LOG_INFO);
}
// Remember the last non-surcharge product line
if (!$is_surcharge && $processed_line['fk_product'] > 0) {
$last_product_index = $current_index;
}
}
return $processed;
@ -577,9 +620,125 @@ class ActionsImportZugferd
$this->updateSupplierPriceBarcode($invoice->socid, $line['fk_product'], $line['ean'], $line['supplier_ref']);
}
// Check if this line has a metal surcharge associated and update extrafield
if ($line['fk_product'] > 0 && !empty($line['metal_surcharge']) && $line['metal_surcharge'] > 0) {
$this->updateSupplierPriceMetalSurcharge(
$invoice->socid,
$line['fk_product'],
$line['metal_surcharge'],
$line['supplier_ref']
);
}
return 1;
}
/**
* Check if a line is a metal surcharge line
*
* @param array $line Line data from invoice
* @return bool
*/
public function isMetalSurchargeLine($line)
{
$name = strtolower($line['product']['name'] ?? '');
$description = strtolower($line['product']['description'] ?? '');
$text = $name . ' ' . $description;
// Keywords that indicate metal surcharge
$keywords = array(
'metallzuschlag',
'kupferzuschlag',
'cu-zuschlag',
'cuzuschlag',
'metallnotierung',
'kupfernotierung',
'metal surcharge',
'copper surcharge',
'metallaufschlag',
'kupferaufschlag',
'mez ', // MEZ = Metallzuschlag (with space to avoid false positives)
' mez',
);
foreach ($keywords as $keyword) {
if (strpos($text, $keyword) !== false) {
return true;
}
}
return false;
}
/**
* Update metal surcharge extrafield on supplier price
*
* @param int $supplier_id Supplier ID
* @param int $product_id Product ID
* @param float $surcharge Metal surcharge amount per unit
* @param string $ref_fourn Supplier reference
* @return int >0 if updated, 0 if no update, <0 if error
*/
public function updateSupplierPriceMetalSurcharge($supplier_id, $product_id, $surcharge, $ref_fourn = '')
{
global $conf;
if ($surcharge <= 0) {
return 0;
}
// Find supplier price record
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sql .= " WHERE fk_soc = " . (int) $supplier_id;
$sql .= " AND fk_product = " . (int) $product_id;
$sql .= " AND entity IN (" . getEntity('product') . ")";
if (!empty($ref_fourn)) {
$sql .= " AND ref_fourn = '" . $this->db->escape($ref_fourn) . "'";
}
$sql .= " ORDER BY rowid DESC LIMIT 1";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
$price_id = $obj->rowid;
// Check if extrafield table exists
$sql_check = "SHOW TABLES LIKE '" . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields'";
$res_check = $this->db->query($sql_check);
if (!$res_check || $this->db->num_rows($res_check) == 0) {
dol_syslog("Extrafield table does not exist, skipping metal surcharge update", LOG_WARNING);
return 0;
}
// Check if record exists in extrafields
$sql_exists = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sql_exists .= " WHERE fk_object = " . (int) $price_id;
$res_exists = $this->db->query($sql_exists);
if ($res_exists && $this->db->num_rows($res_exists) > 0) {
// Update existing record
$sql_update = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sql_update .= " SET kupferzuschlag = " . (float) $surcharge;
$sql_update .= " WHERE fk_object = " . (int) $price_id;
} else {
// Insert new record
$sql_update = "INSERT INTO " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sql_update .= " (fk_object, kupferzuschlag) VALUES (" . (int) $price_id . ", " . (float) $surcharge . ")";
}
$res = $this->db->query($sql_update);
if ($res) {
dol_syslog("Updated metal surcharge for product " . $product_id . " supplier " . $supplier_id . " to " . $surcharge, LOG_INFO);
return 1;
} else {
dol_syslog("Error updating metal surcharge: " . $this->db->lasterror(), LOG_ERR);
return -1;
}
}
return 0; // No supplier price record found
}
/**
* Update barcode in supplier price record
*

View file

@ -87,10 +87,30 @@ class Datanorm extends CommonObject
public $price = 0;
/**
* @var int Price unit (pieces per price)
* @var int Price unit (actual quantity: 1, 10, 100, or 1000)
*/
public $price_unit = 1;
/**
* @var int Price unit code (original Datanorm PE code: 0, 1, 2, or 3)
*/
public $price_unit_code = 0;
/**
* @var int Price type (1=gross/Brutto, 2=net/Netto)
*/
public $price_type = 1;
/**
* @var int VPE - Verpackungseinheit (packaging quantity from B-record)
*/
public $vpe;
/**
* @var float Metal surcharge (Metallzuschlag/Kupferzuschlag) for cables
*/
public $metal_surcharge = 0;
/**
* @var string Discount group
*/
@ -126,6 +146,11 @@ class Datanorm extends CommonObject
*/
public $datanorm_version;
/**
* @var string Action code (N=New, A=Update, L=Delete)
*/
public $action_code = 'N';
/**
* @var string Import date
*/
@ -182,12 +207,17 @@ class Datanorm extends CommonObject
$this->fk_user_creat = $user->id;
// Set active=0 if action_code is L (deleted article)
if ($this->action_code === 'L') {
$this->active = 0;
}
$sql = "INSERT INTO " . MAIN_DB_PREFIX . $this->table_element . " (";
$sql .= "fk_soc, article_number, short_text1, short_text2, long_text,";
$sql .= "ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= "price, price_unit, discount_group, product_group,";
$sql .= "price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group,";
$sql .= "alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= "datanorm_version, import_date, active, date_creation, fk_user_creat, entity";
$sql .= "datanorm_version, action_code, import_date, active, date_creation, fk_user_creat, entity";
$sql .= ") VALUES (";
$sql .= (int) $this->fk_soc . ",";
$sql .= "'" . $this->db->escape($this->article_number) . "',";
@ -200,6 +230,10 @@ class Datanorm extends CommonObject
$sql .= "'" . $this->db->escape($this->unit_code) . "',";
$sql .= (float) $this->price . ",";
$sql .= (int) $this->price_unit . ",";
$sql .= (int) $this->price_unit_code . ",";
$sql .= (int) $this->price_type . ",";
$sql .= (float) $this->metal_surcharge . ",";
$sql .= ($this->vpe !== null ? (int) $this->vpe : 'NULL') . ",";
$sql .= "'" . $this->db->escape($this->discount_group) . "',";
$sql .= "'" . $this->db->escape($this->product_group) . "',";
$sql .= "'" . $this->db->escape($this->alt_unit) . "',";
@ -207,6 +241,7 @@ class Datanorm extends CommonObject
$sql .= ($this->weight !== null ? (float) $this->weight : 'NULL') . ",";
$sql .= "'" . $this->db->escape($this->matchcode) . "',";
$sql .= "'" . $this->db->escape($this->datanorm_version) . "',";
$sql .= "'" . $this->db->escape($this->action_code) . "',";
$sql .= "'" . $this->db->escape($this->db->idate($this->import_date)) . "',";
$sql .= (int) $this->active . ",";
$sql .= "'" . $this->db->escape($this->db->idate($this->date_creation)) . "',";
@ -254,9 +289,9 @@ class Datanorm extends CommonObject
{
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2, long_text,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, discount_group, product_group,";
$sql .= " price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group,";
$sql .= " alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= " datanorm_version, import_date, active, date_creation, tms, fk_user_creat, fk_user_modif, entity";
$sql .= " datanorm_version, action_code, import_date, active, date_creation, tms, fk_user_creat, fk_user_modif, entity";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE rowid = " . (int) $id;
@ -292,9 +327,9 @@ class Datanorm extends CommonObject
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2, long_text,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, discount_group, product_group,";
$sql .= " price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group,";
$sql .= " alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= " datanorm_version, import_date, active, date_creation, tms, fk_user_creat, fk_user_modif, entity";
$sql .= " datanorm_version, action_code, import_date, active, date_creation, tms, fk_user_creat, fk_user_modif, entity";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE fk_soc = " . (int) $fk_soc;
$sql .= " AND article_number = '" . $this->db->escape($article_number) . "'";
@ -338,6 +373,10 @@ class Datanorm extends CommonObject
$this->unit_code = $obj->unit_code;
$this->price = $obj->price;
$this->price_unit = $obj->price_unit;
$this->price_unit_code = $obj->price_unit_code ?? 0;
$this->price_type = $obj->price_type ?? 1;
$this->metal_surcharge = $obj->metal_surcharge ?? 0;
$this->vpe = $obj->vpe;
$this->discount_group = $obj->discount_group;
$this->product_group = $obj->product_group;
$this->alt_unit = $obj->alt_unit;
@ -345,6 +384,7 @@ class Datanorm extends CommonObject
$this->weight = $obj->weight;
$this->matchcode = $obj->matchcode;
$this->datanorm_version = $obj->datanorm_version;
$this->action_code = $obj->action_code ?? 'N';
$this->import_date = $this->db->jdate($obj->import_date);
$this->active = $obj->active;
$this->date_creation = $this->db->jdate($obj->date_creation);
@ -365,6 +405,11 @@ class Datanorm extends CommonObject
$this->fk_user_modif = $user->id;
$this->import_date = dol_now();
// Set active=0 if action_code is L (deleted article)
if ($this->action_code === 'L') {
$this->active = 0;
}
$sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET";
$sql .= " short_text1 = '" . $this->db->escape($this->short_text1) . "',";
$sql .= " short_text2 = '" . $this->db->escape($this->short_text2) . "',";
@ -375,6 +420,10 @@ class Datanorm extends CommonObject
$sql .= " unit_code = '" . $this->db->escape($this->unit_code) . "',";
$sql .= " price = " . (float) $this->price . ",";
$sql .= " price_unit = " . (int) $this->price_unit . ",";
$sql .= " price_unit_code = " . (int) $this->price_unit_code . ",";
$sql .= " price_type = " . (int) $this->price_type . ",";
$sql .= " metal_surcharge = " . (float) $this->metal_surcharge . ",";
$sql .= " vpe = " . ($this->vpe !== null ? (int) $this->vpe : 'NULL') . ",";
$sql .= " discount_group = '" . $this->db->escape($this->discount_group) . "',";
$sql .= " product_group = '" . $this->db->escape($this->product_group) . "',";
$sql .= " alt_unit = '" . $this->db->escape($this->alt_unit) . "',";
@ -382,6 +431,7 @@ class Datanorm extends CommonObject
$sql .= " weight = " . ($this->weight !== null ? (float) $this->weight : 'NULL') . ",";
$sql .= " matchcode = '" . $this->db->escape($this->matchcode) . "',";
$sql .= " datanorm_version = '" . $this->db->escape($this->datanorm_version) . "',";
$sql .= " action_code = '" . $this->db->escape($this->action_code) . "',";
$sql .= " import_date = '" . $this->db->escape($this->db->idate($this->import_date)) . "',";
$sql .= " active = " . (int) $this->active . ",";
$sql .= " fk_user_modif = " . (int) $this->fk_user_modif;
@ -649,10 +699,15 @@ class Datanorm extends CommonObject
$article->unit_code = $articleData['unit_code'] ?? '';
$article->price = $articleData['price'] ?? 0;
$article->price_unit = $articleData['price_unit'] ?? 1;
$article->price_unit_code = $articleData['price_unit_code'] ?? 0;
$article->price_type = $articleData['price_type'] ?? 1;
$article->metal_surcharge = $articleData['metal_surcharge'] ?? 0;
$article->vpe = $articleData['vpe'] ?? null;
$article->discount_group = $articleData['discount_group'] ?? '';
$article->product_group = $articleData['product_group'] ?? '';
$article->matchcode = $articleData['matchcode'] ?? '';
$article->datanorm_version = $parser->version;
$article->action_code = $articleData['action_code'] ?? 'N';
$result = $article->createOrUpdate($user);
if ($result > 0) {
@ -712,8 +767,11 @@ class Datanorm extends CommonObject
$now = $db->idate(dol_now());
foreach ($articles as $articleData) {
$vpe = isset($articleData['vpe']) ? (int)$articleData['vpe'] : 'NULL';
$actionCode = $articleData['action_code'] ?? 'N';
$active = ($actionCode === 'L') ? 0 : 1; // Set active=0 for deleted articles
$values[] = sprintf(
"(%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %f, %d, '%s', '%s', '%s', '%s', '%s', %d, '%s', %d)",
"(%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %f, %d, %d, %d, %f, %s, '%s', '%s', '%s', '%s', '%s', %d, '%s', %d, '%s', %d)",
(int) $fk_soc,
$db->escape($articleData['article_number'] ?? ''),
$db->escape($articleData['short_text1'] ?? ''),
@ -725,10 +783,16 @@ class Datanorm extends CommonObject
$db->escape($articleData['unit_code'] ?? ''),
(float) ($articleData['price'] ?? 0),
(int) ($articleData['price_unit'] ?? 1),
(int) ($articleData['price_unit_code'] ?? 0),
(int) ($articleData['price_type'] ?? 1),
(float) ($articleData['metal_surcharge'] ?? 0),
$vpe,
$db->escape($articleData['discount_group'] ?? ''),
$db->escape($articleData['product_group'] ?? ''),
$db->escape($articleData['matchcode'] ?? ''),
$db->escape($version),
$db->escape($actionCode),
$active,
$now,
(int) $user->id,
$now,
@ -741,8 +805,8 @@ class Datanorm extends CommonObject
$sql = "INSERT INTO " . MAIN_DB_PREFIX . "importzugferd_datanorm ";
$sql .= "(fk_soc, article_number, short_text1, short_text2, long_text, ";
$sql .= "ean, manufacturer_ref, manufacturer_name, unit_code, ";
$sql .= "price, price_unit, discount_group, product_group, matchcode, ";
$sql .= "datanorm_version, import_date, fk_user_creat, date_creation, entity) VALUES ";
$sql .= "price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group, matchcode, ";
$sql .= "datanorm_version, action_code, active, import_date, fk_user_creat, date_creation, entity) VALUES ";
$sql .= implode(", ", $values);
// For updates of existing articles, use ON DUPLICATE KEY UPDATE
@ -756,10 +820,16 @@ class Datanorm extends CommonObject
$sql .= "unit_code = VALUES(unit_code), ";
$sql .= "price = VALUES(price), ";
$sql .= "price_unit = VALUES(price_unit), ";
$sql .= "price_unit_code = VALUES(price_unit_code), ";
$sql .= "price_type = VALUES(price_type), ";
$sql .= "metal_surcharge = VALUES(metal_surcharge), ";
$sql .= "vpe = VALUES(vpe), ";
$sql .= "discount_group = VALUES(discount_group), ";
$sql .= "product_group = VALUES(product_group), ";
$sql .= "matchcode = VALUES(matchcode), ";
$sql .= "datanorm_version = VALUES(datanorm_version), ";
$sql .= "action_code = VALUES(action_code), ";
$sql .= "active = VALUES(active), ";
$sql .= "import_date = VALUES(import_date), ";
$sql .= "fk_user_modif = " . (int) $user->id;
@ -785,15 +855,20 @@ class Datanorm extends CommonObject
}
// Second pass: Update prices from DATPREIS files
$priceFiles = glob($directory . '/DATPREIS.*');
// Use case-insensitive search for Linux compatibility
$priceFiles = array();
$allFiles = glob($directory . '/*');
foreach ($allFiles as $file) {
$basename = strtoupper(basename($file));
if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) {
$priceFiles[] = $file;
}
}
if (!empty($priceFiles)) {
foreach ($priceFiles as $file) {
$ext = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
if (preg_match('/^\d{3}$/', $ext)) {
$this->updatePricesFromFile($fk_soc, $file);
}
}
}
return $importCount;
}
@ -835,22 +910,33 @@ class Datanorm extends CommonObject
$recordType = trim($parts[0] ?? '');
// P;A format - multiple articles per line
// Format: P;A;ArtNr;PreisKz;Preis;PE;Zuschlag;x;x;x;ArtNr2;...
// PE is the price unit code from DATPREIS (may differ from A-record!)
if ($recordType === 'P' && isset($parts[1]) && $parts[1] === 'A') {
$i = 2;
while ($i < count($parts) - 2) {
$articleNumber = trim($parts[$i] ?? '');
$priceRaw = trim($parts[$i + 2] ?? '0');
$datpreisPeCode = (int)trim($parts[$i + 3] ?? '0'); // PE code from DATPREIS
$metalSurchargeRaw = trim($parts[$i + 4] ?? '0');
$price = (float)$priceRaw / 100; // Convert cents to euros
$metalSurcharge = (float)$metalSurchargeRaw / 100; // Convert cents to euros
if (!empty($articleNumber) && $price > 0) {
$batch[$articleNumber] = $price;
$batch[$articleNumber] = array(
'price' => $price,
'metal_surcharge' => $metalSurcharge,
'datpreis_pe_code' => $datpreisPeCode
);
}
$i += 9; // 9 fields per article
}
} elseif ($recordType === 'P' || $recordType === '0') {
// Simple format: P;ArtNr;PreisKz;Preis;PE;...
$articleNumber = trim($parts[1] ?? '');
$priceRaw = trim($parts[3] ?? '0');
$datpreisPeCode = (int)trim($parts[4] ?? '0'); // PE code if available
if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) {
$price = (float)$priceRaw / 100;
@ -860,7 +946,11 @@ class Datanorm extends CommonObject
}
if (!empty($articleNumber) && $price > 0) {
$batch[$articleNumber] = $price;
$batch[$articleNumber] = array(
'price' => $price,
'metal_surcharge' => 0,
'datpreis_pe_code' => $datpreisPeCode
);
}
}
@ -882,9 +972,10 @@ class Datanorm extends CommonObject
/**
* Flush price batch to database
* DATPREIS prices are already given for the A-Satz PE unit - no normalization needed!
*
* @param int $fk_soc Supplier ID
* @param array $prices Array of article_number => price
* @param array $prices Array of article_number => array('price' => ..., 'metal_surcharge' => ...)
* @return int Number of rows updated
*/
protected function flushPriceBatch($fk_soc, $prices)
@ -897,19 +988,25 @@ class Datanorm extends CommonObject
$updated = 0;
// Build CASE statement for batch update
$cases = array();
// Build CASE statements for batch update
// Note: DATPREIS prices are already for the A-Satz PE unit, no normalization needed
$priceCases = array();
$metalCases = array();
$articleNumbers = array();
foreach ($prices as $artNum => $price) {
$cases[] = "WHEN '" . $this->db->escape($artNum) . "' THEN " . (float)$price;
foreach ($prices as $artNum => $priceData) {
$priceCases[] = "WHEN '" . $this->db->escape($artNum) . "' THEN " . (float)$priceData['price'];
$metalCases[] = "WHEN '" . $this->db->escape($artNum) . "' THEN " . (float)$priceData['metal_surcharge'];
$articleNumbers[] = "'" . $this->db->escape($artNum) . "'";
}
if (!empty($cases)) {
if (!empty($priceCases)) {
$sql = "UPDATE " . MAIN_DB_PREFIX . "importzugferd_datanorm SET ";
$sql .= "price = CASE article_number ";
$sql .= implode(" ", $cases);
$sql .= implode(" ", $priceCases);
$sql .= " END, ";
$sql .= "metal_surcharge = CASE article_number ";
$sql .= implode(" ", $metalCases);
$sql .= " END ";
$sql .= "WHERE fk_soc = " . (int)$fk_soc;
$sql .= " AND entity = " . (int)$conf->entity;

View file

@ -16,9 +16,41 @@
/**
* Class DatanormParser
* Parses Datanorm catalog files (Version 4.0 and 5.0)
*
* Datanorm Price Unit (PE) Codes:
* 0 or empty = per 1 piece
* 1 = per 10 pieces
* 2 = per 100 pieces
* 3 = per 1000 pieces
*
* The price in Datanorm is given for the quantity specified by the PE code.
* To get the unit price: divide price by PE quantity.
*/
class DatanormParser
{
/**
* Price unit code mapping
* Datanorm uses codes 0-3 to represent price units
*/
const PRICE_UNIT_CODES = array(
0 => 1,
1 => 10,
2 => 100,
3 => 1000,
);
/**
* Convert Datanorm PE code to actual quantity
*
* @param int|string $peCode The PE code from Datanorm (0, 1, 2, or 3)
* @return int The actual quantity (1, 10, 100, or 1000)
*/
public static function convertPriceUnitCode($peCode)
{
$code = (int)$peCode;
return self::PRICE_UNIT_CODES[$code] ?? 1;
}
/**
* @var string Detected Datanorm version
*/
@ -122,6 +154,9 @@ class DatanormParser
foreach ($this->batchArticles as $artNum => &$article) {
if (isset($this->prices[$artNum])) {
$article['price'] = $this->prices[$artNum]['price'];
if (!empty($this->prices[$artNum]['metal_surcharge'])) {
$article['metal_surcharge'] = $this->prices[$artNum]['metal_surcharge'];
}
unset($this->prices[$artNum]); // Free memory
}
}
@ -157,40 +192,54 @@ class DatanormParser
{
$totalArticles = 0;
// For non-streaming mode, load prices first
// For streaming mode with very large files, prices must be handled separately
// Use case-insensitive search for Linux compatibility
$allFiles = glob($dir . '/*');
// For non-streaming mode, load prices first into memory
// For streaming mode, prices are updated via second pass directly to DB
if (!$this->streamingMode) {
$priceFiles = glob($dir . '/DATPREIS.*');
$priceFiles = array();
foreach ($allFiles as $file) {
$basename = strtoupper(basename($file));
if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) {
$priceFiles[] = $file;
}
}
if (!empty($priceFiles)) {
$this->version = '4.0';
foreach ($priceFiles as $file) {
$ext = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
if (preg_match('/^\d{3}$/', $ext)) {
$this->parseDatapreis4File($file);
}
}
}
}
// Look for Datanorm 4.0 files (DATANORM.xxx)
$files = glob($dir . '/DATANORM.*');
if (!empty($files)) {
// Look for Datanorm 4.0 files (DATANORM.xxx) - case-insensitive
$datanormFiles = array();
$wrgFiles = array();
$rabFiles = array();
foreach ($allFiles as $file) {
$basename = strtoupper(basename($file));
if (preg_match('/^DATANORM\.\d{3}$/', $basename)) {
$datanormFiles[] = $file;
} elseif ($basename === 'DATANORM.WRG') {
$wrgFiles[] = $file;
} elseif ($basename === 'DATANORM.RAB') {
$rabFiles[] = $file;
}
}
if (!empty($datanormFiles)) {
$this->version = '4.0';
foreach ($files as $file) {
$ext = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
if (preg_match('/^\d{3}$/', $ext)) {
// Main article file (DATANORM.001, etc.)
foreach ($datanormFiles as $file) {
$count = $this->parseDatanorm4File($file);
if ($count > 0) {
$totalArticles += $count;
}
} elseif ($ext === 'WRG') {
// Product groups file
$this->parseDatanorm4Groups($file);
} elseif ($ext === 'RAB') {
// Discount groups file
$this->parseDatanorm4Discounts($file);
}
foreach ($wrgFiles as $file) {
$this->parseDatanorm4Groups($file);
}
foreach ($rabFiles as $file) {
$this->parseDatanorm4Discounts($file);
}
}
@ -329,14 +378,20 @@ class DatanormParser
return $this->parseDatanorm4TypeASemicolon($line);
}
// Fixed-width format (classic)
// Fixed-width format (classic Datanorm 3.0/4.0)
// PE code is at position 112-116 and is a CODE (0=1, 1=10, 2=100, 3=1000)
$peCode = (int)trim(substr($line, 111, 5));
$priceUnit = self::convertPriceUnitCode($peCode);
$article = array(
'article_number' => trim(substr($line, 1, 15)), // Pos 2-16: Artikelnummer
'action_code' => 'N', // Fixed-width format has no action code
'matchcode' => trim(substr($line, 16, 12)), // Pos 17-28: Matchcode
'short_text1' => trim(substr($line, 28, 40)), // Pos 29-68: Kurztext 1
'short_text2' => trim(substr($line, 68, 40)), // Pos 69-108: Kurztext 2
'unit_code' => trim(substr($line, 108, 3)), // Pos 109-111: Mengeneinheit
'price_unit' => (int)trim(substr($line, 111, 5)), // Pos 112-116: Preiseinheit
'price_unit' => $priceUnit, // Converted from PE code
'price_unit_code' => $peCode, // Original PE code
'discount_group' => trim(substr($line, 116, 4)), // Pos 117-120: Rabattgruppe
'product_group' => trim(substr($line, 120, 7)), // Pos 121-127: Warengruppe
'manufacturer_ref' => trim(substr($line, 127, 15)), // Pos 128-142: Hersteller-Artikelnummer
@ -355,11 +410,6 @@ class DatanormParser
return null;
}
// Default price unit to 1 if not set
if ($article['price_unit'] <= 0) {
$article['price_unit'] = 1;
}
return $article;
}
@ -378,22 +428,32 @@ class DatanormParser
}
// Detect format variant
// Sonepar format: A;N;ArtNr;WG;Kurztext1;Kurztext2;PE;ME;METext;RabGrp;PreisGrp;WG2;...
// Sonepar format: A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;...
// Index: 0 1 2 3 4 5 6 7 8 9 10 11
// Standard format: A;ArtNr;Matchcode;Kurztext1;Kurztext2;ME;PE;RabGrp;WG;...
$firstField = trim($parts[0] ?? '');
if ($firstField === 'A' && isset($parts[1]) && strlen(trim($parts[1])) <= 2) {
// Sonepar format: A;N;ArtNr;WG;Kurztext1;Kurztext2;PE;ME;METext;RabGrp;PreisGrp;WG2;...
// Sonepar format with action code (N=New, L=Delete, A=Update)
// A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;LangTextKey
// PE is at index 7 and is a CODE (0=1, 1=10, 2=100, 3=1000)
$actionCode = strtoupper(trim($parts[1] ?? 'N'));
$peCode = (int)trim($parts[7] ?? '0');
$priceUnit = self::convertPriceUnitCode($peCode);
$article = array(
'article_number' => trim($parts[2] ?? ''),
'action_code' => $actionCode, // N=New, A=Update, L=Delete
'matchcode' => '', // Will be set from B record
'short_text1' => trim($parts[4] ?? ''),
'short_text2' => trim($parts[5] ?? ''),
'unit_code' => trim($parts[8] ?? trim($parts[7] ?? '')), // METext or ME
'price_unit' => (int)trim($parts[6] ?? '1'), // PE
'discount_group' => trim($parts[9] ?? ''),
'product_group' => trim($parts[3] ?? ''), // WG at position 3
'unit_code' => trim($parts[8] ?? ''), // ME (Mengeneinheit) at index 8
'price_unit' => $priceUnit, // Converted from PE code at index 7
'price_unit_code' => $peCode, // Original PE code for reference
'discount_group' => trim($parts[10] ?? ''), // Rabattgruppe at index 10
'product_group' => trim($parts[11] ?? ''), // Warengruppe at index 11
'price_type' => trim($parts[6] ?? ''), // Preiskennzeichen (1=Brutto, 2=Netto)
'manufacturer_ref' => '',
'manufacturer_name' => '',
'ean' => '',
@ -401,14 +461,20 @@ class DatanormParser
'price' => 0,
);
} else {
// Standard format
// Standard format: A;ArtNr;Matchcode;Kurztext1;Kurztext2;ME;PE;RabGrp;WG;...
// PE at index 6 is a CODE (0=1, 1=10, 2=100, 3=1000)
$peCode = (int)trim($parts[6] ?? '0');
$priceUnit = self::convertPriceUnitCode($peCode);
$article = array(
'article_number' => trim($parts[1] ?? ''),
'action_code' => 'N', // Default to New for standard format
'matchcode' => trim($parts[2] ?? ''),
'short_text1' => trim($parts[3] ?? ''),
'short_text2' => trim($parts[4] ?? ''),
'unit_code' => trim($parts[5] ?? ''),
'price_unit' => (int)trim($parts[6] ?? '1'),
'price_unit' => $priceUnit,
'price_unit_code' => $peCode,
'discount_group' => trim($parts[7] ?? ''),
'product_group' => trim($parts[8] ?? ''),
'manufacturer_ref' => trim($parts[14] ?? ''),
@ -423,10 +489,6 @@ class DatanormParser
return null;
}
if ($article['price_unit'] <= 0) {
$article['price_unit'] = 1;
}
return $article;
}
@ -453,6 +515,7 @@ class DatanormParser
/**
* Parse Datanorm 4.0 Type B record (Article info/long text)
* Sonepar format: B;N;ArtNr;Matchcode; ; ;;;;EAN; ; ;0;VPE;;;
*
* @param string $line Record line
* @param string $articleNumber Current article number
@ -467,7 +530,8 @@ class DatanormParser
if (strpos($line, ';') !== false) {
$parts = explode(';', $line);
// Sonepar format: B;N;ArtNr;Matchcode;...
// Sonepar format: B;N;ArtNr;Matchcode; ; ;...;EAN; ; ;0;VPE;;;
// Field positions can vary, so we search for EAN and VPE
if (isset($parts[1]) && strlen(trim($parts[1])) <= 2) {
// Get article number from B record to verify
$bArticleNumber = trim($parts[2] ?? '');
@ -477,6 +541,28 @@ class DatanormParser
if (!empty($matchcode) && empty($article['matchcode'])) {
$article['matchcode'] = $matchcode;
}
// Search for EAN (13-digit numeric code) in any field
if (empty($article['ean'])) {
foreach ($parts as $part) {
$part = trim($part);
if (preg_match('/^\d{13}$/', $part)) {
$article['ean'] = $part;
break;
}
}
}
// VPE (Verpackungseinheit) in B record is the packaging quantity
// This is informational - the price unit from A record PE code is authoritative
// We store VPE separately for reference but don't override price_unit
for ($i = 12; $i <= min(15, count($parts) - 1); $i++) {
$vpe = (int)trim($parts[$i] ?? '0');
if ($vpe > 1) {
$article['vpe'] = $vpe; // Store as separate field
break;
}
}
}
} else {
// Standard format: text at position 2
@ -604,27 +690,34 @@ class DatanormParser
$recordType = trim($parts[0] ?? '');
// P;A format - multiple articles per line
// Format: P;A;ArtNr;PreisKz;Preis;PE;x;x;x;x;ArtNr2;PreisKz2;Preis2;...
// Format: P;A;ArtNr;PreisKz;Preis;PE;Zuschlag;x;x;x;ArtNr2;PreisKz2;Preis2;...
// For cables: Preis = Materialpreis, Zuschlag = Metallzuschlag (copper surcharge)
// PE code from DATPREIS may differ from A-record - used for price normalization
if ($recordType === 'P' && isset($parts[1]) && $parts[1] === 'A') {
// Parse multiple price entries per line
// Each entry is: ArtNr;PreisKz;Preis;PE;0;1;0;1;0
// Each entry is: ArtNr;PreisKz;Preis;PE;Zuschlag;x;x;x;x
$i = 2; // Start after P;A
while ($i < count($parts) - 2) {
$articleNumber = trim($parts[$i] ?? '');
$priceType = trim($parts[$i + 1] ?? '');
$priceRaw = trim($parts[$i + 2] ?? '0');
$datpreisPeCode = (int)trim($parts[$i + 3] ?? '0'); // PE code from DATPREIS
$metalSurchargeRaw = trim($parts[$i + 4] ?? '0');
// Price is in cents, convert to euros
$price = (float)$priceRaw / 100;
$metalSurcharge = (float)$metalSurchargeRaw / 100;
if (!empty($articleNumber) && $price > 0) {
$this->prices[$articleNumber] = array(
'price' => $price,
'price_type' => $priceType,
'metal_surcharge' => $metalSurcharge,
'datpreis_pe_code' => $datpreisPeCode,
);
}
// Move to next article (9 fields per article: ArtNr;Kz;Preis;PE;0;1;0;1;0)
// Move to next article (9 fields per article: ArtNr;Kz;Preis;PE;Zuschlag;x;x;x;x)
$i += 9;
}
} elseif ($recordType === 'P' || $recordType === '0') {
@ -678,12 +771,16 @@ class DatanormParser
/**
* Merge prices into articles
* DATPREIS prices are already for the A-Satz PE unit - no normalization needed!
*/
protected function mergePricesIntoArticles()
{
foreach ($this->prices as $articleNumber => $priceData) {
if (isset($this->articles[$articleNumber])) {
$this->articles[$articleNumber]['price'] = $priceData['price'];
if (!empty($priceData['metal_surcharge'])) {
$this->articles[$articleNumber]['metal_surcharge'] = $priceData['metal_surcharge'];
}
}
}
}

View file

@ -327,6 +327,45 @@ class ZugferdParser
$unitPrice = $chargeAmount;
}
// Extract copper surcharge (Kupferzuschlag) from AppliedTradeAllowanceCharge
$copperSurcharge = null;
$copperSurchargeBasisQty = null;
$allowanceCharges = $line->xpath('ram:SpecifiedSupplyChainTradeAgreement/ram:GrossPriceProductTradePrice/ram:AppliedTradeAllowanceCharge');
foreach ($allowanceCharges as $charge) {
$charge->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12');
$reason = (string) $this->getNodeValue($charge->xpath('ram:Reason'));
if (stripos($reason, 'Kupfer') !== false || stripos($reason, 'copper') !== false || stripos($reason, 'Metall') !== false) {
$copperSurcharge = (float) $this->getNodeValue($charge->xpath('ram:ActualAmount'));
$copperSurchargeBasisQty = (float) $this->getNodeValue($charge->xpath('ram:BasisQuantity'));
break;
}
}
// Also check ApplicableProductCharacteristic for copper surcharge
if ($copperSurcharge === null) {
$characteristics = $line->xpath('ram:SpecifiedTradeProduct/ram:ApplicableProductCharacteristic');
foreach ($characteristics as $char) {
$char->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12');
$desc = (string) $this->getNodeValue($char->xpath('ram:Description'));
if (stripos($desc, 'Kupfer') !== false || stripos($desc, 'copper') !== false || stripos($desc, 'Metall') !== false) {
$copperSurcharge = (float) $this->getNodeValue($char->xpath('ram:Value'));
// Usually refers to same basis quantity as the price
$copperSurchargeBasisQty = $basisQuantity ?: 1;
break;
}
}
}
// Calculate copper surcharge per single unit
$copperSurchargePerUnit = null;
if ($copperSurcharge !== null && $copperSurcharge > 0) {
if ($copperSurchargeBasisQty > 0 && $copperSurchargeBasisQty != 1) {
$copperSurchargePerUnit = $copperSurcharge / $copperSurchargeBasisQty;
} else {
$copperSurchargePerUnit = $copperSurcharge;
}
}
$lineData = array(
'line_id' => (string) $this->getNodeValue($line->xpath('ram:AssociatedDocumentLineDocument/ram:LineID')),
'product' => array(
@ -344,6 +383,10 @@ class ZugferdParser
'basis_quantity_unit' => $basisQuantityUnit,
'line_total' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeSettlement/ram:SpecifiedTradeSettlementMonetarySummation/ram:LineTotalAmount')),
'tax_percent' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeSettlement/ram:ApplicableTradeTax/ram:ApplicablePercent')),
// Copper surcharge data
'copper_surcharge' => $copperSurcharge,
'copper_surcharge_basis_qty' => $copperSurchargeBasisQty,
'copper_surcharge_per_unit' => $copperSurchargePerUnit,
);
$data['lines'][] = $lineData;
@ -426,6 +469,44 @@ class ZugferdParser
$unitPrice = $chargeAmount;
}
// Extract copper surcharge (Kupferzuschlag) from AppliedTradeAllowanceCharge (v2)
$copperSurcharge = null;
$copperSurchargeBasisQty = null;
$allowanceCharges = $line->xpath('ram:SpecifiedLineTradeAgreement/ram:GrossPriceProductTradePrice/ram:AppliedTradeAllowanceCharge');
foreach ($allowanceCharges as $charge) {
$charge->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
$reason = (string) $this->getNodeValue($charge->xpath('ram:Reason'));
if (stripos($reason, 'Kupfer') !== false || stripos($reason, 'copper') !== false || stripos($reason, 'Metall') !== false) {
$copperSurcharge = (float) $this->getNodeValue($charge->xpath('ram:ActualAmount'));
$copperSurchargeBasisQty = (float) $this->getNodeValue($charge->xpath('ram:BasisQuantity'));
break;
}
}
// Also check ApplicableProductCharacteristic for copper surcharge (v2)
if ($copperSurcharge === null) {
$characteristics = $line->xpath('ram:SpecifiedTradeProduct/ram:ApplicableProductCharacteristic');
foreach ($characteristics as $char) {
$char->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
$desc = (string) $this->getNodeValue($char->xpath('ram:Description'));
if (stripos($desc, 'Kupfer') !== false || stripos($desc, 'copper') !== false || stripos($desc, 'Metall') !== false) {
$copperSurcharge = (float) $this->getNodeValue($char->xpath('ram:Value'));
$copperSurchargeBasisQty = $basisQuantity ?: 1;
break;
}
}
}
// Calculate copper surcharge per single unit
$copperSurchargePerUnit = null;
if ($copperSurcharge !== null && $copperSurcharge > 0) {
if ($copperSurchargeBasisQty > 0 && $copperSurchargeBasisQty != 1) {
$copperSurchargePerUnit = $copperSurcharge / $copperSurchargeBasisQty;
} else {
$copperSurchargePerUnit = $copperSurcharge;
}
}
$lineData = array(
'line_id' => (string) $this->getNodeValue($line->xpath('ram:AssociatedDocumentLineDocument/ram:LineID')),
'product' => array(
@ -443,6 +524,10 @@ class ZugferdParser
'basis_quantity_unit' => $basisQuantityUnit,
'line_total' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount')),
'tax_percent' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent')),
// Copper surcharge data
'copper_surcharge' => $copperSurcharge,
'copper_surcharge_basis_qty' => $copperSurchargeBasisQty,
'copper_surcharge_per_unit' => $copperSurchargePerUnit,
);
$data['lines'][] = $lineData;

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 = '2.0';
$this->version = '2.1';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -430,6 +430,40 @@ class modImportZugferd extends DolibarrModules
'user' => 2,
);
// Left menu: Datanorm Mass Update
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=importzugferd',
'type' => 'left',
'titre' => 'DatanormMassUpdate',
'prefix' => img_picto('', 'fa-sync', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'importzugferd',
'leftmenu' => 'zugferd_datanorm_update',
'url' => '/importzugferd/datanorm_update.php',
'langs' => 'importzugferd@importzugferd',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("importzugferd")',
'perms' => '$user->hasRight("produit", "creer")',
'target' => '',
'user' => 2,
);
// Left menu: Datanorm Change Log
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=importzugferd',
'type' => 'left',
'titre' => 'DatanormChangeLog',
'prefix' => img_picto('', 'fa-history', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'importzugferd',
'leftmenu' => 'zugferd_datanorm_log',
'url' => '/importzugferd/datanorm_changelog.php',
'langs' => 'importzugferd@importzugferd',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("importzugferd")',
'perms' => '$user->hasRight("produit", "lire")',
'target' => '',
'user' => 2,
);
// Exports profiles provided by this module
$r = 0;
@ -544,6 +578,72 @@ class modImportZugferd extends DolibarrModules
'isModEnabled("importzugferd")' // enabled condition
);
// Add extrafield for metal surcharge (Kupferzuschlag) on supplier prices
$extrafields->addExtraField(
'kupferzuschlag', // attribute code
'Kupferzuschlag', // label (translation key)
'price', // type (price field)
110, // 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
'Preiseinheit', // label (translation key)
'int', // type
120, // position
11, // size
'product_fournisseur_price', // element type
0, // unique
0, // required
'1', // 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 copper content (Kupfergehalt) on product - kg per km for cables
$extrafields->addExtraField(
'kupfergehalt', // attribute code
'Kupfergehalt', // label (translation key)
'double', // type (decimal number)
130, // position
'24,4', // size (precision,scale)
'product', // 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
);
// Permissions
$this->remove($options);

333
datanorm_changelog.php Normal file
View file

@ -0,0 +1,333 @@
<?php
/* Copyright (C) 2026 ZUGFeRD Import Module
*
* 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 datanorm_changelog.php
* \ingroup importzugferd
* \brief View Datanorm update change log
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formcompany.class.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
dol_include_once('/importzugferd/lib/importzugferd.lib.php');
// Load translations
$langs->loadLangs(array("importzugferd@importzugferd", "products", "bills"));
// Security check
if (!$user->hasRight('produit', 'lire')) {
accessforbidden();
}
// Get parameters
$action = GETPOST('action', 'aZ09');
$batch_id = GETPOST('batch_id', 'alphanohtml');
$fk_product = GETPOSTINT('fk_product');
$fk_soc = GETPOSTINT('fk_soc');
$date_start = GETPOST('date_start', 'alpha');
$date_end = GETPOST('date_end', 'alpha');
// Pagination
$sortfield = GETPOST('sortfield', 'aZ09comma');
$sortorder = GETPOST('sortorder', 'aZ09comma');
$page = GETPOSTISSET('pageplusone') ? (GETPOSTINT('pageplusone') - 1) : GETPOSTINT('page');
$limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit;
$offset = $limit * $page;
if (!$sortfield) $sortfield = 'l.date_change';
if (!$sortorder) $sortorder = 'DESC';
// Initialize objects
$form = new Form($db);
$formcompany = new FormCompany($db);
/*
* View
*/
$title = $langs->trans('DatanormChangeLog');
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm-changelog');
print load_fiche_titre($title, '<a href="'.dol_buildpath('/importzugferd/datanorm_update.php', 1).'" class="button">'.$langs->trans('DatanormMassUpdate').'</a>', 'fa-history');
// Filter form
print '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="6">'.$langs->trans('Filters').'</td>';
print '</tr>';
print '<tr class="oddeven">';
// Batch ID filter
print '<td class="titlefield">'.$langs->trans('BatchUpdate').'</td>';
print '<td>';
print '<input type="text" name="batch_id" value="'.dol_escape_htmltag($batch_id).'" class="minwidth200" placeholder="batch_...">';
print '</td>';
// Supplier filter
print '<td>'.$langs->trans('Supplier').'</td>';
print '<td>';
$sql = "SELECT DISTINCT s.rowid, s.nom FROM ".MAIN_DB_PREFIX."societe s";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."importzugferd_datanorm_log l ON l.fk_soc = s.rowid";
$sql .= " ORDER BY s.nom";
$resql = $db->query($sql);
print '<select name="fk_soc" class="flat minwidth200">';
print '<option value="">'.$langs->trans('All').'</option>';
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$selected = ($obj->rowid == $fk_soc) ? 'selected' : '';
print '<option value="'.$obj->rowid.'" '.$selected.'>'.dol_escape_htmltag($obj->nom).'</option>';
}
}
print '</select>';
print '</td>';
// Date range
print '<td>'.$langs->trans('DateRange').'</td>';
print '<td>';
print '<input type="date" name="date_start" value="'.dol_escape_htmltag($date_start).'" class="minwidth100">';
print ' - ';
print '<input type="date" name="date_end" value="'.dol_escape_htmltag($date_end).'" class="minwidth100">';
print '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td colspan="6" class="center">';
print '<input type="submit" class="button" value="'.$langs->trans('Search').'">';
print ' &nbsp; <a href="'.$_SERVER['PHP_SELF'].'" class="button">'.$langs->trans('Reset').'</a>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</form>';
// Build SQL query
$sql = "SELECT l.*, p.ref as product_ref, p.label as product_label, s.nom as supplier_name, u.login as user_login";
$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_datanorm_log l";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe s ON s.rowid = l.fk_soc";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user u ON u.rowid = l.fk_user";
$sql .= " WHERE l.entity IN (".getEntity('product').")";
if (!empty($batch_id)) {
$sql .= " AND l.batch_id = '".$db->escape($batch_id)."'";
}
if ($fk_soc > 0) {
$sql .= " AND l.fk_soc = ".((int)$fk_soc);
}
if ($fk_product > 0) {
$sql .= " AND l.fk_product = ".((int)$fk_product);
}
if (!empty($date_start)) {
$sql .= " AND l.date_change >= '".$db->escape($date_start)." 00:00:00'";
}
if (!empty($date_end)) {
$sql .= " AND l.date_change <= '".$db->escape($date_end)." 23:59:59'";
}
// Count total
$sqlcount = preg_replace('/SELECT.*FROM/', 'SELECT COUNT(*) as total FROM', $sql);
$resqlcount = $db->query($sqlcount);
$total = 0;
if ($resqlcount) {
$objcount = $db->fetch_object($resqlcount);
$total = $objcount->total;
}
$sql .= $db->order($sortfield, $sortorder);
$sql .= $db->plimit($limit + 1, $offset);
$resql = $db->query($sql);
print '<br>';
// Batch summary if filtering by batch
if (!empty($batch_id)) {
$sql_batch = "SELECT MIN(date_change) as start_date, MAX(date_change) as end_date, COUNT(DISTINCT fk_product) as product_count, COUNT(*) as change_count";
$sql_batch .= " FROM ".MAIN_DB_PREFIX."importzugferd_datanorm_log";
$sql_batch .= " WHERE batch_id = '".$db->escape($batch_id)."'";
$res_batch = $db->query($sql_batch);
if ($res_batch) {
$batch_info = $db->fetch_object($res_batch);
print '<div class="info">';
print '<strong>'.$langs->trans('BatchUpdate').':</strong> '.$batch_id.'<br>';
print '<strong>'.$langs->trans('DateChange').':</strong> '.dol_print_date($db->jdate($batch_info->start_date), 'dayhour').'<br>';
print '<strong>'.$langs->trans('Products').':</strong> '.$batch_info->product_count.' | ';
print '<strong>'.$langs->trans('Changes').':</strong> '.$batch_info->change_count;
print '</div><br>';
}
}
// Navigation
print_barre_liste($langs->trans('ChangeHistory'), $page, $_SERVER['PHP_SELF'], '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc.'&date_start='.$date_start.'&date_end='.$date_end, $sortfield, $sortorder, '', $resql ? $db->num_rows($resql) : 0, $total, '', 0, '', '', $limit);
if ($resql) {
$num = $db->num_rows($resql);
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
// Header
print '<tr class="liste_titre">';
print_liste_field_titre($langs->trans('DateChange'), $_SERVER['PHP_SELF'], 'l.date_change', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre($langs->trans('Product'), $_SERVER['PHP_SELF'], 'p.ref', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre($langs->trans('Supplier'), $_SERVER['PHP_SELF'], 's.nom', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre($langs->trans('DatanormArticle'), $_SERVER['PHP_SELF'], 'l.datanorm_ref', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre($langs->trans('FieldChanged'), $_SERVER['PHP_SELF'], 'l.field_changed', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre($langs->trans('OldValue'), $_SERVER['PHP_SELF'], '', '', '', '');
print_liste_field_titre($langs->trans('NewValue'), $_SERVER['PHP_SELF'], '', '', '', '');
print_liste_field_titre($langs->trans('ChangedBy'), $_SERVER['PHP_SELF'], 'u.login', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print '</tr>';
if ($num > 0) {
$i = 0;
while ($i < min($num, $limit)) {
$obj = $db->fetch_object($resql);
print '<tr class="oddeven">';
// Date
print '<td class="nowraponall">'.dol_print_date($db->jdate($obj->date_change), 'dayhour').'</td>';
// Product
print '<td>';
$product = new Product($db);
if ($product->fetch($obj->fk_product) > 0) {
print $product->getNomUrl(1);
print '<br><span class="opacitymedium">'.$obj->product_label.'</span>';
}
print '</td>';
// Supplier
print '<td>'.dol_escape_htmltag($obj->supplier_name).'</td>';
// Datanorm ref
print '<td>'.dol_escape_htmltag($obj->datanorm_ref).'</td>';
// Field changed
print '<td>';
$field_label = '';
switch ($obj->field_changed) {
case 'price':
$field_label = $langs->trans('Price');
print '<i class="fas fa-euro-sign paddingright"></i>';
break;
case 'description':
$field_label = $langs->trans('Description');
print '<i class="fas fa-align-left paddingright"></i>';
break;
case 'label':
$field_label = $langs->trans('Label');
print '<i class="fas fa-tag paddingright"></i>';
break;
default:
$field_label = $obj->field_changed;
}
print $field_label;
print '</td>';
// Old value
print '<td class="tdoverflowmax200" style="background-color: #fff3cd;">';
if ($obj->field_changed == 'price') {
print price($obj->old_value);
} else {
print dol_escape_htmltag(dol_trunc($obj->old_value, 100));
}
print '</td>';
// New value
print '<td class="tdoverflowmax200" style="background-color: #d4edda;">';
if ($obj->field_changed == 'price') {
print price($obj->new_value);
// Show difference
$diff = $obj->new_value - $obj->old_value;
if ($obj->old_value > 0) {
$diff_percent = ($diff / $obj->old_value) * 100;
print '<br>';
if ($diff > 0) {
print '<span style="color: #d9534f;">+'.number_format($diff_percent, 1).'%</span>';
} else {
print '<span style="color: #5cb85c;">'.number_format($diff_percent, 1).'%</span>';
}
}
} else {
print dol_escape_htmltag(dol_trunc($obj->new_value, 100));
}
print '</td>';
// User
print '<td>'.dol_escape_htmltag($obj->user_login).'</td>';
print '</tr>';
$i++;
}
} else {
print '<tr class="oddeven"><td colspan="8" class="opacitymedium center">'.$langs->trans('NoChangesRecorded').'</td></tr>';
}
print '</table>';
print '</div>';
$db->free($resql);
} else {
dol_print_error($db);
}
// Export buttons
if ($total > 0) {
print '<br><div class="center">';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=export&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc.'&date_start='.$date_start.'&date_end='.$date_end.'" class="button">';
print '<i class="fas fa-download paddingright"></i>'.$langs->trans('Export');
print '</a>';
print '</div>';
}
llxFooter();
$db->close();

File diff suppressed because it is too large Load diff

215
docs/DATANORM_FORMAT.md Normal file
View file

@ -0,0 +1,215 @@
# Datanorm Format Dokumentation
## Allgemeines
Datanorm ist ein Dateiformat für den Datenaustausch von Artikelstammdaten zwischen Produktlieferant, Fachgroßhandel und Handwerksbetrieb. Es wird vornehmlich im Baunebengewerbe (Sanitär, Heizung, Elektro, Maler) verwendet.
**Wichtig:** Datanorm ist kein offener Standard. Die offizielle Spezifikation ist kostenpflichtig über den Krammer Verlag erhältlich.
## Datanorm Versionen
- **Datanorm 3.0**: Feste Feldbreiten (128 Zeichen pro Satz), ASCII
- **Datanorm 4.0**: Semikolon-getrennte Felder, erweiterte Funktionen
- **Datanorm 5.0**: XML-basiert
## Dateistruktur
Eine Datanorm-Lieferung besteht aus mehreren Dateien:
| Datei | Inhalt |
|-------|--------|
| `DATANORM.001` - `.999` | Artikelstammdaten (A/B-Sätze) |
| `DATPREIS.001` - `.999` | Preisdaten (P-Sätze) |
| `DATANORM.WRG` | Warengruppen |
| `DATANORM.RAB` | Rabattgruppen |
## Satzarten
| Kennzeichen | Typ | Beschreibung |
|-------------|-----|--------------|
| A | Artikelsatz | Stammdaten des Artikels |
| B | Ergänzungssatz | Zusatzinfos, EAN, VPE, Langtext |
| P | Preissatz | Preisinformationen |
| T | Textsatz | Mehrzeilige Texte |
| G | Grafiksatz | Bildverknüpfungen |
## A-Satz (Artikelstammdaten) - Datanorm 4.0 Semikolon-Format
### Sonepar-Format
```
A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;LangTextKey
```
| Index | Feld | Beschreibung | Beispiel |
|-------|------|--------------|----------|
| 0 | Satzart | Immer "A" | `A` |
| 1 | **Aktionscode** | **N=Neu, A=Ändern, L=Löschen** | `N` |
| 2 | Artikelnummer | Eindeutige Nummer | `0480145` |
| 3 | Textkennzeichen | Text-Typ | `00` |
| 4 | Kurztext 1 | Erste Bezeichnung (max 40 Z.) | `OBO BETT. Verschraubung` |
| 5 | Kurztext 2 | Zweite Bezeichnung (max 40 Z.) | `V-TEC PG21 LGR` |
| 6 | Preiskennzeichen | 1=Brutto, 2=Netto | `1` |
| 7 | **PE (Preiseinheit)** | **CODE** (siehe unten) | `2` |
| 8 | ME (Mengeneinheit) | Einheit | `Stck` |
| 9 | Preis | In Cent (wenn vorhanden) | `59085` |
| 10 | Rabattgruppe | Rabatt-Code | `A12N` |
| 11 | Warengruppe | Waren-Code | `303` |
| 12 | Langtextschlüssel | Verknüpfung zu Texten | ` ` |
### Preiseinheit-Codes (PE) - WICHTIG!
**Die Preiseinheit ist ein CODE, nicht die tatsächliche Menge!**
| Code | Bedeutung | Divisor |
|------|-----------|---------|
| 0 (oder leer) | Preis pro 1 Stück | 1 |
| 1 | Preis pro 10 Stück | 10 |
| 2 | Preis pro 100 Stück | 100 |
| 3 | Preis pro 1000 Stück | 1000 |
**Beispiel:**
```
A;N;0480145;00;OBO BETT. Verschraubung;V-TEC PG21 LGR;1;2;Stck;...
PE-Code 2 = pro 100 Stück
```
Wenn DATPREIS den Preis 9997 (= 99,97 €) liefert:
- Stückpreis = 99,97 € / 100 = **0,9997 €**
### Aktionscode - Artikelstatus
Der Aktionscode gibt an, ob ein Artikel neu ist, geändert wurde oder nicht mehr verfügbar ist:
| Code | Bedeutung | Verhalten |
|------|-----------|-----------|
| N | Neu | Artikel wird angelegt |
| A | Ändern | Artikel wird aktualisiert |
| L | Löschen | Artikel wird als inaktiv markiert (`active=0`) |
**Wichtig:** Bei `L`-Artikeln wird das Feld `active` auf `0` gesetzt. Diese Artikel erscheinen nicht mehr in Suchergebnissen und können beim Massenupdate als "nicht mehr verfügbar" gekennzeichnet werden.
## B-Satz (Ergänzungssatz) - Sonepar-Format
```
B;N;ArtNr;Matchcode; ; ;;;;EAN; ; ;0;VPE;;;
```
| Index | Feld | Beschreibung |
|-------|------|--------------|
| 0 | Satzart | `B` |
| 1 | Aktion | N/L/A |
| 2 | Artikelnummer | Bezug zum A-Satz |
| 3 | Matchcode | Suchbegriff |
| 8 | EAN | 13-stellige EAN/GTIN |
| 13 | VPE | Verpackungseinheit (tatsächliche Menge) |
**Hinweis:** Die VPE im B-Satz ist die Verpackungseinheit (z.B. 100 Stück pro Packung), während der PE-Code im A-Satz die Preisbasis definiert. Diese können unterschiedlich sein!
## P-Satz (Preissatz) - DATPREIS-Datei
### Format: Mehrere Artikel pro Zeile
```
P;A;ArtNr1;PreisKz1;Preis1;x;Zuschlag1;x;x;x;ArtNr2;PreisKz2;Preis2;x;Zuschlag2;...
```
| Index | Feld | Beschreibung |
|-------|------|--------------|
| 0 | P | Satzkennung |
| 1 | A | Aktionskennung |
| 2 | ArtNr | Artikelnummer |
| 3 | PreisKz | Preiskennzeichen (2=Nettopreis) |
| 4 | Preis | Materialpreis in **Cent** (für A-Satz PE-Einheit!) |
| 5 | x | Unbekannt (immer 1 bei Sonepar) |
| 6 | Zuschlag | **Metallzuschlag** in Cent (Kupfer/Aluminium) |
| 7-10 | x | Weitere Felder (Flags) |
**Wichtig:** Der Preis in DATPREIS ist bereits für die PE-Einheit aus dem A-Satz angegeben! Keine Normalisierung nötig.
### Metallzuschlag (für Kabel)
Bei Kabeln und metallhaltigen Produkten gibt es oft zwei Preiskomponenten:
- **Materialpreis** (Preis): Grundpreis des Produkts
- **Metallzuschlag** (Zuschlag): Zusatzkosten für Kupfer/Aluminium
**Gesamtpreis = Materialpreis + Metallzuschlag**
**Beispiel Kabel NYM-J 5x1,5:**
```
P;A;0110350;2;2920;2;7629;0;1;0;...
```
- Materialpreis: 2920 Cent = 29,20 €/100m
- Metallzuschlag: 7629 Cent = 76,29 €/100m
- **Gesamtpreis: 105,49 €/100m = 1,05 €/m**
**Beispiel ohne Metallzuschlag:**
```
P;A;0480145;2;9997;1;0;1;0;1;0;0480146;2;20689;1;0;1;0;1;0;
```
- Artikel 0480145: Preis = 9997 Cent = 99,97 €
## Preisberechnung
### Formel für Stückpreis
```
Stückpreis = Preis / PE_Divisor
```
Wobei PE_Divisor aus dem PE-Code berechnet wird:
- Code 0 → Divisor 1
- Code 1 → Divisor 10
- Code 2 → Divisor 100
- Code 3 → Divisor 1000
### Beispiel
```
Artikel: 0480145
DATPREIS: 9997 (Cent) = 99,97 €
A-Satz PE-Code: 2 → Divisor 100
Stückpreis = 99,97 € / 100 = 0,9997 €
```
## Datenbankfelder
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `price` | DOUBLE | Materialpreis aus DATPREIS (in Euro) |
| `price_unit` | INT | Konvertierter PE-Divisor (1, 10, 100, 1000) |
| `price_unit_code` | TINYINT | Originaler PE-Code (0, 1, 2, 3) |
| `price_type` | TINYINT | Preiskennzeichen (1=Brutto, 2=Netto) |
| `metal_surcharge` | DOUBLE | Metallzuschlag (Kupfer/Aluminium) in Euro |
| `vpe` | INT | VPE aus B-Satz (Verpackungseinheit) |
| `action_code` | CHAR(1) | Aktionscode (N=Neu, A=Ändern, L=Löschen) |
| `active` | TINYINT | Artikelstatus (1=aktiv, 0=gelöscht bei L) |
### Preisberechnung mit Metallzuschlag
```
Gesamtpreis = price + metal_surcharge
Stückpreis = Gesamtpreis / price_unit
```
**Beispiel:**
```
price = 29.20 €
metal_surcharge = 76.29 €
price_unit = 100
Gesamtpreis = 29.20 + 76.29 = 105.49 €
Stückpreis = 105.49 / 100 = 1.0549 €
```
## Quellen
- [Datanorm Wikipedia](https://de.wikipedia.org/wiki/Datanorm)
- [DATANORM.de](https://www.datanorm.de/)
- [Comtech Hilfe](https://hilfe.comtech.at/ce/773/html/datanorm_datei.htm)
## Hinweis
Diese Dokumentation basiert auf der Analyse von Sonepar-Datanorm-Dateien und öffentlich verfügbaren Informationen. Für die vollständige offizielle Spezifikation wird das Datanorm-Taschenbuch vom Krammer Verlag empfohlen.

View file

@ -778,8 +778,9 @@ if ($action == 'createinvoice' && $id > 0) {
$line->quantity,
$line->fk_product,
0, '', '',
0, 0, '',
'HT'
0, 0,
'HT', // price_base_type - Netto-Preise aus ZUGFeRD
0 // type (0=product)
);
if ($res < 0) {
$error++;

View file

@ -344,3 +344,82 @@ NotifyTestSuccess = Die E-Mail-Konfiguration funktioniert einwandfrei!
CurrentSettings = Aktuelle Einstellungen
NotificationsNotEnabled = Benachrichtigungen sind nicht aktiviert oder keine E-Mail-Adresse konfiguriert
NotifyEmail = Empfänger-E-Mail
#
# Datanorm Massenaktualisierung
#
DatanormMassUpdate = Datanorm Massenaktualisierung
SelectSupplier = Lieferant auswählen
SelectASupplier = -- Lieferant wählen --
SearchMode = Suchmodus
SearchBySupplierProducts = Nach Lieferanten-Produkten suchen
ManualSearch = Manuelle Suche
SearchTerm = Suchbegriff
ArticleNumberOrName = Artikelnummer oder Name
AdditionalSearchOptions = Zusätzliche Suchoptionen
AlsoSearchByName = Auch nach Name suchen
AlsoSearchByEAN = Auch nach EAN suchen
AlsoSearchByRef = Auch nach Artikelref. suchen
FieldsToCompare = Felder zum Vergleichen
OnlyShowDifferences = Nur Unterschiede anzeigen
CurrentPrice = Aktueller Preis
DatanormPrice = Datanorm Preis
CurrentDescription = Aktuelle Beschreibung
DatanormDescription = Datanorm Beschreibung
CurrentLabel = Aktueller Name
DatanormLabel = Datanorm Name
DatanormArticle = Datanorm Artikel
ProductNotInDatabase = Produkt nicht in Datenbank
ApplyChanges = Änderungen übernehmen
AddToPending = Zur Liste hinzufügen
Pending = Ausstehend
PendingChanges = Ausstehende Änderungen
NoChanges = Keine Änderungen
ApplyAllPendingChanges = Alle ausstehenden Änderungen übernehmen
ClearPendingChanges = Ausstehende Änderungen löschen
AddedToPendingChanges = Zur Liste hinzugefügt
PendingChangesCleared = Ausstehende Änderungen gelöscht
ConfirmMassUpdate = Massenaktualisierung bestätigen
FollowingProductsWillBeUpdated = Folgende Produkte werden aktualisiert
Changes = Änderungen
DatanormMassUpdateComplete = Massenaktualisierung abgeschlossen: %s erfolgreich, %s Fehler
ProductUpdated = Produkt aktualisiert
ErrorUpdatingProduct = Fehler beim Aktualisieren des Produkts
NoResultsFound = Keine Ergebnisse gefunden
Results = Ergebnisse
WithDifferences = mit Unterschieden
OnlyShowingDifferences = Nur Unterschiede werden angezeigt
#
# Änderungsprotokoll
#
DatanormChangeLog = Änderungsprotokoll
ChangeHistory = Änderungsverlauf
FieldChanged = Geändertes Feld
OldValue = Alter Wert
NewValue = Neuer Wert
DateChange = Änderungsdatum
ChangedBy = Geändert von
BatchUpdate = Stapelaktualisierung
ViewChangeLog = Änderungsprotokoll anzeigen
NoChangesRecorded = Keine Änderungen protokolliert
PriceChange = Preisänderung
DescriptionChange = Beschreibungsänderung
LabelChange = Namensänderung
#
# Kupferzuschlag / Metallzuschlag
#
Kupferzuschlag = Kupferzuschlag
KupferzuschlagHelp = Metallzuschlag pro Einheit (wird aus Rechnungen extrahiert)
Preiseinheit = Preiseinheit
PreiseinheitHelp = Anzahl Einheiten pro Preis (z.B. 100 = Preis pro 100 Stück)
MetalSurchargeDetected = Metallzuschlag erkannt
MetalSurchargeUpdated = Kupferzuschlag aktualisiert auf %s €/Einheit
AddAllWithDifferences = Alle mit Unterschieden hinzufügen
AddedAllToPendingChanges = %s Produkte zur Aktualisierungsliste hinzugefügt
ConfirmAddAllToPending = Alle Produkte mit Unterschieden zur Aktualisierungsliste hinzufügen?
Kupfergehalt = Kupfergehalt (kg/km)
KupfergehaltHelp = Kupfergewicht pro Kilometer Kabel (konstant je Kabeltyp)
CopperSurchargeFromInvoice = Kupferzuschlag aus Rechnung
CopperSurchargePerUnit = Kupferzuschlag/Einheit

View file

@ -344,3 +344,13 @@ NotifyTestSuccess = The email configuration is working properly!
CurrentSettings = Current settings
NotificationsNotEnabled = Notifications are not enabled or no email address configured
NotifyEmail = Recipient email
#
# Metal Surcharge
#
Kupferzuschlag = Copper Surcharge
KupferzuschlagHelp = Metal surcharge per unit (extracted from invoices)
Preiseinheit = Price Unit
PreiseinheitHelp = Number of units per price (e.g. 100 = price per 100 pieces)
MetalSurchargeDetected = Metal surcharge detected
MetalSurchargeUpdated = Metal surcharge updated to %s €/unit

View file

@ -15,8 +15,12 @@ CREATE TABLE llx_importzugferd_datanorm (
manufacturer_ref varchar(128), -- Hersteller-Artikelnummer (Typ A Feld 15)
manufacturer_name varchar(128), -- Herstellername (Typ A Feld 16)
unit_code varchar(8), -- Mengeneinheit (Typ A Feld 6)
price double(24,8) DEFAULT 0, -- Listenpreis (Typ P)
price_unit integer DEFAULT 1, -- Preiseinheit (Stück pro Preis)
price double(24,8) DEFAULT 0, -- Listenpreis/Materialpreis (Typ P)
price_unit integer DEFAULT 1, -- Preiseinheit (Stück pro Preis) - konvertiert aus PE-Code
price_unit_code tinyint DEFAULT 0, -- Original PE-Code (0=1, 1=10, 2=100, 3=1000)
price_type tinyint DEFAULT 1, -- Preiskennzeichen (1=Brutto, 2=Netto)
metal_surcharge double(24,8) DEFAULT 0, -- Metallzuschlag/Kupferzuschlag (Typ P)
vpe integer DEFAULT NULL, -- VPE aus B-Satz (Verpackungseinheit)
discount_group varchar(32), -- Rabattgruppe (Typ A Feld 8)
product_group varchar(64), -- Warengruppe (Typ A Feld 9)
alt_unit varchar(8), -- Alternative Mengeneinheit
@ -24,8 +28,9 @@ CREATE TABLE llx_importzugferd_datanorm (
weight double(10,4), -- Gewicht in kg
matchcode varchar(128), -- Matchcode für Suche (Typ A Feld 3)
datanorm_version varchar(8), -- Datanorm Version (4.0, 5.0)
action_code char(1) DEFAULT 'N', -- Aktionscode (N=Neu, A=Ändern, L=Löschen)
import_date datetime NOT NULL, -- Importzeitpunkt
active tinyint DEFAULT 1, -- Aktiv/Inaktiv
active tinyint DEFAULT 1, -- Aktiv/Inaktiv (0 bei action_code='L')
date_creation datetime NOT NULL,
tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
fk_user_creat integer,

View file

@ -0,0 +1,8 @@
-- Copyright (C) 2026 ZUGFeRD Import Module
--
-- 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.
-- No additional keys needed, all defined in main SQL file

View file

@ -0,0 +1,25 @@
-- Copyright (C) 2026 ZUGFeRD Import Module
--
-- 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.
CREATE TABLE llx_importzugferd_datanorm_log (
rowid integer AUTO_INCREMENT PRIMARY KEY,
fk_product integer NOT NULL,
fk_soc integer NOT NULL,
fk_user integer NOT NULL,
datanorm_ref varchar(100),
field_changed varchar(50) NOT NULL,
old_value text,
new_value text,
date_change datetime NOT NULL,
batch_id varchar(50),
entity integer DEFAULT 1 NOT NULL
) ENGINE=innodb;
ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_product (fk_product);
ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_soc (fk_soc);
ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_date (date_change);
ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_batch (batch_id);