Komplett überarbeitet Import müsste laufen E-Mail Benachrichtigung,

Postfach und Ordner usw
This commit is contained in:
Eduard Wisch 2026-02-01 09:25:12 +01:00
parent 424b2379ef
commit e420698a58
20 changed files with 5702 additions and 19004 deletions

View file

@ -3,7 +3,9 @@
"allow": [ "allow": [
"Bash(pdfdetach:*)", "Bash(pdfdetach:*)",
"Bash(python3:*)", "Bash(python3:*)",
"Bash(xmllint:*)" "Bash(xmllint:*)",
"Bash(php -r:*)",
"Bash(chmod:*)"
] ]
} }
} }

File diff suppressed because one or more lines are too long

View file

@ -100,6 +100,40 @@ $formSetup->newItem('ImportSettings')->setAsTitle();
$formSetup->newItem('IMPORTZUGFERD_AUTO_CREATE_INVOICE')->setAsYesNo(); $formSetup->newItem('IMPORTZUGFERD_AUTO_CREATE_INVOICE')->setAsYesNo();
// Email Notification Settings Section
$formSetup->newItem('NotificationSettings')->setAsTitle();
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_ENABLED')->setAsYesNo();
$item = $formSetup->newItem('IMPORTZUGFERD_NOTIFY_EMAIL');
$item->defaultFieldValue = '';
$item->cssClass = 'minwidth300';
$item->fieldAttr['placeholder'] = 'admin@example.com';
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_MANUAL')->setAsYesNo();
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_ERROR')->setAsYesNo();
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')->setAsYesNo();
$item = $formSetup->newItem('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD');
$item->defaultFieldValue = '10';
$item->cssClass = 'width75';
$item->fieldAttr['type'] = 'number';
$item->fieldAttr['min'] = '0';
$item->fieldAttr['max'] = '100';
$item->fieldAttr['step'] = '1';
// Scheduling Settings Section
$formSetup->newItem('SchedulingSettings')->setAsTitle();
$item = $formSetup->newItem('IMPORTZUGFERD_IMPORT_FREQUENCY');
$item->setAsSelect(array(
'manual' => $langs->trans('FrequencyManual'),
'hourly' => $langs->trans('FrequencyHourly'),
'daily' => $langs->trans('FrequencyDaily'),
'weekly' => $langs->trans('FrequencyWeekly')
));
$item->defaultFieldValue = 'manual';
// Folder Import Settings Section // Folder Import Settings Section
$formSetup->newItem('FolderImportSettings')->setAsTitle(); $formSetup->newItem('FolderImportSettings')->setAsTitle();
@ -113,10 +147,25 @@ $item->defaultFieldValue = '';
$item->cssClass = 'minwidth400'; $item->cssClass = 'minwidth400';
$item->fieldAttr['placeholder'] = '/path/to/archive'; $item->fieldAttr['placeholder'] = '/path/to/archive';
$item = $formSetup->newItem('IMPORTZUGFERD_ERROR_FOLDER');
$item->defaultFieldValue = '';
$item->cssClass = 'minwidth400';
$item->fieldAttr['placeholder'] = '/path/to/errors';
$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER'); $item = $formSetup->newItem('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER');
$item->defaultFieldValue = 'Archive'; $item->defaultFieldValue = 'Archive';
$item->cssClass = 'minwidth200'; $item->cssClass = 'minwidth200';
// Datanorm Settings Section
$formSetup->newItem('DatanormSettings')->setAsTitle();
$item = $formSetup->newItem('IMPORTZUGFERD_DATANORM_MARKUP');
$item->defaultFieldValue = '30';
$item->cssClass = 'width100';
$item->fieldAttr['placeholder'] = '30';
$formSetup->newItem('IMPORTZUGFERD_DATANORM_SEARCH_ALL')->setAsYesNo();
/* /*
* Actions * Actions
*/ */
@ -127,6 +176,69 @@ if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'upda
include DOL_DOCUMENT_ROOT.'/core/actions_setmoduleoptions.inc.php'; include DOL_DOCUMENT_ROOT.'/core/actions_setmoduleoptions.inc.php';
// AJAX action for folder browsing
if ($action == 'browse_folders') {
$path = GETPOST('path', 'alpha');
$target = GETPOST('target', 'alpha');
// Sanitize path - default to /home for easier navigation
if (empty($path)) {
$path = '/home';
}
$path = realpath($path);
if ($path === false) {
$path = '/home';
if (!is_dir($path)) {
$path = '/';
}
}
// Get directories
$dirs = array();
if (is_dir($path) && is_readable($path)) {
$entries = @scandir($path);
if ($entries) {
foreach ($entries as $entry) {
if ($entry == '.') continue;
$fullPath = $path . '/' . $entry;
if (is_dir($fullPath) && is_readable($fullPath)) {
$dirs[] = array(
'name' => $entry,
'path' => $fullPath
);
}
}
}
}
// Return JSON
header('Content-Type: application/json');
echo json_encode(array(
'current' => $path,
'parent' => dirname($path),
'dirs' => $dirs,
'target' => $target
));
exit;
}
// Save folder from browser
if ($action == 'set_folder') {
$target = GETPOST('target', 'alpha');
$folder_path = GETPOST('folder_path', 'alpha');
if (in_array($target, array('IMPORTZUGFERD_WATCH_FOLDER', 'IMPORTZUGFERD_ARCHIVE_FOLDER'))) {
if (is_dir($folder_path)) {
dolibarr_set_const($db, $target, $folder_path, 'chaine', 0, '', $conf->entity);
setEventMessages($langs->trans('FolderSelected').': '.$folder_path, null, 'mesgs');
} else {
setEventMessages($langs->trans('ErrorFolderNotFound'), null, 'errors');
}
}
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
/* /*
* View * View
*/ */
@ -151,6 +263,229 @@ print '<span class="opacitymedium">'.$langs->trans("ImportZugferdSetupPage").'</
// Display the form // Display the form
print $formSetup->generateOutput(true); print $formSetup->generateOutput(true);
// Build folder validation data for JavaScript
$folderValidation = array();
// Watch folder - only needs to be readable
$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
if (!empty($watchFolder)) {
$watchExists = is_dir($watchFolder);
$watchReadable = $watchExists && is_readable($watchFolder);
if (!$watchExists) {
$folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound'));
} elseif (!$watchReadable) {
$folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotReadable'));
} else {
$files = glob($watchFolder.'/*.pdf');
$files = array_merge($files ?: [], glob($watchFolder.'/*.PDF') ?: []);
$folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK').' ('.count($files).' PDF)');
}
}
// Archive folder - needs to be writable
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
if (!empty($archiveFolder)) {
$archiveExists = is_dir($archiveFolder);
$archiveWritable = $archiveExists && is_writable($archiveFolder);
if (!$archiveExists) {
$folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound'));
} elseif (!$archiveWritable) {
$folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotWritable'));
} else {
$folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK'));
}
}
// Error folder - needs to be writable
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
if (!empty($errorFolder)) {
$errorExists = is_dir($errorFolder);
$errorWritable = $errorExists && is_writable($errorFolder);
if (!$errorExists) {
$folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound'));
} elseif (!$errorWritable) {
$folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotWritable'));
} else {
$folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK'));
}
}
// Folder Browser Modal
print '
<div id="folderBrowserModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:9999;">
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); background:white; padding:20px; border-radius:8px; min-width:500px; max-width:80%; max-height:80%; overflow:auto; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 style="margin-top:0;"><i class="fas fa-folder-open paddingright"></i>'.$langs->trans('SelectFolder').'</h3>
<div style="margin-bottom:10px;">
<input type="text" id="pathInput" style="width:80%; font-family:monospace;" placeholder="/path/to/folder">
<a class="button buttongen smallpaddingimp" href="#" onclick="goToPath(); return false;" title="'.$langs->trans('Go').'"><i class="fas fa-arrow-right"></i></a>
</div>
<div style="margin-bottom:5px;">
<span class="opacitymedium">'.$langs->trans('QuickLinks').':</span>
<a href="#" onclick="loadFolderContents(\'/home\'); return false;" class="paddingleft">/home</a>
<a href="#" onclick="loadFolderContents(\'/srv\'); return false;" class="paddingleft">/srv</a>
<a href="#" onclick="loadFolderContents(\'/var\'); return false;" class="paddingleft">/var</a>
<a href="#" onclick="loadFolderContents(\'/tmp\'); return false;" class="paddingleft">/tmp</a>
</div>
<div style="border:1px solid #ccc; padding:10px; max-height:300px; overflow-y:auto; background:#f9f9f9;" id="folderList">
</div>
<div style="margin-top:15px; text-align:right;">
<input type="hidden" id="folderTarget" value="">
<a class="button" href="#" onclick="selectCurrentFolder(); return false;"><i class="fas fa-check paddingright"></i>'.$langs->trans('SelectThisFolder').'</a>
<a class="button" href="#" onclick="closeFolderBrowser(); return false;">'.$langs->trans('Cancel').'</a>
</div>
</div>
</div>
<script>
// Folder validation data from PHP
var folderValidation = '.json_encode($folderValidation).';
// Add browse buttons and validation icons next to folder input fields
document.addEventListener("DOMContentLoaded", function() {
var folderFields = ["IMPORTZUGFERD_WATCH_FOLDER", "IMPORTZUGFERD_ARCHIVE_FOLDER", "IMPORTZUGFERD_ERROR_FOLDER"];
folderFields.forEach(function(fieldName) {
var input = document.querySelector("input[name=\"" + fieldName + "\"]");
if (input) {
// Add browse button
var btn = document.createElement("a");
btn.href = "#";
btn.className = "button buttongen smallpaddingimp";
btn.style.marginLeft = "5px";
btn.innerHTML = "<i class=\"fas fa-folder-open\"></i>";
btn.title = "'.$langs->trans('Browse').'";
btn.onclick = function(e) {
e.preventDefault();
var startPath = input.value || "/home";
openFolderBrowser(fieldName, startPath);
};
input.parentNode.insertBefore(btn, input.nextSibling);
// Add validation icon if folder is configured
if (folderValidation[fieldName]) {
var validIcon = document.createElement("span");
validIcon.style.marginLeft = "10px";
validIcon.id = "validation_" + fieldName;
if (folderValidation[fieldName].ok) {
validIcon.innerHTML = "<i class=\"fas fa-check-circle\" style=\"color:green;\"></i> <span class=\"opacitymedium\">" + folderValidation[fieldName].msg + "</span>";
} else {
validIcon.innerHTML = "<i class=\"fas fa-times-circle\" style=\"color:red;\"></i> <span style=\"color:red;\">" + folderValidation[fieldName].msg + "</span>";
}
btn.parentNode.insertBefore(validIcon, btn.nextSibling);
}
}
});
});
function openFolderBrowser(target, startPath) {
document.getElementById("folderTarget").value = target;
document.getElementById("folderBrowserModal").style.display = "block";
document.getElementById("pathInput").value = startPath || "/home";
loadFolderContents(startPath || "/home");
}
function goToPath() {
var path = document.getElementById("pathInput").value;
if (path) {
loadFolderContents(path);
}
}
function closeFolderBrowser() {
document.getElementById("folderBrowserModal").style.display = "none";
}
function loadFolderContents(path) {
var target = document.getElementById("folderTarget").value;
var xhr = new XMLHttpRequest();
xhr.open("GET", "'.$_SERVER['PHP_SELF'].'?action=browse_folders&path=" + encodeURIComponent(path) + "&target=" + target + "&token='.newToken().'", true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
document.getElementById("pathInput").value = data.current;
var html = "";
if (data.current !== "/" && data.parent) {
html += "<div style=\"padding:5px; cursor:pointer; border-bottom:1px solid #eee;\" onclick=\"loadFolderContents(\'" + data.parent.replace(/\'/g, "\\\'") + "\')\">";
html += "<i class=\"fas fa-level-up-alt paddingright\"></i><strong>..</strong> ('.$langs->trans('ParentFolder').')";
html += "</div>";
}
for (var i = 0; i < data.dirs.length; i++) {
var dir = data.dirs[i];
html += "<div style=\"padding:5px; cursor:pointer; border-bottom:1px solid #eee;\" onclick=\"loadFolderContents(\'" + dir.path.replace(/\'/g, "\\\'") + "\')\" onmouseover=\"this.style.background=\'#e8f4fc\'\" onmouseout=\"this.style.background=\'transparent\'\">";
html += "<i class=\"fas fa-folder paddingright\" style=\"color:#f0ad4e;\"></i>" + dir.name;
html += "</div>";
}
if (data.dirs.length === 0 && data.current !== "/") {
html += "<div style=\"padding:10px; color:#666; text-align:center;\">'.$langs->trans('NoSubfolders').'</div>";
}
document.getElementById("folderList").innerHTML = html;
}
};
xhr.send();
}
function selectCurrentFolder() {
var path = document.getElementById("pathInput").value;
var target = document.getElementById("folderTarget").value;
// Update the input field directly
var input = document.querySelector("input[name=\"" + target + "\"]");
if (input) {
input.value = path;
}
closeFolderBrowser();
}
// Close modal on escape key
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closeFolderBrowser();
});
// Close modal on background click
document.getElementById("folderBrowserModal").addEventListener("click", function(e) {
if (e.target === this) closeFolderBrowser();
});
</script>
';
// Email Notification Test Section
if (getDolGlobalString('IMPORTZUGFERD_NOTIFY_ENABLED') && getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL')) {
print '<br>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans('TestEmailNotification').'</td>';
print '</tr>';
// Handle test email action
if ($action == 'test_email') {
dol_include_once('/importzugferd/class/importnotification.class.php');
$notification = new ImportNotification($db);
$result = $notification->sendTestNotification();
if ($result > 0) {
setEventMessages($langs->trans('TestEmailSent', getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL')), null, 'mesgs');
} else {
setEventMessages($langs->trans('TestEmailFailed').': '.$notification->error, null, 'errors');
}
}
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans('SendTestEmail').'</td>';
print '<td>';
print '<a class="button" href="'.$_SERVER['PHP_SELF'].'?action=test_email&token='.newToken().'">';
print '<i class="fas fa-paper-plane paddingright"></i>'.$langs->trans('SendTestEmail');
print '</a>';
print ' <span class="opacitymedium">'.$langs->trans('SendTo').': '.getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL').'</span>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
}
// Test IMAP connection button and folder selection // Test IMAP connection button and folder selection
if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) { if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
print '<br>'; print '<br>';
@ -277,6 +612,222 @@ if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
print '</div>'; print '</div>';
} }
// Manual Import Trigger Section
$hasFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER') && is_dir(getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'));
$hasImap = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST') && getDolGlobalString('IMPORTZUGFERD_IMAP_USER');
if ($hasFolder || $hasImap) {
print '<br>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans('ManualImportTrigger').'</td>';
print '</tr>';
// Handle manual import action
if ($action == 'run_import') {
$source = GETPOST('import_source', 'alpha');
dol_include_once('/importzugferd/class/zugferdimport.class.php');
dol_include_once('/importzugferd/class/zugferdparser.class.php');
$successCount = 0;
$errorCount = 0;
$skippedCount = 0;
if ($source == 'folder' && $hasFolder) {
$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
$autoCreate = getDolGlobalInt('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
$files = glob($watchFolder.'/*.pdf');
$files = array_merge($files, glob($watchFolder.'/*.PDF'));
// Create archive folder if configured but doesn't exist
if (!empty($archiveFolder) && !is_dir($archiveFolder)) {
dol_mkdir($archiveFolder);
}
// Create error folder if configured but doesn't exist
if (!empty($errorFolder) && !is_dir($errorFolder)) {
dol_mkdir($errorFolder);
}
// Helper function for moving files with fallback
$moveFile = function($file, $targetFolder, $prefix) {
if (empty($targetFolder)) {
return false;
}
if (!is_dir($targetFolder)) {
dol_mkdir($targetFolder);
}
if (!is_dir($targetFolder) || !is_writable($targetFolder)) {
dol_syslog("ImportZugferd: Target folder not accessible: ".$targetFolder, LOG_WARNING);
return false;
}
$destFile = $targetFolder.'/'.$prefix.date('Y-m-d_His').'_'.basename($file);
if (@rename($file, $destFile)) {
dol_syslog("ImportZugferd: Moved file to: ".$destFile, LOG_INFO);
return true;
}
// Fallback: copy + delete (for cross-filesystem moves)
if (@copy($file, $destFile)) {
@unlink($file);
dol_syslog("ImportZugferd: Copied file to: ".$destFile, LOG_INFO);
return true;
}
dol_syslog("ImportZugferd: Failed to move file: ".$file." to ".$destFile, LOG_ERR);
return false;
};
foreach ($files as $file) {
$import = new ZugferdImport($db);
$result = $import->importFromFile($user, $file, $autoCreate);
if ($result > 0) {
$successCount++;
$moveFile($file, $archiveFolder, 'imported_');
} elseif ($result == -2) {
// Duplicate - move to archive
$skippedCount++;
if (!$moveFile($file, $archiveFolder, 'duplicate_')) {
@unlink($file);
}
} else {
// Error - move to error folder, fallback to archive
$errorCount++;
if (!$moveFile($file, $errorFolder, 'error_')) {
if (!$moveFile($file, $archiveFolder, 'error_')) {
dol_syslog("ImportZugferd: File stays in watch folder: ".$file, LOG_WARNING);
}
}
}
}
} elseif ($source == 'imap' && $hasImap && function_exists('imap_open')) {
$host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST');
$port = getDolGlobalString('IMPORTZUGFERD_IMAP_PORT', '993');
$imap_user = getDolGlobalString('IMPORTZUGFERD_IMAP_USER');
$password = getDolGlobalString('IMPORTZUGFERD_IMAP_PASSWORD');
$ssl = getDolGlobalString('IMPORTZUGFERD_IMAP_SSL');
$folder = getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX');
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER');
$autoCreate = getDolGlobalInt('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
$connection = @imap_open($mailbox, $imap_user, $password);
if ($connection) {
$emails = imap_search($connection, 'UNSEEN');
if ($emails) {
foreach ($emails as $email_number) {
$structure = imap_fetchstructure($connection, $email_number);
// Find PDF attachments
if (isset($structure->parts)) {
foreach ($structure->parts as $partIndex => $part) {
$filename = '';
if ($part->ifdparameters) {
foreach ($part->dparameters as $param) {
if (strtolower($param->attribute) == 'filename') {
$filename = $param->value;
}
}
}
if (empty($filename) && $part->ifparameters) {
foreach ($part->parameters as $param) {
if (strtolower($param->attribute) == 'name') {
$filename = $param->value;
}
}
}
if (!empty($filename) && preg_match('/\.pdf$/i', $filename)) {
$attachment = imap_fetchbody($connection, $email_number, $partIndex + 1);
if ($part->encoding == 3) { // BASE64
$attachment = base64_decode($attachment);
} elseif ($part->encoding == 4) { // QUOTED-PRINTABLE
$attachment = quoted_printable_decode($attachment);
}
// Save to temp file
$tempFile = $conf->importzugferd->dir_temp.'/'.uniqid().'_'.$filename;
if (!is_dir($conf->importzugferd->dir_temp)) {
dol_mkdir($conf->importzugferd->dir_temp);
}
file_put_contents($tempFile, $attachment);
// Import
$import = new ZugferdImport($db);
$result = $import->importFromFile($user, $tempFile, $autoCreate);
if ($result > 0) {
$successCount++;
} elseif ($result == -2) {
$skippedCount++;
} else {
$errorCount++;
}
unlink($tempFile);
}
}
}
}
// Archive processed emails
if (!empty($archiveFolder) && $successCount > 0) {
foreach ($emails as $email_number) {
imap_mail_move($connection, $email_number, $archiveFolder);
}
imap_expunge($connection);
}
}
imap_close($connection);
} else {
setEventMessages($langs->trans('ConnectionFailed').': '.imap_last_error(), null, 'errors');
}
}
if ($successCount > 0 || $errorCount > 0 || $skippedCount > 0) {
setEventMessages($langs->trans('BatchImportComplete', $successCount, $errorCount, $skippedCount), null, 'mesgs');
} else {
setEventMessages($langs->trans('NoFilesFound'), null, 'warnings');
}
}
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans('ImportFromFolder').'</td>';
print '<td>';
if ($hasFolder) {
print '<a class="button" href="'.$_SERVER['PHP_SELF'].'?action=run_import&import_source=folder&token='.newToken().'">';
print '<i class="fas fa-folder-open paddingright"></i>'.$langs->trans('StartImport');
print '</a>';
print ' <span class="opacitymedium">'.getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER').'</span>';
} else {
print '<span class="opacitymedium">'.$langs->trans('ErrorWatchFolderNotConfigured').'</span>';
}
print '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.$langs->trans('ImportFromIMAP').'</td>';
print '<td>';
if ($hasImap && function_exists('imap_open')) {
print '<a class="button" href="'.$_SERVER['PHP_SELF'].'?action=run_import&import_source=imap&token='.newToken().'">';
print '<i class="fas fa-envelope paddingright"></i>'.$langs->trans('StartImport');
print '</a>';
print ' <span class="opacitymedium">'.getDolGlobalString('IMPORTZUGFERD_IMAP_USER').'</span>';
} elseif (!function_exists('imap_open')) {
print '<span class="opacitymedium">'.$langs->trans('IMAPExtensionNotInstalled').'</span>';
} else {
print '<span class="opacitymedium">'.$langs->trans('ErrorIMAPNotConfigured').'</span>';
}
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
}
print '<br>'; print '<br>';
// Page end // Page end

View file

@ -80,6 +80,7 @@ if ($action == 'process') {
// Import from local folder // Import from local folder
$watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'); $watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
$archive_folder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER'); $archive_folder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
$error_folder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
if (empty($watch_folder) || !is_dir($watch_folder)) { if (empty($watch_folder) || !is_dir($watch_folder)) {
setEventMessages($langs->trans('ErrorWatchFolderNotConfigured'), null, 'errors'); setEventMessages($langs->trans('ErrorWatchFolderNotConfigured'), null, 'errors');
@ -89,6 +90,10 @@ if ($action == 'process') {
if (!empty($archive_folder) && !is_dir($archive_folder)) { if (!empty($archive_folder) && !is_dir($archive_folder)) {
dol_mkdir($archive_folder); dol_mkdir($archive_folder);
} }
// Create error folder if needed
if (!empty($error_folder) && !is_dir($error_folder)) {
dol_mkdir($error_folder);
}
// Get PDF files from watch folder // Get PDF files from watch folder
$files = glob($watch_folder . '/*.pdf'); $files = glob($watch_folder . '/*.pdf');
@ -115,17 +120,32 @@ if ($action == 'process') {
// Move to archive // Move to archive
if (!empty($archive_folder) && is_dir($archive_folder)) { if (!empty($archive_folder) && is_dir($archive_folder)) {
$archive_path = $archive_folder . '/' . basename($pdf_path); $archive_path = $archive_folder . '/success_' . date('Y-m-d_His') . '_' . basename($pdf_path);
if (rename($pdf_path, $archive_path)) { if (@rename($pdf_path, $archive_path) || (@copy($pdf_path, $archive_path) && @unlink($pdf_path))) {
$result['archived'] = true; $result['archived'] = true;
} }
} }
} elseif ($res == -3) { } elseif ($res == -3) {
// Duplicate // Duplicate - move to archive (already imported)
$result['status'] = 'skipped'; $result['status'] = 'skipped';
$result['message'] = $langs->trans('ErrorDuplicateInvoice'); $result['message'] = $langs->trans('ErrorDuplicateInvoice');
if (!empty($archive_folder) && is_dir($archive_folder)) {
$archive_path = $archive_folder . '/duplicate_' . date('Y-m-d_His') . '_' . basename($pdf_path);
if (@rename($pdf_path, $archive_path) || (@copy($pdf_path, $archive_path) && @unlink($pdf_path))) {
$result['archived'] = true;
}
}
} else { } else {
// Error - move to error folder
$result['message'] = $actions->error; $result['message'] = $actions->error;
if (!empty($error_folder) && is_dir($error_folder)) {
$error_path = $error_folder . '/error_' . date('Y-m-d_His') . '_' . basename($pdf_path);
if (@rename($pdf_path, $error_path) || (@copy($pdf_path, $error_path) && @unlink($pdf_path))) {
$result['moved_to_error'] = true;
}
}
} }
$import_results[] = $result; $import_results[] = $result;

View file

@ -21,7 +21,7 @@ dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
/** /**
* Class CronImportZugferd * Class CronImportZugferd
* Cronjob handler for fetching ZUGFeRD invoices from IMAP mailbox * Cronjob handler for fetching ZUGFeRD invoices from IMAP mailbox and folder
*/ */
class CronImportZugferd class CronImportZugferd
{ {
@ -70,6 +70,180 @@ class CronImportZugferd
$this->db = $db; $this->db = $db;
} }
/**
* Check if import should run based on configured frequency
*
* @return bool True if import should run
*/
protected function shouldRunImport()
{
global $conf;
$frequency = getDolGlobalString('IMPORTZUGFERD_IMPORT_FREQUENCY', 'manual');
if ($frequency === 'manual') {
return false;
}
// Get last run timestamp
$lastRun = getDolGlobalInt('IMPORTZUGFERD_LAST_IMPORT_RUN', 0);
$now = dol_now();
// Calculate minimum interval based on frequency
$interval = 0;
switch ($frequency) {
case 'hourly':
$interval = 3600; // 1 hour
break;
case 'daily':
$interval = 86400; // 24 hours
break;
case 'weekly':
$interval = 604800; // 7 days
break;
}
// Check if enough time has passed
if ($now - $lastRun < $interval) {
return false;
}
return true;
}
/**
* Update last run timestamp
*/
protected function updateLastRunTime()
{
global $conf;
dolibarr_set_const($this->db, 'IMPORTZUGFERD_LAST_IMPORT_RUN', dol_now(), 'chaine', 0, '', $conf->entity);
}
/**
* Main import method - imports from both folder and mailbox
*
* @return int 0 if OK, <0 if error
*/
public function runScheduledImport()
{
global $conf, $user, $langs;
$langs->load('importzugferd@importzugferd');
// Check if we should run based on frequency
if (!$this->shouldRunImport()) {
$this->output = 'Skipped - not scheduled to run (frequency: '.getDolGlobalString('IMPORTZUGFERD_IMPORT_FREQUENCY', 'manual').')';
return 0;
}
// Reset counters
$this->imported_count = 0;
$this->skipped_count = 0;
$this->error_count = 0;
$this->errors = array();
$folderResult = $this->importFromFolder();
$mailboxResult = $this->fetchFromMailbox();
// Update last run time
$this->updateLastRunTime();
// Build combined output
$this->output = sprintf(
"Scheduled import complete. Imported: %d, Skipped: %d, Errors: %d",
$this->imported_count,
$this->skipped_count,
$this->error_count
);
if ($this->error_count > 0 && !empty($this->errors)) {
$this->output .= "\nErrors: " . implode(", ", array_slice($this->errors, 0, 5));
}
return ($this->error_count > 0) ? -1 : 0;
}
/**
* Import ZUGFeRD invoices from watch folder
*
* @return int 0 if OK, <0 if error
*/
public function importFromFolder()
{
global $conf, $user, $langs;
$langs->load('importzugferd@importzugferd');
$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
$autoCreate = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
// Validate settings
if (empty($watchFolder) || !is_dir($watchFolder)) {
$this->output = 'Watch folder not configured or not accessible';
return 0; // Not an error, just not configured
}
// Load admin user for import actions
$admin_user = new User($this->db);
$admin_user->fetch(1);
// Find PDF files
$files = glob($watchFolder . '/*.pdf');
$files = array_merge($files, glob($watchFolder . '/*.PDF'));
if (empty($files)) {
return 0;
}
// Ensure archive folder exists if configured
if (!empty($archiveFolder) && !is_dir($archiveFolder)) {
dol_mkdir($archiveFolder);
}
// Ensure error folder exists if configured
if (!empty($errorFolder) && !is_dir($errorFolder)) {
dol_mkdir($errorFolder);
}
foreach ($files as $file) {
// Use ZugferdImport::importFromFile for consistent handling
$import = new ZugferdImport($this->db);
$result = $import->importFromFile($admin_user, $file, $autoCreate);
if ($result > 0) {
$this->imported_count++;
dol_syslog("CronImportZugferd: Imported invoice from folder: " . basename($file), LOG_INFO);
// Archive the file
$this->moveFile($file, $archiveFolder, 'imported_');
} elseif ($result == -2) {
// Duplicate - already imported
$this->skipped_count++;
dol_syslog("CronImportZugferd: Skipped duplicate invoice: " . basename($file), LOG_INFO);
// Archive duplicates - delete if no archive folder
if (!$this->moveFile($file, $archiveFolder, 'duplicate_')) {
@unlink($file);
}
} else {
$this->error_count++;
$this->errors[] = basename($file) . ': ' . $import->error;
dol_syslog("CronImportZugferd: Error importing: " . basename($file) . " - " . $import->error, LOG_WARNING);
// Try error folder first, fall back to archive folder
if (!$this->moveFile($file, $errorFolder, 'error_')) {
// Use archive folder as fallback for errors
$this->moveFile($file, $archiveFolder, 'error_');
}
}
}
return 0;
}
/** /**
* Fetch ZUGFeRD invoices from configured IMAP mailbox * Fetch ZUGFeRD invoices from configured IMAP mailbox
* *
@ -296,4 +470,68 @@ class CronImportZugferd
'data' => $data 'data' => $data
); );
} }
/**
* Move file to target folder with proper error handling
*
* @param string $file Source file path
* @param string $targetFolder Target folder path
* @param string $prefix Filename prefix (e.g., 'imported_', 'duplicate_', 'error_')
* @return bool True if moved/deleted, false on failure
*/
protected function moveFile($file, $targetFolder, $prefix = '')
{
if (!file_exists($file)) {
dol_syslog("CronImportZugferd: File not found: " . $file, LOG_WARNING);
return false;
}
// If target folder is configured and exists/writable
if (!empty($targetFolder)) {
// Create folder if it doesn't exist
if (!is_dir($targetFolder)) {
$result = dol_mkdir($targetFolder);
if ($result < 0) {
dol_syslog("CronImportZugferd: Failed to create folder: " . $targetFolder, LOG_WARNING);
}
}
if (is_dir($targetFolder) && is_writable($targetFolder)) {
$targetPath = $targetFolder . '/' . $prefix . date('Y-m-d_His') . '_' . basename($file);
if (@rename($file, $targetPath)) {
dol_syslog("CronImportZugferd: Moved file to: " . $targetPath, LOG_INFO);
return true;
} else {
// Try copy + delete as fallback (for cross-filesystem moves)
if (@copy($file, $targetPath)) {
@unlink($file);
dol_syslog("CronImportZugferd: Copied file to: " . $targetPath, LOG_INFO);
return true;
} else {
dol_syslog("CronImportZugferd: Failed to move/copy file to: " . $targetPath, LOG_ERR);
return false;
}
}
} else {
dol_syslog("CronImportZugferd: Target folder not writable: " . $targetFolder, LOG_WARNING);
}
}
// No target folder configured or not writable - delete file from watch folder
// to prevent re-processing (except for errors without error folder)
if ($prefix !== 'error_') {
if (@unlink($file)) {
dol_syslog("CronImportZugferd: Deleted processed file: " . $file, LOG_INFO);
return true;
} else {
dol_syslog("CronImportZugferd: Failed to delete file: " . $file, LOG_ERR);
return false;
}
}
// Error files stay in watch folder if no error folder configured
dol_syslog("CronImportZugferd: Keeping error file in watch folder: " . $file, LOG_INFO);
return true;
}
} }

989
class/datanorm.class.php Normal file
View file

@ -0,0 +1,989 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file class/datanorm.class.php
* \ingroup importzugferd
* \brief Class for Datanorm article database operations
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
/**
* Class Datanorm
* Manages Datanorm articles in database
*/
class Datanorm extends CommonObject
{
/**
* @var string ID to identify managed object
*/
public $element = 'datanorm';
/**
* @var string Name of table without prefix
*/
public $table_element = 'importzugferd_datanorm';
/**
* @var int Does object support multicompany
*/
public $ismultientitymanaged = 1;
/**
* @var int Supplier ID
*/
public $fk_soc;
/**
* @var string Article number
*/
public $article_number;
/**
* @var string Short text 1
*/
public $short_text1;
/**
* @var string Short text 2
*/
public $short_text2;
/**
* @var string Long text
*/
public $long_text;
/**
* @var string EAN/GTIN
*/
public $ean;
/**
* @var string Manufacturer article number
*/
public $manufacturer_ref;
/**
* @var string Manufacturer name
*/
public $manufacturer_name;
/**
* @var string Unit code
*/
public $unit_code;
/**
* @var float Price
*/
public $price = 0;
/**
* @var int Price unit (pieces per price)
*/
public $price_unit = 1;
/**
* @var string Discount group
*/
public $discount_group;
/**
* @var string Product group
*/
public $product_group;
/**
* @var string Alternative unit
*/
public $alt_unit;
/**
* @var float Alternative unit factor
*/
public $alt_unit_factor = 1;
/**
* @var float Weight in kg
*/
public $weight;
/**
* @var string Matchcode
*/
public $matchcode;
/**
* @var string Datanorm version
*/
public $datanorm_version;
/**
* @var string Import date
*/
public $import_date;
/**
* @var int Active flag
*/
public $active = 1;
/**
* @var string Date creation
*/
public $date_creation;
/**
* @var int User creator
*/
public $fk_user_creat;
/**
* @var int User modifier
*/
public $fk_user_modif;
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Create object into database
*
* @param User $user User that creates
* @return int <0 if KO, Id of created object if OK
*/
public function create($user)
{
global $conf;
$this->entity = $conf->entity;
if (empty($this->date_creation)) {
$this->date_creation = dol_now();
}
if (empty($this->import_date)) {
$this->import_date = dol_now();
}
$this->fk_user_creat = $user->id;
$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 .= "alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= "datanorm_version, import_date, active, date_creation, fk_user_creat, entity";
$sql .= ") VALUES (";
$sql .= (int) $this->fk_soc . ",";
$sql .= "'" . $this->db->escape($this->article_number) . "',";
$sql .= "'" . $this->db->escape($this->short_text1) . "',";
$sql .= "'" . $this->db->escape($this->short_text2) . "',";
$sql .= "'" . $this->db->escape($this->long_text) . "',";
$sql .= "'" . $this->db->escape($this->ean) . "',";
$sql .= "'" . $this->db->escape($this->manufacturer_ref) . "',";
$sql .= "'" . $this->db->escape($this->manufacturer_name) . "',";
$sql .= "'" . $this->db->escape($this->unit_code) . "',";
$sql .= (float) $this->price . ",";
$sql .= (int) $this->price_unit . ",";
$sql .= "'" . $this->db->escape($this->discount_group) . "',";
$sql .= "'" . $this->db->escape($this->product_group) . "',";
$sql .= "'" . $this->db->escape($this->alt_unit) . "',";
$sql .= (float) $this->alt_unit_factor . ",";
$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->db->idate($this->import_date)) . "',";
$sql .= (int) $this->active . ",";
$sql .= "'" . $this->db->escape($this->db->idate($this->date_creation)) . "',";
$sql .= (int) $this->fk_user_creat . ",";
$sql .= (int) $this->entity;
$sql .= ")";
dol_syslog(get_class($this) . "::create", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX . $this->table_element);
return $this->id;
}
/**
* Create or update article (upsert)
*
* @param User $user User that creates/modifies
* @return int <0 if KO, Id of object if OK
*/
public function createOrUpdate($user)
{
// Check if article exists
$existing = $this->fetchByArticleNumber($this->fk_soc, $this->article_number);
if ($existing > 0) {
return $this->update($user);
} else {
return $this->create($user);
}
}
/**
* Load object in memory from database
*
* @param int $id Id object
* @return int <0 if KO, 0 if not found, >0 if OK
*/
public function fetch($id)
{
$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 .= " alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= " datanorm_version, 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;
dol_syslog(get_class($this) . "::fetch", LOG_DEBUG);
$resql = $this->db->query($sql);
if ($resql) {
if ($this->db->num_rows($resql)) {
$obj = $this->db->fetch_object($resql);
$this->setFromObject($obj);
$this->db->free($resql);
return 1;
} else {
$this->db->free($resql);
return 0;
}
} else {
$this->error = $this->db->lasterror();
return -1;
}
}
/**
* Fetch by supplier and article number
*
* @param int $fk_soc Supplier ID
* @param string $article_number Article number
* @return int <0 if KO, 0 if not found, >0 if OK
*/
public function fetchByArticleNumber($fk_soc, $article_number)
{
global $conf;
$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 .= " alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= " datanorm_version, 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) . "'";
$sql .= " AND entity = " . (int) $conf->entity;
dol_syslog(get_class($this) . "::fetchByArticleNumber", LOG_DEBUG);
$resql = $this->db->query($sql);
if ($resql) {
if ($this->db->num_rows($resql)) {
$obj = $this->db->fetch_object($resql);
$this->setFromObject($obj);
$this->db->free($resql);
return 1;
} else {
$this->db->free($resql);
return 0;
}
} else {
$this->error = $this->db->lasterror();
return -1;
}
}
/**
* Set object properties from database object
*
* @param object $obj Database row object
*/
protected function setFromObject($obj)
{
$this->id = $obj->rowid;
$this->fk_soc = $obj->fk_soc;
$this->article_number = $obj->article_number;
$this->short_text1 = $obj->short_text1;
$this->short_text2 = $obj->short_text2;
$this->long_text = $obj->long_text;
$this->ean = $obj->ean;
$this->manufacturer_ref = $obj->manufacturer_ref;
$this->manufacturer_name = $obj->manufacturer_name;
$this->unit_code = $obj->unit_code;
$this->price = $obj->price;
$this->price_unit = $obj->price_unit;
$this->discount_group = $obj->discount_group;
$this->product_group = $obj->product_group;
$this->alt_unit = $obj->alt_unit;
$this->alt_unit_factor = $obj->alt_unit_factor;
$this->weight = $obj->weight;
$this->matchcode = $obj->matchcode;
$this->datanorm_version = $obj->datanorm_version;
$this->import_date = $this->db->jdate($obj->import_date);
$this->active = $obj->active;
$this->date_creation = $this->db->jdate($obj->date_creation);
$this->tms = $this->db->jdate($obj->tms);
$this->fk_user_creat = $obj->fk_user_creat;
$this->fk_user_modif = $obj->fk_user_modif;
$this->entity = $obj->entity;
}
/**
* Update object in database
*
* @param User $user User that modifies
* @return int <0 if KO, >0 if OK
*/
public function update($user)
{
$this->fk_user_modif = $user->id;
$this->import_date = dol_now();
$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) . "',";
$sql .= " long_text = '" . $this->db->escape($this->long_text) . "',";
$sql .= " ean = '" . $this->db->escape($this->ean) . "',";
$sql .= " manufacturer_ref = '" . $this->db->escape($this->manufacturer_ref) . "',";
$sql .= " manufacturer_name = '" . $this->db->escape($this->manufacturer_name) . "',";
$sql .= " unit_code = '" . $this->db->escape($this->unit_code) . "',";
$sql .= " price = " . (float) $this->price . ",";
$sql .= " price_unit = " . (int) $this->price_unit . ",";
$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) . "',";
$sql .= " alt_unit_factor = " . (float) $this->alt_unit_factor . ",";
$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 .= " 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;
$sql .= " WHERE rowid = " . (int) $this->id;
dol_syslog(get_class($this) . "::update", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
return 1;
}
/**
* Delete object from database
*
* @param User $user User that deletes
* @return int <0 if KO, >0 if OK
*/
public function delete($user)
{
$sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE rowid = " . (int) $this->id;
dol_syslog(get_class($this) . "::delete", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
return 1;
}
/**
* Delete all articles for a supplier
*
* @param User $user User that deletes
* @param int $fk_soc Supplier ID
* @return int <0 if KO, number of deleted rows if OK
*/
public function deleteAllBySupplier($user, $fk_soc)
{
global $conf;
$sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE fk_soc = " . (int) $fk_soc;
$sql .= " AND entity = " . (int) $conf->entity;
dol_syslog(get_class($this) . "::deleteAllBySupplier", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
return $this->db->affected_rows($resql);
}
/**
* Search articles by article number (exact or partial)
*
* @param string $article_number Article number to search
* @param int $fk_soc Supplier ID (0 = all suppliers)
* @param bool $searchAll Search all suppliers if not found in specified
* @param int $limit Maximum results
* @return array Array of matching articles
*/
public function searchByArticleNumber($article_number, $fk_soc = 0, $searchAll = false, $limit = 50)
{
global $conf;
$results = array();
// First try exact match with specified supplier
if ($fk_soc > 0) {
$result = $this->fetchByArticleNumber($fk_soc, $article_number);
if ($result > 0) {
$results[] = $this->toArray();
return $results;
}
}
// Search partial match
$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) . "%')";
if ($fk_soc > 0 && !$searchAll) {
$sql .= " AND fk_soc = " . (int) $fk_soc;
} elseif ($fk_soc > 0 && $searchAll) {
// Order by matching supplier first
$sql .= " ORDER BY CASE WHEN fk_soc = " . (int) $fk_soc . " THEN 0 ELSE 1 END, article_number";
}
$sql .= " AND active = 1";
$sql .= " AND entity = " . (int) $conf->entity;
if ($fk_soc == 0 || !$searchAll) {
$sql .= " ORDER BY article_number";
}
$sql .= " LIMIT " . (int) $limit;
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$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,
);
}
$this->db->free($resql);
}
return $results;
}
/**
* Convert object to array
*
* @return array Object as array
*/
public function toArray()
{
return array(
'id' => $this->id,
'fk_soc' => $this->fk_soc,
'article_number' => $this->article_number,
'short_text1' => $this->short_text1,
'short_text2' => $this->short_text2,
'long_text' => $this->long_text,
'ean' => $this->ean,
'manufacturer_ref' => $this->manufacturer_ref,
'manufacturer_name' => $this->manufacturer_name,
'unit_code' => $this->unit_code,
'price' => $this->price,
'price_unit' => $this->price_unit,
'discount_group' => $this->discount_group,
'product_group' => $this->product_group,
'alt_unit' => $this->alt_unit,
'alt_unit_factor' => $this->alt_unit_factor,
'weight' => $this->weight,
'matchcode' => $this->matchcode,
'datanorm_version' => $this->datanorm_version,
'import_date' => $this->import_date,
'active' => $this->active,
);
}
/**
* Count articles for a supplier
*
* @param int $fk_soc Supplier ID
* @return int Count
*/
public function countBySupplier($fk_soc)
{
global $conf;
$sql = "SELECT COUNT(*) as nb FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE fk_soc = " . (int) $fk_soc;
$sql .= " AND entity = " . (int) $conf->entity;
$resql = $this->db->query($sql);
if ($resql) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->nb;
}
return 0;
}
/**
* Get all suppliers with Datanorm data
*
* @return array Array of suppliers with article counts
*/
public function getSuppliersWithData()
{
global $conf;
$suppliers = array();
$sql = "SELECT d.fk_soc, s.nom as supplier_name, COUNT(*) as article_count,";
$sql .= " MAX(d.import_date) as last_import";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element . " as d";
$sql .= " LEFT JOIN " . MAIN_DB_PREFIX . "societe as s ON s.rowid = d.fk_soc";
$sql .= " WHERE d.entity = " . (int) $conf->entity;
$sql .= " GROUP BY d.fk_soc, s.nom";
$sql .= " ORDER BY s.nom";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$suppliers[] = array(
'fk_soc' => $obj->fk_soc,
'name' => $obj->supplier_name,
'article_count' => $obj->article_count,
'last_import' => $this->db->jdate($obj->last_import),
);
}
$this->db->free($resql);
}
return $suppliers;
}
/**
* Import articles from parser
*
* @param User $user User that imports
* @param int $fk_soc Supplier ID
* @param DatanormParser $parser Parser with parsed articles
* @param bool $deleteExisting Delete existing articles before import
* @return int Number of imported articles, <0 on error
*/
public function importFromParser($user, $fk_soc, $parser, $deleteExisting = false)
{
$this->db->begin();
// Delete existing if requested
if ($deleteExisting) {
$result = $this->deleteAllBySupplier($user, $fk_soc);
if ($result < 0) {
$this->db->rollback();
return -1;
}
}
$count = 0;
$errors = 0;
foreach ($parser->getArticles() as $articleData) {
$article = new Datanorm($this->db);
$article->fk_soc = $fk_soc;
$article->article_number = $articleData['article_number'];
$article->short_text1 = $articleData['short_text1'] ?? '';
$article->short_text2 = $articleData['short_text2'] ?? '';
$article->long_text = $articleData['long_text'] ?? '';
$article->ean = $articleData['ean'] ?? '';
$article->manufacturer_ref = $articleData['manufacturer_ref'] ?? '';
$article->manufacturer_name = $articleData['manufacturer_name'] ?? '';
$article->unit_code = $articleData['unit_code'] ?? '';
$article->price = $articleData['price'] ?? 0;
$article->price_unit = $articleData['price_unit'] ?? 1;
$article->discount_group = $articleData['discount_group'] ?? '';
$article->product_group = $articleData['product_group'] ?? '';
$article->matchcode = $articleData['matchcode'] ?? '';
$article->datanorm_version = $parser->version;
$result = $article->createOrUpdate($user);
if ($result > 0) {
$count++;
} else {
$errors++;
$this->errors[] = 'Error importing ' . $articleData['article_number'] . ': ' . $article->error;
}
}
if ($errors > 0 && $count == 0) {
$this->db->rollback();
$this->error = 'All imports failed';
return -1;
}
$this->db->commit();
return $count;
}
/**
* Import articles from directory using streaming (for large files)
* Uses batch inserts to minimize memory usage
*
* @param User $user User that imports
* @param int $fk_soc Supplier ID
* @param string $directory Directory with Datanorm files
* @param bool $deleteExisting Delete existing articles before import
* @return int Number of imported articles, <0 on error
*/
public function importFromDirectoryStreaming($user, $fk_soc, $directory, $deleteExisting = false)
{
global $conf;
require_once __DIR__ . '/datanormparser.class.php';
// Delete existing if requested
if ($deleteExisting) {
$result = $this->deleteAllBySupplier($user, $fk_soc);
if ($result < 0) {
return -1;
}
}
$db = $this->db;
$importCount = 0;
$version = '';
// Create batch callback that inserts articles directly to database
$batchCallback = function ($articles) use ($db, $user, $fk_soc, &$importCount, &$version, $conf) {
if (empty($articles)) {
return;
}
// Use multi-row INSERT for better performance
$values = array();
$now = $db->idate(dol_now());
foreach ($articles as $articleData) {
$values[] = sprintf(
"(%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %f, %d, '%s', '%s', '%s', '%s', '%s', %d, '%s', %d)",
(int) $fk_soc,
$db->escape($articleData['article_number'] ?? ''),
$db->escape($articleData['short_text1'] ?? ''),
$db->escape($articleData['short_text2'] ?? ''),
$db->escape($articleData['long_text'] ?? ''),
$db->escape($articleData['ean'] ?? ''),
$db->escape($articleData['manufacturer_ref'] ?? ''),
$db->escape($articleData['manufacturer_name'] ?? ''),
$db->escape($articleData['unit_code'] ?? ''),
(float) ($articleData['price'] ?? 0),
(int) ($articleData['price_unit'] ?? 1),
$db->escape($articleData['discount_group'] ?? ''),
$db->escape($articleData['product_group'] ?? ''),
$db->escape($articleData['matchcode'] ?? ''),
$db->escape($version),
$now,
(int) $user->id,
$now,
(int) $conf->entity
);
}
if (!empty($values)) {
// Use INSERT IGNORE to skip duplicates (for the same supplier + article_number)
$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 .= implode(", ", $values);
// For updates of existing articles, use ON DUPLICATE KEY UPDATE
$sql .= " ON DUPLICATE KEY UPDATE ";
$sql .= "short_text1 = VALUES(short_text1), ";
$sql .= "short_text2 = VALUES(short_text2), ";
$sql .= "long_text = VALUES(long_text), ";
$sql .= "ean = VALUES(ean), ";
$sql .= "manufacturer_ref = VALUES(manufacturer_ref), ";
$sql .= "manufacturer_name = VALUES(manufacturer_name), ";
$sql .= "unit_code = VALUES(unit_code), ";
$sql .= "price = VALUES(price), ";
$sql .= "price_unit = VALUES(price_unit), ";
$sql .= "discount_group = VALUES(discount_group), ";
$sql .= "product_group = VALUES(product_group), ";
$sql .= "matchcode = VALUES(matchcode), ";
$sql .= "datanorm_version = VALUES(datanorm_version), ";
$sql .= "import_date = VALUES(import_date), ";
$sql .= "fk_user_modif = " . (int) $user->id;
$resql = $db->query($sql);
if ($resql) {
$importCount += count($values);
}
}
};
// Parse with streaming enabled
// The parser now loads prices first, then articles
$parser = new DatanormParser();
$parser->enableStreaming($batchCallback, 500);
// Parse directory - prices are loaded first, then articles with streaming
$count = $parser->parseDirectory($directory);
$version = $parser->version;
if ($count < 0) {
$this->error = $parser->error;
return -1;
}
// Second pass: Update prices from DATPREIS files
$priceFiles = glob($directory . '/DATPREIS.*');
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;
}
/**
* Update prices from DATPREIS file (streaming)
* Processes file line by line and updates database directly
*
* @param int $fk_soc Supplier ID
* @param string $file Path to DATPREIS file
* @return int Number of prices updated
*/
protected function updatePricesFromFile($fk_soc, $file)
{
global $conf;
$handle = fopen($file, 'r');
if ($handle === false) {
return 0;
}
$updated = 0;
$batch = array();
$batchSize = 500;
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
// Convert encoding if needed
if (!mb_check_encoding($line, 'UTF-8')) {
$line = mb_convert_encoding($line, 'UTF-8', 'ISO-8859-1');
}
if (strlen($line) < 10 || strpos($line, ';') === false) {
continue;
}
$parts = explode(';', $line);
$recordType = trim($parts[0] ?? '');
// P;A format - multiple articles per line
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');
$price = (float)$priceRaw / 100; // Convert cents to euros
if (!empty($articleNumber) && $price > 0) {
$batch[$articleNumber] = $price;
}
$i += 9; // 9 fields per article
}
} elseif ($recordType === 'P' || $recordType === '0') {
$articleNumber = trim($parts[1] ?? '');
$priceRaw = trim($parts[3] ?? '0');
if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) {
$price = (float)$priceRaw / 100;
} else {
$priceRaw = str_replace(',', '.', $priceRaw);
$price = (float)$priceRaw;
}
if (!empty($articleNumber) && $price > 0) {
$batch[$articleNumber] = $price;
}
}
// Flush batch when it reaches the limit
if (count($batch) >= $batchSize) {
$updated += $this->flushPriceBatch($fk_soc, $batch);
$batch = array();
}
}
// Flush remaining
if (!empty($batch)) {
$updated += $this->flushPriceBatch($fk_soc, $batch);
}
fclose($handle);
return $updated;
}
/**
* Flush price batch to database
*
* @param int $fk_soc Supplier ID
* @param array $prices Array of article_number => price
* @return int Number of rows updated
*/
protected function flushPriceBatch($fk_soc, $prices)
{
global $conf;
if (empty($prices)) {
return 0;
}
$updated = 0;
// Build CASE statement for batch update
$cases = array();
$articleNumbers = array();
foreach ($prices as $artNum => $price) {
$cases[] = "WHEN '" . $this->db->escape($artNum) . "' THEN " . (float)$price;
$articleNumbers[] = "'" . $this->db->escape($artNum) . "'";
}
if (!empty($cases)) {
$sql = "UPDATE " . MAIN_DB_PREFIX . "importzugferd_datanorm SET ";
$sql .= "price = CASE article_number ";
$sql .= implode(" ", $cases);
$sql .= " END ";
$sql .= "WHERE fk_soc = " . (int)$fk_soc;
$sql .= " AND entity = " . (int)$conf->entity;
$sql .= " AND article_number IN (" . implode(",", $articleNumbers) . ")";
$resql = $this->db->query($sql);
if ($resql) {
$updated = $this->db->affected_rows($resql);
}
}
return $updated;
}
/**
* Get full description for product creation
*
* @return string Full description
*/
public function getFullDescription()
{
$desc = '';
if (!empty($this->short_text1)) {
$desc .= $this->short_text1;
}
if (!empty($this->short_text2)) {
$desc .= ($desc ? "\n" : '') . $this->short_text2;
}
if (!empty($this->long_text)) {
$desc .= ($desc ? "\n\n" : '') . $this->long_text;
}
// Add metadata
$meta = array();
if (!empty($this->manufacturer_name)) {
$meta[] = 'Hersteller: ' . $this->manufacturer_name;
}
if (!empty($this->manufacturer_ref)) {
$meta[] = 'Hersteller-Nr: ' . $this->manufacturer_ref;
}
if (!empty($this->ean)) {
$meta[] = 'EAN: ' . $this->ean;
}
if (!empty($this->product_group)) {
$meta[] = 'Warengruppe: ' . $this->product_group;
}
if (!empty($meta)) {
$desc .= ($desc ? "\n\n" : '') . implode("\n", $meta);
}
return $desc;
}
/**
* Calculate selling price with markup
*
* @param float $markupPercent Markup percentage
* @return float Selling price
*/
public function getSellingPrice($markupPercent = 0)
{
$basePrice = $this->price;
// Adjust for price unit
if ($this->price_unit > 1) {
$basePrice = $basePrice / $this->price_unit;
}
if ($markupPercent > 0) {
return $basePrice * (1 + $markupPercent / 100);
}
return $basePrice;
}
}

View file

@ -0,0 +1,909 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file class/datanormparser.class.php
* \ingroup importzugferd
* \brief Parser for Datanorm 4.0 and 5.0 catalog files
*/
/**
* Class DatanormParser
* Parses Datanorm catalog files (Version 4.0 and 5.0)
*/
class DatanormParser
{
/**
* @var string Detected Datanorm version
*/
public $version = '';
/**
* @var array Parsed articles (only used for small imports)
*/
public $articles = array();
/**
* @var array Parsed price information
*/
public $prices = array();
/**
* @var array Product groups/categories
*/
public $groups = array();
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Error messages
*/
public $errors = array();
/**
* @var callable Callback for batch processing articles
*/
protected $batchCallback = null;
/**
* @var int Batch size for database inserts
*/
protected $batchSize = 1000;
/**
* @var array Current batch of articles
*/
protected $batchArticles = array();
/**
* @var bool Whether to use streaming mode (for large files)
*/
protected $streamingMode = false;
/**
* Enable streaming mode for large files
* In streaming mode, articles are processed in batches via callback
*
* @param callable $callback Function to call with batch of articles
* @param int $batchSize Number of articles per batch
*/
public function enableStreaming($callback, $batchSize = 1000)
{
$this->streamingMode = true;
$this->batchCallback = $callback;
$this->batchSize = $batchSize;
$this->batchArticles = array();
}
/**
* Disable streaming mode
*/
public function disableStreaming()
{
$this->streamingMode = false;
$this->batchCallback = null;
$this->batchArticles = array();
}
/**
* Add article to batch (streaming mode) or to articles array
*
* @param array $article Article data
*/
protected function addArticle($article)
{
if ($this->streamingMode && $this->batchCallback) {
$this->batchArticles[$article['article_number']] = $article;
if (count($this->batchArticles) >= $this->batchSize) {
$this->flushBatch();
}
} else {
$this->articles[$article['article_number']] = $article;
}
}
/**
* Flush current batch to callback
*/
protected function flushBatch()
{
if (!empty($this->batchArticles) && $this->batchCallback) {
// Merge prices into batch articles before flushing
foreach ($this->batchArticles as $artNum => &$article) {
if (isset($this->prices[$artNum])) {
$article['price'] = $this->prices[$artNum]['price'];
unset($this->prices[$artNum]); // Free memory
}
}
unset($article);
call_user_func($this->batchCallback, $this->batchArticles);
$this->batchArticles = array();
}
}
/**
* Parse a Datanorm file or directory
*
* @param string $path Path to file or directory
* @return int Number of articles parsed, -1 on error
*/
public function parse($path)
{
if (is_dir($path)) {
return $this->parseDirectory($path);
} else {
return $this->parseFile($path);
}
}
/**
* Parse all Datanorm files in a directory
*
* @param string $dir Directory path
* @return int Number of articles parsed, -1 on error
*/
public function parseDirectory($dir)
{
$totalArticles = 0;
// For non-streaming mode, load prices first
// For streaming mode with very large files, prices must be handled separately
if (!$this->streamingMode) {
$priceFiles = glob($dir . '/DATPREIS.*');
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)) {
$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.)
$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);
}
}
}
// Merge prices into articles (non-streaming mode only)
// In streaming mode, prices are merged in flushBatch()
if (!$this->streamingMode && !empty($this->prices)) {
$this->mergePricesIntoArticles();
}
// Look for Datanorm 5.0 files (*.xml)
$xmlFiles = glob($dir . '/*.xml');
foreach ($xmlFiles as $file) {
if ($this->isDatanorm5File($file)) {
$this->version = '5.0';
$count = $this->parseDatanorm5File($file);
if ($count > 0) {
$totalArticles += $count;
}
}
}
return $totalArticles;
}
/**
* Parse a single file (auto-detect format)
*
* @param string $file File path
* @return int Number of articles parsed, -1 on error
*/
public function parseFile($file)
{
if (!file_exists($file)) {
$this->error = 'File not found: ' . $file;
return -1;
}
// Check if XML (Datanorm 5.0)
$content = file_get_contents($file, false, null, 0, 1000);
if (strpos($content, '<?xml') !== false || strpos($content, '<DATANORM') !== false) {
$this->version = '5.0';
return $this->parseDatanorm5File($file);
}
// Assume Datanorm 4.0
$this->version = '4.0';
return $this->parseDatanorm4File($file);
}
/**
* Parse Datanorm 4.0 file (fixed-width format)
* Uses streaming to handle large files
*
* @param string $file File path
* @return int Number of articles parsed
*/
protected function parseDatanorm4File($file)
{
$handle = fopen($file, 'r');
if ($handle === false) {
$this->error = 'Cannot read file: ' . $file;
return -1;
}
$count = 0;
$currentArticle = null;
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
// Convert encoding if needed (Datanorm 4 often uses ISO-8859-1 or CP850)
if (!mb_check_encoding($line, 'UTF-8')) {
$line = mb_convert_encoding($line, 'UTF-8', 'ISO-8859-1');
}
if (strlen($line) < 2) {
continue;
}
$recordType = substr($line, 0, 1);
switch ($recordType) {
case 'A':
// Article master record
$article = $this->parseDatanorm4TypeA($line);
if ($article) {
$this->addArticle($article);
$currentArticle = $article['article_number'];
$count++;
}
break;
case 'B':
// Article info/long text
if ($currentArticle) {
$this->parseDatanorm4TypeB($line, $currentArticle);
}
break;
case 'P':
// Price record
$this->parseDatanorm4TypeP($line);
break;
}
}
fclose($handle);
// Flush any remaining batch in streaming mode
if ($this->streamingMode) {
$this->flushBatch();
} else {
// Merge prices into articles (only in non-streaming mode)
$this->mergePricesIntoArticles();
}
return $count;
}
/**
* Parse Datanorm 4.0 Type A record (Article master)
* Field positions based on Datanorm 4.0 specification
*
* @param string $line Record line
* @return array|null Article data
*/
protected function parseDatanorm4TypeA($line)
{
// Minimum length check
if (strlen($line) < 50) {
return null;
}
// Datanorm 4.0 Type A field layout (semicolon-separated in newer versions)
if (strpos($line, ';') !== false) {
return $this->parseDatanorm4TypeASemicolon($line);
}
// Fixed-width format (classic)
$article = array(
'article_number' => trim(substr($line, 1, 15)), // Pos 2-16: Artikelnummer
'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
'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
'manufacturer_name' => trim(substr($line, 142, 20)), // Pos 143-162: Herstellername
'ean' => '',
'long_text' => '',
'price' => 0,
);
// EAN if available (extended format)
if (strlen($line) >= 175) {
$article['ean'] = trim(substr($line, 162, 13));
}
if (empty($article['article_number'])) {
return null;
}
// Default price unit to 1 if not set
if ($article['price_unit'] <= 0) {
$article['price_unit'] = 1;
}
return $article;
}
/**
* Parse Datanorm 4.0 Type A record (semicolon-separated format)
*
* @param string $line Record line
* @return array|null Article data
*/
protected function parseDatanorm4TypeASemicolon($line)
{
$parts = explode(';', $line);
if (count($parts) < 6) {
return null;
}
// Detect format variant
// Sonepar format: A;N;ArtNr;WG;Kurztext1;Kurztext2;PE;ME;METext;RabGrp;PreisGrp;WG2;...
// 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;...
$article = array(
'article_number' => trim($parts[2] ?? ''),
'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
'manufacturer_ref' => '',
'manufacturer_name' => '',
'ean' => '',
'long_text' => '',
'price' => 0,
);
} else {
// Standard format
$article = array(
'article_number' => trim($parts[1] ?? ''),
'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'),
'discount_group' => trim($parts[7] ?? ''),
'product_group' => trim($parts[8] ?? ''),
'manufacturer_ref' => trim($parts[14] ?? ''),
'manufacturer_name' => trim($parts[15] ?? ''),
'ean' => trim($parts[16] ?? ''),
'long_text' => '',
'price' => 0,
);
}
if (empty($article['article_number'])) {
return null;
}
if ($article['price_unit'] <= 0) {
$article['price_unit'] = 1;
}
return $article;
}
/**
* Get article reference for modification (handles both streaming and non-streaming mode)
*
* @param string $articleNumber Article number
* @return array|null Reference to article or null
*/
protected function &getArticleRef($articleNumber)
{
$null = null;
if ($this->streamingMode) {
if (isset($this->batchArticles[$articleNumber])) {
return $this->batchArticles[$articleNumber];
}
} else {
if (isset($this->articles[$articleNumber])) {
return $this->articles[$articleNumber];
}
}
return $null;
}
/**
* Parse Datanorm 4.0 Type B record (Article info/long text)
*
* @param string $line Record line
* @param string $articleNumber Current article number
*/
protected function parseDatanorm4TypeB($line, $articleNumber)
{
$article = &$this->getArticleRef($articleNumber);
if ($article === null) {
return;
}
if (strpos($line, ';') !== false) {
$parts = explode(';', $line);
// Sonepar format: B;N;ArtNr;Matchcode;...
if (isset($parts[1]) && strlen(trim($parts[1])) <= 2) {
// Get article number from B record to verify
$bArticleNumber = trim($parts[2] ?? '');
if ($bArticleNumber === $articleNumber) {
// Matchcode is at position 3
$matchcode = trim($parts[3] ?? '');
if (!empty($matchcode) && empty($article['matchcode'])) {
$article['matchcode'] = $matchcode;
}
}
} else {
// Standard format: text at position 2
$text = trim($parts[2] ?? '');
if (!empty($text)) {
if (!empty($article['long_text'])) {
$article['long_text'] .= "\n";
}
$article['long_text'] .= $text;
}
}
} else {
$text = trim(substr($line, 16));
if (!empty($text)) {
if (!empty($article['long_text'])) {
$article['long_text'] .= "\n";
}
$article['long_text'] .= $text;
}
}
}
/**
* Parse Datanorm 4.0 Type P record (Price)
*
* @param string $line Record line
*/
protected function parseDatanorm4TypeP($line)
{
if (strpos($line, ';') !== false) {
$parts = explode(';', $line);
$articleNumber = trim($parts[1] ?? '');
$priceType = trim($parts[2] ?? '');
$price = $this->parsePrice(trim($parts[3] ?? '0'));
} else {
$articleNumber = trim(substr($line, 1, 15));
$priceType = trim(substr($line, 16, 1));
$price = $this->parsePrice(trim(substr($line, 17, 12)));
}
if (!empty($articleNumber) && $price > 0) {
$this->prices[$articleNumber] = array(
'price' => $price,
'price_type' => $priceType,
);
}
}
/**
* Parse Datanorm 4.0 product groups file (DATANORM.WRG)
*
* @param string $file File path
*/
protected function parseDatanorm4Groups($file)
{
$content = file_get_contents($file);
if ($content === false) {
return;
}
if (!mb_check_encoding($content, 'UTF-8')) {
$content = mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
}
$lines = explode("\n", $content);
foreach ($lines as $line) {
$line = rtrim($line, "\r\n");
if (strlen($line) < 10) {
continue;
}
if (strpos($line, ';') !== false) {
$parts = explode(';', $line);
$code = trim($parts[0] ?? '');
$name = trim($parts[1] ?? '');
} else {
$code = trim(substr($line, 0, 7));
$name = trim(substr($line, 7));
}
if (!empty($code)) {
$this->groups[$code] = $name;
}
}
}
/**
* Parse Datanorm 4.0 discount groups file (DATANORM.RAB)
*
* @param string $file File path
*/
protected function parseDatanorm4Discounts($file)
{
// Discount parsing - can be extended if needed
}
/**
* Parse DATPREIS.xxx price file
* Uses streaming to handle large files
*
* @param string $file File path
*/
protected function parseDatapreis4File($file)
{
$handle = fopen($file, 'r');
if ($handle === false) {
return;
}
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
// Convert encoding if needed
if (!mb_check_encoding($line, 'UTF-8')) {
$line = mb_convert_encoding($line, 'UTF-8', 'ISO-8859-1');
}
if (strlen($line) < 10) {
continue;
}
// DATPREIS format - semicolon separated
if (strpos($line, ';') !== false) {
$parts = explode(';', $line);
$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;...
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
$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');
// Price is in cents, convert to euros
$price = (float)$priceRaw / 100;
if (!empty($articleNumber) && $price > 0) {
$this->prices[$articleNumber] = array(
'price' => $price,
'price_type' => $priceType,
);
}
// Move to next article (9 fields per article: ArtNr;Kz;Preis;PE;0;1;0;1;0)
$i += 9;
}
} elseif ($recordType === 'P' || $recordType === '0') {
// Simple format: P;ArtNr;PreisKz;Preis
$articleNumber = trim($parts[1] ?? '');
$priceType = trim($parts[2] ?? '');
$priceRaw = trim($parts[3] ?? '0');
// Check if price is in cents (no decimal point)
if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) {
$price = (float)$priceRaw / 100;
} else {
$price = $this->parsePrice($priceRaw);
}
if (!empty($articleNumber) && $price > 0) {
$this->prices[$articleNumber] = array(
'price' => $price,
'price_type' => $priceType,
);
}
}
} else {
// Fixed width format
$recordType = substr($line, 0, 1);
if ($recordType === 'P' || $recordType === '0') {
$articleNumber = trim(substr($line, 1, 15));
$priceType = trim(substr($line, 16, 1));
$priceRaw = trim(substr($line, 17, 12));
// Check if price is in cents
if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) {
$price = (float)$priceRaw / 100;
} else {
$price = $this->parsePrice($priceRaw);
}
if (!empty($articleNumber) && $price > 0) {
$this->prices[$articleNumber] = array(
'price' => $price,
'price_type' => $priceType,
);
}
}
}
}
fclose($handle);
}
/**
* Merge prices into articles
*/
protected function mergePricesIntoArticles()
{
foreach ($this->prices as $articleNumber => $priceData) {
if (isset($this->articles[$articleNumber])) {
$this->articles[$articleNumber]['price'] = $priceData['price'];
}
}
}
/**
* Check if file is Datanorm 5.0 format
*
* @param string $file File path
* @return bool
*/
protected function isDatanorm5File($file)
{
$content = file_get_contents($file, false, null, 0, 2000);
return (strpos($content, '<DATANORM') !== false || strpos($content, '<datanorm') !== false);
}
/**
* Parse Datanorm 5.0 file (XML format)
*
* @param string $file File path
* @return int Number of articles parsed
*/
protected function parseDatanorm5File($file)
{
libxml_use_internal_errors(true);
$xml = simplexml_load_file($file);
if ($xml === false) {
$errors = libxml_get_errors();
$this->error = 'XML parse error: ' . ($errors[0]->message ?? 'Unknown error');
libxml_clear_errors();
return -1;
}
$count = 0;
// Register namespaces if present
$namespaces = $xml->getNamespaces(true);
// Find article nodes (various possible node names)
$articleNodes = $xml->xpath('//Artikel') ?: $xml->xpath('//Article') ?: $xml->xpath('//article') ?: array();
foreach ($articleNodes as $node) {
$article = $this->parseDatanorm5Article($node);
if ($article) {
$this->articles[$article['article_number']] = $article;
$count++;
}
}
return $count;
}
/**
* Parse Datanorm 5.0 article node
*
* @param SimpleXMLElement $node Article XML node
* @return array|null Article data
*/
protected function parseDatanorm5Article($node)
{
$article = array(
'article_number' => $this->getXmlValue($node, array('Artikelnummer', 'ArticleNumber', 'ArtNr', 'artNr')),
'matchcode' => $this->getXmlValue($node, array('Matchcode', 'matchcode')),
'short_text1' => $this->getXmlValue($node, array('Kurztext1', 'Kurztext', 'ShortText1', 'ShortText', 'Bezeichnung', 'Name')),
'short_text2' => $this->getXmlValue($node, array('Kurztext2', 'ShortText2')),
'long_text' => $this->getXmlValue($node, array('Langtext', 'LongText', 'Beschreibung', 'Description')),
'unit_code' => $this->getXmlValue($node, array('Mengeneinheit', 'Unit', 'ME')),
'price_unit' => (int)$this->getXmlValue($node, array('Preiseinheit', 'PriceUnit', 'PE')) ?: 1,
'price' => $this->parsePrice($this->getXmlValue($node, array('Preis', 'Price', 'Listenpreis', 'ListPrice'))),
'discount_group' => $this->getXmlValue($node, array('Rabattgruppe', 'DiscountGroup', 'RG')),
'product_group' => $this->getXmlValue($node, array('Warengruppe', 'ProductGroup', 'WG')),
'manufacturer_ref' => $this->getXmlValue($node, array('HerstellerArtNr', 'ManufacturerArticleNumber')),
'manufacturer_name' => $this->getXmlValue($node, array('Hersteller', 'Manufacturer')),
'ean' => $this->getXmlValue($node, array('EAN', 'GTIN', 'Barcode')),
);
if (empty($article['article_number'])) {
return null;
}
return $article;
}
/**
* Get value from XML node trying multiple possible element names
*
* @param SimpleXMLElement $node XML node
* @param array $names Possible element names
* @return string Value or empty string
*/
protected function getXmlValue($node, $names)
{
foreach ($names as $name) {
// Try as child element
if (isset($node->$name)) {
return trim((string)$node->$name);
}
// Try as attribute
if (isset($node[$name])) {
return trim((string)$node[$name]);
}
}
return '';
}
/**
* Parse price string to float
*
* @param string $priceStr Price string
* @return float Price value
*/
protected function parsePrice($priceStr)
{
if (empty($priceStr)) {
return 0.0;
}
// Remove currency symbols and whitespace
$priceStr = preg_replace('/[^\d,.\-]/', '', $priceStr);
// Handle German number format (1.234,56)
if (preg_match('/^\d{1,3}(\.\d{3})*,\d{2}$/', $priceStr)) {
$priceStr = str_replace('.', '', $priceStr);
$priceStr = str_replace(',', '.', $priceStr);
} elseif (strpos($priceStr, ',') !== false && strpos($priceStr, '.') === false) {
// Simple comma as decimal separator
$priceStr = str_replace(',', '.', $priceStr);
}
return (float)$priceStr;
}
/**
* Convert Datanorm unit code to UN/ECE code
*
* @param string $datanormUnit Datanorm unit code
* @return string UN/ECE unit code
*/
public static function convertUnitCode($datanormUnit)
{
$mapping = array(
'ST' => 'C62', // Stück
'STK' => 'C62', // Stück
'PCE' => 'C62', // Piece
'M' => 'MTR', // Meter
'MTR' => 'MTR', // Meter
'CM' => 'CMT', // Zentimeter
'MM' => 'MMT', // Millimeter
'L' => 'LTR', // Liter
'LTR' => 'LTR', // Liter
'KG' => 'KGM', // Kilogramm
'G' => 'GRM', // Gramm
'M2' => 'MTK', // Quadratmeter
'M3' => 'MTQ', // Kubikmeter
'PAK' => 'PK', // Packung
'PAC' => 'PK', // Package
'SET' => 'SET', // Set
'ROL' => 'RL', // Rolle
'RLL' => 'RL', // Roll
'BDL' => 'BE', // Bündel
'KRT' => 'CT', // Karton
'CTN' => 'CT', // Carton
);
$unit = strtoupper(trim($datanormUnit));
return $mapping[$unit] ?? 'C62'; // Default to piece
}
/**
* Get all parsed articles
*
* @return array Articles
*/
public function getArticles()
{
return $this->articles;
}
/**
* Find article by number
*
* @param string $articleNumber Article number to find
* @return array|null Article data or null
*/
public function findArticle($articleNumber)
{
return $this->articles[$articleNumber] ?? null;
}
/**
* Search articles by text
*
* @param string $searchText Search text
* @param int $limit Maximum results
* @return array Matching articles
*/
public function searchArticles($searchText, $limit = 50)
{
$results = array();
$searchText = strtolower($searchText);
foreach ($this->articles as $article) {
$searchFields = strtolower(
$article['article_number'] . ' ' .
$article['matchcode'] . ' ' .
$article['short_text1'] . ' ' .
$article['short_text2'] . ' ' .
$article['ean'] . ' ' .
$article['manufacturer_ref']
);
if (strpos($searchFields, $searchText) !== false) {
$results[] = $article;
if (count($results) >= $limit) {
break;
}
}
}
return $results;
}
}

View file

@ -0,0 +1,389 @@
<?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 class/importnotification.class.php
* \ingroup importzugferd
* \brief Email notification class for ZUGFeRD imports
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/CMailFile.class.php';
/**
* Class ImportNotification
* Handles email notifications for ZUGFeRD import events
*/
class ImportNotification
{
/**
* @var DoliDB Database handler
*/
public $db;
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Error messages
*/
public $errors = array();
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Check if notifications are enabled
*
* @return bool True if enabled
*/
public function isEnabled()
{
return getDolGlobalString('IMPORTZUGFERD_NOTIFY_ENABLED') && getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL');
}
/**
* Get notification email address
*
* @return string Email address
*/
public function getNotifyEmail()
{
return getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL');
}
/**
* Send notification for manual intervention required
*
* @param ZugferdImport $import Import object
* @param array $lines Import lines
* @return int 1 if sent, 0 if not needed, -1 on error
*/
public function sendManualInterventionNotification($import, $lines = array())
{
global $conf, $langs;
if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_MANUAL')) {
return 0;
}
$langs->load('importzugferd@importzugferd');
$subject = $langs->trans('NotifySubjectManualIntervention', $import->invoice_number);
$body = $langs->trans('NotifyBodyManualIntervention', $import->invoice_number, $import->seller_name);
$body .= "\n\n";
$body .= $langs->trans('InvoiceNumber').': '.$import->invoice_number."\n";
$body .= $langs->trans('Supplier').': '.$import->seller_name."\n";
$body .= $langs->trans('InvoiceDate').': '.dol_print_date($import->invoice_date, 'day')."\n";
$body .= $langs->trans('TotalTTC').': '.price($import->total_ttc).' '.$import->currency."\n";
$body .= "\n";
// List issues
$missingProducts = 0;
$missingSupplier = ($import->fk_soc <= 0);
if (!empty($lines)) {
foreach ($lines as $line) {
if ($line->fk_product <= 0) {
$missingProducts++;
}
}
}
if ($missingSupplier) {
$body .= "- ".$langs->trans('SupplierNotAssigned')."\n";
}
if ($missingProducts > 0) {
$body .= "- ".$missingProducts." ".$langs->trans('ProductsNotAssigned')."\n";
}
$body .= "\n";
$body .= $langs->trans('NotifyLinkToImport').': '.dol_buildpath('/importzugferd/import.php', 2).'?action=edit&id='.$import->id;
return $this->sendEmail($subject, $body);
}
/**
* Send notification for import error
*
* @param ZugferdImport $import Import object (may be partial)
* @param string $errorMessage Error message
* @param string $filename Original filename
* @return int 1 if sent, 0 if not needed, -1 on error
*/
public function sendErrorNotification($import, $errorMessage, $filename = '')
{
global $conf, $langs;
if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_ERROR')) {
return 0;
}
$langs->load('importzugferd@importzugferd');
$invoiceNum = !empty($import->invoice_number) ? $import->invoice_number : $filename;
$subject = $langs->trans('NotifySubjectError', $invoiceNum);
$body = $langs->trans('NotifyBodyError', $invoiceNum);
$body .= "\n\n";
if (!empty($import->invoice_number)) {
$body .= $langs->trans('InvoiceNumber').': '.$import->invoice_number."\n";
}
if (!empty($import->seller_name)) {
$body .= $langs->trans('Supplier').': '.$import->seller_name."\n";
}
if (!empty($filename)) {
$body .= $langs->trans('File').': '.$filename."\n";
}
$body .= "\n";
$body .= $langs->trans('ErrorMessage').":\n";
$body .= $errorMessage."\n";
if ($import->id > 0) {
$body .= "\n";
$body .= $langs->trans('NotifyLinkToImport').': '.dol_buildpath('/importzugferd/import.php', 2).'?action=edit&id='.$import->id;
}
return $this->sendEmail($subject, $body);
}
/**
* Send notification for significant price differences
*
* @param ZugferdImport $import Import object
* @param array $priceDiffs Array of price differences: array of ['line' => ImportLine, 'product' => Product, 'old_price' => float, 'new_price' => float, 'diff_percent' => float]
* @return int 1 if sent, 0 if not needed, -1 on error
*/
public function sendPriceDifferenceNotification($import, $priceDiffs)
{
global $conf, $langs;
if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')) {
return 0;
}
if (empty($priceDiffs)) {
return 0;
}
$langs->load('importzugferd@importzugferd');
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
$subject = $langs->trans('NotifySubjectPriceDiff', $import->invoice_number, count($priceDiffs));
$body = $langs->trans('NotifyBodyPriceDiff', $import->invoice_number, $import->seller_name, $threshold);
$body .= "\n\n";
$body .= $langs->trans('InvoiceNumber').': '.$import->invoice_number."\n";
$body .= $langs->trans('Supplier').': '.$import->seller_name."\n";
$body .= $langs->trans('InvoiceDate').': '.dol_print_date($import->invoice_date, 'day')."\n";
$body .= "\n";
// Table header
$body .= str_pad($langs->trans('Product'), 40)." | ";
$body .= str_pad($langs->trans('OldPrice'), 12)." | ";
$body .= str_pad($langs->trans('NewPrice'), 12)." | ";
$body .= str_pad($langs->trans('Difference'), 10)."\n";
$body .= str_repeat('-', 80)."\n";
// List products with price differences
foreach ($priceDiffs as $diff) {
$productName = $diff['product']->ref.' - '.$diff['product']->label;
if (strlen($productName) > 38) {
$productName = substr($productName, 0, 35).'...';
}
$oldPrice = price($diff['old_price']).' '.$import->currency;
$newPrice = price($diff['new_price']).' '.$import->currency;
$diffPercent = ($diff['diff_percent'] > 0 ? '+' : '').number_format($diff['diff_percent'], 1).'%';
$body .= str_pad($productName, 40)." | ";
$body .= str_pad($oldPrice, 12)." | ";
$body .= str_pad($newPrice, 12)." | ";
$body .= str_pad($diffPercent, 10)."\n";
}
$body .= "\n";
$body .= $langs->trans('NotifyLinkToImport').': '.dol_buildpath('/importzugferd/import.php', 2).'?action=edit&id='.$import->id;
return $this->sendEmail($subject, $body);
}
/**
* Check for price differences and send notification if needed
*
* @param ZugferdImport $import Import object
* @param array $lines Import lines with fk_product set
* @return int 1 if notification sent, 0 if not needed, -1 on error
*/
public function checkAndNotifyPriceDifferences($import, $lines)
{
global $conf;
if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')) {
return 0;
}
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
$priceDiffs = array();
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
foreach ($lines as $line) {
if ($line->fk_product <= 0) {
continue;
}
// Get current supplier price
$productFourn = new ProductFournisseur($this->db);
$result = $productFourn->find_min_price_product_fournisseur($line->fk_product, 1, $import->fk_soc);
if ($result > 0 && $productFourn->fourn_price > 0) {
$oldPrice = $productFourn->fourn_price;
$newPrice = $line->unit_price;
// Calculate percentage difference
$diffPercent = (($newPrice - $oldPrice) / $oldPrice) * 100;
if (abs($diffPercent) >= $threshold) {
$product = new Product($this->db);
$product->fetch($line->fk_product);
$priceDiffs[] = array(
'line' => $line,
'product' => $product,
'old_price' => $oldPrice,
'new_price' => $newPrice,
'diff_percent' => $diffPercent
);
}
}
}
if (!empty($priceDiffs)) {
return $this->sendPriceDifferenceNotification($import, $priceDiffs);
}
return 0;
}
/**
* Send test notification email
*
* @return int 1 if sent, -1 on error
*/
public function sendTestNotification()
{
global $conf, $langs;
if (!$this->isEnabled()) {
$this->error = $langs->trans('NotificationsNotEnabled');
return -1;
}
$langs->load('importzugferd@importzugferd');
$subject = $langs->trans('NotifySubjectTest');
$body = $langs->trans('NotifyBodyTest');
$body .= "\n\n";
$body .= $langs->trans('NotifyTestInfo')."\n\n";
// Show current notification settings
$body .= $langs->trans('CurrentSettings').":\n";
$body .= "- ".$langs->trans('NotifyEmail').": ".$this->getNotifyEmail()."\n";
$body .= "- ".$langs->trans('IMPORTZUGFERD_NOTIFY_MANUAL').": ".(getDolGlobalString('IMPORTZUGFERD_NOTIFY_MANUAL') ? $langs->trans('Yes') : $langs->trans('No'))."\n";
$body .= "- ".$langs->trans('IMPORTZUGFERD_NOTIFY_ERROR').": ".(getDolGlobalString('IMPORTZUGFERD_NOTIFY_ERROR') ? $langs->trans('Yes') : $langs->trans('No'))."\n";
$body .= "- ".$langs->trans('IMPORTZUGFERD_NOTIFY_PRICE_DIFF').": ".(getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF') ? $langs->trans('Yes') : $langs->trans('No'))."\n";
if (getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')) {
$body .= "- ".$langs->trans('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD').": ".getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10)."%\n";
}
$body .= "\n";
$body .= $langs->trans('NotifyTestSuccess');
return $this->sendEmail($subject, $body);
}
/**
* Send email using Dolibarr's mail system
*
* @param string $subject Email subject
* @param string $body Email body (plain text)
* @return int 1 if sent, -1 on error
*/
protected function sendEmail($subject, $body)
{
global $conf, $langs, $mysoc;
$to = $this->getNotifyEmail();
if (empty($to)) {
$this->error = 'No notification email configured';
return -1;
}
// Get sender
$from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM');
if (empty($from)) {
$from = $mysoc->email;
}
if (empty($from)) {
$this->error = 'No sender email configured';
return -1;
}
// Add module prefix to subject
$subject = '[ZUGFeRD Import] '.$subject;
// Create mail object
$mailfile = new CMailFile(
$subject,
$to,
$from,
$body,
array(), // files
array(), // mimefiles
array(), // ccfiles
'', // cc
'', // bcc
0, // deliveryreceipt
0, // msgishtml
'', // errors_to
'', // css
'', // trackid
'', // moreinheader
'standard', // sendcontext
'' // replyto
);
$result = $mailfile->sendfile();
if ($result) {
dol_syslog("ImportNotification: Email sent to ".$to." - Subject: ".$subject, LOG_INFO);
return 1;
} else {
$this->error = $mailfile->error;
$this->errors = $mailfile->errors;
dol_syslog("ImportNotification: Failed to send email - ".$this->error, LOG_ERR);
return -1;
}
}
}

View file

@ -362,25 +362,27 @@ class ZugferdImport extends CommonObject
$sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET"; $sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET";
$sql .= " ref = '" . $this->db->escape($this->ref) . "',"; $sql .= " ref = '" . $this->db->escape($this->ref) . "',";
$sql .= " invoice_number = '" . $this->db->escape($this->invoice_number) . "',"; $sql .= " invoice_number = '" . $this->db->escape($this->invoice_number) . "',";
$sql .= " invoice_date = '" . $this->db->escape($this->invoice_date) . "',"; $sql .= " invoice_date = " . ($this->invoice_date ? "'" . $this->db->idate($this->invoice_date) . "'" : "null") . ",";
$sql .= " seller_name = '" . $this->db->escape($this->seller_name) . "',"; $sql .= " seller_name = '" . $this->db->escape($this->seller_name) . "',";
$sql .= " seller_vat = '" . $this->db->escape($this->seller_vat) . "',"; $sql .= " seller_vat = '" . $this->db->escape($this->seller_vat) . "',";
$sql .= " buyer_reference = '" . $this->db->escape($this->buyer_reference) . "',"; $sql .= " buyer_reference = '" . $this->db->escape($this->buyer_reference) . "',";
$sql .= " total_ht = " . price2num($this->total_ht) . ","; $sql .= " total_ht = " . price2num($this->total_ht) . ",";
$sql .= " total_ttc = " . price2num($this->total_ttc) . ","; $sql .= " total_ttc = " . price2num($this->total_ttc) . ",";
$sql .= " currency = '" . $this->db->escape($this->currency) . "',"; $sql .= " currency = '" . $this->db->escape($this->currency) . "',";
$sql .= " fk_soc = " . ($this->fk_soc > 0 ? $this->fk_soc : "null") . ","; $sql .= " fk_soc = " . ($this->fk_soc > 0 ? (int) $this->fk_soc : "null") . ",";
$sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? $this->fk_facture_fourn : "null") . ","; $sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? (int) $this->fk_facture_fourn : "null") . ",";
$sql .= " status = " . (int) $this->status . ","; $sql .= " status = " . (int) $this->status . ",";
$sql .= " error_message = '" . $this->db->escape($this->error_message) . "',"; $sql .= " date_import = " . ($this->date_import ? "'" . $this->db->idate($this->date_import) . "'" : "null") . ",";
$sql .= " error_message = " . ($this->error_message ? "'" . $this->db->escape($this->error_message) . "'" : "null") . ",";
$sql .= " fk_user_modif = " . (int) $this->fk_user_modif; $sql .= " fk_user_modif = " . (int) $this->fk_user_modif;
$sql .= " WHERE rowid = " . (int) $this->id; $sql .= " WHERE rowid = " . (int) $this->id;
dol_syslog(get_class($this) . "::update", LOG_DEBUG); dol_syslog(get_class($this) . "::update sql=" . $sql, LOG_DEBUG);
$resql = $this->db->query($sql); $resql = $this->db->query($sql);
if (!$resql) { if (!$resql) {
$this->error = $this->db->lasterror(); $this->error = $this->db->lasterror();
dol_syslog(get_class($this) . "::update error=" . $this->error, LOG_ERR);
return -1; return -1;
} }
@ -514,6 +516,211 @@ class ZugferdImport extends CommonObject
return $xml; return $xml;
} }
/**
* Import a ZUGFeRD invoice from PDF file
* This is the main entry point for batch/automated imports
*
* @param User $user User performing import
* @param string $file_path Path to PDF file
* @param bool $auto_create_invoice Whether to auto-create supplier invoice
* @return int >0 (import ID) if OK, -2 if duplicate, <0 if error
*/
public function importFromFile($user, $file_path, $auto_create_invoice = false)
{
global $conf, $langs;
$langs->load('importzugferd@importzugferd');
dol_include_once('/importzugferd/class/zugferdparser.class.php');
dol_include_once('/importzugferd/class/productmapping.class.php');
dol_include_once('/importzugferd/class/importline.class.php');
dol_include_once('/importzugferd/class/importnotification.class.php');
// Parse PDF
$parser = new ZugferdParser($this->db);
$result = $parser->extractFromPdf($file_path);
if ($result < 0) {
$this->error = $parser->error;
return -1;
}
$result = $parser->parse();
if ($result < 0) {
$this->error = $parser->error;
return -1;
}
$invoice_data = $parser->getInvoiceData();
// Check for duplicates
$file_hash = $parser->getFileHash($file_path);
if ($this->isDuplicate($file_hash)) {
$this->error = $langs->trans('ErrorDuplicateInvoice');
return -2; // Duplicate
}
// Find supplier
$supplier_id = $this->findSupplier($invoice_data);
// Set import record data
$this->invoice_number = $invoice_data['invoice_number'];
$this->invoice_date = $invoice_data['invoice_date'];
$this->seller_name = $invoice_data['seller']['name'];
$this->seller_vat = $invoice_data['seller']['vat_id'];
$this->buyer_reference = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id'];
$this->total_ht = $invoice_data['totals']['net'];
$this->total_ttc = $invoice_data['totals']['gross'];
$this->currency = $invoice_data['totals']['currency'] ?: 'EUR';
$this->fk_soc = $supplier_id;
$this->xml_content = $parser->getXmlContent();
$this->pdf_filename = basename($file_path);
$this->file_hash = $file_hash;
$this->date_import = dol_now();
// Create import record
$import_id = $this->create($user);
if ($import_id < 0) {
return -3;
}
// Process and store line items
$mapping = new ProductMapping($this->db);
$unmatched_count = 0;
$matched_count = 0;
$total_lines = count($invoice_data['lines']);
foreach ($invoice_data['lines'] as $line_data) {
$line = new ImportLine($this->db);
$line->fk_import = $import_id;
$line->line_id = $line_data['line_id'];
$line->supplier_ref = $line_data['product']['seller_id'];
$line->product_name = $line_data['product']['name'];
$line->description = $line_data['product']['description'];
$line->quantity = $line_data['quantity'];
$line->unit_code = $line_data['unit_code'];
$line->unit_price = $line_data['unit_price'];
$line->unit_price_raw = isset($line_data['unit_price_raw']) ? $line_data['unit_price_raw'] : $line_data['unit_price'];
$line->basis_quantity = isset($line_data['basis_quantity']) ? $line_data['basis_quantity'] : 1;
$line->basis_quantity_unit = isset($line_data['basis_quantity_unit']) ? $line_data['basis_quantity_unit'] : '';
$line->line_total = $line_data['line_total'];
$line->tax_percent = $line_data['tax_percent'];
$line->ean = $line_data['product']['global_id'];
// Try to match product
$fk_product = 0;
$match_method = '';
if ($supplier_id > 0) {
$match = $mapping->findProduct($supplier_id, $line_data['product']);
if (!empty($match) && $match['fk_product'] > 0) {
$fk_product = $match['fk_product'];
$match_method = $match['method'];
}
}
$line->fk_product = $fk_product;
$line->match_method = $match_method;
if ($fk_product == 0) {
$unmatched_count++;
} else {
$matched_count++;
}
$line->create($user);
}
// Determine status based on matching results
// STATUS_IMPORTED only if: supplier found, has lines, and ALL lines have matched products
if ($supplier_id == 0 || $total_lines == 0 || $unmatched_count > 0 || $matched_count == 0) {
// Missing supplier, no lines, unmatched products, or no matches at all - needs manual intervention
$this->status = self::STATUS_PENDING;
} else {
// All lines matched
$this->status = self::STATUS_IMPORTED;
}
// Copy PDF to documents
$destdir = $conf->importzugferd->dir_output . '/imports';
if (!is_dir($destdir)) {
dol_mkdir($destdir);
}
$destfile = $destdir . '/' . $this->ref . '_' . basename($file_path);
copy($file_path, $destfile);
// Update status
$this->update($user);
// Send notification if manual intervention required
if ($this->status == self::STATUS_PENDING && class_exists('ImportNotification')) {
$notification = new ImportNotification($this->db);
$importLine = new ImportLine($this->db);
$storedLines = $importLine->fetchAllByImport($this->id);
$notification->sendManualInterventionNotification($this, $storedLines);
}
return $import_id;
}
/**
* Find supplier by buyer reference or VAT ID
*
* @param array $invoice_data Parsed invoice data
* @return int Supplier ID or 0
*/
protected function findSupplier($invoice_data)
{
global $conf;
$buyer_ref = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id'];
$seller_vat = $invoice_data['seller']['vat_id'];
$seller_name = $invoice_data['seller']['name'];
// 1. Search by buyer reference in extrafield
if (!empty($buyer_ref)) {
$sql = "SELECT fk_object FROM " . MAIN_DB_PREFIX . "societe_extrafields";
$sql .= " WHERE supplier_customer_number = '" . $this->db->escape($buyer_ref) . "'";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->fk_object;
}
}
// 2. Search by VAT ID
if (!empty($seller_vat)) {
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe";
$sql .= " WHERE tva_intra = '" . $this->db->escape($seller_vat) . "'";
$sql .= " AND fournisseur = 1";
$sql .= " AND entity IN (" . getEntity('societe') . ")";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->rowid;
}
}
// 3. Search by name (fuzzy)
if (!empty($seller_name)) {
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe";
$sql .= " WHERE (nom LIKE '" . $this->db->escape($seller_name) . "%'";
$sql .= " OR nom LIKE '%" . $this->db->escape(substr($seller_name, 0, 20)) . "%')";
$sql .= " AND fournisseur = 1";
$sql .= " AND entity IN (" . getEntity('societe') . ")";
$sql .= " LIMIT 1";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->rowid;
}
}
return 0;
}
/** /**
* Get status label * Get status label
* *

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' $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' // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '1.1'; $this->version = '2.0';
// Url to the file with your last numberversion of this module // Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt'; //$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -136,7 +136,7 @@ class modImportZugferd extends DolibarrModules
); );
// Data directories to create when module is enabled. // Data directories to create when module is enabled.
$this->dirs = array("/importzugferd/temp", "/importzugferd/imports"); $this->dirs = array("/importzugferd/temp", "/importzugferd/imports", "/importzugferd/datanorm");
// Config pages. Put here list of php page, stored into importzugferd/admin directory, to use to setup module. // Config pages. Put here list of php page, stored into importzugferd/admin directory, to use to setup module.
$this->config_page_url = array("setup.php@importzugferd"); $this->config_page_url = array("setup.php@importzugferd");
@ -268,16 +268,16 @@ class modImportZugferd extends DolibarrModules
/* BEGIN MODULEBUILDER CRON */ /* BEGIN MODULEBUILDER CRON */
$this->cronjobs = array( $this->cronjobs = array(
0 => array( 0 => array(
'label' => 'ImportZugferdFromMailbox', 'label' => 'ImportZugferdScheduled',
'jobtype' => 'method', 'jobtype' => 'method',
'class' => '/importzugferd/class/cron_importzugferd.class.php', 'class' => '/importzugferd/class/cron_importzugferd.class.php',
'objectname' => 'CronImportZugferd', 'objectname' => 'CronImportZugferd',
'method' => 'fetchFromMailbox', 'method' => 'runScheduledImport',
'parameters' => '', 'parameters' => '',
'comment' => 'Fetch ZUGFeRD invoices from configured mailbox', 'comment' => 'Scheduled import from folder and mailbox (frequency controlled by module settings)',
'frequency' => 15, 'frequency' => 15,
'unitfrequency' => 60, 'unitfrequency' => 60,
'status' => 0, 'status' => 1,
'test' => 'isModEnabled("importzugferd")', 'test' => 'isModEnabled("importzugferd")',
'priority' => 50, 'priority' => 50,
), ),
@ -316,6 +316,12 @@ class modImportZugferd extends DolibarrModules
$this->rights[$r][5] = 'write'; $this->rights[$r][5] = 'write';
$r++; $r++;
$this->rights[$r][0] = $this->numero . sprintf("%02d", 5);
$this->rights[$r][1] = 'Manage Datanorm catalogs';
$this->rights[$r][4] = 'datanorm';
$this->rights[$r][5] = 'write';
$r++;
// Main menu entries to add // Main menu entries to add
$this->menu = array(); $this->menu = array();
@ -407,6 +413,23 @@ class modImportZugferd extends DolibarrModules
'user' => 2, 'user' => 2,
); );
// Left menu: Datanorm Catalogs
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=importzugferd',
'type' => 'left',
'titre' => 'DatanormCatalogs',
'prefix' => img_picto('', 'fa-database', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'importzugferd',
'leftmenu' => 'zugferd_datanorm',
'url' => '/importzugferd/datanorm.php',
'langs' => 'importzugferd@importzugferd',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("importzugferd")',
'perms' => '$user->hasRight("importzugferd", "datanorm", "write")',
'target' => '',
'user' => 2,
);
// Exports profiles provided by this module // Exports profiles provided by this module
$r = 0; $r = 0;

309
datanorm.php Normal file
View file

@ -0,0 +1,309 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file datanorm.php
* \ingroup importzugferd
* \brief Datanorm catalog management page
*/
// 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) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
require_once './class/datanorm.class.php';
require_once './class/datanormparser.class.php';
require_once './lib/importzugferd.lib.php';
$langs->loadLangs(array('importzugferd@importzugferd', 'companies', 'products'));
// Access control
if (!$user->hasRight('importzugferd', 'datanorm', 'write')) {
accessforbidden();
}
// Parameters
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');
$fk_soc = GETPOSTINT('fk_soc');
$id = GETPOSTINT('id');
// Objects
$form = new Form($db);
$datanorm = new Datanorm($db);
/*
* Actions
*/
// Upload Datanorm file
if ($action == 'upload' && !empty($_FILES['datanormfile']['name']) && $fk_soc > 0) {
$error = 0;
// Check supplier exists and is a supplier
$supplier = new Societe($db);
if ($supplier->fetch($fk_soc) <= 0 || $supplier->fournisseur != 1) {
setEventMessages($langs->trans('ErrorSupplierNotFound'), null, 'errors');
$error++;
}
if (!$error) {
// Create upload directory
$upload_dir = $conf->importzugferd->dir_output.'/datanorm/'.$fk_soc;
if (!dol_is_dir($upload_dir)) {
dol_mkdir($upload_dir);
}
// Handle file upload
$uploaded_files = array();
// Check if multiple files or single file
if (is_array($_FILES['datanormfile']['name'])) {
$file_count = count($_FILES['datanormfile']['name']);
for ($i = 0; $i < $file_count; $i++) {
if ($_FILES['datanormfile']['error'][$i] == UPLOAD_ERR_OK) {
$tmp_name = $_FILES['datanormfile']['tmp_name'][$i];
$name = $_FILES['datanormfile']['name'][$i];
$dest = $upload_dir.'/'.$name;
if (dol_move_uploaded_file($tmp_name, $dest, 1) > 0) {
$uploaded_files[] = $dest;
}
}
}
} else {
if ($_FILES['datanormfile']['error'] == UPLOAD_ERR_OK) {
$tmp_name = $_FILES['datanormfile']['tmp_name'];
$name = $_FILES['datanormfile']['name'];
$dest = $upload_dir.'/'.$name;
if (dol_move_uploaded_file($tmp_name, $dest, 1) > 0) {
$uploaded_files[] = $dest;
}
}
}
if (empty($uploaded_files)) {
setEventMessages($langs->trans('ErrorUploadFailed'), null, 'errors');
$error++;
}
}
if (!$error && !empty($uploaded_files)) {
// Use streaming import for large files (directory-based)
$delete_existing = GETPOST('delete_existing', 'int') ? true : false;
$imported = $datanorm->importFromDirectoryStreaming($user, $fk_soc, $upload_dir, $delete_existing);
if ($imported > 0) {
setEventMessages($langs->trans('DatanormImportSuccess', $imported), null, 'mesgs');
} elseif ($imported == 0) {
setEventMessages($langs->trans('DatanormNoArticlesFound'), null, 'warnings');
} else {
setEventMessages($langs->trans('DatanormImportFailed').': '.$datanorm->error, null, 'errors');
}
}
}
// Delete all articles for supplier
if ($action == 'delete' && $confirm == 'yes' && $fk_soc > 0) {
$result = $datanorm->deleteAllBySupplier($user, $fk_soc);
if ($result >= 0) {
setEventMessages($langs->trans('DatanormDeleted', $result), null, 'mesgs');
} else {
setEventMessages($langs->trans('DatanormDeleteFailed').': '.$datanorm->error, null, 'errors');
}
$action = '';
}
/*
* View
*/
$title = $langs->trans('DatanormCatalogs');
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm');
print load_fiche_titre($title, '', 'fa-database');
// Confirmation dialog for delete
if ($action == 'delete' && $fk_soc > 0) {
$supplier = new Societe($db);
$supplier->fetch($fk_soc);
$formconfirm = $form->formconfirm(
$_SERVER["PHP_SELF"].'?fk_soc='.$fk_soc,
$langs->trans('DeleteDatanorm'),
$langs->trans('ConfirmDeleteDatanorm', $supplier->name),
'delete',
'',
0,
1
);
print $formconfirm;
}
// Upload form
print '<div class="fichecenter">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans('UploadDatanorm').'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td colspan="2">';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" enctype="multipart/form-data">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="upload">';
print '<table class="nobordernopadding">';
// Supplier selection
print '<tr>';
print '<td class="titlefield">'.$langs->trans('Supplier').' <span class="fieldrequired">*</span></td>';
print '<td>';
print $form->select_company($fk_soc, 'fk_soc', 's.fournisseur = 1', 1, 0, 0, array(), 0, 'minwidth300');
print '</td>';
print '</tr>';
// File upload
print '<tr>';
print '<td>'.$langs->trans('DatanormFiles').' <span class="fieldrequired">*</span></td>';
print '<td>';
print '<input type="file" name="datanormfile[]" multiple accept=".001,.002,.003,.004,.005,.wrg,.rab,.xml" class="flat">';
print '<br><span class="opacitymedium small">'.$langs->trans('DatanormFileHelp').'</span>';
print '</td>';
print '</tr>';
// Delete existing option
print '<tr>';
print '<td>'.$langs->trans('DeleteExisting').'</td>';
print '<td>';
print '<input type="checkbox" name="delete_existing" value="1" checked>';
print ' <span class="opacitymedium">'.$langs->trans('DeleteExistingHelp').'</span>';
print '</td>';
print '</tr>';
// Submit button
print '<tr>';
print '<td></td>';
print '<td>';
print '<input type="submit" class="button" value="'.$langs->trans('Upload').'">';
print '</td>';
print '</tr>';
print '</table>';
print '</form>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '<br>';
// List of suppliers with Datanorm data
$suppliers = $datanorm->getSuppliersWithData();
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td>'.$langs->trans('Supplier').'</td>';
print '<td class="right">'.$langs->trans('ArticleCount').'</td>';
print '<td class="center">'.$langs->trans('LastImport').'</td>';
print '<td class="center">'.$langs->trans('Actions').'</td>';
print '</tr>';
if (!empty($suppliers)) {
foreach ($suppliers as $sup) {
print '<tr class="oddeven">';
// Supplier name with link
print '<td>';
$supplier = new Societe($db);
$supplier->fetch($sup['fk_soc']);
print $supplier->getNomUrl(1, 'supplier');
print '</td>';
// Article count
print '<td class="right">';
print '<span class="badge badge-info">'.$sup['article_count'].'</span>';
print '</td>';
// Last import
print '<td class="center">';
print dol_print_date($sup['last_import'], 'dayhour');
print '</td>';
// Actions
print '<td class="center nowraponall">';
// View articles button
print '<a class="paddingright" href="datanorm_list.php?fk_soc='.$sup['fk_soc'].'" title="'.$langs->trans('ViewArticles').'">';
print img_picto($langs->trans('ViewArticles'), 'list');
print '</a>';
// Delete button
print '<a class="paddingright" href="'.$_SERVER['PHP_SELF'].'?action=delete&fk_soc='.$sup['fk_soc'].'&token='.newToken().'" title="'.$langs->trans('Delete').'">';
print img_picto($langs->trans('Delete'), 'delete');
print '</a>';
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven">';
print '<td colspan="4" class="opacitymedium center">'.$langs->trans('NoDatanormData').'</td>';
print '</tr>';
}
print '</table>';
print '</div>';
print '</div>';
// Settings info
print '<br>';
print '<div class="info">';
print '<i class="fas fa-info-circle paddingright"></i>';
print $langs->trans('DatanormSettingsInfo');
print ' <a href="'.dol_buildpath('/importzugferd/admin/setup.php', 1).'">'.$langs->trans('Settings').'</a>';
print '</div>';
llxFooter();
$db->close();

258
datanorm_list.php Normal file
View file

@ -0,0 +1,258 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file datanorm_list.php
* \ingroup importzugferd
* \brief List of Datanorm articles for a supplier
*/
// 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) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once './class/datanorm.class.php';
require_once './lib/importzugferd.lib.php';
$langs->loadLangs(array('importzugferd@importzugferd', 'companies', 'products'));
// Access control
if (!$user->hasRight('importzugferd', 'datanorm', 'write')) {
accessforbidden();
}
// Parameters
$fk_soc = GETPOSTINT('fk_soc');
$search_article = GETPOST('search_article', 'alpha');
$search_text = GETPOST('search_text', 'alpha');
$limit = GETPOSTINT('limit') ?: $conf->liste_limit;
$page = GETPOSTINT('page');
$offset = $limit * $page;
$sortfield = GETPOST('sortfield', 'aZ09comma');
$sortorder = GETPOST('sortorder', 'aZ09comma');
if (empty($sortfield)) {
$sortfield = 'article_number';
}
if (empty($sortorder)) {
$sortorder = 'ASC';
}
// Check supplier
if ($fk_soc <= 0) {
header('Location: datanorm.php');
exit;
}
$supplier = new Societe($db);
if ($supplier->fetch($fk_soc) <= 0) {
header('Location: datanorm.php');
exit;
}
// Objects
$form = new Form($db);
$datanorm = new Datanorm($db);
/*
* View
*/
$title = $langs->trans('DatanormArticles').' - '.$supplier->name;
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm-list');
// Build SQL
$sql = "SELECT rowid, article_number, short_text1, short_text2, ean,";
$sql .= " manufacturer_ref, manufacturer_name, unit_code, price, price_unit,";
$sql .= " discount_group, product_group";
$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_datanorm";
$sql .= " WHERE fk_soc = ".(int)$fk_soc;
$sql .= " AND entity = ".(int)$conf->entity;
$sql .= " AND active = 1";
// Search filters
if (!empty($search_article)) {
$sql .= " AND (article_number LIKE '%".$db->escape($search_article)."%'";
$sql .= " OR ean LIKE '%".$db->escape($search_article)."%'";
$sql .= " OR manufacturer_ref LIKE '%".$db->escape($search_article)."%')";
}
if (!empty($search_text)) {
$sql .= " AND (short_text1 LIKE '%".$db->escape($search_text)."%'";
$sql .= " OR short_text2 LIKE '%".$db->escape($search_text)."%')";
}
// Count total
$sqlcount = preg_replace('/^SELECT .* FROM/', 'SELECT COUNT(*) as nb FROM', $sql);
$resqlcount = $db->query($sqlcount);
$total = 0;
if ($resqlcount) {
$objcount = $db->fetch_object($resqlcount);
$total = $objcount->nb;
}
// Sort and limit
$sql .= $db->order($sortfield, $sortorder);
$sql .= $db->plimit($limit + 1, $offset);
// Header with back link
$linkback = '<a href="datanorm.php">'.$langs->trans("Back").'</a>';
print load_fiche_titre($title, $linkback, 'fa-database');
// Search form
print '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="fk_soc" value="'.$fk_soc.'">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
// Header row
print '<tr class="liste_titre">';
print_liste_field_titre('ArticleNumber', $_SERVER['PHP_SELF'], 'article_number', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre('Description', $_SERVER['PHP_SELF'], 'short_text1', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre('EAN', $_SERVER['PHP_SELF'], 'ean', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre('Manufacturer', $_SERVER['PHP_SELF'], 'manufacturer_name', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder);
print_liste_field_titre('Price', $_SERVER['PHP_SELF'], 'price', '', '&fk_soc='.$fk_soc, 'class="right"', $sortfield, $sortorder);
print_liste_field_titre('Unit', $_SERVER['PHP_SELF'], 'unit_code', '', '&fk_soc='.$fk_soc, 'class="center"', $sortfield, $sortorder);
print '<td class="liste_titre"></td>';
print '</tr>';
// Search row
print '<tr class="liste_titre">';
print '<td><input type="text" name="search_article" value="'.dol_escape_htmltag($search_article).'" class="flat width150"></td>';
print '<td><input type="text" name="search_text" value="'.dol_escape_htmltag($search_text).'" class="flat width200"></td>';
print '<td></td>';
print '<td></td>';
print '<td></td>';
print '<td></td>';
print '<td class="center">';
print '<input type="submit" class="button small" value="'.$langs->trans('Search').'">';
print ' <a href="'.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'" class="button small">'.$langs->trans('Reset').'</a>';
print '</td>';
print '</tr>';
// Data rows
$resql = $db->query($sql);
if ($resql) {
$num = $db->num_rows($resql);
$i = 0;
while ($i < min($num, $limit)) {
$obj = $db->fetch_object($resql);
print '<tr class="oddeven">';
// Article number
print '<td class="nowrap">';
print '<strong>'.dol_escape_htmltag($obj->article_number).'</strong>';
print '</td>';
// Description
print '<td>';
print dol_escape_htmltag($obj->short_text1);
if (!empty($obj->short_text2)) {
print '<br><span class="opacitymedium small">'.dol_escape_htmltag($obj->short_text2).'</span>';
}
print '</td>';
// EAN
print '<td>';
if (!empty($obj->ean)) {
print '<span class="opacitymedium"><i class="fas fa-barcode paddingright"></i></span>';
print dol_escape_htmltag($obj->ean);
}
print '</td>';
// Manufacturer
print '<td>';
if (!empty($obj->manufacturer_name)) {
print dol_escape_htmltag($obj->manufacturer_name);
}
if (!empty($obj->manufacturer_ref)) {
print '<br><span class="opacitymedium small">'.dol_escape_htmltag($obj->manufacturer_ref).'</span>';
}
print '</td>';
// Price
print '<td class="right nowrap">';
$price = $obj->price;
if ($obj->price_unit > 1) {
print price($price).' / '.$obj->price_unit;
} else {
print price($price);
}
print '</td>';
// Unit
print '<td class="center">';
print dol_escape_htmltag($obj->unit_code);
print '</td>';
// Actions placeholder
print '<td class="center">';
print '</td>';
print '</tr>';
$i++;
}
if ($num == 0) {
print '<tr class="oddeven">';
print '<td colspan="7" class="opacitymedium center">'.$langs->trans('NoRecordsFound').'</td>';
print '</tr>';
}
$db->free($resql);
} else {
dol_print_error($db);
}
print '</table>';
print '</div>';
print '</form>';
// Pagination
print_barre_liste('', $page, $_SERVER['PHP_SELF'], '&fk_soc='.$fk_soc.'&search_article='.urlencode($search_article).'&search_text='.urlencode($search_text), $sortfield, $sortorder, '', $num, $total, '', 0, '', '', $limit);
// Stats
print '<br>';
print '<div class="opacitymedium">';
print $langs->trans('TotalArticles').': <strong>'.$total.'</strong>';
print '</div>';
llxFooter();
$db->close();

868
datanorm_update.php Normal file
View file

@ -0,0 +1,868 @@
<?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_update.php
* \ingroup importzugferd
* \brief Mass update products from Datanorm catalogs
*/
// 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.'/fourn/class/fournisseur.product.class.php';
dol_include_once('/importzugferd/class/datanorm.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', 'creer')) {
accessforbidden();
}
// Get parameters
$action = GETPOST('action', 'aZ09');
$fk_soc = GETPOSTINT('fk_soc');
$search_mode = GETPOST('search_mode', 'alpha') ?: 'supplier'; // supplier, manual
$search_term = GETPOST('search_term', 'alphanohtml');
$search_by_name = GETPOSTINT('search_by_name');
$search_by_ean = GETPOSTINT('search_by_ean');
$search_by_ref = GETPOSTINT('search_by_ref');
// Filters for what to update
$filter_price = GETPOSTISSET('filter_price') ? GETPOSTINT('filter_price') : 1;
$filter_description = GETPOSTISSET('filter_description') ? GETPOSTINT('filter_description') : 1;
$filter_label = GETPOSTISSET('filter_label') ? GETPOSTINT('filter_label') : 0;
// Only show differences
$only_differences = GETPOSTINT('only_differences');
// Initialize objects
$form = new Form($db);
$formcompany = new FormCompany($db);
$datanorm = new Datanorm($db);
// Store pending changes in session
if (!isset($_SESSION['datanorm_pending_changes'])) {
$_SESSION['datanorm_pending_changes'] = array();
}
/*
* Actions
*/
// Apply single row update
if ($action == 'apply_single' && GETPOSTINT('product_id') && GETPOST('datanorm_key', 'alphanohtml')) {
$product_id = GETPOSTINT('product_id');
$datanorm_key = GETPOST('datanorm_key', 'alphanohtml');
$apply_price = GETPOSTINT('apply_price');
$apply_description = GETPOSTINT('apply_description');
$apply_label = GETPOSTINT('apply_label');
$result = applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $apply_price, $apply_description, $apply_label);
if ($result > 0) {
setEventMessages($langs->trans('ProductUpdated'), null, 'mesgs');
} else {
setEventMessages($langs->trans('ErrorUpdatingProduct'), null, 'errors');
}
// Redirect to same page with same parameters
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences);
exit;
}
// Add to pending changes
if ($action == 'add_pending') {
$product_id = GETPOSTINT('product_id');
$datanorm_key = GETPOST('datanorm_key', 'alphanohtml');
$apply_fields = GETPOST('apply_fields', 'array');
if ($product_id > 0 && !empty($datanorm_key)) {
$_SESSION['datanorm_pending_changes'][$product_id] = array(
'datanorm_key' => $datanorm_key,
'fk_soc' => $fk_soc,
'apply_fields' => $apply_fields
);
setEventMessages($langs->trans('AddedToPendingChanges'), null, 'mesgs');
}
}
// Remove from pending
if ($action == 'remove_pending') {
$product_id = GETPOSTINT('product_id');
unset($_SESSION['datanorm_pending_changes'][$product_id]);
}
// Clear all pending
if ($action == 'clear_pending') {
$_SESSION['datanorm_pending_changes'] = array();
setEventMessages($langs->trans('PendingChangesCleared'), null, 'mesgs');
}
// Show confirmation dialog
if ($action == 'confirm_apply_all') {
// Will be handled in view section
}
// Apply all pending changes
if ($action == 'apply_all_confirmed' && GETPOST('confirm', 'alpha') == 'yes') {
$success = 0;
$errors = 0;
foreach ($_SESSION['datanorm_pending_changes'] as $product_id => $change) {
$apply_price = in_array('price', $change['apply_fields']) ? 1 : 0;
$apply_description = in_array('description', $change['apply_fields']) ? 1 : 0;
$apply_label = in_array('label', $change['apply_fields']) ? 1 : 0;
$result = applyDatanormUpdate($db, $user, $product_id, $change['datanorm_key'], $change['fk_soc'], $apply_price, $apply_description, $apply_label);
if ($result > 0) {
$success++;
} else {
$errors++;
}
}
$_SESSION['datanorm_pending_changes'] = array();
setEventMessages($langs->trans('DatanormMassUpdateComplete', $success, $errors), null, 'mesgs');
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
/*
* View
*/
$title = $langs->trans('DatanormMassUpdate');
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm-update');
print load_fiche_titre($title, '', 'fa-sync');
// Check if Datanorm data exists
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."importzugferd_datanorm";
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
if ($obj->cnt == 0) {
print '<div class="warning">'.$langs->trans('NoDatanormData').'</div>';
print '<br><a href="'.dol_buildpath('/importzugferd/datanorm.php', 1).'" class="button">'.$langs->trans('UploadDatanorm').'</a>';
llxFooter();
$db->close();
exit;
}
// Search form
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" name="searchform">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="search">';
print '<div class="fichecenter">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
// Supplier selection
print '<tr class="liste_titre">';
print '<td colspan="4">'.$langs->trans('SelectSupplier').'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans('Supplier').'</td>';
print '<td colspan="3">';
// Get suppliers with Datanorm data
$sql = "SELECT DISTINCT s.rowid, s.nom FROM ".MAIN_DB_PREFIX."societe s";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."importzugferd_datanorm d ON d.fk_soc = s.rowid";
$sql .= " WHERE s.fournisseur = 1";
$sql .= " ORDER BY s.nom";
$resql = $db->query($sql);
print '<select name="fk_soc" class="flat minwidth300" onchange="this.form.submit()">';
print '<option value="">'.$langs->trans('SelectASupplier').'</option>';
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>';
print '</tr>';
// Search mode
print '<tr class="oddeven">';
print '<td>'.$langs->trans('SearchMode').'</td>';
print '<td colspan="3">';
print '<input type="radio" name="search_mode" value="supplier" id="mode_supplier" '.($search_mode == 'supplier' ? 'checked' : '').'>';
print '<label for="mode_supplier"> '.$langs->trans('SearchBySupplierProducts').'</label>';
print ' &nbsp; &nbsp; ';
print '<input type="radio" name="search_mode" value="manual" id="mode_manual" '.($search_mode == 'manual' ? 'checked' : '').'>';
print '<label for="mode_manual"> '.$langs->trans('ManualSearch').'</label>';
print '</td>';
print '</tr>';
// Manual search term
print '<tr class="oddeven" id="manual_search_row" style="'.($search_mode != 'manual' ? 'display:none;' : '').'">';
print '<td>'.$langs->trans('SearchTerm').'</td>';
print '<td colspan="3">';
print '<input type="text" name="search_term" value="'.dol_escape_htmltag($search_term).'" class="minwidth300" placeholder="'.$langs->trans('ArticleNumberOrName').'">';
print '</td>';
print '</tr>';
// Additional search options
print '<tr class="oddeven">';
print '<td>'.$langs->trans('AdditionalSearchOptions').'</td>';
print '<td colspan="3">';
print '<input type="checkbox" name="search_by_name" value="1" id="search_by_name" '.($search_by_name ? 'checked' : '').'>';
print '<label for="search_by_name"> '.$langs->trans('AlsoSearchByName').'</label>';
print ' &nbsp; ';
print '<input type="checkbox" name="search_by_ean" value="1" id="search_by_ean" '.($search_by_ean ? 'checked' : '').'>';
print '<label for="search_by_ean"> '.$langs->trans('AlsoSearchByEAN').'</label>';
print ' &nbsp; ';
print '<input type="checkbox" name="search_by_ref" value="1" id="search_by_ref" '.($search_by_ref ? 'checked' : '').'>';
print '<label for="search_by_ref"> '.$langs->trans('AlsoSearchByRef').'</label>';
print '</td>';
print '</tr>';
// Filter: What to compare/update
print '<tr class="liste_titre">';
print '<td colspan="4">'.$langs->trans('FieldsToCompare').'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.$langs->trans('Fields').'</td>';
print '<td colspan="3">';
print '<input type="checkbox" name="filter_price" value="1" id="filter_price" '.($filter_price ? 'checked' : '').'>';
print '<label for="filter_price"> '.$langs->trans('Price').'</label>';
print ' &nbsp; ';
print '<input type="checkbox" name="filter_description" value="1" id="filter_description" '.($filter_description ? 'checked' : '').'>';
print '<label for="filter_description"> '.$langs->trans('Description').'</label>';
print ' &nbsp; ';
print '<input type="checkbox" name="filter_label" value="1" id="filter_label" '.($filter_label ? 'checked' : '').'>';
print '<label for="filter_label"> '.$langs->trans('Label').'</label>';
print '</td>';
print '</tr>';
// Only show differences
print '<tr class="oddeven">';
print '<td>'.$langs->trans('Display').'</td>';
print '<td colspan="3">';
print '<input type="checkbox" name="only_differences" value="1" id="only_differences" '.($only_differences ? 'checked' : '').'>';
print '<label for="only_differences"> '.$langs->trans('OnlyShowDifferences').'</label>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</div>';
print '<div class="center" style="margin: 10px;">';
print '<input type="submit" class="button button-primary" value="'.$langs->trans('Search').'">';
if (!empty($_SESSION['datanorm_pending_changes'])) {
print ' &nbsp; <a href="'.$_SERVER['PHP_SELF'].'?action=clear_pending&token='.newToken().'" class="button">'.$langs->trans('ClearPendingChanges').' ('.count($_SESSION['datanorm_pending_changes']).')</a>';
}
print '</div>';
print '</form>';
// JavaScript for toggling manual search
print '<script>
document.querySelectorAll("input[name=search_mode]").forEach(function(radio) {
radio.addEventListener("change", function() {
document.getElementById("manual_search_row").style.display = (this.value == "manual") ? "" : "none";
});
});
</script>';
// Results
if ($fk_soc > 0 && ($action == 'search' || GETPOST('search_mode'))) {
$comparison_results = array();
if ($search_mode == 'supplier') {
// Find all products linked to this supplier
$comparison_results = findProductsForSupplier($db, $fk_soc, $search_by_name, $search_by_ean, $search_by_ref);
} elseif ($search_mode == 'manual' && !empty($search_term)) {
// Manual search in Datanorm
$comparison_results = searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name, $search_by_ean, $search_by_ref);
}
// Filter results if needed
if ($only_differences) {
$comparison_results = array_filter($comparison_results, function($item) use ($filter_price, $filter_description, $filter_label) {
return ($filter_price && $item['price_differs']) ||
($filter_description && $item['description_differs']) ||
($filter_label && $item['label_differs']);
});
}
if (!empty($comparison_results)) {
print '<br>';
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
// Header
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('Product').'</th>';
print '<th>'.$langs->trans('DatanormArticle').'</th>';
if ($filter_price) {
print '<th class="right">'.$langs->trans('CurrentPrice').'</th>';
print '<th class="right">'.$langs->trans('DatanormPrice').'</th>';
}
if ($filter_description) {
print '<th>'.$langs->trans('CurrentDescription').'</th>';
print '<th>'.$langs->trans('DatanormDescription').'</th>';
}
if ($filter_label) {
print '<th>'.$langs->trans('CurrentLabel').'</th>';
print '<th>'.$langs->trans('DatanormLabel').'</th>';
}
print '<th class="center">'.$langs->trans('Actions').'</th>';
print '</tr>';
foreach ($comparison_results as $item) {
$has_difference = ($filter_price && $item['price_differs']) ||
($filter_description && $item['description_differs']) ||
($filter_label && $item['label_differs']);
$rowClass = $has_difference ? 'oddeven highlighted' : 'oddeven';
print '<tr class="'.$rowClass.'">';
// Product
print '<td>';
if ($item['product_id'] > 0) {
$product = new Product($db);
$product->fetch($item['product_id']);
print $product->getNomUrl(1, '', 0, 0, 0, 1, 1); // Open in new tab
print '<br><span class="opacitymedium">'.$product->ref.'</span>';
} else {
print '<span class="opacitymedium">'.$langs->trans('ProductNotInDatabase').'</span>';
}
print '</td>';
// Datanorm article
print '<td>';
print '<strong>'.dol_escape_htmltag($item['datanorm_ref']).'</strong>';
print '<br><span class="opacitymedium">'.dol_escape_htmltag(dol_trunc($item['datanorm_name'], 50)).'</span>';
print '</td>';
// Price comparison
if ($filter_price) {
$priceStyle = $item['price_differs'] ? 'background-color: #fcf8e3;' : '';
print '<td class="right nowraponall" style="'.$priceStyle.'">';
if ($item['product_id'] > 0) {
print price($item['current_price']);
} else {
print '-';
}
print '</td>';
print '<td class="right nowraponall" style="'.$priceStyle.'">';
print price($item['datanorm_price']);
if ($item['price_differs'] && $item['product_id'] > 0) {
$diff = $item['datanorm_price'] - $item['current_price'];
$diffPercent = ($item['current_price'] > 0) ? ($diff / $item['current_price'] * 100) : 0;
print '<br>';
if ($diff > 0) {
print '<span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.number_format($diffPercent, 1).'%</span>';
} else {
print '<span style="color: #5cb85c;"><i class="fas fa-arrow-down"></i> '.number_format($diffPercent, 1).'%</span>';
}
}
print '</td>';
}
// Description comparison
if ($filter_description) {
$descStyle = $item['description_differs'] ? 'background-color: #fcf8e3;' : '';
print '<td style="'.$descStyle.'">';
print dol_escape_htmltag(dol_trunc($item['current_description'], 80));
print '</td>';
print '<td style="'.$descStyle.'">';
print dol_escape_htmltag(dol_trunc($item['datanorm_description'], 80));
print '</td>';
}
// Label comparison
if ($filter_label) {
$labelStyle = $item['label_differs'] ? 'background-color: #fcf8e3;' : '';
print '<td style="'.$labelStyle.'">';
print dol_escape_htmltag($item['current_label']);
print '</td>';
print '<td style="'.$labelStyle.'">';
print dol_escape_htmltag($item['datanorm_label']);
print '</td>';
}
// Actions
print '<td class="center nowraponall">';
if ($item['product_id'] > 0 && $has_difference) {
// Quick apply form
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" style="display:inline;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="apply_single">';
print '<input type="hidden" name="product_id" value="'.$item['product_id'].'">';
print '<input type="hidden" name="datanorm_key" value="'.dol_escape_htmltag($item['datanorm_key']).'">';
print '<input type="hidden" name="fk_soc" value="'.$fk_soc.'">';
print '<input type="hidden" name="search_mode" value="'.$search_mode.'">';
print '<input type="hidden" name="search_term" value="'.dol_escape_htmltag($search_term).'">';
print '<input type="hidden" name="filter_price" value="'.$filter_price.'">';
print '<input type="hidden" name="filter_description" value="'.$filter_description.'">';
print '<input type="hidden" name="filter_label" value="'.$filter_label.'">';
print '<input type="hidden" name="only_differences" value="'.$only_differences.'">';
// Checkboxes for what to apply
if ($filter_price && $item['price_differs']) {
print '<input type="checkbox" name="apply_price" value="1" checked title="'.$langs->trans('Price').'">';
print '<span class="opacitymedium">P</span> ';
}
if ($filter_description && $item['description_differs']) {
print '<input type="checkbox" name="apply_description" value="1" checked title="'.$langs->trans('Description').'">';
print '<span class="opacitymedium">D</span> ';
}
if ($filter_label && $item['label_differs']) {
print '<input type="checkbox" name="apply_label" value="1" checked title="'.$langs->trans('Label').'">';
print '<span class="opacitymedium">L</span> ';
}
print '<button type="submit" class="button smallpaddingimp" title="'.$langs->trans('ApplyChanges').'">';
print '<i class="fas fa-check"></i>';
print '</button>';
print '</form>';
// Add to pending
$isPending = isset($_SESSION['datanorm_pending_changes'][$item['product_id']]);
if (!$isPending) {
print ' <a href="'.$_SERVER['PHP_SELF'].'?action=add_pending&product_id='.$item['product_id'].'&datanorm_key='.urlencode($item['datanorm_key']).'&fk_soc='.$fk_soc.'&apply_fields[]=price&apply_fields[]=description&apply_fields[]=label&token='.newToken().'" class="button smallpaddingimp" title="'.$langs->trans('AddToPending').'">';
print '<i class="fas fa-plus"></i>';
print '</a>';
} else {
print ' <span class="badge badge-status4">'.$langs->trans('Pending').'</span>';
}
} elseif ($item['product_id'] == 0) {
// Create product link
print '<a href="'.dol_buildpath('/product/card.php', 1).'?action=create&type=0" class="button smallpaddingimp" title="'.$langs->trans('CreateProduct').'" target="_blank">';
print '<i class="fas fa-plus"></i>';
print '</a>';
} else {
print '<span class="opacitymedium">'.$langs->trans('NoChanges').'</span>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '</div>';
// Summary and mass apply button
$pendingCount = count($_SESSION['datanorm_pending_changes']);
if ($pendingCount > 0) {
print '<br>';
print '<div class="center">';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=confirm_apply_all&token='.newToken().'" class="button button-primary">';
print '<i class="fas fa-check-double paddingright"></i>'.$langs->trans('ApplyAllPendingChanges').' ('.$pendingCount.')';
print '</a>';
print '</div>';
}
} else {
print '<br><div class="opacitymedium center">'.$langs->trans('NoResultsFound').'</div>';
}
}
// Confirmation dialog for mass apply
if ($action == 'confirm_apply_all' && !empty($_SESSION['datanorm_pending_changes'])) {
print '<br><br>';
print '<div class="confirmmessage">';
print '<h3>'.$langs->trans('ConfirmMassUpdate').'</h3>';
print '<p>'.$langs->trans('FollowingProductsWillBeUpdated').':</p>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('Product').'</th>';
print '<th>'.$langs->trans('Changes').'</th>';
print '</tr>';
foreach ($_SESSION['datanorm_pending_changes'] as $product_id => $change) {
$product = new Product($db);
$product->fetch($product_id);
print '<tr class="oddeven">';
print '<td>'.$product->getNomUrl(1).' - '.$product->label.'</td>';
print '<td>';
$changes = array();
if (in_array('price', $change['apply_fields'])) $changes[] = $langs->trans('Price');
if (in_array('description', $change['apply_fields'])) $changes[] = $langs->trans('Description');
if (in_array('label', $change['apply_fields'])) $changes[] = $langs->trans('Label');
print implode(', ', $changes);
print '</td>';
print '</tr>';
}
print '</table>';
print '<br>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="apply_all_confirmed">';
print '<input type="hidden" name="confirm" value="yes">';
print '<div class="center">';
print '<input type="submit" class="button button-primary" value="'.$langs->trans('Confirm').'">';
print ' &nbsp; ';
print '<a href="'.$_SERVER['PHP_SELF'].'" class="button">'.$langs->trans('Cancel').'</a>';
print '</div>';
print '</form>';
print '</div>';
}
print '<style>
.highlighted { background-color: #fff3cd !important; }
.confirmmessage { background: #f8f9fa; padding: 20px; border: 1px solid #dee2e6; border-radius: 5px; }
</style>';
llxFooter();
$db->close();
/*
* Helper functions
*/
/**
* Find products linked to a supplier and compare with Datanorm
*/
function findProductsForSupplier($db, $fk_soc, $search_by_name = 0, $search_by_ean = 0, $search_by_ref = 0)
{
global $conf;
$results = array();
// Get all supplier products
$sql = "SELECT DISTINCT pf.fk_product, pf.ref_fourn, pf.price as fourn_price, p.ref, p.label, p.description, p.barcode";
$sql .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price pf";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = pf.fk_product";
$sql .= " WHERE pf.fk_soc = ".((int)$fk_soc);
$sql .= " AND pf.entity IN (".getEntity('product').")";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
// Try to find matching Datanorm article
$datanorm = findDatanormMatch($db, $fk_soc, $obj->ref_fourn, $obj->label, $obj->barcode, $obj->ref, $search_by_name, $search_by_ean, $search_by_ref);
if ($datanorm) {
$results[] = buildComparisonResult($obj, $datanorm);
}
}
}
return $results;
}
/**
* Search Datanorm products manually
*/
function searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name = 0, $search_by_ean = 0, $search_by_ref = 0)
{
global $conf;
$results = array();
// Search in Datanorm
$sql = "SELECT d.* FROM ".MAIN_DB_PREFIX."importzugferd_datanorm d";
$sql .= " WHERE d.fk_soc = ".((int)$fk_soc);
$sql .= " AND (d.article_number LIKE '%".$db->escape($search_term)."%'";
$sql .= " OR d.short_text1 LIKE '%".$db->escape($search_term)."%'";
$sql .= " OR d.short_text2 LIKE '%".$db->escape($search_term)."%'";
if ($search_by_ean) {
$sql .= " OR d.ean LIKE '%".$db->escape($search_term)."%'";
}
$sql .= ")";
$sql .= " ORDER BY d.article_number";
$sql .= " LIMIT 100";
$resql = $db->query($sql);
if ($resql) {
while ($datanorm = $db->fetch_object($resql)) {
// Try to find matching product in database
$product = findProductMatch($db, $fk_soc, $datanorm);
$results[] = array(
'product_id' => $product ? $product->rowid : 0,
'current_price' => $product ? getSupplierPrice($db, $product->rowid, $fk_soc) : 0,
'current_description' => $product ? $product->description : '',
'current_label' => $product ? $product->label : '',
'datanorm_key' => $datanorm->article_number,
'datanorm_ref' => $datanorm->article_number,
'datanorm_name' => $datanorm->short_text1,
'datanorm_price' => $datanorm->price,
'datanorm_description' => trim($datanorm->short_text1.' '.$datanorm->short_text2),
'datanorm_label' => $datanorm->short_text1,
'price_differs' => $product && abs(getSupplierPrice($db, $product->rowid, $fk_soc) - $datanorm->price) > 0.01,
'description_differs' => $product && $product->description != trim($datanorm->short_text1.' '.$datanorm->short_text2),
'label_differs' => $product && $product->label != $datanorm->short_text1,
);
}
}
return $results;
}
/**
* Find Datanorm match for a product
*/
function findDatanormMatch($db, $fk_soc, $ref_fourn, $label, $barcode, $ref, $search_by_name, $search_by_ean, $search_by_ref)
{
// First try by supplier reference (article number)
$sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm";
$sql .= " WHERE fk_soc = ".((int)$fk_soc);
$sql .= " AND article_number = '".$db->escape($ref_fourn)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
return $db->fetch_object($resql);
}
// Try by EAN if enabled
if ($search_by_ean && !empty($barcode)) {
$sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm";
$sql .= " WHERE fk_soc = ".((int)$fk_soc);
$sql .= " AND ean = '".$db->escape($barcode)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
return $db->fetch_object($resql);
}
}
// Try by product ref if enabled
if ($search_by_ref && !empty($ref)) {
$sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm";
$sql .= " WHERE fk_soc = ".((int)$fk_soc);
$sql .= " AND article_number = '".$db->escape($ref)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
return $db->fetch_object($resql);
}
}
return null;
}
/**
* Find product match for Datanorm article
*/
function findProductMatch($db, $fk_soc, $datanorm)
{
// Try by supplier reference
$sql = "SELECT p.* FROM ".MAIN_DB_PREFIX."product p";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."product_fournisseur_price pf ON pf.fk_product = p.rowid";
$sql .= " WHERE pf.fk_soc = ".((int)$fk_soc);
$sql .= " AND pf.ref_fourn = '".$db->escape($datanorm->article_number)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
return $db->fetch_object($resql);
}
// Try by EAN
if (!empty($datanorm->ean)) {
$sql = "SELECT * FROM ".MAIN_DB_PREFIX."product";
$sql .= " WHERE barcode = '".$db->escape($datanorm->ean)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
return $db->fetch_object($resql);
}
}
return null;
}
/**
* Get supplier price for a product
*/
function getSupplierPrice($db, $product_id, $fk_soc)
{
$sql = "SELECT price FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sql .= " WHERE fk_product = ".((int)$product_id);
$sql .= " AND fk_soc = ".((int)$fk_soc);
$sql .= " ORDER BY rowid DESC LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
return $obj->price;
}
return 0;
}
/**
* Build comparison result array
*/
function buildComparisonResult($product, $datanorm)
{
global $db;
$fk_soc = $datanorm->fk_soc;
$current_price = $product->fourn_price;
return array(
'product_id' => $product->fk_product,
'current_price' => $current_price,
'current_description' => $product->description,
'current_label' => $product->label,
'datanorm_key' => $datanorm->article_number,
'datanorm_ref' => $datanorm->article_number,
'datanorm_name' => $datanorm->short_text1,
'datanorm_price' => $datanorm->price,
'datanorm_description' => trim($datanorm->short_text1.' '.$datanorm->short_text2),
'datanorm_label' => $datanorm->short_text1,
'price_differs' => abs($current_price - $datanorm->price) > 0.01,
'description_differs' => $product->description != trim($datanorm->short_text1.' '.$datanorm->short_text2),
'label_differs' => $product->label != $datanorm->short_text1,
);
}
/**
* Apply Datanorm update to a product
*/
function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $apply_price, $apply_description, $apply_label)
{
// Get Datanorm data
$sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm";
$sql .= " WHERE fk_soc = ".((int)$fk_soc);
$sql .= " AND article_number = '".$db->escape($datanorm_key)."'";
$resql = $db->query($sql);
if (!$resql || $db->num_rows($resql) == 0) {
return -1;
}
$datanorm = $db->fetch_object($resql);
// Load product
$product = new Product($db);
$result = $product->fetch($product_id);
if ($result <= 0) {
return -2;
}
$updated = false;
// Update label
if ($apply_label && $product->label != $datanorm->short_text1) {
$product->label = $datanorm->short_text1;
$updated = true;
}
// Update description
if ($apply_description) {
$new_desc = trim($datanorm->short_text1.' '.$datanorm->short_text2);
if ($product->description != $new_desc) {
$product->description = $new_desc;
$updated = true;
}
}
// Save product changes
if ($updated) {
$result = $product->update($product->id, $user);
if ($result < 0) {
return -3;
}
}
// Update supplier price
if ($apply_price) {
$productFourn = new ProductFournisseur($db);
$productFourn->fetch($product_id);
// Find existing supplier price
$sql = "SELECT rowid, quantity FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sql .= " WHERE fk_product = ".((int)$product_id);
$sql .= " AND fk_soc = ".((int)$fk_soc);
$sql .= " ORDER BY rowid DESC LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$priceObj = $db->fetch_object($resql);
// Update existing price
$result = $productFourn->update_buyprice(
$priceObj->quantity,
$datanorm->price,
$user,
'HT',
$fk_soc,
0, // availability
$datanorm->article_number, // ref_fourn
0, // tva_tx
0, // charges
0, // remise_percent
0, // remise
0, // newnpr
0, // delivery_time_days
'', // supplier_reputation
array(), // localtaxes
'', // newdefaultvatcode
0, // multicurrency_buyprice
'', // multicurrency_price_base_type
0, // multicurrency_tx
'', // multicurrency_code
'', // desc_fourn
'', // barcode
0, // fk_barcode_type
array() // options
);
if ($result < 0) {
return -4;
}
}
}
return 1;
}

View file

@ -56,6 +56,9 @@ dol_include_once('/importzugferd/class/zugferdimport.class.php');
dol_include_once('/importzugferd/class/importline.class.php'); dol_include_once('/importzugferd/class/importline.class.php');
dol_include_once('/importzugferd/class/productmapping.class.php'); dol_include_once('/importzugferd/class/productmapping.class.php');
dol_include_once('/importzugferd/class/actions_importzugferd.class.php'); dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
dol_include_once('/importzugferd/class/datanorm.class.php');
dol_include_once('/importzugferd/class/datanormparser.class.php');
dol_include_once('/importzugferd/class/importnotification.class.php');
dol_include_once('/importzugferd/lib/importzugferd.lib.php'); dol_include_once('/importzugferd/lib/importzugferd.lib.php');
// Load translation files // Load translation files
@ -81,6 +84,7 @@ $formfile = new FormFile($db);
$actions = new ActionsImportZugferd($db); $actions = new ActionsImportZugferd($db);
$import = new ZugferdImport($db); $import = new ZugferdImport($db);
$importLine = new ImportLine($db); $importLine = new ImportLine($db);
$notification = new ImportNotification($db);
$error = 0; $error = 0;
$message = ''; $message = '';
@ -160,15 +164,19 @@ if ($action == 'upload') {
// Check if all lines have products // Check if all lines have products
$all_have_products = true; $all_have_products = true;
$has_any_product = false;
$total_lines = count($processed_lines);
foreach ($processed_lines as $line) { foreach ($processed_lines as $line) {
if ($line['fk_product'] <= 0) { if ($line['fk_product'] <= 0) {
$all_have_products = false; $all_have_products = false;
break; } else {
$has_any_product = true;
} }
} }
// Set status based on product matching // Set status based on product matching
if ($all_have_products && $supplier_id > 0) { // STATUS_IMPORTED only if: supplier found, has lines, ALL lines have products
if ($all_have_products && $supplier_id > 0 && $total_lines > 0 && $has_any_product) {
$import->status = ZugferdImport::STATUS_IMPORTED; $import->status = ZugferdImport::STATUS_IMPORTED;
} else { } else {
$import->status = ZugferdImport::STATUS_PENDING; $import->status = ZugferdImport::STATUS_PENDING;
@ -207,6 +215,18 @@ if ($action == 'upload') {
} }
rename($destfile, $final_dir.'/'.$filename); rename($destfile, $final_dir.'/'.$filename);
// Send notification if manual intervention required
if ($import->status == ZugferdImport::STATUS_PENDING) {
$storedLines = $importLine->fetchAllByImport($import->id);
$notification->sendManualInterventionNotification($import, $storedLines);
}
// Check for price differences
if ($import->status == ZugferdImport::STATUS_IMPORTED) {
$storedLines = $importLine->fetchAllByImport($import->id);
$notification->checkAndNotifyPriceDifferences($import, $storedLines);
}
// Redirect to edit page // Redirect to edit page
$id = $import->id; $id = $import->id;
$action = 'edit'; $action = 'edit';
@ -215,6 +235,8 @@ if ($action == 'upload') {
$error++; $error++;
$message = $import->error; $message = $import->error;
@unlink($destfile); @unlink($destfile);
// Send error notification
$notification->sendErrorNotification($import, $message, $filename);
} }
} else { } else {
$error++; $error++;
@ -270,6 +292,10 @@ if ($action == 'assignproduct' && $line_id > 0 && $product_id > 0) {
if ($import->status == ZugferdImport::STATUS_PENDING) { if ($import->status == ZugferdImport::STATUS_PENDING) {
$import->status = ZugferdImport::STATUS_IMPORTED; $import->status = ZugferdImport::STATUS_IMPORTED;
$import->update($user); $import->update($user);
// Check for price differences now that all products are assigned
$storedLines = $importLine->fetchAllByImport($id);
$notification->checkAndNotifyPriceDifferences($import, $storedLines);
} }
} }
} }
@ -368,6 +394,345 @@ if ($action == 'duplicateproduct' && $template_product_id > 0 && $line_id > 0) {
$import->fetch($id); $import->fetch($id);
} }
// Create product from Datanorm
if ($action == 'createfromdatanorm' && $line_id > 0) {
$lineObj = new ImportLine($db);
$result = $lineObj->fetch($line_id);
if ($result > 0) {
$id = $lineObj->fk_import;
$import->fetch($id);
// Get Datanorm settings
$markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30);
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
// Search in Datanorm database
$datanorm = new Datanorm($db);
$results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1);
if (empty($results)) {
// Try with EAN if available
if (!empty($lineObj->ean)) {
$results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1);
}
}
if (!empty($results)) {
$datanormArticle = $results[0];
$datanorm->fetch($datanormArticle['id']);
// Load supplier for ref prefix
$supplier = new Societe($db);
$supplier->fetch($import->fk_soc);
$supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3));
// Create new product
$newproduct = new Product($db);
$newproduct->type = 0; // Product
$newproduct->status = 1; // On sale
$newproduct->status_buy = 1; // On purchase
// Generate reference
$newproduct->ref = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
// Label from Datanorm
$newproduct->label = $datanorm->short_text1;
if (!empty($datanorm->short_text2) && strlen($newproduct->label) < 100) {
$newproduct->label .= ' '.$datanorm->short_text2;
}
// Description
$newproduct->description = $datanorm->getFullDescription();
// Prices
$purchasePrice = $datanorm->price;
if ($datanorm->price_unit > 1) {
$purchasePrice = $datanorm->price / $datanorm->price_unit;
}
// Selling price with markup
$sellingPrice = $purchasePrice * (1 + $markup / 100);
$newproduct->price = $sellingPrice;
$newproduct->price_base_type = 'HT';
$newproduct->tva_tx = $lineObj->tax_percent ?: 19;
// Weight if available
if (!empty($datanorm->weight)) {
$newproduct->weight = $datanorm->weight;
$newproduct->weight_units = 0; // kg
}
// Let Dolibarr auto-generate barcode if configured
// Setting barcode to '-1' triggers automatic generation
if (isModEnabled('barcode') && getDolGlobalString('BARCODE_PRODUCT_ADDON_NUM')) {
$newproduct->barcode = '-1';
}
// Create the product
$result = $newproduct->create($user);
if ($result > 0) {
// Add supplier price
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
$prodfourn = new ProductFournisseur($db);
$prodfourn->id = $newproduct->id;
$prodfourn->fourn_ref = $datanorm->article_number;
// Determine EAN for supplier price
$supplierEan = '';
$supplierEanType = 0;
if (!empty($datanorm->ean)) {
$supplierEan = $datanorm->ean;
$supplierEanType = 2; // EAN13
} elseif (!empty($lineObj->ean)) {
$supplierEan = $lineObj->ean;
$supplierEanType = 2; // EAN13
}
// Add supplier price entry with EAN
$res = $prodfourn->update_buyprice(
1, // Quantity
$purchasePrice, // Price
$user,
'HT', // Price base
$supplier, // Supplier
0, // Availability
$datanorm->article_number, // Supplier ref
$lineObj->tax_percent ?: 19, // VAT
0, // Charges
0, // Remise
0, // Remise percentage
0, // No price minimum
0, // Delivery delay
0, // Reputation
array(), // Extra fields
0, // Charges array
$supplierEan, // Barcode/EAN in supplier price
$supplierEanType // Barcode type (EAN13)
);
// Create product mapping for future imports
$mapping = new ProductMapping($db);
$mapping->fk_soc = $import->fk_soc;
$mapping->supplier_ref = $datanorm->article_number;
$mapping->fk_product = $newproduct->id;
$mapping->ean = $datanorm->ean;
$mapping->manufacturer_ref = $datanorm->manufacturer_ref;
$mapping->description = $datanorm->short_text1;
$mapping->create($user);
// Assign to import line
$lineObj->setProduct($newproduct->id, 'datanorm', $user);
setEventMessages($langs->trans('ProductCreatedFromDatanorm', $newproduct->ref), null, 'mesgs');
// Check if all lines now have products
$allHaveProducts = $importLine->allLinesHaveProducts($id);
if ($allHaveProducts) {
$import->status = ZugferdImport::STATUS_IMPORTED;
$import->update($user);
}
} else {
setEventMessages($newproduct->error, $newproduct->errors, 'errors');
}
} else {
setEventMessages($langs->trans('DatanormArticleNotFound', $lineObj->supplier_ref), null, 'errors');
}
}
$action = 'edit';
$import->fetch($id);
}
// Create ALL products from Datanorm (batch)
if ($action == 'createallfromdatanorm' && $id > 0) {
$import->fetch($id);
if ($import->fk_soc > 0) {
// Get Datanorm settings
$markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30);
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
// Load supplier
$supplier = new Societe($db);
$supplier->fetch($import->fk_soc);
$supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3));
// Get all lines without product
$lines = $importLine->fetchAllByImport($import->id);
$datanorm = new Datanorm($db);
$createdCount = 0;
$assignedCount = 0;
$errorCount = 0;
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
foreach ($lines as $lineObj) {
// Skip lines that already have a product
if ($lineObj->fk_product > 0) {
continue;
}
// Skip lines without supplier_ref
if (empty($lineObj->supplier_ref)) {
continue;
}
// Search in Datanorm database
$results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1);
if (empty($results) && !empty($lineObj->ean)) {
$results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1);
}
if (!empty($results)) {
$datanormArticle = $results[0];
$datanorm->fetch($datanormArticle['id']);
$purchasePrice = $datanorm->price;
if ($datanorm->price_unit > 1) {
$purchasePrice = $datanorm->price / $datanorm->price_unit;
}
// Check if product already exists in Dolibarr
$existingProduct = new Product($db);
$productExists = false;
$existingProductId = 0;
// 1. Check by supplier reference (ProductFournisseur)
$sqlCheck = "SELECT DISTINCT pf.fk_product FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pf";
$sqlCheck .= " WHERE pf.fk_soc = ".(int)$import->fk_soc;
$sqlCheck .= " AND pf.ref_fourn = '".$db->escape($datanorm->article_number)."'";
$sqlCheck .= " AND pf.entity IN (".getEntity('product').")";
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck && $db->num_rows($resqlCheck) > 0) {
$objCheck = $db->fetch_object($resqlCheck);
$existingProductId = $objCheck->fk_product;
$productExists = true;
}
// 2. Check by product reference pattern
if (!$productExists) {
$expectedRef = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
$fetchResult = $existingProduct->fetch(0, $expectedRef);
if ($fetchResult > 0) {
$existingProductId = $existingProduct->id;
$productExists = true;
}
}
// 3. Check by EAN if available
if (!$productExists && !empty($datanorm->ean)) {
$sqlEan = "SELECT rowid FROM ".MAIN_DB_PREFIX."product";
$sqlEan .= " WHERE barcode = '".$db->escape($datanorm->ean)."'";
$sqlEan .= " AND entity IN (".getEntity('product').")";
$resqlEan = $db->query($sqlEan);
if ($resqlEan && $db->num_rows($resqlEan) > 0) {
$objEan = $db->fetch_object($resqlEan);
$existingProductId = $objEan->rowid;
$productExists = true;
}
}
if ($productExists && $existingProductId > 0) {
// Product exists - just assign it to the line
$lineObj->setProduct($existingProductId, 'datanorm', $user);
$assignedCount++;
} else {
// Create new product
$newproduct = new Product($db);
$newproduct->type = 0;
$newproduct->status = 1;
$newproduct->status_buy = 1;
$newproduct->ref = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
$newproduct->label = $datanorm->short_text1;
if (!empty($datanorm->short_text2) && strlen($newproduct->label) < 100) {
$newproduct->label .= ' '.$datanorm->short_text2;
}
$newproduct->description = $datanorm->getFullDescription();
$sellingPrice = $purchasePrice * (1 + $markup / 100);
$newproduct->price = $sellingPrice;
$newproduct->price_base_type = 'HT';
$newproduct->tva_tx = $lineObj->tax_percent ?: 19;
if (!empty($datanorm->weight)) {
$newproduct->weight = $datanorm->weight;
$newproduct->weight_units = 0;
}
if (isModEnabled('barcode') && getDolGlobalString('BARCODE_PRODUCT_ADDON_NUM')) {
$newproduct->barcode = '-1';
}
$result = $newproduct->create($user);
if ($result > 0) {
// Add supplier price
$prodfourn = new ProductFournisseur($db);
$prodfourn->id = $newproduct->id;
$prodfourn->fourn_ref = $datanorm->article_number;
$supplierEan = '';
$supplierEanType = 0;
if (!empty($datanorm->ean)) {
$supplierEan = $datanorm->ean;
$supplierEanType = 2;
} elseif (!empty($lineObj->ean)) {
$supplierEan = $lineObj->ean;
$supplierEanType = 2;
}
$prodfourn->update_buyprice(
1, $purchasePrice, $user, 'HT', $supplier, 0,
$datanorm->article_number, $lineObj->tax_percent ?: 19,
0, 0, 0, 0, 0, 0, array(), 0, $supplierEan, $supplierEanType
);
// Create product mapping
$mapping = new ProductMapping($db);
$mapping->fk_soc = $import->fk_soc;
$mapping->supplier_ref = $datanorm->article_number;
$mapping->fk_product = $newproduct->id;
$mapping->ean = $datanorm->ean;
$mapping->manufacturer_ref = $datanorm->manufacturer_ref;
$mapping->description = $datanorm->short_text1;
$mapping->create($user);
// Assign to import line
$lineObj->setProduct($newproduct->id, 'datanorm', $user);
$createdCount++;
} else {
$errorCount++;
}
}
}
}
if ($createdCount > 0) {
setEventMessages($langs->trans('DatanormBatchCreated', $createdCount), null, 'mesgs');
}
if ($assignedCount > 0) {
setEventMessages($langs->trans('DatanormBatchAssigned', $assignedCount), null, 'mesgs');
}
if ($errorCount > 0) {
setEventMessages($langs->trans('DatanormBatchErrors', $errorCount), null, 'warnings');
}
if ($createdCount == 0 && $assignedCount == 0 && $errorCount == 0) {
setEventMessages($langs->trans('DatanormBatchNoMatches'), null, 'warnings');
}
// Check if all lines now have products
$allHaveProducts = $importLine->allLinesHaveProducts($id);
if ($allHaveProducts) {
$import->status = ZugferdImport::STATUS_IMPORTED;
$import->update($user);
}
}
$action = 'edit';
$import->fetch($id);
}
// Create supplier invoice // Create supplier invoice
if ($action == 'createinvoice' && $id > 0) { if ($action == 'createinvoice' && $id > 0) {
$import->fetch($id); $import->fetch($id);
@ -435,8 +800,7 @@ if ($action == 'createinvoice' && $id > 0) {
} }
if (!$error) { if (!$error) {
// Validate invoice // Invoice stays as draft - user can validate manually
$invoice->validate($user);
// Copy PDF to invoice // Copy PDF to invoice
$source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename; $source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename;
@ -473,6 +837,63 @@ if ($action == 'createinvoice' && $id > 0) {
$action = 'edit'; $action = 'edit';
} }
// Finish import - check for existing invoice and update status
if ($action == 'finishimport' && $id > 0) {
$import->fetch($id);
// Check all lines have products
$lines = $importLine->fetchAllByImport($id);
$allHaveProducts = true;
foreach ($lines as $line) {
if ($line->fk_product <= 0) {
$allHaveProducts = false;
break;
}
}
if (!$allHaveProducts) {
$error++;
setEventMessages($langs->trans('ErrorNotAllProductsAssigned'), null, 'errors');
} elseif ($import->fk_soc <= 0) {
$error++;
setEventMessages($langs->trans('ErrorSupplierRequired'), null, 'errors');
} else {
// Search for existing supplier invoice with this ref_supplier
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_fourn";
$sql .= " WHERE fk_soc = ".((int) $import->fk_soc);
$sql .= " AND ref_supplier = '".$db->escape($import->invoice_number)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
// Found existing invoice - link it
$import->fk_facture_fourn = $obj->rowid;
$import->status = ZugferdImport::STATUS_PROCESSED;
$import->date_import = dol_now();
$result = $import->update($user);
if ($result > 0) {
$invoiceLink = '<a href="'.DOL_URL_ROOT.'/fourn/facture/card.php?facid='.$obj->rowid.'">'.$import->invoice_number.'</a>';
setEventMessages($langs->trans('ImportLinkedToExistingInvoice', $invoiceLink), null, 'mesgs');
} else {
setEventMessages($import->error, null, 'errors');
}
} else {
// No existing invoice - mark as imported (ready for invoice creation)
$import->status = ZugferdImport::STATUS_IMPORTED;
$result = $import->update($user);
if ($result > 0) {
setEventMessages($langs->trans('ImportFinished'), null, 'mesgs');
} else {
setEventMessages($import->error, null, 'errors');
}
}
}
$action = 'edit';
}
// Delete import record // Delete import record
if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0) { if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0) {
$import->fetch($id); $import->fetch($id);
@ -601,20 +1022,27 @@ if (empty($action) || ($action == 'upload' && $error)) {
} }
/* /*
* Edit/Review import * Delete confirmation dialog
*/ */
if ($action == 'edit' && $import->id > 0) { if ($action == 'delete' && $id > 0) {
// Delete confirmation $import->fetch($id);
if ($action == 'delete') {
$formconfirm = $form->formconfirm( $formconfirm = $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$import->id, $_SERVER['PHP_SELF'].'?id='.$import->id,
$langs->trans('DeleteImportRecord'), $langs->trans('DeleteImportRecord'),
$langs->trans('ConfirmDeleteImportRecord', $import->ref), $langs->trans('ConfirmDeleteImportRecord', $import->ref),
'confirm_delete' 'confirm_delete',
'',
0,
1
); );
print $formconfirm; print $formconfirm;
$action = 'edit'; // Continue showing the edit form
} }
/*
* Edit/Review import
*/
if ($action == 'edit' && $import->id > 0) {
// Fetch lines // Fetch lines
$lines = $importLine->fetchAllByImport($import->id); $lines = $importLine->fetchAllByImport($import->id);
$missingProducts = $importLine->countLinesWithoutProduct($import->id); $missingProducts = $importLine->countLinesWithoutProduct($import->id);
@ -715,11 +1143,20 @@ if ($action == 'edit' && $import->id > 0) {
print '<td>'.$langs->trans('ProductDescription').'</td>'; print '<td>'.$langs->trans('ProductDescription').'</td>';
print '<td class="right">'.$langs->trans('Qty').'</td>'; print '<td class="right">'.$langs->trans('Qty').'</td>';
print '<td class="right">'.$langs->trans('UnitPrice').'</td>'; print '<td class="right">'.$langs->trans('UnitPrice').'</td>';
print '<td class="right">'.$langs->trans('DolibarrPrice').'</td>';
print '<td class="right">'.$langs->trans('TotalHT').'</td>'; print '<td class="right">'.$langs->trans('TotalHT').'</td>';
print '<td>'.$langs->trans('MatchedProduct').'</td>'; print '<td>'.$langs->trans('MatchedProduct').'</td>';
print '<td>'.$langs->trans('Action').'</td>'; print '<td>'.$langs->trans('Action').'</td>';
print '</tr>'; print '</tr>';
// Initialize totals for summary row
$totalDolibarrHT = 0;
$totalZugferdHT = 0;
$hasDolibarrPrices = false;
$allProductsMatched = true;
$matchedLinesCount = 0;
$totalLinesCount = count($lines);
foreach ($lines as $line) { foreach ($lines as $line) {
$hasProduct = ($line->fk_product > 0); $hasProduct = ($line->fk_product > 0);
$rowClass = $hasProduct ? 'oddeven opacitymedium' : 'oddeven'; $rowClass = $hasProduct ? 'oddeven opacitymedium' : 'oddeven';
@ -740,6 +1177,60 @@ if ($action == 'edit' && $import->id > 0) {
print '<br><span class="opacitymedium">('.price($line->unit_price_raw).'/'.price2num($line->basis_quantity, 'MS').zugferdGetUnitLabel($line->basis_quantity_unit).')</span>'; print '<br><span class="opacitymedium">('.price($line->unit_price_raw).'/'.price2num($line->basis_quantity, 'MS').zugferdGetUnitLabel($line->basis_quantity_unit).')</span>';
} }
print '</td>'; print '</td>';
// Dolibarr price column - show supplier price and difference
print '<td class="right nowraponall">';
$lineDolibarrTotal = 0;
if ($hasProduct && $import->fk_soc > 0) {
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
$productFourn = new ProductFournisseur($db);
$result = $productFourn->find_min_price_product_fournisseur($line->fk_product, 1, $import->fk_soc);
if ($result > 0 && $productFourn->fourn_price > 0) {
$dolibarrPrice = $productFourn->fourn_price;
$zugferdPrice = $line->unit_price;
$priceDiff = $zugferdPrice - $dolibarrPrice;
$priceDiffPercent = ($dolibarrPrice > 0) ? (($priceDiff / $dolibarrPrice) * 100) : 0;
// Accumulate for summary
$lineDolibarrTotal = $dolibarrPrice * $line->quantity;
$totalDolibarrHT += $lineDolibarrTotal;
$hasDolibarrPrices = true;
$matchedLinesCount++;
print price($dolibarrPrice);
if (abs($priceDiffPercent) >= 0.01) {
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
$isSignificant = (abs($priceDiffPercent) >= $threshold);
print '<br>';
if ($priceDiff > 0) {
// ZUGFeRD price is higher
$iconColor = $isSignificant ? 'color: #d9534f;' : 'color: #f0ad4e;';
print '<span style="'.$iconColor.'" title="'.$langs->trans('PriceIncrease').'">';
print '<i class="fas fa-arrow-up"></i> +'.number_format($priceDiffPercent, 1).'%';
print '</span>';
} else {
// ZUGFeRD price is lower
$iconColor = $isSignificant ? 'color: #5cb85c;' : 'color: #5bc0de;';
print '<span style="'.$iconColor.'" title="'.$langs->trans('PriceDecrease').'">';
print '<i class="fas fa-arrow-down"></i> '.number_format($priceDiffPercent, 1).'%';
print '</span>';
}
} else {
print '<br><span class="opacitymedium"><i class="fas fa-equals"></i></span>';
}
} else {
print '<span class="opacitymedium">'.$langs->trans('NoPriceFound').'</span>';
$allProductsMatched = false; // No price found for matched product
}
} else {
print '<span class="opacitymedium">-</span>';
$allProductsMatched = false; // Product not matched
}
print '</td>';
print '<td class="right">'.price($line->line_total).'</td>'; print '<td class="right">'.price($line->line_total).'</td>';
print '<td>'; print '<td>';
@ -810,11 +1301,93 @@ if ($action == 'edit' && $import->id > 0) {
print '<i class="fas fa-copy"></i>'; print '<i class="fas fa-copy"></i>';
print '</button>'; print '</button>';
print '</form>'; print '</form>';
// Datanorm button (only if supplier is set and supplier_ref exists)
if ($import->fk_soc > 0 && !empty($line->supplier_ref)) {
// Check if Datanorm article exists
$datanormCheck = new Datanorm($db);
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
$datanormResults = $datanormCheck->searchByArticleNumber($line->supplier_ref, $import->fk_soc, $searchAll, 1);
if (!empty($datanormResults)) {
$datanormArticle = $datanormResults[0];
print '<br>';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=createfromdatanorm&line_id='.$line->id.'&id='.$import->id.'&token='.newToken().'" class="button buttongen margintoponlyshort" title="'.$langs->trans('CreateFromDatanormHelp').'">';
print '<i class="fas fa-database paddingright"></i>'.$langs->trans('CreateFromDatanorm');
print '</a>';
print '<br><span class="opacitymedium small">';
print dol_trunc($datanormArticle['short_text1'], 40);
print ' - '.price($datanormArticle['price']);
print '</span>';
}
}
} }
print '</td>'; print '</td>';
print '</tr>'; print '</tr>';
// Accumulate ZUGFeRD total
$totalZugferdHT += $line->line_total;
} }
// Summary row with total comparison
// Only show full comparison if ALL products are matched with Dolibarr prices
print '<tr style="background-color: #f5f5f5; font-weight: bold;">';
print '<td colspan="5" class="right"><strong>'.$langs->trans('Total').' '.$langs->trans('TotalHT').'</strong></td>';
if ($allProductsMatched && $hasDolibarrPrices) {
// Full comparison possible - all products matched with prices
$totalDiff = $totalZugferdHT - $totalDolibarrHT;
$totalDiffPercent = ($totalDolibarrHT > 0) ? (($totalDiff / $totalDolibarrHT) * 100) : 0;
// Determine colors: green if close match, red if significant difference
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
$isMatch = (abs($totalDiffPercent) < 0.5); // Less than 0.5% difference = match
$isSignificant = (abs($totalDiffPercent) >= $threshold);
if ($isMatch) {
$cellStyle = 'background-color: #dff0d8;'; // Green
} elseif ($isSignificant) {
$cellStyle = 'background-color: #f2dede;'; // Red
} else {
$cellStyle = 'background-color: #fcf8e3;'; // Yellow/warning
}
print '<td class="right nowraponall" style="'.$cellStyle.'">';
print '<strong>'.price($totalDolibarrHT).'</strong>';
if (abs($totalDiffPercent) >= 0.01) {
print '<br>';
if ($totalDiff > 0) {
print '<span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.number_format($totalDiffPercent, 1).'%</span>';
} elseif ($totalDiff < 0) {
print '<span style="color: #5cb85c;"><i class="fas fa-arrow-down"></i> '.number_format($totalDiffPercent, 1).'%</span>';
}
}
print '</td>';
print '<td class="right" style="'.$cellStyle.'"><strong>'.price($totalZugferdHT).'</strong></td>';
print '<td colspan="2" class="nowraponall" style="'.$cellStyle.'">';
if ($isMatch) {
print '<span style="color: #3c763d;"><i class="fas fa-check-circle"></i> '.$langs->trans('SumValidationOk').'</span>';
} else {
print '<span style="color: #a94442;"><i class="fas fa-exclamation-triangle"></i> '.$langs->trans('Difference').': '.price($totalDiff).' '.$import->currency.'</span>';
}
print '</td>';
} else {
// Not all products matched - show totals but no comparison
print '<td class="right nowraponall">';
if ($hasDolibarrPrices) {
print '<span class="opacitymedium">'.price($totalDolibarrHT).'</span>';
print '<br><span class="opacitymedium small">('.$matchedLinesCount.'/'.$totalLinesCount.')</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '<td class="right"><strong>'.price($totalZugferdHT).'</strong></td>';
print '<td colspan="2" class="nowraponall">';
print '<span class="opacitymedium"><i class="fas fa-info-circle"></i> '.$langs->trans('ProductsNotAssigned').'</span>';
print '</td>';
}
print '</tr>';
print '</table>'; print '</table>';
print '</div>'; print '</div>';
@ -828,9 +1401,33 @@ if ($action == 'edit' && $import->id > 0) {
print ' &nbsp; '; print ' &nbsp; ';
} }
print '<a href="'.dol_buildpath('/importzugferd/list.php', 1).'" class="button">'.$langs->trans('BackToList').'</a>'; // Finish import button - shown when pending status and all products assigned
if ($import->status == ZugferdImport::STATUS_PENDING && $allComplete) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=finishimport&id='.$import->id.'&token='.newToken().'" class="button">';
print '<i class="fas fa-check paddingright"></i>'.$langs->trans('FinishImport');
print '</a>';
print ' &nbsp; '; print ' &nbsp; ';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&id='.$import->id.'&token='.newToken().'" class="button button-cancel">'.$langs->trans('Delete').'</a>'; }
// Button to create all products from Datanorm
if ($missingProducts > 0 && $import->fk_soc > 0) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=createallfromdatanorm&id='.$import->id.'&token='.newToken().'" class="button">';
print '<i class="fas fa-database paddingright"></i>'.$langs->trans('CreateAllFromDatanorm');
print '</a>';
print ' &nbsp; ';
}
print '<a href="'.dol_buildpath('/importzugferd/list.php', 1).'" class="button">'.$langs->trans('BackToList').'</a>';
// Delete button - show for pending imports or imports without linked invoice
$canDelete = ($import->status == ZugferdImport::STATUS_PENDING) ||
($import->status == ZugferdImport::STATUS_IMPORTED && $import->fk_facture_fourn <= 0);
if ($canDelete) {
print ' &nbsp; ';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&id='.$import->id.'&token='.newToken().'" class="button button-cancel">';
print '<i class="fas fa-trash paddingright"></i>'.$langs->trans('Delete');
print '</a>';
}
print '</div>'; print '</div>';

View file

@ -35,6 +35,8 @@ IMPORTZUGFERD_WATCH_FOLDER = Überwachungsordner
IMPORTZUGFERD_WATCH_FOLDERTooltip = Ordner für eingehende ZUGFeRD-Rechnungen (lokaler Pfad) IMPORTZUGFERD_WATCH_FOLDERTooltip = Ordner für eingehende ZUGFeRD-Rechnungen (lokaler Pfad)
IMPORTZUGFERD_ARCHIVE_FOLDER = Archivordner IMPORTZUGFERD_ARCHIVE_FOLDER = Archivordner
IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Ordner für erfolgreich importierte Rechnungen IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Ordner für erfolgreich importierte Rechnungen
IMPORTZUGFERD_ERROR_FOLDER = Fehlerordner
IMPORTZUGFERD_ERROR_FOLDERTooltip = Ordner für fehlerhafte Rechnungen (nicht ZUGFeRD oder Importfehler)
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archivordner IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archivordner
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP-Ordner für archivierte E-Mails nach Import IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP-Ordner für archivierte E-Mails nach Import
@ -109,6 +111,7 @@ SupplierCustomerNumberHelp = Ihre Kundennummer bei diesem Lieferanten (für auto
# Cronjob # Cronjob
# #
ImportZugferdFromMailbox = ZUGFeRD Rechnungen aus Postfach importieren ImportZugferdFromMailbox = ZUGFeRD Rechnungen aus Postfach importieren
ImportZugferdScheduled = ZUGFeRD geplanter Import (Ordner und E-Mail)
# #
# Fehler # Fehler
@ -213,3 +216,131 @@ ImportRecordCreated = Import-Datensatz erstellt
ErrorNotAllProductsAssigned = Nicht alle Produkte zugeordnet ErrorNotAllProductsAssigned = Nicht alle Produkte zugeordnet
BackToList = Zurück zur Liste BackToList = Zurück zur Liste
ErrorRecordNotFound = Datensatz nicht gefunden ErrorRecordNotFound = Datensatz nicht gefunden
FinishImport = Abschließen
ImportFinished = Import abgeschlossen
ImportLinkedToExistingInvoice = Import mit bestehender Rechnung %s verknüpft
#
# Datanorm
#
DatanormCatalogs = Datanorm Kataloge
DatanormSettings = Datanorm Einstellungen
IMPORTZUGFERD_DATANORM_MARKUP = Preisaufschlag (%)
IMPORTZUGFERD_DATANORM_MARKUPTooltip = Prozentualer Aufschlag auf den Datanorm-Einkaufspreis für den Verkaufspreis
IMPORTZUGFERD_DATANORM_SEARCH_ALL = In allen Lieferanten-Katalogen suchen
IMPORTZUGFERD_DATANORM_SEARCH_ALLTooltip = Bei Aktivierung wird nicht nur im Katalog des aktuellen Lieferanten gesucht, sondern in allen Datanorm-Katalogen
UploadDatanorm = Datanorm hochladen
DatanormFiles = Datanorm Dateien
DatanormFileHelp = DATANORM.001, DATANORM.WRG oder XML-Dateien (Datanorm 4.0/5.0)
DeleteExisting = Vorhandene Artikel löschen
DeleteExistingHelp = Löscht alle vorhandenen Artikel dieses Lieferanten vor dem Import
DatanormImportSuccess = %s Artikel erfolgreich importiert
DatanormImportFailed = Datanorm Import fehlgeschlagen
DatanormNoArticlesFound = Keine Artikel in der Datanorm-Datei gefunden
NoDatanormData = Keine Datanorm-Daten vorhanden
DatanormDeleted = %s Artikel gelöscht
DatanormDeleteFailed = Löschen fehlgeschlagen
DeleteDatanorm = Datanorm-Katalog löschen
ConfirmDeleteDatanorm = Möchten Sie alle Datanorm-Artikel von %s löschen?
DatanormArticles = Datanorm Artikel
ArticleNumber = Artikelnummer
ArticleCount = Artikelanzahl
LastImport = Letzter Import
ViewArticles = Artikel anzeigen
TotalArticles = Gesamtanzahl Artikel
DatanormSettingsInfo = Preisaufschlag und Suchverhalten können in den Moduleinstellungen konfiguriert werden:
CreateFromDatanorm = Aus Datanorm
CreateFromDatanormHelp = Neues Produkt aus Datanorm-Daten anlegen
ProductCreatedFromDatanorm = Produkt %s aus Datanorm erstellt
DatanormArticleNotFound = Kein Datanorm-Artikel für Artikelnummer '%s' gefunden
CreateAllFromDatanorm = Alle aus Datanorm
CreateAllFromDatanormHelp = Alle fehlenden Produkte aus Datanorm-Daten anlegen
DatanormBatchCreated = %s Produkte aus Datanorm erstellt
DatanormBatchAssigned = %s vorhandene Produkte zugeordnet
DatanormBatchErrors = %s Produkte konnten nicht erstellt werden
DatanormBatchNoMatches = Keine passenden Datanorm-Artikel gefunden
#
# Scheduling
#
SchedulingSettings = Zeitplanung
IMPORTZUGFERD_IMPORT_FREQUENCY = Import-Häufigkeit
IMPORTZUGFERD_IMPORT_FREQUENCYTooltip = Wie oft sollen Ordner und E-Mails automatisch auf neue Rechnungen geprüft werden
FrequencyManual = Nur manuell
FrequencyHourly = Stündlich
FrequencyDaily = Täglich
FrequencyWeekly = Wöchentlich
ManualImportTrigger = Manueller Import
#
# Folder Browser
#
FolderBrowser = Ordner-Auswahl
Browse = Durchsuchen
SelectFolder = Ordner auswählen
SelectThisFolder = Diesen Ordner wählen
CurrentPath = Aktueller Pfad
ParentFolder = Übergeordneter Ordner
NoSubfolders = Keine Unterordner
NotConfigured = Nicht konfiguriert
ErrorFolderNotFound = Ordner nicht gefunden
Go = Los
QuickLinks = Schnellzugriff
#
# Folder Validation
#
FolderValidation = Ordner-Prüfung
FolderOK = OK
FolderNotFound = Ordner nicht gefunden
FolderNotReadable = Ordner nicht lesbar
FolderNotWritable = Ordner nicht beschreibbar
#
# Email Notifications
#
NotificationSettings = E-Mail-Benachrichtigungen
IMPORTZUGFERD_NOTIFY_ENABLED = Benachrichtigungen aktivieren
IMPORTZUGFERD_NOTIFY_ENABLEDTooltip = E-Mail-Benachrichtigungen für Import-Ereignisse aktivieren
IMPORTZUGFERD_NOTIFY_EMAIL = Benachrichtigungs-E-Mail
IMPORTZUGFERD_NOTIFY_EMAILTooltip = E-Mail-Adresse für Import-Benachrichtigungen
IMPORTZUGFERD_NOTIFY_MANUAL = Bei manuellem Eingriff
IMPORTZUGFERD_NOTIFY_MANUALTooltip = E-Mail senden wenn ein Import manuellen Eingriff benötigt
IMPORTZUGFERD_NOTIFY_ERROR = Bei Fehlern
IMPORTZUGFERD_NOTIFY_ERRORTooltip = E-Mail senden wenn beim Import ein Fehler auftritt
IMPORTZUGFERD_NOTIFY_PRICE_DIFF = Bei Preisabweichungen
IMPORTZUGFERD_NOTIFY_PRICE_DIFFTooltip = E-Mail senden wenn Produktpreise um mehr als den Schwellenwert abweichen
IMPORTZUGFERD_PRICE_DIFF_THRESHOLD = Preisabweichung Schwelle (%)
IMPORTZUGFERD_PRICE_DIFF_THRESHOLDTooltip = Prozentuale Preisabweichung ab der eine Benachrichtigung gesendet wird
# Email content
NotifySubjectManualIntervention = Manueller Eingriff erforderlich: Rechnung %s
NotifySubjectError = Import-Fehler: %s
NotifySubjectPriceDiff = Preisabweichungen erkannt: Rechnung %s (%s Produkte)
NotifyBodyManualIntervention = Der Import der Rechnung %s von %s erfordert manuellen Eingriff.
NotifyBodyError = Beim Import der Rechnung/Datei %s ist ein Fehler aufgetreten.
NotifyBodyPriceDiff = Bei der Rechnung %s von %s wurden Preisabweichungen von mehr als %s%% erkannt.
NotifyLinkToImport = Link zum Import
OldPrice = Alter Preis
NewPrice = Neuer Preis
File = Datei
# Price comparison
DolibarrPrice = Dolibarr Preis
PriceIncrease = Preiserhöhung
PriceDecrease = Preissenkung
NoPriceFound = Kein Preis
# Test Email
TestEmailNotification = E-Mail-Benachrichtigung testen
SendTestEmail = Test-E-Mail senden
TestEmailSent = Test-E-Mail erfolgreich gesendet an %s
TestEmailFailed = Test-E-Mail konnte nicht gesendet werden
SendTo = Senden an
NotifySubjectTest = Test-E-Mail Benachrichtigung
NotifyBodyTest = Dies ist eine Test-E-Mail vom ZUGFeRD Import Modul.
NotifyTestInfo = Diese E-Mail bestätigt, dass die E-Mail-Benachrichtigungen korrekt konfiguriert sind.
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

View file

@ -35,6 +35,8 @@ IMPORTZUGFERD_WATCH_FOLDER = Watch Folder
IMPORTZUGFERD_WATCH_FOLDERTooltip = Folder for incoming ZUGFeRD invoices (local path) IMPORTZUGFERD_WATCH_FOLDERTooltip = Folder for incoming ZUGFeRD invoices (local path)
IMPORTZUGFERD_ARCHIVE_FOLDER = Archive Folder IMPORTZUGFERD_ARCHIVE_FOLDER = Archive Folder
IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Folder for successfully imported invoices IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Folder for successfully imported invoices
IMPORTZUGFERD_ERROR_FOLDER = Error Folder
IMPORTZUGFERD_ERROR_FOLDERTooltip = Folder for failed invoices (not ZUGFeRD or import errors)
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archive Folder IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archive Folder
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP folder for archived emails after import IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP folder for archived emails after import
@ -109,6 +111,7 @@ SupplierCustomerNumberHelp = Your customer number at this supplier (used for aut
# Cronjob # Cronjob
# #
ImportZugferdFromMailbox = Import ZUGFeRD invoices from mailbox ImportZugferdFromMailbox = Import ZUGFeRD invoices from mailbox
ImportZugferdScheduled = ZUGFeRD scheduled import (folder and email)
# #
# Errors # Errors
@ -213,3 +216,131 @@ ImportRecordCreated = Import record created
ErrorNotAllProductsAssigned = Not all products assigned ErrorNotAllProductsAssigned = Not all products assigned
BackToList = Back to list BackToList = Back to list
ErrorRecordNotFound = Record not found ErrorRecordNotFound = Record not found
FinishImport = Finish Import
ImportFinished = Import finished
ImportLinkedToExistingInvoice = Import linked to existing invoice %s
#
# Datanorm
#
DatanormCatalogs = Datanorm Catalogs
DatanormSettings = Datanorm Settings
IMPORTZUGFERD_DATANORM_MARKUP = Price Markup (%)
IMPORTZUGFERD_DATANORM_MARKUPTooltip = Percentage markup on Datanorm purchase price for selling price
IMPORTZUGFERD_DATANORM_SEARCH_ALL = Search in all supplier catalogs
IMPORTZUGFERD_DATANORM_SEARCH_ALLTooltip = When enabled, search all Datanorm catalogs, not just the current supplier
UploadDatanorm = Upload Datanorm
DatanormFiles = Datanorm Files
DatanormFileHelp = DATANORM.001, DATANORM.WRG or XML files (Datanorm 4.0/5.0)
DeleteExisting = Delete existing articles
DeleteExistingHelp = Deletes all existing articles for this supplier before import
DatanormImportSuccess = %s articles imported successfully
DatanormImportFailed = Datanorm import failed
DatanormNoArticlesFound = No articles found in Datanorm file
NoDatanormData = No Datanorm data available
DatanormDeleted = %s articles deleted
DatanormDeleteFailed = Deletion failed
DeleteDatanorm = Delete Datanorm catalog
ConfirmDeleteDatanorm = Are you sure you want to delete all Datanorm articles from %s?
DatanormArticles = Datanorm Articles
ArticleNumber = Article Number
ArticleCount = Article Count
LastImport = Last Import
ViewArticles = View Articles
TotalArticles = Total Articles
DatanormSettingsInfo = Price markup and search behavior can be configured in module settings:
CreateFromDatanorm = From Datanorm
CreateFromDatanormHelp = Create new product from Datanorm data
ProductCreatedFromDatanorm = Product %s created from Datanorm
DatanormArticleNotFound = No Datanorm article found for article number '%s'
CreateAllFromDatanorm = All from Datanorm
CreateAllFromDatanormHelp = Create all missing products from Datanorm data
DatanormBatchCreated = %s products created from Datanorm
DatanormBatchAssigned = %s existing products assigned
DatanormBatchErrors = %s products could not be created
DatanormBatchNoMatches = No matching Datanorm articles found
#
# Scheduling
#
SchedulingSettings = Scheduling
IMPORTZUGFERD_IMPORT_FREQUENCY = Import Frequency
IMPORTZUGFERD_IMPORT_FREQUENCYTooltip = How often should folders and emails be checked for new invoices automatically
FrequencyManual = Manual only
FrequencyHourly = Hourly
FrequencyDaily = Daily
FrequencyWeekly = Weekly
ManualImportTrigger = Manual Import
#
# Folder Browser
#
FolderBrowser = Folder Selection
Browse = Browse
SelectFolder = Select Folder
SelectThisFolder = Select This Folder
CurrentPath = Current Path
ParentFolder = Parent Folder
NoSubfolders = No subfolders
NotConfigured = Not configured
ErrorFolderNotFound = Folder not found
Go = Go
QuickLinks = Quick links
#
# Folder Validation
#
FolderValidation = Folder Validation
FolderOK = OK
FolderNotFound = Folder not found
FolderNotReadable = Folder not readable
FolderNotWritable = Folder not writable
#
# Email Notifications
#
NotificationSettings = Email Notifications
IMPORTZUGFERD_NOTIFY_ENABLED = Enable notifications
IMPORTZUGFERD_NOTIFY_ENABLEDTooltip = Enable email notifications for import events
IMPORTZUGFERD_NOTIFY_EMAIL = Notification email
IMPORTZUGFERD_NOTIFY_EMAILTooltip = Email address for import notifications
IMPORTZUGFERD_NOTIFY_MANUAL = On manual intervention
IMPORTZUGFERD_NOTIFY_MANUALTooltip = Send email when an import requires manual intervention
IMPORTZUGFERD_NOTIFY_ERROR = On errors
IMPORTZUGFERD_NOTIFY_ERRORTooltip = Send email when an import error occurs
IMPORTZUGFERD_NOTIFY_PRICE_DIFF = On price differences
IMPORTZUGFERD_NOTIFY_PRICE_DIFFTooltip = Send email when product prices differ by more than the threshold
IMPORTZUGFERD_PRICE_DIFF_THRESHOLD = Price difference threshold (%)
IMPORTZUGFERD_PRICE_DIFF_THRESHOLDTooltip = Percentage price difference that triggers a notification
# Email content
NotifySubjectManualIntervention = Manual intervention required: Invoice %s
NotifySubjectError = Import error: %s
NotifySubjectPriceDiff = Price differences detected: Invoice %s (%s products)
NotifyBodyManualIntervention = The import of invoice %s from %s requires manual intervention.
NotifyBodyError = An error occurred while importing invoice/file %s.
NotifyBodyPriceDiff = Invoice %s from %s has price differences of more than %s%%.
NotifyLinkToImport = Link to import
OldPrice = Old price
NewPrice = New price
File = File
# Price comparison
DolibarrPrice = Dolibarr Price
PriceIncrease = Price increase
PriceDecrease = Price decrease
NoPriceFound = No price
# Test Email
TestEmailNotification = Test Email Notification
SendTestEmail = Send Test Email
TestEmailSent = Test email successfully sent to %s
TestEmailFailed = Failed to send test email
SendTo = Send to
NotifySubjectTest = Test Email Notification
NotifyBodyTest = This is a test email from the ZUGFeRD Import module.
NotifyTestInfo = This email confirms that email notifications are correctly configured.
NotifyTestSuccess = The email configuration is working properly!
CurrentSettings = Current settings
NotificationsNotEnabled = Notifications are not enabled or no email address configured
NotifyEmail = Recipient email

View file

@ -0,0 +1,13 @@
-- ============================================================================
-- Copyright (C) 2026 ZUGFeRD Import Module
-- ============================================================================
ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_fk_soc (fk_soc);
ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_article_number (article_number);
ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_ean (ean);
ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_manufacturer_ref (manufacturer_ref);
ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_matchcode (matchcode);
ALTER TABLE llx_importzugferd_datanorm ADD UNIQUE INDEX uk_datanorm_soc_article (fk_soc, article_number, entity);
ALTER TABLE llx_importzugferd_datanorm ADD CONSTRAINT fk_datanorm_fk_soc FOREIGN KEY (fk_soc) REFERENCES llx_societe(rowid);
ALTER TABLE llx_importzugferd_datanorm ADD CONSTRAINT fk_datanorm_fk_user_creat FOREIGN KEY (fk_user_creat) REFERENCES llx_user(rowid);

View file

@ -0,0 +1,34 @@
-- ============================================================================
-- Copyright (C) 2026 ZUGFeRD Import Module
--
-- Datanorm-Artikeltabelle: Importierte Artikeldaten aus Datanorm-Dateien
-- ============================================================================
CREATE TABLE llx_importzugferd_datanorm (
rowid integer AUTO_INCREMENT PRIMARY KEY NOT NULL,
fk_soc integer NOT NULL, -- Lieferant
article_number varchar(128) NOT NULL, -- Artikelnummer (Typ A Feld 2)
short_text1 varchar(255), -- Kurztext 1 (Typ A Feld 4)
short_text2 varchar(255), -- Kurztext 2 (Typ A Feld 5)
long_text text, -- Langtext (Typ B)
ean varchar(32), -- EAN/GTIN (Typ A Feld 17)
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)
discount_group varchar(32), -- Rabattgruppe (Typ A Feld 8)
product_group varchar(64), -- Warengruppe (Typ A Feld 9)
alt_unit varchar(8), -- Alternative Mengeneinheit
alt_unit_factor double(10,4) DEFAULT 1, -- Umrechnungsfaktor
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)
import_date datetime NOT NULL, -- Importzeitpunkt
active tinyint DEFAULT 1, -- Aktiv/Inaktiv
date_creation datetime NOT NULL,
tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
fk_user_creat integer,
fk_user_modif integer,
entity integer DEFAULT 1 NOT NULL
) ENGINE=innodb;