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": [
|
"allow": [
|
||||||
"Bash(pdfdetach:*)",
|
"Bash(pdfdetach:*)",
|
||||||
"Bash(python3:*)",
|
"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();
|
$formSetup->newItem('IMPORTZUGFERD_AUTO_CREATE_INVOICE')->setAsYesNo();
|
||||||
|
|
||||||
|
// Email Notification Settings Section
|
||||||
|
$formSetup->newItem('NotificationSettings')->setAsTitle();
|
||||||
|
|
||||||
|
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_ENABLED')->setAsYesNo();
|
||||||
|
|
||||||
|
$item = $formSetup->newItem('IMPORTZUGFERD_NOTIFY_EMAIL');
|
||||||
|
$item->defaultFieldValue = '';
|
||||||
|
$item->cssClass = 'minwidth300';
|
||||||
|
$item->fieldAttr['placeholder'] = 'admin@example.com';
|
||||||
|
|
||||||
|
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_MANUAL')->setAsYesNo();
|
||||||
|
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_ERROR')->setAsYesNo();
|
||||||
|
$formSetup->newItem('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')->setAsYesNo();
|
||||||
|
|
||||||
|
$item = $formSetup->newItem('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD');
|
||||||
|
$item->defaultFieldValue = '10';
|
||||||
|
$item->cssClass = 'width75';
|
||||||
|
$item->fieldAttr['type'] = 'number';
|
||||||
|
$item->fieldAttr['min'] = '0';
|
||||||
|
$item->fieldAttr['max'] = '100';
|
||||||
|
$item->fieldAttr['step'] = '1';
|
||||||
|
|
||||||
|
// Scheduling Settings Section
|
||||||
|
$formSetup->newItem('SchedulingSettings')->setAsTitle();
|
||||||
|
|
||||||
|
$item = $formSetup->newItem('IMPORTZUGFERD_IMPORT_FREQUENCY');
|
||||||
|
$item->setAsSelect(array(
|
||||||
|
'manual' => $langs->trans('FrequencyManual'),
|
||||||
|
'hourly' => $langs->trans('FrequencyHourly'),
|
||||||
|
'daily' => $langs->trans('FrequencyDaily'),
|
||||||
|
'weekly' => $langs->trans('FrequencyWeekly')
|
||||||
|
));
|
||||||
|
$item->defaultFieldValue = 'manual';
|
||||||
|
|
||||||
// Folder Import Settings Section
|
// Folder Import Settings Section
|
||||||
$formSetup->newItem('FolderImportSettings')->setAsTitle();
|
$formSetup->newItem('FolderImportSettings')->setAsTitle();
|
||||||
|
|
||||||
|
|
@ -113,10 +147,25 @@ $item->defaultFieldValue = '';
|
||||||
$item->cssClass = 'minwidth400';
|
$item->cssClass = 'minwidth400';
|
||||||
$item->fieldAttr['placeholder'] = '/path/to/archive';
|
$item->fieldAttr['placeholder'] = '/path/to/archive';
|
||||||
|
|
||||||
|
$item = $formSetup->newItem('IMPORTZUGFERD_ERROR_FOLDER');
|
||||||
|
$item->defaultFieldValue = '';
|
||||||
|
$item->cssClass = 'minwidth400';
|
||||||
|
$item->fieldAttr['placeholder'] = '/path/to/errors';
|
||||||
|
|
||||||
$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER');
|
$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER');
|
||||||
$item->defaultFieldValue = 'Archive';
|
$item->defaultFieldValue = 'Archive';
|
||||||
$item->cssClass = 'minwidth200';
|
$item->cssClass = 'minwidth200';
|
||||||
|
|
||||||
|
// Datanorm Settings Section
|
||||||
|
$formSetup->newItem('DatanormSettings')->setAsTitle();
|
||||||
|
|
||||||
|
$item = $formSetup->newItem('IMPORTZUGFERD_DATANORM_MARKUP');
|
||||||
|
$item->defaultFieldValue = '30';
|
||||||
|
$item->cssClass = 'width100';
|
||||||
|
$item->fieldAttr['placeholder'] = '30';
|
||||||
|
|
||||||
|
$formSetup->newItem('IMPORTZUGFERD_DATANORM_SEARCH_ALL')->setAsYesNo();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Actions
|
* Actions
|
||||||
*/
|
*/
|
||||||
|
|
@ -127,6 +176,69 @@ if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'upda
|
||||||
|
|
||||||
include DOL_DOCUMENT_ROOT.'/core/actions_setmoduleoptions.inc.php';
|
include DOL_DOCUMENT_ROOT.'/core/actions_setmoduleoptions.inc.php';
|
||||||
|
|
||||||
|
// AJAX action for folder browsing
|
||||||
|
if ($action == 'browse_folders') {
|
||||||
|
$path = GETPOST('path', 'alpha');
|
||||||
|
$target = GETPOST('target', 'alpha');
|
||||||
|
|
||||||
|
// Sanitize path - default to /home for easier navigation
|
||||||
|
if (empty($path)) {
|
||||||
|
$path = '/home';
|
||||||
|
}
|
||||||
|
$path = realpath($path);
|
||||||
|
if ($path === false) {
|
||||||
|
$path = '/home';
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
$path = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get directories
|
||||||
|
$dirs = array();
|
||||||
|
if (is_dir($path) && is_readable($path)) {
|
||||||
|
$entries = @scandir($path);
|
||||||
|
if ($entries) {
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry == '.') continue;
|
||||||
|
$fullPath = $path . '/' . $entry;
|
||||||
|
if (is_dir($fullPath) && is_readable($fullPath)) {
|
||||||
|
$dirs[] = array(
|
||||||
|
'name' => $entry,
|
||||||
|
'path' => $fullPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return JSON
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(array(
|
||||||
|
'current' => $path,
|
||||||
|
'parent' => dirname($path),
|
||||||
|
'dirs' => $dirs,
|
||||||
|
'target' => $target
|
||||||
|
));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save folder from browser
|
||||||
|
if ($action == 'set_folder') {
|
||||||
|
$target = GETPOST('target', 'alpha');
|
||||||
|
$folder_path = GETPOST('folder_path', 'alpha');
|
||||||
|
|
||||||
|
if (in_array($target, array('IMPORTZUGFERD_WATCH_FOLDER', 'IMPORTZUGFERD_ARCHIVE_FOLDER'))) {
|
||||||
|
if (is_dir($folder_path)) {
|
||||||
|
dolibarr_set_const($db, $target, $folder_path, 'chaine', 0, '', $conf->entity);
|
||||||
|
setEventMessages($langs->trans('FolderSelected').': '.$folder_path, null, 'mesgs');
|
||||||
|
} else {
|
||||||
|
setEventMessages($langs->trans('ErrorFolderNotFound'), null, 'errors');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: '.$_SERVER['PHP_SELF']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* View
|
* View
|
||||||
*/
|
*/
|
||||||
|
|
@ -151,6 +263,229 @@ print '<span class="opacitymedium">'.$langs->trans("ImportZugferdSetupPage").'</
|
||||||
// Display the form
|
// Display the form
|
||||||
print $formSetup->generateOutput(true);
|
print $formSetup->generateOutput(true);
|
||||||
|
|
||||||
|
// Build folder validation data for JavaScript
|
||||||
|
$folderValidation = array();
|
||||||
|
|
||||||
|
// Watch folder - only needs to be readable
|
||||||
|
$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
|
||||||
|
if (!empty($watchFolder)) {
|
||||||
|
$watchExists = is_dir($watchFolder);
|
||||||
|
$watchReadable = $watchExists && is_readable($watchFolder);
|
||||||
|
if (!$watchExists) {
|
||||||
|
$folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound'));
|
||||||
|
} elseif (!$watchReadable) {
|
||||||
|
$folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotReadable'));
|
||||||
|
} else {
|
||||||
|
$files = glob($watchFolder.'/*.pdf');
|
||||||
|
$files = array_merge($files ?: [], glob($watchFolder.'/*.PDF') ?: []);
|
||||||
|
$folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK').' ('.count($files).' PDF)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive folder - needs to be writable
|
||||||
|
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
|
||||||
|
if (!empty($archiveFolder)) {
|
||||||
|
$archiveExists = is_dir($archiveFolder);
|
||||||
|
$archiveWritable = $archiveExists && is_writable($archiveFolder);
|
||||||
|
if (!$archiveExists) {
|
||||||
|
$folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound'));
|
||||||
|
} elseif (!$archiveWritable) {
|
||||||
|
$folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotWritable'));
|
||||||
|
} else {
|
||||||
|
$folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error folder - needs to be writable
|
||||||
|
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
|
||||||
|
if (!empty($errorFolder)) {
|
||||||
|
$errorExists = is_dir($errorFolder);
|
||||||
|
$errorWritable = $errorExists && is_writable($errorFolder);
|
||||||
|
if (!$errorExists) {
|
||||||
|
$folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound'));
|
||||||
|
} elseif (!$errorWritable) {
|
||||||
|
$folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotWritable'));
|
||||||
|
} else {
|
||||||
|
$folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder Browser Modal
|
||||||
|
print '
|
||||||
|
<div id="folderBrowserModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:9999;">
|
||||||
|
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); background:white; padding:20px; border-radius:8px; min-width:500px; max-width:80%; max-height:80%; overflow:auto; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||||
|
<h3 style="margin-top:0;"><i class="fas fa-folder-open paddingright"></i>'.$langs->trans('SelectFolder').'</h3>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<input type="text" id="pathInput" style="width:80%; font-family:monospace;" placeholder="/path/to/folder">
|
||||||
|
<a class="button buttongen smallpaddingimp" href="#" onclick="goToPath(); return false;" title="'.$langs->trans('Go').'"><i class="fas fa-arrow-right"></i></a>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:5px;">
|
||||||
|
<span class="opacitymedium">'.$langs->trans('QuickLinks').':</span>
|
||||||
|
<a href="#" onclick="loadFolderContents(\'/home\'); return false;" class="paddingleft">/home</a>
|
||||||
|
<a href="#" onclick="loadFolderContents(\'/srv\'); return false;" class="paddingleft">/srv</a>
|
||||||
|
<a href="#" onclick="loadFolderContents(\'/var\'); return false;" class="paddingleft">/var</a>
|
||||||
|
<a href="#" onclick="loadFolderContents(\'/tmp\'); return false;" class="paddingleft">/tmp</a>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #ccc; padding:10px; max-height:300px; overflow-y:auto; background:#f9f9f9;" id="folderList">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:15px; text-align:right;">
|
||||||
|
<input type="hidden" id="folderTarget" value="">
|
||||||
|
<a class="button" href="#" onclick="selectCurrentFolder(); return false;"><i class="fas fa-check paddingright"></i>'.$langs->trans('SelectThisFolder').'</a>
|
||||||
|
<a class="button" href="#" onclick="closeFolderBrowser(); return false;">'.$langs->trans('Cancel').'</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Folder validation data from PHP
|
||||||
|
var folderValidation = '.json_encode($folderValidation).';
|
||||||
|
|
||||||
|
// Add browse buttons and validation icons next to folder input fields
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
var folderFields = ["IMPORTZUGFERD_WATCH_FOLDER", "IMPORTZUGFERD_ARCHIVE_FOLDER", "IMPORTZUGFERD_ERROR_FOLDER"];
|
||||||
|
folderFields.forEach(function(fieldName) {
|
||||||
|
var input = document.querySelector("input[name=\"" + fieldName + "\"]");
|
||||||
|
if (input) {
|
||||||
|
// Add browse button
|
||||||
|
var btn = document.createElement("a");
|
||||||
|
btn.href = "#";
|
||||||
|
btn.className = "button buttongen smallpaddingimp";
|
||||||
|
btn.style.marginLeft = "5px";
|
||||||
|
btn.innerHTML = "<i class=\"fas fa-folder-open\"></i>";
|
||||||
|
btn.title = "'.$langs->trans('Browse').'";
|
||||||
|
btn.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var startPath = input.value || "/home";
|
||||||
|
openFolderBrowser(fieldName, startPath);
|
||||||
|
};
|
||||||
|
input.parentNode.insertBefore(btn, input.nextSibling);
|
||||||
|
|
||||||
|
// Add validation icon if folder is configured
|
||||||
|
if (folderValidation[fieldName]) {
|
||||||
|
var validIcon = document.createElement("span");
|
||||||
|
validIcon.style.marginLeft = "10px";
|
||||||
|
validIcon.id = "validation_" + fieldName;
|
||||||
|
if (folderValidation[fieldName].ok) {
|
||||||
|
validIcon.innerHTML = "<i class=\"fas fa-check-circle\" style=\"color:green;\"></i> <span class=\"opacitymedium\">" + folderValidation[fieldName].msg + "</span>";
|
||||||
|
} else {
|
||||||
|
validIcon.innerHTML = "<i class=\"fas fa-times-circle\" style=\"color:red;\"></i> <span style=\"color:red;\">" + folderValidation[fieldName].msg + "</span>";
|
||||||
|
}
|
||||||
|
btn.parentNode.insertBefore(validIcon, btn.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openFolderBrowser(target, startPath) {
|
||||||
|
document.getElementById("folderTarget").value = target;
|
||||||
|
document.getElementById("folderBrowserModal").style.display = "block";
|
||||||
|
document.getElementById("pathInput").value = startPath || "/home";
|
||||||
|
loadFolderContents(startPath || "/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPath() {
|
||||||
|
var path = document.getElementById("pathInput").value;
|
||||||
|
if (path) {
|
||||||
|
loadFolderContents(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFolderBrowser() {
|
||||||
|
document.getElementById("folderBrowserModal").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFolderContents(path) {
|
||||||
|
var target = document.getElementById("folderTarget").value;
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", "'.$_SERVER['PHP_SELF'].'?action=browse_folders&path=" + encodeURIComponent(path) + "&target=" + target + "&token='.newToken().'", true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||||
|
var data = JSON.parse(xhr.responseText);
|
||||||
|
document.getElementById("pathInput").value = data.current;
|
||||||
|
|
||||||
|
var html = "";
|
||||||
|
if (data.current !== "/" && data.parent) {
|
||||||
|
html += "<div style=\"padding:5px; cursor:pointer; border-bottom:1px solid #eee;\" onclick=\"loadFolderContents(\'" + data.parent.replace(/\'/g, "\\\'") + "\')\">";
|
||||||
|
html += "<i class=\"fas fa-level-up-alt paddingright\"></i><strong>..</strong> ('.$langs->trans('ParentFolder').')";
|
||||||
|
html += "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < data.dirs.length; i++) {
|
||||||
|
var dir = data.dirs[i];
|
||||||
|
html += "<div style=\"padding:5px; cursor:pointer; border-bottom:1px solid #eee;\" onclick=\"loadFolderContents(\'" + dir.path.replace(/\'/g, "\\\'") + "\')\" onmouseover=\"this.style.background=\'#e8f4fc\'\" onmouseout=\"this.style.background=\'transparent\'\">";
|
||||||
|
html += "<i class=\"fas fa-folder paddingright\" style=\"color:#f0ad4e;\"></i>" + dir.name;
|
||||||
|
html += "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.dirs.length === 0 && data.current !== "/") {
|
||||||
|
html += "<div style=\"padding:10px; color:#666; text-align:center;\">'.$langs->trans('NoSubfolders').'</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("folderList").innerHTML = html;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCurrentFolder() {
|
||||||
|
var path = document.getElementById("pathInput").value;
|
||||||
|
var target = document.getElementById("folderTarget").value;
|
||||||
|
// Update the input field directly
|
||||||
|
var input = document.querySelector("input[name=\"" + target + "\"]");
|
||||||
|
if (input) {
|
||||||
|
input.value = path;
|
||||||
|
}
|
||||||
|
closeFolderBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
document.addEventListener("keydown", function(e) {
|
||||||
|
if (e.key === "Escape") closeFolderBrowser();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on background click
|
||||||
|
document.getElementById("folderBrowserModal").addEventListener("click", function(e) {
|
||||||
|
if (e.target === this) closeFolderBrowser();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
';
|
||||||
|
|
||||||
|
// Email Notification Test Section
|
||||||
|
if (getDolGlobalString('IMPORTZUGFERD_NOTIFY_ENABLED') && getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL')) {
|
||||||
|
print '<br>';
|
||||||
|
print '<div class="div-table-responsive-no-min">';
|
||||||
|
print '<table class="noborder centpercent">';
|
||||||
|
print '<tr class="liste_titre">';
|
||||||
|
print '<td colspan="2">'.$langs->trans('TestEmailNotification').'</td>';
|
||||||
|
print '</tr>';
|
||||||
|
|
||||||
|
// Handle test email action
|
||||||
|
if ($action == 'test_email') {
|
||||||
|
dol_include_once('/importzugferd/class/importnotification.class.php');
|
||||||
|
$notification = new ImportNotification($db);
|
||||||
|
$result = $notification->sendTestNotification();
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
setEventMessages($langs->trans('TestEmailSent', getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL')), null, 'mesgs');
|
||||||
|
} else {
|
||||||
|
setEventMessages($langs->trans('TestEmailFailed').': '.$notification->error, null, 'errors');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print '<tr class="oddeven">';
|
||||||
|
print '<td class="titlefield">'.$langs->trans('SendTestEmail').'</td>';
|
||||||
|
print '<td>';
|
||||||
|
print '<a class="button" href="'.$_SERVER['PHP_SELF'].'?action=test_email&token='.newToken().'">';
|
||||||
|
print '<i class="fas fa-paper-plane paddingright"></i>'.$langs->trans('SendTestEmail');
|
||||||
|
print '</a>';
|
||||||
|
print ' <span class="opacitymedium">'.$langs->trans('SendTo').': '.getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL').'</span>';
|
||||||
|
print '</td>';
|
||||||
|
print '</tr>';
|
||||||
|
|
||||||
|
print '</table>';
|
||||||
|
print '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Test IMAP connection button and folder selection
|
// Test IMAP connection button and folder selection
|
||||||
if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
|
if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
|
||||||
print '<br>';
|
print '<br>';
|
||||||
|
|
@ -277,6 +612,222 @@ if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) {
|
||||||
print '</div>';
|
print '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual Import Trigger Section
|
||||||
|
$hasFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER') && is_dir(getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'));
|
||||||
|
$hasImap = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST') && getDolGlobalString('IMPORTZUGFERD_IMAP_USER');
|
||||||
|
|
||||||
|
if ($hasFolder || $hasImap) {
|
||||||
|
print '<br>';
|
||||||
|
print '<div class="div-table-responsive-no-min">';
|
||||||
|
print '<table class="noborder centpercent">';
|
||||||
|
print '<tr class="liste_titre">';
|
||||||
|
print '<td colspan="2">'.$langs->trans('ManualImportTrigger').'</td>';
|
||||||
|
print '</tr>';
|
||||||
|
|
||||||
|
// Handle manual import action
|
||||||
|
if ($action == 'run_import') {
|
||||||
|
$source = GETPOST('import_source', 'alpha');
|
||||||
|
dol_include_once('/importzugferd/class/zugferdimport.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/zugferdparser.class.php');
|
||||||
|
|
||||||
|
$successCount = 0;
|
||||||
|
$errorCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
if ($source == 'folder' && $hasFolder) {
|
||||||
|
$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
|
||||||
|
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
|
||||||
|
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
|
||||||
|
$autoCreate = getDolGlobalInt('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
|
||||||
|
|
||||||
|
$files = glob($watchFolder.'/*.pdf');
|
||||||
|
$files = array_merge($files, glob($watchFolder.'/*.PDF'));
|
||||||
|
|
||||||
|
// Create archive folder if configured but doesn't exist
|
||||||
|
if (!empty($archiveFolder) && !is_dir($archiveFolder)) {
|
||||||
|
dol_mkdir($archiveFolder);
|
||||||
|
}
|
||||||
|
// Create error folder if configured but doesn't exist
|
||||||
|
if (!empty($errorFolder) && !is_dir($errorFolder)) {
|
||||||
|
dol_mkdir($errorFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for moving files with fallback
|
||||||
|
$moveFile = function($file, $targetFolder, $prefix) {
|
||||||
|
if (empty($targetFolder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_dir($targetFolder)) {
|
||||||
|
dol_mkdir($targetFolder);
|
||||||
|
}
|
||||||
|
if (!is_dir($targetFolder) || !is_writable($targetFolder)) {
|
||||||
|
dol_syslog("ImportZugferd: Target folder not accessible: ".$targetFolder, LOG_WARNING);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$destFile = $targetFolder.'/'.$prefix.date('Y-m-d_His').'_'.basename($file);
|
||||||
|
if (@rename($file, $destFile)) {
|
||||||
|
dol_syslog("ImportZugferd: Moved file to: ".$destFile, LOG_INFO);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback: copy + delete (for cross-filesystem moves)
|
||||||
|
if (@copy($file, $destFile)) {
|
||||||
|
@unlink($file);
|
||||||
|
dol_syslog("ImportZugferd: Copied file to: ".$destFile, LOG_INFO);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
dol_syslog("ImportZugferd: Failed to move file: ".$file." to ".$destFile, LOG_ERR);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$import = new ZugferdImport($db);
|
||||||
|
$result = $import->importFromFile($user, $file, $autoCreate);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
$successCount++;
|
||||||
|
$moveFile($file, $archiveFolder, 'imported_');
|
||||||
|
} elseif ($result == -2) {
|
||||||
|
// Duplicate - move to archive
|
||||||
|
$skippedCount++;
|
||||||
|
if (!$moveFile($file, $archiveFolder, 'duplicate_')) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Error - move to error folder, fallback to archive
|
||||||
|
$errorCount++;
|
||||||
|
if (!$moveFile($file, $errorFolder, 'error_')) {
|
||||||
|
if (!$moveFile($file, $archiveFolder, 'error_')) {
|
||||||
|
dol_syslog("ImportZugferd: File stays in watch folder: ".$file, LOG_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($source == 'imap' && $hasImap && function_exists('imap_open')) {
|
||||||
|
$host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST');
|
||||||
|
$port = getDolGlobalString('IMPORTZUGFERD_IMAP_PORT', '993');
|
||||||
|
$imap_user = getDolGlobalString('IMPORTZUGFERD_IMAP_USER');
|
||||||
|
$password = getDolGlobalString('IMPORTZUGFERD_IMAP_PASSWORD');
|
||||||
|
$ssl = getDolGlobalString('IMPORTZUGFERD_IMAP_SSL');
|
||||||
|
$folder = getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX');
|
||||||
|
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER');
|
||||||
|
$autoCreate = getDolGlobalInt('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
|
||||||
|
|
||||||
|
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
|
||||||
|
$connection = @imap_open($mailbox, $imap_user, $password);
|
||||||
|
|
||||||
|
if ($connection) {
|
||||||
|
$emails = imap_search($connection, 'UNSEEN');
|
||||||
|
if ($emails) {
|
||||||
|
foreach ($emails as $email_number) {
|
||||||
|
$structure = imap_fetchstructure($connection, $email_number);
|
||||||
|
|
||||||
|
// Find PDF attachments
|
||||||
|
if (isset($structure->parts)) {
|
||||||
|
foreach ($structure->parts as $partIndex => $part) {
|
||||||
|
$filename = '';
|
||||||
|
if ($part->ifdparameters) {
|
||||||
|
foreach ($part->dparameters as $param) {
|
||||||
|
if (strtolower($param->attribute) == 'filename') {
|
||||||
|
$filename = $param->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($filename) && $part->ifparameters) {
|
||||||
|
foreach ($part->parameters as $param) {
|
||||||
|
if (strtolower($param->attribute) == 'name') {
|
||||||
|
$filename = $param->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filename) && preg_match('/\.pdf$/i', $filename)) {
|
||||||
|
$attachment = imap_fetchbody($connection, $email_number, $partIndex + 1);
|
||||||
|
if ($part->encoding == 3) { // BASE64
|
||||||
|
$attachment = base64_decode($attachment);
|
||||||
|
} elseif ($part->encoding == 4) { // QUOTED-PRINTABLE
|
||||||
|
$attachment = quoted_printable_decode($attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file
|
||||||
|
$tempFile = $conf->importzugferd->dir_temp.'/'.uniqid().'_'.$filename;
|
||||||
|
if (!is_dir($conf->importzugferd->dir_temp)) {
|
||||||
|
dol_mkdir($conf->importzugferd->dir_temp);
|
||||||
|
}
|
||||||
|
file_put_contents($tempFile, $attachment);
|
||||||
|
|
||||||
|
// Import
|
||||||
|
$import = new ZugferdImport($db);
|
||||||
|
$result = $import->importFromFile($user, $tempFile, $autoCreate);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
$successCount++;
|
||||||
|
} elseif ($result == -2) {
|
||||||
|
$skippedCount++;
|
||||||
|
} else {
|
||||||
|
$errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink($tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive processed emails
|
||||||
|
if (!empty($archiveFolder) && $successCount > 0) {
|
||||||
|
foreach ($emails as $email_number) {
|
||||||
|
imap_mail_move($connection, $email_number, $archiveFolder);
|
||||||
|
}
|
||||||
|
imap_expunge($connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imap_close($connection);
|
||||||
|
} else {
|
||||||
|
setEventMessages($langs->trans('ConnectionFailed').': '.imap_last_error(), null, 'errors');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($successCount > 0 || $errorCount > 0 || $skippedCount > 0) {
|
||||||
|
setEventMessages($langs->trans('BatchImportComplete', $successCount, $errorCount, $skippedCount), null, 'mesgs');
|
||||||
|
} else {
|
||||||
|
setEventMessages($langs->trans('NoFilesFound'), null, 'warnings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print '<tr class="oddeven">';
|
||||||
|
print '<td class="titlefield">'.$langs->trans('ImportFromFolder').'</td>';
|
||||||
|
print '<td>';
|
||||||
|
if ($hasFolder) {
|
||||||
|
print '<a class="button" href="'.$_SERVER['PHP_SELF'].'?action=run_import&import_source=folder&token='.newToken().'">';
|
||||||
|
print '<i class="fas fa-folder-open paddingright"></i>'.$langs->trans('StartImport');
|
||||||
|
print '</a>';
|
||||||
|
print ' <span class="opacitymedium">'.getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER').'</span>';
|
||||||
|
} else {
|
||||||
|
print '<span class="opacitymedium">'.$langs->trans('ErrorWatchFolderNotConfigured').'</span>';
|
||||||
|
}
|
||||||
|
print '</td>';
|
||||||
|
print '</tr>';
|
||||||
|
|
||||||
|
print '<tr class="oddeven">';
|
||||||
|
print '<td>'.$langs->trans('ImportFromIMAP').'</td>';
|
||||||
|
print '<td>';
|
||||||
|
if ($hasImap && function_exists('imap_open')) {
|
||||||
|
print '<a class="button" href="'.$_SERVER['PHP_SELF'].'?action=run_import&import_source=imap&token='.newToken().'">';
|
||||||
|
print '<i class="fas fa-envelope paddingright"></i>'.$langs->trans('StartImport');
|
||||||
|
print '</a>';
|
||||||
|
print ' <span class="opacitymedium">'.getDolGlobalString('IMPORTZUGFERD_IMAP_USER').'</span>';
|
||||||
|
} elseif (!function_exists('imap_open')) {
|
||||||
|
print '<span class="opacitymedium">'.$langs->trans('IMAPExtensionNotInstalled').'</span>';
|
||||||
|
} else {
|
||||||
|
print '<span class="opacitymedium">'.$langs->trans('ErrorIMAPNotConfigured').'</span>';
|
||||||
|
}
|
||||||
|
print '</td>';
|
||||||
|
print '</tr>';
|
||||||
|
|
||||||
|
print '</table>';
|
||||||
|
print '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
print '<br>';
|
print '<br>';
|
||||||
|
|
||||||
// Page end
|
// Page end
|
||||||
|
|
|
||||||
26
batch.php
26
batch.php
|
|
@ -80,6 +80,7 @@ if ($action == 'process') {
|
||||||
// Import from local folder
|
// Import from local folder
|
||||||
$watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
|
$watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
|
||||||
$archive_folder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
|
$archive_folder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
|
||||||
|
$error_folder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
|
||||||
|
|
||||||
if (empty($watch_folder) || !is_dir($watch_folder)) {
|
if (empty($watch_folder) || !is_dir($watch_folder)) {
|
||||||
setEventMessages($langs->trans('ErrorWatchFolderNotConfigured'), null, 'errors');
|
setEventMessages($langs->trans('ErrorWatchFolderNotConfigured'), null, 'errors');
|
||||||
|
|
@ -89,6 +90,10 @@ if ($action == 'process') {
|
||||||
if (!empty($archive_folder) && !is_dir($archive_folder)) {
|
if (!empty($archive_folder) && !is_dir($archive_folder)) {
|
||||||
dol_mkdir($archive_folder);
|
dol_mkdir($archive_folder);
|
||||||
}
|
}
|
||||||
|
// Create error folder if needed
|
||||||
|
if (!empty($error_folder) && !is_dir($error_folder)) {
|
||||||
|
dol_mkdir($error_folder);
|
||||||
|
}
|
||||||
|
|
||||||
// Get PDF files from watch folder
|
// Get PDF files from watch folder
|
||||||
$files = glob($watch_folder . '/*.pdf');
|
$files = glob($watch_folder . '/*.pdf');
|
||||||
|
|
@ -115,17 +120,32 @@ if ($action == 'process') {
|
||||||
|
|
||||||
// Move to archive
|
// Move to archive
|
||||||
if (!empty($archive_folder) && is_dir($archive_folder)) {
|
if (!empty($archive_folder) && is_dir($archive_folder)) {
|
||||||
$archive_path = $archive_folder . '/' . basename($pdf_path);
|
$archive_path = $archive_folder . '/success_' . date('Y-m-d_His') . '_' . basename($pdf_path);
|
||||||
if (rename($pdf_path, $archive_path)) {
|
if (@rename($pdf_path, $archive_path) || (@copy($pdf_path, $archive_path) && @unlink($pdf_path))) {
|
||||||
$result['archived'] = true;
|
$result['archived'] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif ($res == -3) {
|
} elseif ($res == -3) {
|
||||||
// Duplicate
|
// Duplicate - move to archive (already imported)
|
||||||
$result['status'] = 'skipped';
|
$result['status'] = 'skipped';
|
||||||
$result['message'] = $langs->trans('ErrorDuplicateInvoice');
|
$result['message'] = $langs->trans('ErrorDuplicateInvoice');
|
||||||
|
|
||||||
|
if (!empty($archive_folder) && is_dir($archive_folder)) {
|
||||||
|
$archive_path = $archive_folder . '/duplicate_' . date('Y-m-d_His') . '_' . basename($pdf_path);
|
||||||
|
if (@rename($pdf_path, $archive_path) || (@copy($pdf_path, $archive_path) && @unlink($pdf_path))) {
|
||||||
|
$result['archived'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Error - move to error folder
|
||||||
$result['message'] = $actions->error;
|
$result['message'] = $actions->error;
|
||||||
|
|
||||||
|
if (!empty($error_folder) && is_dir($error_folder)) {
|
||||||
|
$error_path = $error_folder . '/error_' . date('Y-m-d_His') . '_' . basename($pdf_path);
|
||||||
|
if (@rename($pdf_path, $error_path) || (@copy($pdf_path, $error_path) && @unlink($pdf_path))) {
|
||||||
|
$result['moved_to_error'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$import_results[] = $result;
|
$import_results[] = $result;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class CronImportZugferd
|
* Class CronImportZugferd
|
||||||
* Cronjob handler for fetching ZUGFeRD invoices from IMAP mailbox
|
* Cronjob handler for fetching ZUGFeRD invoices from IMAP mailbox and folder
|
||||||
*/
|
*/
|
||||||
class CronImportZugferd
|
class CronImportZugferd
|
||||||
{
|
{
|
||||||
|
|
@ -70,6 +70,180 @@ class CronImportZugferd
|
||||||
$this->db = $db;
|
$this->db = $db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if import should run based on configured frequency
|
||||||
|
*
|
||||||
|
* @return bool True if import should run
|
||||||
|
*/
|
||||||
|
protected function shouldRunImport()
|
||||||
|
{
|
||||||
|
global $conf;
|
||||||
|
|
||||||
|
$frequency = getDolGlobalString('IMPORTZUGFERD_IMPORT_FREQUENCY', 'manual');
|
||||||
|
|
||||||
|
if ($frequency === 'manual') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last run timestamp
|
||||||
|
$lastRun = getDolGlobalInt('IMPORTZUGFERD_LAST_IMPORT_RUN', 0);
|
||||||
|
$now = dol_now();
|
||||||
|
|
||||||
|
// Calculate minimum interval based on frequency
|
||||||
|
$interval = 0;
|
||||||
|
switch ($frequency) {
|
||||||
|
case 'hourly':
|
||||||
|
$interval = 3600; // 1 hour
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
$interval = 86400; // 24 hours
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
$interval = 604800; // 7 days
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed
|
||||||
|
if ($now - $lastRun < $interval) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last run timestamp
|
||||||
|
*/
|
||||||
|
protected function updateLastRunTime()
|
||||||
|
{
|
||||||
|
global $conf;
|
||||||
|
dolibarr_set_const($this->db, 'IMPORTZUGFERD_LAST_IMPORT_RUN', dol_now(), 'chaine', 0, '', $conf->entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main import method - imports from both folder and mailbox
|
||||||
|
*
|
||||||
|
* @return int 0 if OK, <0 if error
|
||||||
|
*/
|
||||||
|
public function runScheduledImport()
|
||||||
|
{
|
||||||
|
global $conf, $user, $langs;
|
||||||
|
|
||||||
|
$langs->load('importzugferd@importzugferd');
|
||||||
|
|
||||||
|
// Check if we should run based on frequency
|
||||||
|
if (!$this->shouldRunImport()) {
|
||||||
|
$this->output = 'Skipped - not scheduled to run (frequency: '.getDolGlobalString('IMPORTZUGFERD_IMPORT_FREQUENCY', 'manual').')';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset counters
|
||||||
|
$this->imported_count = 0;
|
||||||
|
$this->skipped_count = 0;
|
||||||
|
$this->error_count = 0;
|
||||||
|
$this->errors = array();
|
||||||
|
|
||||||
|
$folderResult = $this->importFromFolder();
|
||||||
|
$mailboxResult = $this->fetchFromMailbox();
|
||||||
|
|
||||||
|
// Update last run time
|
||||||
|
$this->updateLastRunTime();
|
||||||
|
|
||||||
|
// Build combined output
|
||||||
|
$this->output = sprintf(
|
||||||
|
"Scheduled import complete. Imported: %d, Skipped: %d, Errors: %d",
|
||||||
|
$this->imported_count,
|
||||||
|
$this->skipped_count,
|
||||||
|
$this->error_count
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->error_count > 0 && !empty($this->errors)) {
|
||||||
|
$this->output .= "\nErrors: " . implode(", ", array_slice($this->errors, 0, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->error_count > 0) ? -1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import ZUGFeRD invoices from watch folder
|
||||||
|
*
|
||||||
|
* @return int 0 if OK, <0 if error
|
||||||
|
*/
|
||||||
|
public function importFromFolder()
|
||||||
|
{
|
||||||
|
global $conf, $user, $langs;
|
||||||
|
|
||||||
|
$langs->load('importzugferd@importzugferd');
|
||||||
|
|
||||||
|
$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER');
|
||||||
|
$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER');
|
||||||
|
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
|
||||||
|
$autoCreate = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
|
||||||
|
|
||||||
|
// Validate settings
|
||||||
|
if (empty($watchFolder) || !is_dir($watchFolder)) {
|
||||||
|
$this->output = 'Watch folder not configured or not accessible';
|
||||||
|
return 0; // Not an error, just not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load admin user for import actions
|
||||||
|
$admin_user = new User($this->db);
|
||||||
|
$admin_user->fetch(1);
|
||||||
|
|
||||||
|
// Find PDF files
|
||||||
|
$files = glob($watchFolder . '/*.pdf');
|
||||||
|
$files = array_merge($files, glob($watchFolder . '/*.PDF'));
|
||||||
|
|
||||||
|
if (empty($files)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure archive folder exists if configured
|
||||||
|
if (!empty($archiveFolder) && !is_dir($archiveFolder)) {
|
||||||
|
dol_mkdir($archiveFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure error folder exists if configured
|
||||||
|
if (!empty($errorFolder) && !is_dir($errorFolder)) {
|
||||||
|
dol_mkdir($errorFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
// Use ZugferdImport::importFromFile for consistent handling
|
||||||
|
$import = new ZugferdImport($this->db);
|
||||||
|
$result = $import->importFromFile($admin_user, $file, $autoCreate);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
$this->imported_count++;
|
||||||
|
dol_syslog("CronImportZugferd: Imported invoice from folder: " . basename($file), LOG_INFO);
|
||||||
|
|
||||||
|
// Archive the file
|
||||||
|
$this->moveFile($file, $archiveFolder, 'imported_');
|
||||||
|
} elseif ($result == -2) {
|
||||||
|
// Duplicate - already imported
|
||||||
|
$this->skipped_count++;
|
||||||
|
dol_syslog("CronImportZugferd: Skipped duplicate invoice: " . basename($file), LOG_INFO);
|
||||||
|
|
||||||
|
// Archive duplicates - delete if no archive folder
|
||||||
|
if (!$this->moveFile($file, $archiveFolder, 'duplicate_')) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->error_count++;
|
||||||
|
$this->errors[] = basename($file) . ': ' . $import->error;
|
||||||
|
dol_syslog("CronImportZugferd: Error importing: " . basename($file) . " - " . $import->error, LOG_WARNING);
|
||||||
|
|
||||||
|
// Try error folder first, fall back to archive folder
|
||||||
|
if (!$this->moveFile($file, $errorFolder, 'error_')) {
|
||||||
|
// Use archive folder as fallback for errors
|
||||||
|
$this->moveFile($file, $archiveFolder, 'error_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch ZUGFeRD invoices from configured IMAP mailbox
|
* Fetch ZUGFeRD invoices from configured IMAP mailbox
|
||||||
*
|
*
|
||||||
|
|
@ -296,4 +470,68 @@ class CronImportZugferd
|
||||||
'data' => $data
|
'data' => $data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move file to target folder with proper error handling
|
||||||
|
*
|
||||||
|
* @param string $file Source file path
|
||||||
|
* @param string $targetFolder Target folder path
|
||||||
|
* @param string $prefix Filename prefix (e.g., 'imported_', 'duplicate_', 'error_')
|
||||||
|
* @return bool True if moved/deleted, false on failure
|
||||||
|
*/
|
||||||
|
protected function moveFile($file, $targetFolder, $prefix = '')
|
||||||
|
{
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
dol_syslog("CronImportZugferd: File not found: " . $file, LOG_WARNING);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If target folder is configured and exists/writable
|
||||||
|
if (!empty($targetFolder)) {
|
||||||
|
// Create folder if it doesn't exist
|
||||||
|
if (!is_dir($targetFolder)) {
|
||||||
|
$result = dol_mkdir($targetFolder);
|
||||||
|
if ($result < 0) {
|
||||||
|
dol_syslog("CronImportZugferd: Failed to create folder: " . $targetFolder, LOG_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($targetFolder) && is_writable($targetFolder)) {
|
||||||
|
$targetPath = $targetFolder . '/' . $prefix . date('Y-m-d_His') . '_' . basename($file);
|
||||||
|
|
||||||
|
if (@rename($file, $targetPath)) {
|
||||||
|
dol_syslog("CronImportZugferd: Moved file to: " . $targetPath, LOG_INFO);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Try copy + delete as fallback (for cross-filesystem moves)
|
||||||
|
if (@copy($file, $targetPath)) {
|
||||||
|
@unlink($file);
|
||||||
|
dol_syslog("CronImportZugferd: Copied file to: " . $targetPath, LOG_INFO);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
dol_syslog("CronImportZugferd: Failed to move/copy file to: " . $targetPath, LOG_ERR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dol_syslog("CronImportZugferd: Target folder not writable: " . $targetFolder, LOG_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No target folder configured or not writable - delete file from watch folder
|
||||||
|
// to prevent re-processing (except for errors without error folder)
|
||||||
|
if ($prefix !== 'error_') {
|
||||||
|
if (@unlink($file)) {
|
||||||
|
dol_syslog("CronImportZugferd: Deleted processed file: " . $file, LOG_INFO);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
dol_syslog("CronImportZugferd: Failed to delete file: " . $file, LOG_ERR);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error files stay in watch folder if no error folder configured
|
||||||
|
dol_syslog("CronImportZugferd: Keeping error file in watch folder: " . $file, LOG_INFO);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
989
class/datanorm.class.php
Normal file
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 = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET";
|
||||||
$sql .= " ref = '" . $this->db->escape($this->ref) . "',";
|
$sql .= " ref = '" . $this->db->escape($this->ref) . "',";
|
||||||
$sql .= " invoice_number = '" . $this->db->escape($this->invoice_number) . "',";
|
$sql .= " invoice_number = '" . $this->db->escape($this->invoice_number) . "',";
|
||||||
$sql .= " invoice_date = '" . $this->db->escape($this->invoice_date) . "',";
|
$sql .= " invoice_date = " . ($this->invoice_date ? "'" . $this->db->idate($this->invoice_date) . "'" : "null") . ",";
|
||||||
$sql .= " seller_name = '" . $this->db->escape($this->seller_name) . "',";
|
$sql .= " seller_name = '" . $this->db->escape($this->seller_name) . "',";
|
||||||
$sql .= " seller_vat = '" . $this->db->escape($this->seller_vat) . "',";
|
$sql .= " seller_vat = '" . $this->db->escape($this->seller_vat) . "',";
|
||||||
$sql .= " buyer_reference = '" . $this->db->escape($this->buyer_reference) . "',";
|
$sql .= " buyer_reference = '" . $this->db->escape($this->buyer_reference) . "',";
|
||||||
$sql .= " total_ht = " . price2num($this->total_ht) . ",";
|
$sql .= " total_ht = " . price2num($this->total_ht) . ",";
|
||||||
$sql .= " total_ttc = " . price2num($this->total_ttc) . ",";
|
$sql .= " total_ttc = " . price2num($this->total_ttc) . ",";
|
||||||
$sql .= " currency = '" . $this->db->escape($this->currency) . "',";
|
$sql .= " currency = '" . $this->db->escape($this->currency) . "',";
|
||||||
$sql .= " fk_soc = " . ($this->fk_soc > 0 ? $this->fk_soc : "null") . ",";
|
$sql .= " fk_soc = " . ($this->fk_soc > 0 ? (int) $this->fk_soc : "null") . ",";
|
||||||
$sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? $this->fk_facture_fourn : "null") . ",";
|
$sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? (int) $this->fk_facture_fourn : "null") . ",";
|
||||||
$sql .= " status = " . (int) $this->status . ",";
|
$sql .= " status = " . (int) $this->status . ",";
|
||||||
$sql .= " error_message = '" . $this->db->escape($this->error_message) . "',";
|
$sql .= " date_import = " . ($this->date_import ? "'" . $this->db->idate($this->date_import) . "'" : "null") . ",";
|
||||||
|
$sql .= " error_message = " . ($this->error_message ? "'" . $this->db->escape($this->error_message) . "'" : "null") . ",";
|
||||||
$sql .= " fk_user_modif = " . (int) $this->fk_user_modif;
|
$sql .= " fk_user_modif = " . (int) $this->fk_user_modif;
|
||||||
$sql .= " WHERE rowid = " . (int) $this->id;
|
$sql .= " WHERE rowid = " . (int) $this->id;
|
||||||
|
|
||||||
dol_syslog(get_class($this) . "::update", LOG_DEBUG);
|
dol_syslog(get_class($this) . "::update sql=" . $sql, LOG_DEBUG);
|
||||||
$resql = $this->db->query($sql);
|
$resql = $this->db->query($sql);
|
||||||
|
|
||||||
if (!$resql) {
|
if (!$resql) {
|
||||||
$this->error = $this->db->lasterror();
|
$this->error = $this->db->lasterror();
|
||||||
|
dol_syslog(get_class($this) . "::update error=" . $this->error, LOG_ERR);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,6 +516,211 @@ class ZugferdImport extends CommonObject
|
||||||
return $xml;
|
return $xml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a ZUGFeRD invoice from PDF file
|
||||||
|
* This is the main entry point for batch/automated imports
|
||||||
|
*
|
||||||
|
* @param User $user User performing import
|
||||||
|
* @param string $file_path Path to PDF file
|
||||||
|
* @param bool $auto_create_invoice Whether to auto-create supplier invoice
|
||||||
|
* @return int >0 (import ID) if OK, -2 if duplicate, <0 if error
|
||||||
|
*/
|
||||||
|
public function importFromFile($user, $file_path, $auto_create_invoice = false)
|
||||||
|
{
|
||||||
|
global $conf, $langs;
|
||||||
|
|
||||||
|
$langs->load('importzugferd@importzugferd');
|
||||||
|
|
||||||
|
dol_include_once('/importzugferd/class/zugferdparser.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/productmapping.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/importline.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/importnotification.class.php');
|
||||||
|
|
||||||
|
// Parse PDF
|
||||||
|
$parser = new ZugferdParser($this->db);
|
||||||
|
$result = $parser->extractFromPdf($file_path);
|
||||||
|
if ($result < 0) {
|
||||||
|
$this->error = $parser->error;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $parser->parse();
|
||||||
|
if ($result < 0) {
|
||||||
|
$this->error = $parser->error;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice_data = $parser->getInvoiceData();
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
$file_hash = $parser->getFileHash($file_path);
|
||||||
|
if ($this->isDuplicate($file_hash)) {
|
||||||
|
$this->error = $langs->trans('ErrorDuplicateInvoice');
|
||||||
|
return -2; // Duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find supplier
|
||||||
|
$supplier_id = $this->findSupplier($invoice_data);
|
||||||
|
|
||||||
|
// Set import record data
|
||||||
|
$this->invoice_number = $invoice_data['invoice_number'];
|
||||||
|
$this->invoice_date = $invoice_data['invoice_date'];
|
||||||
|
$this->seller_name = $invoice_data['seller']['name'];
|
||||||
|
$this->seller_vat = $invoice_data['seller']['vat_id'];
|
||||||
|
$this->buyer_reference = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id'];
|
||||||
|
$this->total_ht = $invoice_data['totals']['net'];
|
||||||
|
$this->total_ttc = $invoice_data['totals']['gross'];
|
||||||
|
$this->currency = $invoice_data['totals']['currency'] ?: 'EUR';
|
||||||
|
$this->fk_soc = $supplier_id;
|
||||||
|
$this->xml_content = $parser->getXmlContent();
|
||||||
|
$this->pdf_filename = basename($file_path);
|
||||||
|
$this->file_hash = $file_hash;
|
||||||
|
$this->date_import = dol_now();
|
||||||
|
|
||||||
|
// Create import record
|
||||||
|
$import_id = $this->create($user);
|
||||||
|
if ($import_id < 0) {
|
||||||
|
return -3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process and store line items
|
||||||
|
$mapping = new ProductMapping($this->db);
|
||||||
|
$unmatched_count = 0;
|
||||||
|
$matched_count = 0;
|
||||||
|
$total_lines = count($invoice_data['lines']);
|
||||||
|
|
||||||
|
foreach ($invoice_data['lines'] as $line_data) {
|
||||||
|
$line = new ImportLine($this->db);
|
||||||
|
$line->fk_import = $import_id;
|
||||||
|
$line->line_id = $line_data['line_id'];
|
||||||
|
$line->supplier_ref = $line_data['product']['seller_id'];
|
||||||
|
$line->product_name = $line_data['product']['name'];
|
||||||
|
$line->description = $line_data['product']['description'];
|
||||||
|
$line->quantity = $line_data['quantity'];
|
||||||
|
$line->unit_code = $line_data['unit_code'];
|
||||||
|
$line->unit_price = $line_data['unit_price'];
|
||||||
|
$line->unit_price_raw = isset($line_data['unit_price_raw']) ? $line_data['unit_price_raw'] : $line_data['unit_price'];
|
||||||
|
$line->basis_quantity = isset($line_data['basis_quantity']) ? $line_data['basis_quantity'] : 1;
|
||||||
|
$line->basis_quantity_unit = isset($line_data['basis_quantity_unit']) ? $line_data['basis_quantity_unit'] : '';
|
||||||
|
$line->line_total = $line_data['line_total'];
|
||||||
|
$line->tax_percent = $line_data['tax_percent'];
|
||||||
|
$line->ean = $line_data['product']['global_id'];
|
||||||
|
|
||||||
|
// Try to match product
|
||||||
|
$fk_product = 0;
|
||||||
|
$match_method = '';
|
||||||
|
|
||||||
|
if ($supplier_id > 0) {
|
||||||
|
$match = $mapping->findProduct($supplier_id, $line_data['product']);
|
||||||
|
if (!empty($match) && $match['fk_product'] > 0) {
|
||||||
|
$fk_product = $match['fk_product'];
|
||||||
|
$match_method = $match['method'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$line->fk_product = $fk_product;
|
||||||
|
$line->match_method = $match_method;
|
||||||
|
|
||||||
|
if ($fk_product == 0) {
|
||||||
|
$unmatched_count++;
|
||||||
|
} else {
|
||||||
|
$matched_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$line->create($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status based on matching results
|
||||||
|
// STATUS_IMPORTED only if: supplier found, has lines, and ALL lines have matched products
|
||||||
|
if ($supplier_id == 0 || $total_lines == 0 || $unmatched_count > 0 || $matched_count == 0) {
|
||||||
|
// Missing supplier, no lines, unmatched products, or no matches at all - needs manual intervention
|
||||||
|
$this->status = self::STATUS_PENDING;
|
||||||
|
} else {
|
||||||
|
// All lines matched
|
||||||
|
$this->status = self::STATUS_IMPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy PDF to documents
|
||||||
|
$destdir = $conf->importzugferd->dir_output . '/imports';
|
||||||
|
if (!is_dir($destdir)) {
|
||||||
|
dol_mkdir($destdir);
|
||||||
|
}
|
||||||
|
$destfile = $destdir . '/' . $this->ref . '_' . basename($file_path);
|
||||||
|
copy($file_path, $destfile);
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
$this->update($user);
|
||||||
|
|
||||||
|
// Send notification if manual intervention required
|
||||||
|
if ($this->status == self::STATUS_PENDING && class_exists('ImportNotification')) {
|
||||||
|
$notification = new ImportNotification($this->db);
|
||||||
|
$importLine = new ImportLine($this->db);
|
||||||
|
$storedLines = $importLine->fetchAllByImport($this->id);
|
||||||
|
$notification->sendManualInterventionNotification($this, $storedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $import_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find supplier by buyer reference or VAT ID
|
||||||
|
*
|
||||||
|
* @param array $invoice_data Parsed invoice data
|
||||||
|
* @return int Supplier ID or 0
|
||||||
|
*/
|
||||||
|
protected function findSupplier($invoice_data)
|
||||||
|
{
|
||||||
|
global $conf;
|
||||||
|
|
||||||
|
$buyer_ref = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id'];
|
||||||
|
$seller_vat = $invoice_data['seller']['vat_id'];
|
||||||
|
$seller_name = $invoice_data['seller']['name'];
|
||||||
|
|
||||||
|
// 1. Search by buyer reference in extrafield
|
||||||
|
if (!empty($buyer_ref)) {
|
||||||
|
$sql = "SELECT fk_object FROM " . MAIN_DB_PREFIX . "societe_extrafields";
|
||||||
|
$sql .= " WHERE supplier_customer_number = '" . $this->db->escape($buyer_ref) . "'";
|
||||||
|
|
||||||
|
$resql = $this->db->query($sql);
|
||||||
|
if ($resql && $this->db->num_rows($resql) > 0) {
|
||||||
|
$obj = $this->db->fetch_object($resql);
|
||||||
|
return (int) $obj->fk_object;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search by VAT ID
|
||||||
|
if (!empty($seller_vat)) {
|
||||||
|
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe";
|
||||||
|
$sql .= " WHERE tva_intra = '" . $this->db->escape($seller_vat) . "'";
|
||||||
|
$sql .= " AND fournisseur = 1";
|
||||||
|
$sql .= " AND entity IN (" . getEntity('societe') . ")";
|
||||||
|
|
||||||
|
$resql = $this->db->query($sql);
|
||||||
|
if ($resql && $this->db->num_rows($resql) > 0) {
|
||||||
|
$obj = $this->db->fetch_object($resql);
|
||||||
|
return (int) $obj->rowid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Search by name (fuzzy)
|
||||||
|
if (!empty($seller_name)) {
|
||||||
|
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe";
|
||||||
|
$sql .= " WHERE (nom LIKE '" . $this->db->escape($seller_name) . "%'";
|
||||||
|
$sql .= " OR nom LIKE '%" . $this->db->escape(substr($seller_name, 0, 20)) . "%')";
|
||||||
|
$sql .= " AND fournisseur = 1";
|
||||||
|
$sql .= " AND entity IN (" . getEntity('societe') . ")";
|
||||||
|
$sql .= " LIMIT 1";
|
||||||
|
|
||||||
|
$resql = $this->db->query($sql);
|
||||||
|
if ($resql && $this->db->num_rows($resql) > 0) {
|
||||||
|
$obj = $this->db->fetch_object($resql);
|
||||||
|
return (int) $obj->rowid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get status label
|
* Get status label
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class modImportZugferd extends DolibarrModules
|
||||||
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@importzugferd'
|
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@importzugferd'
|
||||||
|
|
||||||
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
|
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
|
||||||
$this->version = '1.1';
|
$this->version = '2.0';
|
||||||
// Url to the file with your last numberversion of this module
|
// Url to the file with your last numberversion of this module
|
||||||
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
|
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
|
||||||
|
|
||||||
|
|
@ -136,7 +136,7 @@ class modImportZugferd extends DolibarrModules
|
||||||
);
|
);
|
||||||
|
|
||||||
// Data directories to create when module is enabled.
|
// Data directories to create when module is enabled.
|
||||||
$this->dirs = array("/importzugferd/temp", "/importzugferd/imports");
|
$this->dirs = array("/importzugferd/temp", "/importzugferd/imports", "/importzugferd/datanorm");
|
||||||
|
|
||||||
// Config pages. Put here list of php page, stored into importzugferd/admin directory, to use to setup module.
|
// Config pages. Put here list of php page, stored into importzugferd/admin directory, to use to setup module.
|
||||||
$this->config_page_url = array("setup.php@importzugferd");
|
$this->config_page_url = array("setup.php@importzugferd");
|
||||||
|
|
@ -268,16 +268,16 @@ class modImportZugferd extends DolibarrModules
|
||||||
/* BEGIN MODULEBUILDER CRON */
|
/* BEGIN MODULEBUILDER CRON */
|
||||||
$this->cronjobs = array(
|
$this->cronjobs = array(
|
||||||
0 => array(
|
0 => array(
|
||||||
'label' => 'ImportZugferdFromMailbox',
|
'label' => 'ImportZugferdScheduled',
|
||||||
'jobtype' => 'method',
|
'jobtype' => 'method',
|
||||||
'class' => '/importzugferd/class/cron_importzugferd.class.php',
|
'class' => '/importzugferd/class/cron_importzugferd.class.php',
|
||||||
'objectname' => 'CronImportZugferd',
|
'objectname' => 'CronImportZugferd',
|
||||||
'method' => 'fetchFromMailbox',
|
'method' => 'runScheduledImport',
|
||||||
'parameters' => '',
|
'parameters' => '',
|
||||||
'comment' => 'Fetch ZUGFeRD invoices from configured mailbox',
|
'comment' => 'Scheduled import from folder and mailbox (frequency controlled by module settings)',
|
||||||
'frequency' => 15,
|
'frequency' => 15,
|
||||||
'unitfrequency' => 60,
|
'unitfrequency' => 60,
|
||||||
'status' => 0,
|
'status' => 1,
|
||||||
'test' => 'isModEnabled("importzugferd")',
|
'test' => 'isModEnabled("importzugferd")',
|
||||||
'priority' => 50,
|
'priority' => 50,
|
||||||
),
|
),
|
||||||
|
|
@ -316,6 +316,12 @@ class modImportZugferd extends DolibarrModules
|
||||||
$this->rights[$r][5] = 'write';
|
$this->rights[$r][5] = 'write';
|
||||||
$r++;
|
$r++;
|
||||||
|
|
||||||
|
$this->rights[$r][0] = $this->numero . sprintf("%02d", 5);
|
||||||
|
$this->rights[$r][1] = 'Manage Datanorm catalogs';
|
||||||
|
$this->rights[$r][4] = 'datanorm';
|
||||||
|
$this->rights[$r][5] = 'write';
|
||||||
|
$r++;
|
||||||
|
|
||||||
|
|
||||||
// Main menu entries to add
|
// Main menu entries to add
|
||||||
$this->menu = array();
|
$this->menu = array();
|
||||||
|
|
@ -407,6 +413,23 @@ class modImportZugferd extends DolibarrModules
|
||||||
'user' => 2,
|
'user' => 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Left menu: Datanorm Catalogs
|
||||||
|
$this->menu[$r++] = array(
|
||||||
|
'fk_menu' => 'fk_mainmenu=importzugferd',
|
||||||
|
'type' => 'left',
|
||||||
|
'titre' => 'DatanormCatalogs',
|
||||||
|
'prefix' => img_picto('', 'fa-database', 'class="pictofixedwidth valignmiddle paddingright"'),
|
||||||
|
'mainmenu' => 'importzugferd',
|
||||||
|
'leftmenu' => 'zugferd_datanorm',
|
||||||
|
'url' => '/importzugferd/datanorm.php',
|
||||||
|
'langs' => 'importzugferd@importzugferd',
|
||||||
|
'position' => 1000 + $r,
|
||||||
|
'enabled' => 'isModEnabled("importzugferd")',
|
||||||
|
'perms' => '$user->hasRight("importzugferd", "datanorm", "write")',
|
||||||
|
'target' => '',
|
||||||
|
'user' => 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
// Exports profiles provided by this module
|
// Exports profiles provided by this module
|
||||||
$r = 0;
|
$r = 0;
|
||||||
|
|
|
||||||
309
datanorm.php
Normal file
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;
|
||||||
|
}
|
||||||
631
import.php
631
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/importline.class.php');
|
||||||
dol_include_once('/importzugferd/class/productmapping.class.php');
|
dol_include_once('/importzugferd/class/productmapping.class.php');
|
||||||
dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
|
dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/datanorm.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/datanormparser.class.php');
|
||||||
|
dol_include_once('/importzugferd/class/importnotification.class.php');
|
||||||
dol_include_once('/importzugferd/lib/importzugferd.lib.php');
|
dol_include_once('/importzugferd/lib/importzugferd.lib.php');
|
||||||
|
|
||||||
// Load translation files
|
// Load translation files
|
||||||
|
|
@ -81,6 +84,7 @@ $formfile = new FormFile($db);
|
||||||
$actions = new ActionsImportZugferd($db);
|
$actions = new ActionsImportZugferd($db);
|
||||||
$import = new ZugferdImport($db);
|
$import = new ZugferdImport($db);
|
||||||
$importLine = new ImportLine($db);
|
$importLine = new ImportLine($db);
|
||||||
|
$notification = new ImportNotification($db);
|
||||||
|
|
||||||
$error = 0;
|
$error = 0;
|
||||||
$message = '';
|
$message = '';
|
||||||
|
|
@ -160,15 +164,19 @@ if ($action == 'upload') {
|
||||||
|
|
||||||
// Check if all lines have products
|
// Check if all lines have products
|
||||||
$all_have_products = true;
|
$all_have_products = true;
|
||||||
|
$has_any_product = false;
|
||||||
|
$total_lines = count($processed_lines);
|
||||||
foreach ($processed_lines as $line) {
|
foreach ($processed_lines as $line) {
|
||||||
if ($line['fk_product'] <= 0) {
|
if ($line['fk_product'] <= 0) {
|
||||||
$all_have_products = false;
|
$all_have_products = false;
|
||||||
break;
|
} else {
|
||||||
|
$has_any_product = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set status based on product matching
|
// Set status based on product matching
|
||||||
if ($all_have_products && $supplier_id > 0) {
|
// STATUS_IMPORTED only if: supplier found, has lines, ALL lines have products
|
||||||
|
if ($all_have_products && $supplier_id > 0 && $total_lines > 0 && $has_any_product) {
|
||||||
$import->status = ZugferdImport::STATUS_IMPORTED;
|
$import->status = ZugferdImport::STATUS_IMPORTED;
|
||||||
} else {
|
} else {
|
||||||
$import->status = ZugferdImport::STATUS_PENDING;
|
$import->status = ZugferdImport::STATUS_PENDING;
|
||||||
|
|
@ -207,6 +215,18 @@ if ($action == 'upload') {
|
||||||
}
|
}
|
||||||
rename($destfile, $final_dir.'/'.$filename);
|
rename($destfile, $final_dir.'/'.$filename);
|
||||||
|
|
||||||
|
// Send notification if manual intervention required
|
||||||
|
if ($import->status == ZugferdImport::STATUS_PENDING) {
|
||||||
|
$storedLines = $importLine->fetchAllByImport($import->id);
|
||||||
|
$notification->sendManualInterventionNotification($import, $storedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for price differences
|
||||||
|
if ($import->status == ZugferdImport::STATUS_IMPORTED) {
|
||||||
|
$storedLines = $importLine->fetchAllByImport($import->id);
|
||||||
|
$notification->checkAndNotifyPriceDifferences($import, $storedLines);
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to edit page
|
// Redirect to edit page
|
||||||
$id = $import->id;
|
$id = $import->id;
|
||||||
$action = 'edit';
|
$action = 'edit';
|
||||||
|
|
@ -215,6 +235,8 @@ if ($action == 'upload') {
|
||||||
$error++;
|
$error++;
|
||||||
$message = $import->error;
|
$message = $import->error;
|
||||||
@unlink($destfile);
|
@unlink($destfile);
|
||||||
|
// Send error notification
|
||||||
|
$notification->sendErrorNotification($import, $message, $filename);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$error++;
|
$error++;
|
||||||
|
|
@ -270,6 +292,10 @@ if ($action == 'assignproduct' && $line_id > 0 && $product_id > 0) {
|
||||||
if ($import->status == ZugferdImport::STATUS_PENDING) {
|
if ($import->status == ZugferdImport::STATUS_PENDING) {
|
||||||
$import->status = ZugferdImport::STATUS_IMPORTED;
|
$import->status = ZugferdImport::STATUS_IMPORTED;
|
||||||
$import->update($user);
|
$import->update($user);
|
||||||
|
|
||||||
|
// Check for price differences now that all products are assigned
|
||||||
|
$storedLines = $importLine->fetchAllByImport($id);
|
||||||
|
$notification->checkAndNotifyPriceDifferences($import, $storedLines);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -368,6 +394,345 @@ if ($action == 'duplicateproduct' && $template_product_id > 0 && $line_id > 0) {
|
||||||
$import->fetch($id);
|
$import->fetch($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create product from Datanorm
|
||||||
|
if ($action == 'createfromdatanorm' && $line_id > 0) {
|
||||||
|
$lineObj = new ImportLine($db);
|
||||||
|
$result = $lineObj->fetch($line_id);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
$id = $lineObj->fk_import;
|
||||||
|
$import->fetch($id);
|
||||||
|
|
||||||
|
// Get Datanorm settings
|
||||||
|
$markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30);
|
||||||
|
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
|
||||||
|
|
||||||
|
// Search in Datanorm database
|
||||||
|
$datanorm = new Datanorm($db);
|
||||||
|
$results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1);
|
||||||
|
|
||||||
|
if (empty($results)) {
|
||||||
|
// Try with EAN if available
|
||||||
|
if (!empty($lineObj->ean)) {
|
||||||
|
$results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($results)) {
|
||||||
|
$datanormArticle = $results[0];
|
||||||
|
$datanorm->fetch($datanormArticle['id']);
|
||||||
|
|
||||||
|
// Load supplier for ref prefix
|
||||||
|
$supplier = new Societe($db);
|
||||||
|
$supplier->fetch($import->fk_soc);
|
||||||
|
$supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3));
|
||||||
|
|
||||||
|
// Create new product
|
||||||
|
$newproduct = new Product($db);
|
||||||
|
$newproduct->type = 0; // Product
|
||||||
|
$newproduct->status = 1; // On sale
|
||||||
|
$newproduct->status_buy = 1; // On purchase
|
||||||
|
|
||||||
|
// Generate reference
|
||||||
|
$newproduct->ref = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
|
||||||
|
|
||||||
|
// Label from Datanorm
|
||||||
|
$newproduct->label = $datanorm->short_text1;
|
||||||
|
if (!empty($datanorm->short_text2) && strlen($newproduct->label) < 100) {
|
||||||
|
$newproduct->label .= ' '.$datanorm->short_text2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
$newproduct->description = $datanorm->getFullDescription();
|
||||||
|
|
||||||
|
// Prices
|
||||||
|
$purchasePrice = $datanorm->price;
|
||||||
|
if ($datanorm->price_unit > 1) {
|
||||||
|
$purchasePrice = $datanorm->price / $datanorm->price_unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selling price with markup
|
||||||
|
$sellingPrice = $purchasePrice * (1 + $markup / 100);
|
||||||
|
$newproduct->price = $sellingPrice;
|
||||||
|
$newproduct->price_base_type = 'HT';
|
||||||
|
$newproduct->tva_tx = $lineObj->tax_percent ?: 19;
|
||||||
|
|
||||||
|
// Weight if available
|
||||||
|
if (!empty($datanorm->weight)) {
|
||||||
|
$newproduct->weight = $datanorm->weight;
|
||||||
|
$newproduct->weight_units = 0; // kg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let Dolibarr auto-generate barcode if configured
|
||||||
|
// Setting barcode to '-1' triggers automatic generation
|
||||||
|
if (isModEnabled('barcode') && getDolGlobalString('BARCODE_PRODUCT_ADDON_NUM')) {
|
||||||
|
$newproduct->barcode = '-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the product
|
||||||
|
$result = $newproduct->create($user);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
// Add supplier price
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
|
||||||
|
$prodfourn = new ProductFournisseur($db);
|
||||||
|
$prodfourn->id = $newproduct->id;
|
||||||
|
$prodfourn->fourn_ref = $datanorm->article_number;
|
||||||
|
|
||||||
|
// Determine EAN for supplier price
|
||||||
|
$supplierEan = '';
|
||||||
|
$supplierEanType = 0;
|
||||||
|
if (!empty($datanorm->ean)) {
|
||||||
|
$supplierEan = $datanorm->ean;
|
||||||
|
$supplierEanType = 2; // EAN13
|
||||||
|
} elseif (!empty($lineObj->ean)) {
|
||||||
|
$supplierEan = $lineObj->ean;
|
||||||
|
$supplierEanType = 2; // EAN13
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add supplier price entry with EAN
|
||||||
|
$res = $prodfourn->update_buyprice(
|
||||||
|
1, // Quantity
|
||||||
|
$purchasePrice, // Price
|
||||||
|
$user,
|
||||||
|
'HT', // Price base
|
||||||
|
$supplier, // Supplier
|
||||||
|
0, // Availability
|
||||||
|
$datanorm->article_number, // Supplier ref
|
||||||
|
$lineObj->tax_percent ?: 19, // VAT
|
||||||
|
0, // Charges
|
||||||
|
0, // Remise
|
||||||
|
0, // Remise percentage
|
||||||
|
0, // No price minimum
|
||||||
|
0, // Delivery delay
|
||||||
|
0, // Reputation
|
||||||
|
array(), // Extra fields
|
||||||
|
0, // Charges array
|
||||||
|
$supplierEan, // Barcode/EAN in supplier price
|
||||||
|
$supplierEanType // Barcode type (EAN13)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create product mapping for future imports
|
||||||
|
$mapping = new ProductMapping($db);
|
||||||
|
$mapping->fk_soc = $import->fk_soc;
|
||||||
|
$mapping->supplier_ref = $datanorm->article_number;
|
||||||
|
$mapping->fk_product = $newproduct->id;
|
||||||
|
$mapping->ean = $datanorm->ean;
|
||||||
|
$mapping->manufacturer_ref = $datanorm->manufacturer_ref;
|
||||||
|
$mapping->description = $datanorm->short_text1;
|
||||||
|
$mapping->create($user);
|
||||||
|
|
||||||
|
// Assign to import line
|
||||||
|
$lineObj->setProduct($newproduct->id, 'datanorm', $user);
|
||||||
|
|
||||||
|
setEventMessages($langs->trans('ProductCreatedFromDatanorm', $newproduct->ref), null, 'mesgs');
|
||||||
|
|
||||||
|
// Check if all lines now have products
|
||||||
|
$allHaveProducts = $importLine->allLinesHaveProducts($id);
|
||||||
|
if ($allHaveProducts) {
|
||||||
|
$import->status = ZugferdImport::STATUS_IMPORTED;
|
||||||
|
$import->update($user);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEventMessages($newproduct->error, $newproduct->errors, 'errors');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEventMessages($langs->trans('DatanormArticleNotFound', $lineObj->supplier_ref), null, 'errors');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$action = 'edit';
|
||||||
|
$import->fetch($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ALL products from Datanorm (batch)
|
||||||
|
if ($action == 'createallfromdatanorm' && $id > 0) {
|
||||||
|
$import->fetch($id);
|
||||||
|
|
||||||
|
if ($import->fk_soc > 0) {
|
||||||
|
// Get Datanorm settings
|
||||||
|
$markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30);
|
||||||
|
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
|
||||||
|
|
||||||
|
// Load supplier
|
||||||
|
$supplier = new Societe($db);
|
||||||
|
$supplier->fetch($import->fk_soc);
|
||||||
|
$supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3));
|
||||||
|
|
||||||
|
// Get all lines without product
|
||||||
|
$lines = $importLine->fetchAllByImport($import->id);
|
||||||
|
$datanorm = new Datanorm($db);
|
||||||
|
$createdCount = 0;
|
||||||
|
$assignedCount = 0;
|
||||||
|
$errorCount = 0;
|
||||||
|
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
|
||||||
|
|
||||||
|
foreach ($lines as $lineObj) {
|
||||||
|
// Skip lines that already have a product
|
||||||
|
if ($lineObj->fk_product > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip lines without supplier_ref
|
||||||
|
if (empty($lineObj->supplier_ref)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in Datanorm database
|
||||||
|
$results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1);
|
||||||
|
|
||||||
|
if (empty($results) && !empty($lineObj->ean)) {
|
||||||
|
$results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($results)) {
|
||||||
|
$datanormArticle = $results[0];
|
||||||
|
$datanorm->fetch($datanormArticle['id']);
|
||||||
|
|
||||||
|
$purchasePrice = $datanorm->price;
|
||||||
|
if ($datanorm->price_unit > 1) {
|
||||||
|
$purchasePrice = $datanorm->price / $datanorm->price_unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if product already exists in Dolibarr
|
||||||
|
$existingProduct = new Product($db);
|
||||||
|
$productExists = false;
|
||||||
|
$existingProductId = 0;
|
||||||
|
|
||||||
|
// 1. Check by supplier reference (ProductFournisseur)
|
||||||
|
$sqlCheck = "SELECT DISTINCT pf.fk_product FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pf";
|
||||||
|
$sqlCheck .= " WHERE pf.fk_soc = ".(int)$import->fk_soc;
|
||||||
|
$sqlCheck .= " AND pf.ref_fourn = '".$db->escape($datanorm->article_number)."'";
|
||||||
|
$sqlCheck .= " AND pf.entity IN (".getEntity('product').")";
|
||||||
|
$resqlCheck = $db->query($sqlCheck);
|
||||||
|
if ($resqlCheck && $db->num_rows($resqlCheck) > 0) {
|
||||||
|
$objCheck = $db->fetch_object($resqlCheck);
|
||||||
|
$existingProductId = $objCheck->fk_product;
|
||||||
|
$productExists = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check by product reference pattern
|
||||||
|
if (!$productExists) {
|
||||||
|
$expectedRef = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
|
||||||
|
$fetchResult = $existingProduct->fetch(0, $expectedRef);
|
||||||
|
if ($fetchResult > 0) {
|
||||||
|
$existingProductId = $existingProduct->id;
|
||||||
|
$productExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check by EAN if available
|
||||||
|
if (!$productExists && !empty($datanorm->ean)) {
|
||||||
|
$sqlEan = "SELECT rowid FROM ".MAIN_DB_PREFIX."product";
|
||||||
|
$sqlEan .= " WHERE barcode = '".$db->escape($datanorm->ean)."'";
|
||||||
|
$sqlEan .= " AND entity IN (".getEntity('product').")";
|
||||||
|
$resqlEan = $db->query($sqlEan);
|
||||||
|
if ($resqlEan && $db->num_rows($resqlEan) > 0) {
|
||||||
|
$objEan = $db->fetch_object($resqlEan);
|
||||||
|
$existingProductId = $objEan->rowid;
|
||||||
|
$productExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($productExists && $existingProductId > 0) {
|
||||||
|
// Product exists - just assign it to the line
|
||||||
|
$lineObj->setProduct($existingProductId, 'datanorm', $user);
|
||||||
|
$assignedCount++;
|
||||||
|
} else {
|
||||||
|
// Create new product
|
||||||
|
$newproduct = new Product($db);
|
||||||
|
$newproduct->type = 0;
|
||||||
|
$newproduct->status = 1;
|
||||||
|
$newproduct->status_buy = 1;
|
||||||
|
$newproduct->ref = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
|
||||||
|
$newproduct->label = $datanorm->short_text1;
|
||||||
|
if (!empty($datanorm->short_text2) && strlen($newproduct->label) < 100) {
|
||||||
|
$newproduct->label .= ' '.$datanorm->short_text2;
|
||||||
|
}
|
||||||
|
$newproduct->description = $datanorm->getFullDescription();
|
||||||
|
|
||||||
|
$sellingPrice = $purchasePrice * (1 + $markup / 100);
|
||||||
|
$newproduct->price = $sellingPrice;
|
||||||
|
$newproduct->price_base_type = 'HT';
|
||||||
|
$newproduct->tva_tx = $lineObj->tax_percent ?: 19;
|
||||||
|
|
||||||
|
if (!empty($datanorm->weight)) {
|
||||||
|
$newproduct->weight = $datanorm->weight;
|
||||||
|
$newproduct->weight_units = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModEnabled('barcode') && getDolGlobalString('BARCODE_PRODUCT_ADDON_NUM')) {
|
||||||
|
$newproduct->barcode = '-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $newproduct->create($user);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
// Add supplier price
|
||||||
|
$prodfourn = new ProductFournisseur($db);
|
||||||
|
$prodfourn->id = $newproduct->id;
|
||||||
|
$prodfourn->fourn_ref = $datanorm->article_number;
|
||||||
|
|
||||||
|
$supplierEan = '';
|
||||||
|
$supplierEanType = 0;
|
||||||
|
if (!empty($datanorm->ean)) {
|
||||||
|
$supplierEan = $datanorm->ean;
|
||||||
|
$supplierEanType = 2;
|
||||||
|
} elseif (!empty($lineObj->ean)) {
|
||||||
|
$supplierEan = $lineObj->ean;
|
||||||
|
$supplierEanType = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prodfourn->update_buyprice(
|
||||||
|
1, $purchasePrice, $user, 'HT', $supplier, 0,
|
||||||
|
$datanorm->article_number, $lineObj->tax_percent ?: 19,
|
||||||
|
0, 0, 0, 0, 0, 0, array(), 0, $supplierEan, $supplierEanType
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create product mapping
|
||||||
|
$mapping = new ProductMapping($db);
|
||||||
|
$mapping->fk_soc = $import->fk_soc;
|
||||||
|
$mapping->supplier_ref = $datanorm->article_number;
|
||||||
|
$mapping->fk_product = $newproduct->id;
|
||||||
|
$mapping->ean = $datanorm->ean;
|
||||||
|
$mapping->manufacturer_ref = $datanorm->manufacturer_ref;
|
||||||
|
$mapping->description = $datanorm->short_text1;
|
||||||
|
$mapping->create($user);
|
||||||
|
|
||||||
|
// Assign to import line
|
||||||
|
$lineObj->setProduct($newproduct->id, 'datanorm', $user);
|
||||||
|
$createdCount++;
|
||||||
|
} else {
|
||||||
|
$errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($createdCount > 0) {
|
||||||
|
setEventMessages($langs->trans('DatanormBatchCreated', $createdCount), null, 'mesgs');
|
||||||
|
}
|
||||||
|
if ($assignedCount > 0) {
|
||||||
|
setEventMessages($langs->trans('DatanormBatchAssigned', $assignedCount), null, 'mesgs');
|
||||||
|
}
|
||||||
|
if ($errorCount > 0) {
|
||||||
|
setEventMessages($langs->trans('DatanormBatchErrors', $errorCount), null, 'warnings');
|
||||||
|
}
|
||||||
|
if ($createdCount == 0 && $assignedCount == 0 && $errorCount == 0) {
|
||||||
|
setEventMessages($langs->trans('DatanormBatchNoMatches'), null, 'warnings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all lines now have products
|
||||||
|
$allHaveProducts = $importLine->allLinesHaveProducts($id);
|
||||||
|
if ($allHaveProducts) {
|
||||||
|
$import->status = ZugferdImport::STATUS_IMPORTED;
|
||||||
|
$import->update($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$action = 'edit';
|
||||||
|
$import->fetch($id);
|
||||||
|
}
|
||||||
|
|
||||||
// Create supplier invoice
|
// Create supplier invoice
|
||||||
if ($action == 'createinvoice' && $id > 0) {
|
if ($action == 'createinvoice' && $id > 0) {
|
||||||
$import->fetch($id);
|
$import->fetch($id);
|
||||||
|
|
@ -435,8 +800,7 @@ if ($action == 'createinvoice' && $id > 0) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$error) {
|
if (!$error) {
|
||||||
// Validate invoice
|
// Invoice stays as draft - user can validate manually
|
||||||
$invoice->validate($user);
|
|
||||||
|
|
||||||
// Copy PDF to invoice
|
// Copy PDF to invoice
|
||||||
$source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename;
|
$source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename;
|
||||||
|
|
@ -473,6 +837,63 @@ if ($action == 'createinvoice' && $id > 0) {
|
||||||
$action = 'edit';
|
$action = 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finish import - check for existing invoice and update status
|
||||||
|
if ($action == 'finishimport' && $id > 0) {
|
||||||
|
$import->fetch($id);
|
||||||
|
|
||||||
|
// Check all lines have products
|
||||||
|
$lines = $importLine->fetchAllByImport($id);
|
||||||
|
$allHaveProducts = true;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($line->fk_product <= 0) {
|
||||||
|
$allHaveProducts = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$allHaveProducts) {
|
||||||
|
$error++;
|
||||||
|
setEventMessages($langs->trans('ErrorNotAllProductsAssigned'), null, 'errors');
|
||||||
|
} elseif ($import->fk_soc <= 0) {
|
||||||
|
$error++;
|
||||||
|
setEventMessages($langs->trans('ErrorSupplierRequired'), null, 'errors');
|
||||||
|
} else {
|
||||||
|
// Search for existing supplier invoice with this ref_supplier
|
||||||
|
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_fourn";
|
||||||
|
$sql .= " WHERE fk_soc = ".((int) $import->fk_soc);
|
||||||
|
$sql .= " AND ref_supplier = '".$db->escape($import->invoice_number)."'";
|
||||||
|
$sql .= " LIMIT 1";
|
||||||
|
|
||||||
|
$resql = $db->query($sql);
|
||||||
|
if ($resql && $db->num_rows($resql) > 0) {
|
||||||
|
$obj = $db->fetch_object($resql);
|
||||||
|
// Found existing invoice - link it
|
||||||
|
$import->fk_facture_fourn = $obj->rowid;
|
||||||
|
$import->status = ZugferdImport::STATUS_PROCESSED;
|
||||||
|
$import->date_import = dol_now();
|
||||||
|
$result = $import->update($user);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
$invoiceLink = '<a href="'.DOL_URL_ROOT.'/fourn/facture/card.php?facid='.$obj->rowid.'">'.$import->invoice_number.'</a>';
|
||||||
|
setEventMessages($langs->trans('ImportLinkedToExistingInvoice', $invoiceLink), null, 'mesgs');
|
||||||
|
} else {
|
||||||
|
setEventMessages($import->error, null, 'errors');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing invoice - mark as imported (ready for invoice creation)
|
||||||
|
$import->status = ZugferdImport::STATUS_IMPORTED;
|
||||||
|
$result = $import->update($user);
|
||||||
|
|
||||||
|
if ($result > 0) {
|
||||||
|
setEventMessages($langs->trans('ImportFinished'), null, 'mesgs');
|
||||||
|
} else {
|
||||||
|
setEventMessages($import->error, null, 'errors');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$action = 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
// Delete import record
|
// Delete import record
|
||||||
if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0) {
|
if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0) {
|
||||||
$import->fetch($id);
|
$import->fetch($id);
|
||||||
|
|
@ -600,21 +1021,28 @@ if (empty($action) || ($action == 'upload' && $error)) {
|
||||||
print '</form>';
|
print '</form>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Delete confirmation dialog
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
print $formconfirm;
|
||||||
|
$action = 'edit'; // Continue showing the edit form
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Edit/Review import
|
* Edit/Review import
|
||||||
*/
|
*/
|
||||||
if ($action == 'edit' && $import->id > 0) {
|
if ($action == 'edit' && $import->id > 0) {
|
||||||
// Delete confirmation
|
|
||||||
if ($action == 'delete') {
|
|
||||||
$formconfirm = $form->formconfirm(
|
|
||||||
$_SERVER['PHP_SELF'].'?id='.$import->id,
|
|
||||||
$langs->trans('DeleteImportRecord'),
|
|
||||||
$langs->trans('ConfirmDeleteImportRecord', $import->ref),
|
|
||||||
'confirm_delete'
|
|
||||||
);
|
|
||||||
print $formconfirm;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch lines
|
// Fetch lines
|
||||||
$lines = $importLine->fetchAllByImport($import->id);
|
$lines = $importLine->fetchAllByImport($import->id);
|
||||||
$missingProducts = $importLine->countLinesWithoutProduct($import->id);
|
$missingProducts = $importLine->countLinesWithoutProduct($import->id);
|
||||||
|
|
@ -715,11 +1143,20 @@ if ($action == 'edit' && $import->id > 0) {
|
||||||
print '<td>'.$langs->trans('ProductDescription').'</td>';
|
print '<td>'.$langs->trans('ProductDescription').'</td>';
|
||||||
print '<td class="right">'.$langs->trans('Qty').'</td>';
|
print '<td class="right">'.$langs->trans('Qty').'</td>';
|
||||||
print '<td class="right">'.$langs->trans('UnitPrice').'</td>';
|
print '<td class="right">'.$langs->trans('UnitPrice').'</td>';
|
||||||
|
print '<td class="right">'.$langs->trans('DolibarrPrice').'</td>';
|
||||||
print '<td class="right">'.$langs->trans('TotalHT').'</td>';
|
print '<td class="right">'.$langs->trans('TotalHT').'</td>';
|
||||||
print '<td>'.$langs->trans('MatchedProduct').'</td>';
|
print '<td>'.$langs->trans('MatchedProduct').'</td>';
|
||||||
print '<td>'.$langs->trans('Action').'</td>';
|
print '<td>'.$langs->trans('Action').'</td>';
|
||||||
print '</tr>';
|
print '</tr>';
|
||||||
|
|
||||||
|
// Initialize totals for summary row
|
||||||
|
$totalDolibarrHT = 0;
|
||||||
|
$totalZugferdHT = 0;
|
||||||
|
$hasDolibarrPrices = false;
|
||||||
|
$allProductsMatched = true;
|
||||||
|
$matchedLinesCount = 0;
|
||||||
|
$totalLinesCount = count($lines);
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$hasProduct = ($line->fk_product > 0);
|
$hasProduct = ($line->fk_product > 0);
|
||||||
$rowClass = $hasProduct ? 'oddeven opacitymedium' : 'oddeven';
|
$rowClass = $hasProduct ? 'oddeven opacitymedium' : 'oddeven';
|
||||||
|
|
@ -740,6 +1177,60 @@ if ($action == 'edit' && $import->id > 0) {
|
||||||
print '<br><span class="opacitymedium">('.price($line->unit_price_raw).'/'.price2num($line->basis_quantity, 'MS').zugferdGetUnitLabel($line->basis_quantity_unit).')</span>';
|
print '<br><span class="opacitymedium">('.price($line->unit_price_raw).'/'.price2num($line->basis_quantity, 'MS').zugferdGetUnitLabel($line->basis_quantity_unit).')</span>';
|
||||||
}
|
}
|
||||||
print '</td>';
|
print '</td>';
|
||||||
|
|
||||||
|
// Dolibarr price column - show supplier price and difference
|
||||||
|
print '<td class="right nowraponall">';
|
||||||
|
$lineDolibarrTotal = 0;
|
||||||
|
if ($hasProduct && $import->fk_soc > 0) {
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
|
||||||
|
$productFourn = new ProductFournisseur($db);
|
||||||
|
$result = $productFourn->find_min_price_product_fournisseur($line->fk_product, 1, $import->fk_soc);
|
||||||
|
|
||||||
|
if ($result > 0 && $productFourn->fourn_price > 0) {
|
||||||
|
$dolibarrPrice = $productFourn->fourn_price;
|
||||||
|
$zugferdPrice = $line->unit_price;
|
||||||
|
$priceDiff = $zugferdPrice - $dolibarrPrice;
|
||||||
|
$priceDiffPercent = ($dolibarrPrice > 0) ? (($priceDiff / $dolibarrPrice) * 100) : 0;
|
||||||
|
|
||||||
|
// Accumulate for summary
|
||||||
|
$lineDolibarrTotal = $dolibarrPrice * $line->quantity;
|
||||||
|
$totalDolibarrHT += $lineDolibarrTotal;
|
||||||
|
$hasDolibarrPrices = true;
|
||||||
|
$matchedLinesCount++;
|
||||||
|
|
||||||
|
print price($dolibarrPrice);
|
||||||
|
|
||||||
|
if (abs($priceDiffPercent) >= 0.01) {
|
||||||
|
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
|
||||||
|
$isSignificant = (abs($priceDiffPercent) >= $threshold);
|
||||||
|
|
||||||
|
print '<br>';
|
||||||
|
if ($priceDiff > 0) {
|
||||||
|
// ZUGFeRD price is higher
|
||||||
|
$iconColor = $isSignificant ? 'color: #d9534f;' : 'color: #f0ad4e;';
|
||||||
|
print '<span style="'.$iconColor.'" title="'.$langs->trans('PriceIncrease').'">';
|
||||||
|
print '<i class="fas fa-arrow-up"></i> +'.number_format($priceDiffPercent, 1).'%';
|
||||||
|
print '</span>';
|
||||||
|
} else {
|
||||||
|
// ZUGFeRD price is lower
|
||||||
|
$iconColor = $isSignificant ? 'color: #5cb85c;' : 'color: #5bc0de;';
|
||||||
|
print '<span style="'.$iconColor.'" title="'.$langs->trans('PriceDecrease').'">';
|
||||||
|
print '<i class="fas fa-arrow-down"></i> '.number_format($priceDiffPercent, 1).'%';
|
||||||
|
print '</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print '<br><span class="opacitymedium"><i class="fas fa-equals"></i></span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print '<span class="opacitymedium">'.$langs->trans('NoPriceFound').'</span>';
|
||||||
|
$allProductsMatched = false; // No price found for matched product
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print '<span class="opacitymedium">-</span>';
|
||||||
|
$allProductsMatched = false; // Product not matched
|
||||||
|
}
|
||||||
|
print '</td>';
|
||||||
|
|
||||||
print '<td class="right">'.price($line->line_total).'</td>';
|
print '<td class="right">'.price($line->line_total).'</td>';
|
||||||
print '<td>';
|
print '<td>';
|
||||||
|
|
||||||
|
|
@ -810,11 +1301,93 @@ if ($action == 'edit' && $import->id > 0) {
|
||||||
print '<i class="fas fa-copy"></i>';
|
print '<i class="fas fa-copy"></i>';
|
||||||
print '</button>';
|
print '</button>';
|
||||||
print '</form>';
|
print '</form>';
|
||||||
|
|
||||||
|
// Datanorm button (only if supplier is set and supplier_ref exists)
|
||||||
|
if ($import->fk_soc > 0 && !empty($line->supplier_ref)) {
|
||||||
|
// Check if Datanorm article exists
|
||||||
|
$datanormCheck = new Datanorm($db);
|
||||||
|
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
|
||||||
|
$datanormResults = $datanormCheck->searchByArticleNumber($line->supplier_ref, $import->fk_soc, $searchAll, 1);
|
||||||
|
|
||||||
|
if (!empty($datanormResults)) {
|
||||||
|
$datanormArticle = $datanormResults[0];
|
||||||
|
print '<br>';
|
||||||
|
print '<a href="'.$_SERVER['PHP_SELF'].'?action=createfromdatanorm&line_id='.$line->id.'&id='.$import->id.'&token='.newToken().'" class="button buttongen margintoponlyshort" title="'.$langs->trans('CreateFromDatanormHelp').'">';
|
||||||
|
print '<i class="fas fa-database paddingright"></i>'.$langs->trans('CreateFromDatanorm');
|
||||||
|
print '</a>';
|
||||||
|
print '<br><span class="opacitymedium small">';
|
||||||
|
print dol_trunc($datanormArticle['short_text1'], 40);
|
||||||
|
print ' - '.price($datanormArticle['price']);
|
||||||
|
print '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
print '</td>';
|
print '</td>';
|
||||||
print '</tr>';
|
print '</tr>';
|
||||||
|
|
||||||
|
// Accumulate ZUGFeRD total
|
||||||
|
$totalZugferdHT += $line->line_total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Summary row with total comparison
|
||||||
|
// Only show full comparison if ALL products are matched with Dolibarr prices
|
||||||
|
print '<tr style="background-color: #f5f5f5; font-weight: bold;">';
|
||||||
|
print '<td colspan="5" class="right"><strong>'.$langs->trans('Total').' '.$langs->trans('TotalHT').'</strong></td>';
|
||||||
|
|
||||||
|
if ($allProductsMatched && $hasDolibarrPrices) {
|
||||||
|
// Full comparison possible - all products matched with prices
|
||||||
|
$totalDiff = $totalZugferdHT - $totalDolibarrHT;
|
||||||
|
$totalDiffPercent = ($totalDolibarrHT > 0) ? (($totalDiff / $totalDolibarrHT) * 100) : 0;
|
||||||
|
|
||||||
|
// Determine colors: green if close match, red if significant difference
|
||||||
|
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
|
||||||
|
$isMatch = (abs($totalDiffPercent) < 0.5); // Less than 0.5% difference = match
|
||||||
|
$isSignificant = (abs($totalDiffPercent) >= $threshold);
|
||||||
|
|
||||||
|
if ($isMatch) {
|
||||||
|
$cellStyle = 'background-color: #dff0d8;'; // Green
|
||||||
|
} elseif ($isSignificant) {
|
||||||
|
$cellStyle = 'background-color: #f2dede;'; // Red
|
||||||
|
} else {
|
||||||
|
$cellStyle = 'background-color: #fcf8e3;'; // Yellow/warning
|
||||||
|
}
|
||||||
|
|
||||||
|
print '<td class="right nowraponall" style="'.$cellStyle.'">';
|
||||||
|
print '<strong>'.price($totalDolibarrHT).'</strong>';
|
||||||
|
if (abs($totalDiffPercent) >= 0.01) {
|
||||||
|
print '<br>';
|
||||||
|
if ($totalDiff > 0) {
|
||||||
|
print '<span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.number_format($totalDiffPercent, 1).'%</span>';
|
||||||
|
} elseif ($totalDiff < 0) {
|
||||||
|
print '<span style="color: #5cb85c;"><i class="fas fa-arrow-down"></i> '.number_format($totalDiffPercent, 1).'%</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print '</td>';
|
||||||
|
print '<td class="right" style="'.$cellStyle.'"><strong>'.price($totalZugferdHT).'</strong></td>';
|
||||||
|
print '<td colspan="2" class="nowraponall" style="'.$cellStyle.'">';
|
||||||
|
if ($isMatch) {
|
||||||
|
print '<span style="color: #3c763d;"><i class="fas fa-check-circle"></i> '.$langs->trans('SumValidationOk').'</span>';
|
||||||
|
} else {
|
||||||
|
print '<span style="color: #a94442;"><i class="fas fa-exclamation-triangle"></i> '.$langs->trans('Difference').': '.price($totalDiff).' '.$import->currency.'</span>';
|
||||||
|
}
|
||||||
|
print '</td>';
|
||||||
|
} else {
|
||||||
|
// Not all products matched - show totals but no comparison
|
||||||
|
print '<td class="right nowraponall">';
|
||||||
|
if ($hasDolibarrPrices) {
|
||||||
|
print '<span class="opacitymedium">'.price($totalDolibarrHT).'</span>';
|
||||||
|
print '<br><span class="opacitymedium small">('.$matchedLinesCount.'/'.$totalLinesCount.')</span>';
|
||||||
|
} else {
|
||||||
|
print '<span class="opacitymedium">-</span>';
|
||||||
|
}
|
||||||
|
print '</td>';
|
||||||
|
print '<td class="right"><strong>'.price($totalZugferdHT).'</strong></td>';
|
||||||
|
print '<td colspan="2" class="nowraponall">';
|
||||||
|
print '<span class="opacitymedium"><i class="fas fa-info-circle"></i> '.$langs->trans('ProductsNotAssigned').'</span>';
|
||||||
|
print '</td>';
|
||||||
|
}
|
||||||
|
print '</tr>';
|
||||||
|
|
||||||
print '</table>';
|
print '</table>';
|
||||||
print '</div>';
|
print '</div>';
|
||||||
|
|
||||||
|
|
@ -828,9 +1401,33 @@ if ($action == 'edit' && $import->id > 0) {
|
||||||
print ' ';
|
print ' ';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>';
|
print '<a href="'.dol_buildpath('/importzugferd/list.php', 1).'" class="button">'.$langs->trans('BackToList').'</a>';
|
||||||
print ' ';
|
|
||||||
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&id='.$import->id.'&token='.newToken().'" class="button button-cancel">'.$langs->trans('Delete').'</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>';
|
print '</div>';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ IMPORTZUGFERD_WATCH_FOLDER = Überwachungsordner
|
||||||
IMPORTZUGFERD_WATCH_FOLDERTooltip = Ordner für eingehende ZUGFeRD-Rechnungen (lokaler Pfad)
|
IMPORTZUGFERD_WATCH_FOLDERTooltip = Ordner für eingehende ZUGFeRD-Rechnungen (lokaler Pfad)
|
||||||
IMPORTZUGFERD_ARCHIVE_FOLDER = Archivordner
|
IMPORTZUGFERD_ARCHIVE_FOLDER = Archivordner
|
||||||
IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Ordner für erfolgreich importierte Rechnungen
|
IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Ordner für erfolgreich importierte Rechnungen
|
||||||
|
IMPORTZUGFERD_ERROR_FOLDER = Fehlerordner
|
||||||
|
IMPORTZUGFERD_ERROR_FOLDERTooltip = Ordner für fehlerhafte Rechnungen (nicht ZUGFeRD oder Importfehler)
|
||||||
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archivordner
|
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archivordner
|
||||||
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP-Ordner für archivierte E-Mails nach Import
|
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP-Ordner für archivierte E-Mails nach Import
|
||||||
|
|
||||||
|
|
@ -109,6 +111,7 @@ SupplierCustomerNumberHelp = Ihre Kundennummer bei diesem Lieferanten (für auto
|
||||||
# Cronjob
|
# Cronjob
|
||||||
#
|
#
|
||||||
ImportZugferdFromMailbox = ZUGFeRD Rechnungen aus Postfach importieren
|
ImportZugferdFromMailbox = ZUGFeRD Rechnungen aus Postfach importieren
|
||||||
|
ImportZugferdScheduled = ZUGFeRD geplanter Import (Ordner und E-Mail)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Fehler
|
# Fehler
|
||||||
|
|
@ -213,3 +216,131 @@ ImportRecordCreated = Import-Datensatz erstellt
|
||||||
ErrorNotAllProductsAssigned = Nicht alle Produkte zugeordnet
|
ErrorNotAllProductsAssigned = Nicht alle Produkte zugeordnet
|
||||||
BackToList = Zurück zur Liste
|
BackToList = Zurück zur Liste
|
||||||
ErrorRecordNotFound = Datensatz nicht gefunden
|
ErrorRecordNotFound = Datensatz nicht gefunden
|
||||||
|
FinishImport = Abschließen
|
||||||
|
ImportFinished = Import abgeschlossen
|
||||||
|
ImportLinkedToExistingInvoice = Import mit bestehender Rechnung %s verknüpft
|
||||||
|
|
||||||
|
#
|
||||||
|
# Datanorm
|
||||||
|
#
|
||||||
|
DatanormCatalogs = Datanorm Kataloge
|
||||||
|
DatanormSettings = Datanorm Einstellungen
|
||||||
|
IMPORTZUGFERD_DATANORM_MARKUP = Preisaufschlag (%)
|
||||||
|
IMPORTZUGFERD_DATANORM_MARKUPTooltip = Prozentualer Aufschlag auf den Datanorm-Einkaufspreis für den Verkaufspreis
|
||||||
|
IMPORTZUGFERD_DATANORM_SEARCH_ALL = In allen Lieferanten-Katalogen suchen
|
||||||
|
IMPORTZUGFERD_DATANORM_SEARCH_ALLTooltip = Bei Aktivierung wird nicht nur im Katalog des aktuellen Lieferanten gesucht, sondern in allen Datanorm-Katalogen
|
||||||
|
UploadDatanorm = Datanorm hochladen
|
||||||
|
DatanormFiles = Datanorm Dateien
|
||||||
|
DatanormFileHelp = DATANORM.001, DATANORM.WRG oder XML-Dateien (Datanorm 4.0/5.0)
|
||||||
|
DeleteExisting = Vorhandene Artikel löschen
|
||||||
|
DeleteExistingHelp = Löscht alle vorhandenen Artikel dieses Lieferanten vor dem Import
|
||||||
|
DatanormImportSuccess = %s Artikel erfolgreich importiert
|
||||||
|
DatanormImportFailed = Datanorm Import fehlgeschlagen
|
||||||
|
DatanormNoArticlesFound = Keine Artikel in der Datanorm-Datei gefunden
|
||||||
|
NoDatanormData = Keine Datanorm-Daten vorhanden
|
||||||
|
DatanormDeleted = %s Artikel gelöscht
|
||||||
|
DatanormDeleteFailed = Löschen fehlgeschlagen
|
||||||
|
DeleteDatanorm = Datanorm-Katalog löschen
|
||||||
|
ConfirmDeleteDatanorm = Möchten Sie alle Datanorm-Artikel von %s löschen?
|
||||||
|
DatanormArticles = Datanorm Artikel
|
||||||
|
ArticleNumber = Artikelnummer
|
||||||
|
ArticleCount = Artikelanzahl
|
||||||
|
LastImport = Letzter Import
|
||||||
|
ViewArticles = Artikel anzeigen
|
||||||
|
TotalArticles = Gesamtanzahl Artikel
|
||||||
|
DatanormSettingsInfo = Preisaufschlag und Suchverhalten können in den Moduleinstellungen konfiguriert werden:
|
||||||
|
CreateFromDatanorm = Aus Datanorm
|
||||||
|
CreateFromDatanormHelp = Neues Produkt aus Datanorm-Daten anlegen
|
||||||
|
ProductCreatedFromDatanorm = Produkt %s aus Datanorm erstellt
|
||||||
|
DatanormArticleNotFound = Kein Datanorm-Artikel für Artikelnummer '%s' gefunden
|
||||||
|
CreateAllFromDatanorm = Alle aus Datanorm
|
||||||
|
CreateAllFromDatanormHelp = Alle fehlenden Produkte aus Datanorm-Daten anlegen
|
||||||
|
DatanormBatchCreated = %s Produkte aus Datanorm erstellt
|
||||||
|
DatanormBatchAssigned = %s vorhandene Produkte zugeordnet
|
||||||
|
DatanormBatchErrors = %s Produkte konnten nicht erstellt werden
|
||||||
|
DatanormBatchNoMatches = Keine passenden Datanorm-Artikel gefunden
|
||||||
|
|
||||||
|
#
|
||||||
|
# Scheduling
|
||||||
|
#
|
||||||
|
SchedulingSettings = Zeitplanung
|
||||||
|
IMPORTZUGFERD_IMPORT_FREQUENCY = Import-Häufigkeit
|
||||||
|
IMPORTZUGFERD_IMPORT_FREQUENCYTooltip = Wie oft sollen Ordner und E-Mails automatisch auf neue Rechnungen geprüft werden
|
||||||
|
FrequencyManual = Nur manuell
|
||||||
|
FrequencyHourly = Stündlich
|
||||||
|
FrequencyDaily = Täglich
|
||||||
|
FrequencyWeekly = Wöchentlich
|
||||||
|
ManualImportTrigger = Manueller Import
|
||||||
|
|
||||||
|
#
|
||||||
|
# Folder Browser
|
||||||
|
#
|
||||||
|
FolderBrowser = Ordner-Auswahl
|
||||||
|
Browse = Durchsuchen
|
||||||
|
SelectFolder = Ordner auswählen
|
||||||
|
SelectThisFolder = Diesen Ordner wählen
|
||||||
|
CurrentPath = Aktueller Pfad
|
||||||
|
ParentFolder = Übergeordneter Ordner
|
||||||
|
NoSubfolders = Keine Unterordner
|
||||||
|
NotConfigured = Nicht konfiguriert
|
||||||
|
ErrorFolderNotFound = Ordner nicht gefunden
|
||||||
|
Go = Los
|
||||||
|
QuickLinks = Schnellzugriff
|
||||||
|
|
||||||
|
#
|
||||||
|
# Folder Validation
|
||||||
|
#
|
||||||
|
FolderValidation = Ordner-Prüfung
|
||||||
|
FolderOK = OK
|
||||||
|
FolderNotFound = Ordner nicht gefunden
|
||||||
|
FolderNotReadable = Ordner nicht lesbar
|
||||||
|
FolderNotWritable = Ordner nicht beschreibbar
|
||||||
|
|
||||||
|
#
|
||||||
|
# Email Notifications
|
||||||
|
#
|
||||||
|
NotificationSettings = E-Mail-Benachrichtigungen
|
||||||
|
IMPORTZUGFERD_NOTIFY_ENABLED = Benachrichtigungen aktivieren
|
||||||
|
IMPORTZUGFERD_NOTIFY_ENABLEDTooltip = E-Mail-Benachrichtigungen für Import-Ereignisse aktivieren
|
||||||
|
IMPORTZUGFERD_NOTIFY_EMAIL = Benachrichtigungs-E-Mail
|
||||||
|
IMPORTZUGFERD_NOTIFY_EMAILTooltip = E-Mail-Adresse für Import-Benachrichtigungen
|
||||||
|
IMPORTZUGFERD_NOTIFY_MANUAL = Bei manuellem Eingriff
|
||||||
|
IMPORTZUGFERD_NOTIFY_MANUALTooltip = E-Mail senden wenn ein Import manuellen Eingriff benötigt
|
||||||
|
IMPORTZUGFERD_NOTIFY_ERROR = Bei Fehlern
|
||||||
|
IMPORTZUGFERD_NOTIFY_ERRORTooltip = E-Mail senden wenn beim Import ein Fehler auftritt
|
||||||
|
IMPORTZUGFERD_NOTIFY_PRICE_DIFF = Bei Preisabweichungen
|
||||||
|
IMPORTZUGFERD_NOTIFY_PRICE_DIFFTooltip = E-Mail senden wenn Produktpreise um mehr als den Schwellenwert abweichen
|
||||||
|
IMPORTZUGFERD_PRICE_DIFF_THRESHOLD = Preisabweichung Schwelle (%)
|
||||||
|
IMPORTZUGFERD_PRICE_DIFF_THRESHOLDTooltip = Prozentuale Preisabweichung ab der eine Benachrichtigung gesendet wird
|
||||||
|
|
||||||
|
# Email content
|
||||||
|
NotifySubjectManualIntervention = Manueller Eingriff erforderlich: Rechnung %s
|
||||||
|
NotifySubjectError = Import-Fehler: %s
|
||||||
|
NotifySubjectPriceDiff = Preisabweichungen erkannt: Rechnung %s (%s Produkte)
|
||||||
|
NotifyBodyManualIntervention = Der Import der Rechnung %s von %s erfordert manuellen Eingriff.
|
||||||
|
NotifyBodyError = Beim Import der Rechnung/Datei %s ist ein Fehler aufgetreten.
|
||||||
|
NotifyBodyPriceDiff = Bei der Rechnung %s von %s wurden Preisabweichungen von mehr als %s%% erkannt.
|
||||||
|
NotifyLinkToImport = Link zum Import
|
||||||
|
OldPrice = Alter Preis
|
||||||
|
NewPrice = Neuer Preis
|
||||||
|
File = Datei
|
||||||
|
|
||||||
|
# Price comparison
|
||||||
|
DolibarrPrice = Dolibarr Preis
|
||||||
|
PriceIncrease = Preiserhöhung
|
||||||
|
PriceDecrease = Preissenkung
|
||||||
|
NoPriceFound = Kein Preis
|
||||||
|
|
||||||
|
# Test Email
|
||||||
|
TestEmailNotification = E-Mail-Benachrichtigung testen
|
||||||
|
SendTestEmail = Test-E-Mail senden
|
||||||
|
TestEmailSent = Test-E-Mail erfolgreich gesendet an %s
|
||||||
|
TestEmailFailed = Test-E-Mail konnte nicht gesendet werden
|
||||||
|
SendTo = Senden an
|
||||||
|
NotifySubjectTest = Test-E-Mail Benachrichtigung
|
||||||
|
NotifyBodyTest = Dies ist eine Test-E-Mail vom ZUGFeRD Import Modul.
|
||||||
|
NotifyTestInfo = Diese E-Mail bestätigt, dass die E-Mail-Benachrichtigungen korrekt konfiguriert sind.
|
||||||
|
NotifyTestSuccess = Die E-Mail-Konfiguration funktioniert einwandfrei!
|
||||||
|
CurrentSettings = Aktuelle Einstellungen
|
||||||
|
NotificationsNotEnabled = Benachrichtigungen sind nicht aktiviert oder keine E-Mail-Adresse konfiguriert
|
||||||
|
NotifyEmail = Empfänger-E-Mail
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ IMPORTZUGFERD_WATCH_FOLDER = Watch Folder
|
||||||
IMPORTZUGFERD_WATCH_FOLDERTooltip = Folder for incoming ZUGFeRD invoices (local path)
|
IMPORTZUGFERD_WATCH_FOLDERTooltip = Folder for incoming ZUGFeRD invoices (local path)
|
||||||
IMPORTZUGFERD_ARCHIVE_FOLDER = Archive Folder
|
IMPORTZUGFERD_ARCHIVE_FOLDER = Archive Folder
|
||||||
IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Folder for successfully imported invoices
|
IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Folder for successfully imported invoices
|
||||||
|
IMPORTZUGFERD_ERROR_FOLDER = Error Folder
|
||||||
|
IMPORTZUGFERD_ERROR_FOLDERTooltip = Folder for failed invoices (not ZUGFeRD or import errors)
|
||||||
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archive Folder
|
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archive Folder
|
||||||
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP folder for archived emails after import
|
IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP folder for archived emails after import
|
||||||
|
|
||||||
|
|
@ -109,6 +111,7 @@ SupplierCustomerNumberHelp = Your customer number at this supplier (used for aut
|
||||||
# Cronjob
|
# Cronjob
|
||||||
#
|
#
|
||||||
ImportZugferdFromMailbox = Import ZUGFeRD invoices from mailbox
|
ImportZugferdFromMailbox = Import ZUGFeRD invoices from mailbox
|
||||||
|
ImportZugferdScheduled = ZUGFeRD scheduled import (folder and email)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Errors
|
# Errors
|
||||||
|
|
@ -213,3 +216,131 @@ ImportRecordCreated = Import record created
|
||||||
ErrorNotAllProductsAssigned = Not all products assigned
|
ErrorNotAllProductsAssigned = Not all products assigned
|
||||||
BackToList = Back to list
|
BackToList = Back to list
|
||||||
ErrorRecordNotFound = Record not found
|
ErrorRecordNotFound = Record not found
|
||||||
|
FinishImport = Finish Import
|
||||||
|
ImportFinished = Import finished
|
||||||
|
ImportLinkedToExistingInvoice = Import linked to existing invoice %s
|
||||||
|
|
||||||
|
#
|
||||||
|
# Datanorm
|
||||||
|
#
|
||||||
|
DatanormCatalogs = Datanorm Catalogs
|
||||||
|
DatanormSettings = Datanorm Settings
|
||||||
|
IMPORTZUGFERD_DATANORM_MARKUP = Price Markup (%)
|
||||||
|
IMPORTZUGFERD_DATANORM_MARKUPTooltip = Percentage markup on Datanorm purchase price for selling price
|
||||||
|
IMPORTZUGFERD_DATANORM_SEARCH_ALL = Search in all supplier catalogs
|
||||||
|
IMPORTZUGFERD_DATANORM_SEARCH_ALLTooltip = When enabled, search all Datanorm catalogs, not just the current supplier
|
||||||
|
UploadDatanorm = Upload Datanorm
|
||||||
|
DatanormFiles = Datanorm Files
|
||||||
|
DatanormFileHelp = DATANORM.001, DATANORM.WRG or XML files (Datanorm 4.0/5.0)
|
||||||
|
DeleteExisting = Delete existing articles
|
||||||
|
DeleteExistingHelp = Deletes all existing articles for this supplier before import
|
||||||
|
DatanormImportSuccess = %s articles imported successfully
|
||||||
|
DatanormImportFailed = Datanorm import failed
|
||||||
|
DatanormNoArticlesFound = No articles found in Datanorm file
|
||||||
|
NoDatanormData = No Datanorm data available
|
||||||
|
DatanormDeleted = %s articles deleted
|
||||||
|
DatanormDeleteFailed = Deletion failed
|
||||||
|
DeleteDatanorm = Delete Datanorm catalog
|
||||||
|
ConfirmDeleteDatanorm = Are you sure you want to delete all Datanorm articles from %s?
|
||||||
|
DatanormArticles = Datanorm Articles
|
||||||
|
ArticleNumber = Article Number
|
||||||
|
ArticleCount = Article Count
|
||||||
|
LastImport = Last Import
|
||||||
|
ViewArticles = View Articles
|
||||||
|
TotalArticles = Total Articles
|
||||||
|
DatanormSettingsInfo = Price markup and search behavior can be configured in module settings:
|
||||||
|
CreateFromDatanorm = From Datanorm
|
||||||
|
CreateFromDatanormHelp = Create new product from Datanorm data
|
||||||
|
ProductCreatedFromDatanorm = Product %s created from Datanorm
|
||||||
|
DatanormArticleNotFound = No Datanorm article found for article number '%s'
|
||||||
|
CreateAllFromDatanorm = All from Datanorm
|
||||||
|
CreateAllFromDatanormHelp = Create all missing products from Datanorm data
|
||||||
|
DatanormBatchCreated = %s products created from Datanorm
|
||||||
|
DatanormBatchAssigned = %s existing products assigned
|
||||||
|
DatanormBatchErrors = %s products could not be created
|
||||||
|
DatanormBatchNoMatches = No matching Datanorm articles found
|
||||||
|
|
||||||
|
#
|
||||||
|
# Scheduling
|
||||||
|
#
|
||||||
|
SchedulingSettings = Scheduling
|
||||||
|
IMPORTZUGFERD_IMPORT_FREQUENCY = Import Frequency
|
||||||
|
IMPORTZUGFERD_IMPORT_FREQUENCYTooltip = How often should folders and emails be checked for new invoices automatically
|
||||||
|
FrequencyManual = Manual only
|
||||||
|
FrequencyHourly = Hourly
|
||||||
|
FrequencyDaily = Daily
|
||||||
|
FrequencyWeekly = Weekly
|
||||||
|
ManualImportTrigger = Manual Import
|
||||||
|
|
||||||
|
#
|
||||||
|
# Folder Browser
|
||||||
|
#
|
||||||
|
FolderBrowser = Folder Selection
|
||||||
|
Browse = Browse
|
||||||
|
SelectFolder = Select Folder
|
||||||
|
SelectThisFolder = Select This Folder
|
||||||
|
CurrentPath = Current Path
|
||||||
|
ParentFolder = Parent Folder
|
||||||
|
NoSubfolders = No subfolders
|
||||||
|
NotConfigured = Not configured
|
||||||
|
ErrorFolderNotFound = Folder not found
|
||||||
|
Go = Go
|
||||||
|
QuickLinks = Quick links
|
||||||
|
|
||||||
|
#
|
||||||
|
# Folder Validation
|
||||||
|
#
|
||||||
|
FolderValidation = Folder Validation
|
||||||
|
FolderOK = OK
|
||||||
|
FolderNotFound = Folder not found
|
||||||
|
FolderNotReadable = Folder not readable
|
||||||
|
FolderNotWritable = Folder not writable
|
||||||
|
|
||||||
|
#
|
||||||
|
# Email Notifications
|
||||||
|
#
|
||||||
|
NotificationSettings = Email Notifications
|
||||||
|
IMPORTZUGFERD_NOTIFY_ENABLED = Enable notifications
|
||||||
|
IMPORTZUGFERD_NOTIFY_ENABLEDTooltip = Enable email notifications for import events
|
||||||
|
IMPORTZUGFERD_NOTIFY_EMAIL = Notification email
|
||||||
|
IMPORTZUGFERD_NOTIFY_EMAILTooltip = Email address for import notifications
|
||||||
|
IMPORTZUGFERD_NOTIFY_MANUAL = On manual intervention
|
||||||
|
IMPORTZUGFERD_NOTIFY_MANUALTooltip = Send email when an import requires manual intervention
|
||||||
|
IMPORTZUGFERD_NOTIFY_ERROR = On errors
|
||||||
|
IMPORTZUGFERD_NOTIFY_ERRORTooltip = Send email when an import error occurs
|
||||||
|
IMPORTZUGFERD_NOTIFY_PRICE_DIFF = On price differences
|
||||||
|
IMPORTZUGFERD_NOTIFY_PRICE_DIFFTooltip = Send email when product prices differ by more than the threshold
|
||||||
|
IMPORTZUGFERD_PRICE_DIFF_THRESHOLD = Price difference threshold (%)
|
||||||
|
IMPORTZUGFERD_PRICE_DIFF_THRESHOLDTooltip = Percentage price difference that triggers a notification
|
||||||
|
|
||||||
|
# Email content
|
||||||
|
NotifySubjectManualIntervention = Manual intervention required: Invoice %s
|
||||||
|
NotifySubjectError = Import error: %s
|
||||||
|
NotifySubjectPriceDiff = Price differences detected: Invoice %s (%s products)
|
||||||
|
NotifyBodyManualIntervention = The import of invoice %s from %s requires manual intervention.
|
||||||
|
NotifyBodyError = An error occurred while importing invoice/file %s.
|
||||||
|
NotifyBodyPriceDiff = Invoice %s from %s has price differences of more than %s%%.
|
||||||
|
NotifyLinkToImport = Link to import
|
||||||
|
OldPrice = Old price
|
||||||
|
NewPrice = New price
|
||||||
|
File = File
|
||||||
|
|
||||||
|
# Price comparison
|
||||||
|
DolibarrPrice = Dolibarr Price
|
||||||
|
PriceIncrease = Price increase
|
||||||
|
PriceDecrease = Price decrease
|
||||||
|
NoPriceFound = No price
|
||||||
|
|
||||||
|
# Test Email
|
||||||
|
TestEmailNotification = Test Email Notification
|
||||||
|
SendTestEmail = Send Test Email
|
||||||
|
TestEmailSent = Test email successfully sent to %s
|
||||||
|
TestEmailFailed = Failed to send test email
|
||||||
|
SendTo = Send to
|
||||||
|
NotifySubjectTest = Test Email Notification
|
||||||
|
NotifyBodyTest = This is a test email from the ZUGFeRD Import module.
|
||||||
|
NotifyTestInfo = This email confirms that email notifications are correctly configured.
|
||||||
|
NotifyTestSuccess = The email configuration is working properly!
|
||||||
|
CurrentSettings = Current settings
|
||||||
|
NotificationsNotEnabled = Notifications are not enabled or no email address configured
|
||||||
|
NotifyEmail = Recipient email
|
||||||
|
|
|
||||||
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