Komplett überarbeitet Import müsste laufen E-Mail Benachrichtigung,
Postfach und Ordner usw
This commit is contained in:
parent
424b2379ef
commit
e420698a58
20 changed files with 5702 additions and 19004 deletions
|
|
@ -3,7 +3,9 @@
|
|||
"allow": [
|
||||
"Bash(pdfdetach:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(xmllint:*)"
|
||||
"Bash(xmllint:*)",
|
||||
"Bash(php -r:*)",
|
||||
"Bash(chmod:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
2025-12-15 - Zugferd Rechnung - Witte - 2489252 - 169,50 EUR.pdf
Executable file
BIN
2025-12-15 - Zugferd Rechnung - Witte - 2489252 - 169,50 EUR.pdf
Executable file
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
551
admin/setup.php
551
admin/setup.php
|
|
@ -100,6 +100,40 @@ $formSetup->newItem('ImportSettings')->setAsTitle();
|
|||
|
||||
$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
|
||||
$formSetup->newItem('FolderImportSettings')->setAsTitle();
|
||||
|
||||
|
|
@ -113,10 +147,25 @@ $item->defaultFieldValue = '';
|
|||
$item->cssClass = 'minwidth400';
|
||||
$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->defaultFieldValue = 'Archive';
|
||||
$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
|
||||
*/
|
||||
|
|
@ -127,6 +176,69 @@ if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'upda
|
|||
|
||||
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
|
||||
*/
|
||||
|
|
@ -151,6 +263,229 @@ print '<span class="opacitymedium">'.$langs->trans("ImportZugferdSetupPage").'</
|
|||
// Display the form
|
||||
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
|
||||
if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
|
||||
print '<br>';
|
||||
|
|
@ -277,6 +612,222 @@ if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
|
|||
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>';
|
||||
|
||||
// Page end
|
||||
|
|
|
|||
26
batch.php
26
batch.php
|
|
@ -80,6 +80,7 @@ if ($action == 'process') {
|
|||
// Import from local folder
|
||||
$watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
|
||||
$archive_folder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
|
||||
$error_folder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
|
||||
|
||||
if (empty($watch_folder) || !is_dir($watch_folder)) {
|
||||
setEventMessages($langs->trans('ErrorWatchFolderNotConfigured'), null, 'errors');
|
||||
|
|
@ -89,6 +90,10 @@ if ($action == 'process') {
|
|||
if (!empty($archive_folder) && !is_dir($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
|
||||
$files = glob($watch_folder . '/*.pdf');
|
||||
|
|
@ -115,17 +120,32 @@ if ($action == 'process') {
|
|||
|
||||
// Move to archive
|
||||
if (!empty($archive_folder) && is_dir($archive_folder)) {
|
||||
$archive_path = $archive_folder . '/' . basename($pdf_path);
|
||||
if (rename($pdf_path, $archive_path)) {
|
||||
$archive_path = $archive_folder . '/success_' . 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;
|
||||
}
|
||||
}
|
||||
} elseif ($res == -3) {
|
||||
// Duplicate
|
||||
// Duplicate - move to archive (already imported)
|
||||
$result['status'] = 'skipped';
|
||||
$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 {
|
||||
// Error - move to error folder
|
||||
$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;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
|
|||
|
||||
/**
|
||||
* Class CronImportZugferd
|
||||
* Cronjob handler for fetching ZUGFeRD invoices from IMAP mailbox
|
||||
* Cronjob handler for fetching ZUGFeRD invoices from IMAP mailbox and folder
|
||||
*/
|
||||
class CronImportZugferd
|
||||
{
|
||||
|
|
@ -70,6 +70,180 @@ class CronImportZugferd
|
|||
$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
|
||||
*
|
||||
|
|
@ -296,4 +470,68 @@ class CronImportZugferd
|
|||
'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
989
class/datanorm.class.php
Normal 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;
|
||||
}
|
||||
}
|
||||
909
class/datanormparser.class.php
Normal file
909
class/datanormparser.class.php
Normal 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;
|
||||
}
|
||||
}
|
||||
389
class/importnotification.class.php
Normal file
389
class/importnotification.class.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -362,25 +362,27 @@ class ZugferdImport extends CommonObject
|
|||
$sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET";
|
||||
$sql .= " ref = '" . $this->db->escape($this->ref) . "',";
|
||||
$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_vat = '" . $this->db->escape($this->seller_vat) . "',";
|
||||
$sql .= " buyer_reference = '" . $this->db->escape($this->buyer_reference) . "',";
|
||||
$sql .= " total_ht = " . price2num($this->total_ht) . ",";
|
||||
$sql .= " total_ttc = " . price2num($this->total_ttc) . ",";
|
||||
$sql .= " currency = '" . $this->db->escape($this->currency) . "',";
|
||||
$sql .= " fk_soc = " . ($this->fk_soc > 0 ? $this->fk_soc : "null") . ",";
|
||||
$sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? $this->fk_facture_fourn : "null") . ",";
|
||||
$sql .= " fk_soc = " . ($this->fk_soc > 0 ? (int) $this->fk_soc : "null") . ",";
|
||||
$sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? (int) $this->fk_facture_fourn : "null") . ",";
|
||||
$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 .= " 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);
|
||||
|
||||
if (!$resql) {
|
||||
$this->error = $this->db->lasterror();
|
||||
dol_syslog(get_class($this) . "::update error=" . $this->error, LOG_ERR);
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
@ -514,6 +516,211 @@ class ZugferdImport extends CommonObject
|
|||
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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class modImportZugferd extends DolibarrModules
|
|||
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@importzugferd'
|
||||
|
||||
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
|
||||
$this->version = '1.1';
|
||||
$this->version = '2.0';
|
||||
// Url to the file with your last numberversion of this module
|
||||
//$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.
|
||||
$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.
|
||||
$this->config_page_url = array("setup.php@importzugferd");
|
||||
|
|
@ -268,16 +268,16 @@ class modImportZugferd extends DolibarrModules
|
|||
/* BEGIN MODULEBUILDER CRON */
|
||||
$this->cronjobs = array(
|
||||
0 => array(
|
||||
'label' => 'ImportZugferdFromMailbox',
|
||||
'label' => 'ImportZugferdScheduled',
|
||||
'jobtype' => 'method',
|
||||
'class' => '/importzugferd/class/cron_importzugferd.class.php',
|
||||
'objectname' => 'CronImportZugferd',
|
||||
'method' => 'fetchFromMailbox',
|
||||
'method' => 'runScheduledImport',
|
||||
'parameters' => '',
|
||||
'comment' => 'Fetch ZUGFeRD invoices from configured mailbox',
|
||||
'comment' => 'Scheduled import from folder and mailbox (frequency controlled by module settings)',
|
||||
'frequency' => 15,
|
||||
'unitfrequency' => 60,
|
||||
'status' => 0,
|
||||
'status' => 1,
|
||||
'test' => 'isModEnabled("importzugferd")',
|
||||
'priority' => 50,
|
||||
),
|
||||
|
|
@ -316,6 +316,12 @@ class modImportZugferd extends DolibarrModules
|
|||
$this->rights[$r][5] = 'write';
|
||||
$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
|
||||
$this->menu = array();
|
||||
|
|
@ -407,6 +413,23 @@ class modImportZugferd extends DolibarrModules
|
|||
'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
|
||||
$r = 0;
|
||||
|
|
|
|||
309
datanorm.php
Normal file
309
datanorm.php
Normal 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
258
datanorm_list.php
Normal 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
868
datanorm_update.php
Normal 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 ' ';
|
||||
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 ' ';
|
||||
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 ' ';
|
||||
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 ' ';
|
||||
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 ' ';
|
||||
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 ' <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 ' ';
|
||||
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;
|
||||
}
|
||||
621
import.php
621
import.php
|
|
@ -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/productmapping.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');
|
||||
|
||||
// Load translation files
|
||||
|
|
@ -81,6 +84,7 @@ $formfile = new FormFile($db);
|
|||
$actions = new ActionsImportZugferd($db);
|
||||
$import = new ZugferdImport($db);
|
||||
$importLine = new ImportLine($db);
|
||||
$notification = new ImportNotification($db);
|
||||
|
||||
$error = 0;
|
||||
$message = '';
|
||||
|
|
@ -160,15 +164,19 @@ if ($action == 'upload') {
|
|||
|
||||
// Check if all lines have products
|
||||
$all_have_products = true;
|
||||
$has_any_product = false;
|
||||
$total_lines = count($processed_lines);
|
||||
foreach ($processed_lines as $line) {
|
||||
if ($line['fk_product'] <= 0) {
|
||||
$all_have_products = false;
|
||||
break;
|
||||
} else {
|
||||
$has_any_product = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
} else {
|
||||
$import->status = ZugferdImport::STATUS_PENDING;
|
||||
|
|
@ -207,6 +215,18 @@ if ($action == 'upload') {
|
|||
}
|
||||
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
|
||||
$id = $import->id;
|
||||
$action = 'edit';
|
||||
|
|
@ -215,6 +235,8 @@ if ($action == 'upload') {
|
|||
$error++;
|
||||
$message = $import->error;
|
||||
@unlink($destfile);
|
||||
// Send error notification
|
||||
$notification->sendErrorNotification($import, $message, $filename);
|
||||
}
|
||||
} else {
|
||||
$error++;
|
||||
|
|
@ -270,6 +292,10 @@ if ($action == 'assignproduct' && $line_id > 0 && $product_id > 0) {
|
|||
if ($import->status == ZugferdImport::STATUS_PENDING) {
|
||||
$import->status = ZugferdImport::STATUS_IMPORTED;
|
||||
$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);
|
||||
}
|
||||
|
||||
// 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
|
||||
if ($action == 'createinvoice' && $id > 0) {
|
||||
$import->fetch($id);
|
||||
|
|
@ -435,8 +800,7 @@ if ($action == 'createinvoice' && $id > 0) {
|
|||
}
|
||||
|
||||
if (!$error) {
|
||||
// Validate invoice
|
||||
$invoice->validate($user);
|
||||
// Invoice stays as draft - user can validate manually
|
||||
|
||||
// Copy PDF to invoice
|
||||
$source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename;
|
||||
|
|
@ -473,6 +837,63 @@ if ($action == 'createinvoice' && $id > 0) {
|
|||
$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
|
||||
if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0) {
|
||||
$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) {
|
||||
// Delete confirmation
|
||||
if ($action == 'delete') {
|
||||
if ($action == 'delete' && $id > 0) {
|
||||
$import->fetch($id);
|
||||
$formconfirm = $form->formconfirm(
|
||||
$_SERVER['PHP_SELF'].'?id='.$import->id,
|
||||
$langs->trans('DeleteImportRecord'),
|
||||
$langs->trans('ConfirmDeleteImportRecord', $import->ref),
|
||||
'confirm_delete'
|
||||
'confirm_delete',
|
||||
'',
|
||||
0,
|
||||
1
|
||||
);
|
||||
print $formconfirm;
|
||||
}
|
||||
$action = 'edit'; // Continue showing the edit form
|
||||
}
|
||||
|
||||
/*
|
||||
* Edit/Review import
|
||||
*/
|
||||
if ($action == 'edit' && $import->id > 0) {
|
||||
// Fetch lines
|
||||
$lines = $importLine->fetchAllByImport($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 class="right">'.$langs->trans('Qty').'</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>'.$langs->trans('MatchedProduct').'</td>';
|
||||
print '<td>'.$langs->trans('Action').'</td>';
|
||||
print '</tr>';
|
||||
|
||||
// Initialize totals for summary row
|
||||
$totalDolibarrHT = 0;
|
||||
$totalZugferdHT = 0;
|
||||
$hasDolibarrPrices = false;
|
||||
$allProductsMatched = true;
|
||||
$matchedLinesCount = 0;
|
||||
$totalLinesCount = count($lines);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$hasProduct = ($line->fk_product > 0);
|
||||
$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 '</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>';
|
||||
|
||||
|
|
@ -810,11 +1301,93 @@ if ($action == 'edit' && $import->id > 0) {
|
|||
print '<i class="fas fa-copy"></i>';
|
||||
print '</button>';
|
||||
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 '</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 '</div>';
|
||||
|
||||
|
|
@ -828,9 +1401,33 @@ if ($action == 'edit' && $import->id > 0) {
|
|||
print ' ';
|
||||
}
|
||||
|
||||
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 ' ';
|
||||
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 ' ';
|
||||
}
|
||||
|
||||
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 ' ';
|
||||
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>';
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ IMPORTZUGFERD_WATCH_FOLDER = Überwachungsordner
|
|||
IMPORTZUGFERD_WATCH_FOLDERTooltip = Ordner für eingehende ZUGFeRD-Rechnungen (lokaler Pfad)
|
||||
IMPORTZUGFERD_ARCHIVE_FOLDER = Archivordner
|
||||
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_FOLDERTooltip = IMAP-Ordner für archivierte E-Mails nach Import
|
||||
|
||||
|
|
@ -109,6 +111,7 @@ SupplierCustomerNumberHelp = Ihre Kundennummer bei diesem Lieferanten (für auto
|
|||
# Cronjob
|
||||
#
|
||||
ImportZugferdFromMailbox = ZUGFeRD Rechnungen aus Postfach importieren
|
||||
ImportZugferdScheduled = ZUGFeRD geplanter Import (Ordner und E-Mail)
|
||||
|
||||
#
|
||||
# Fehler
|
||||
|
|
@ -213,3 +216,131 @@ ImportRecordCreated = Import-Datensatz erstellt
|
|||
ErrorNotAllProductsAssigned = Nicht alle Produkte zugeordnet
|
||||
BackToList = Zurück zur Liste
|
||||
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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ IMPORTZUGFERD_WATCH_FOLDER = Watch Folder
|
|||
IMPORTZUGFERD_WATCH_FOLDERTooltip = Folder for incoming ZUGFeRD invoices (local path)
|
||||
IMPORTZUGFERD_ARCHIVE_FOLDER = Archive Folder
|
||||
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_FOLDERTooltip = IMAP folder for archived emails after import
|
||||
|
||||
|
|
@ -109,6 +111,7 @@ SupplierCustomerNumberHelp = Your customer number at this supplier (used for aut
|
|||
# Cronjob
|
||||
#
|
||||
ImportZugferdFromMailbox = Import ZUGFeRD invoices from mailbox
|
||||
ImportZugferdScheduled = ZUGFeRD scheduled import (folder and email)
|
||||
|
||||
#
|
||||
# Errors
|
||||
|
|
@ -213,3 +216,131 @@ ImportRecordCreated = Import record created
|
|||
ErrorNotAllProductsAssigned = Not all products assigned
|
||||
BackToList = Back to list
|
||||
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
|
||||
|
|
|
|||
13
sql/llx_importzugferd_datanorm.key.sql
Normal file
13
sql/llx_importzugferd_datanorm.key.sql
Normal 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);
|
||||
34
sql/llx_importzugferd_datanorm.sql
Normal file
34
sql/llx_importzugferd_datanorm.sql
Normal 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;
|
||||
Loading…
Reference in a new issue