importzugferd/import.php
data 75ef65c0ee fix: Fallback für alte PDF-Pfade beim Anhängen an Rechnung
Sucht jetzt auch im alten Format /imports/{ref}_{filename}
falls die Datei nicht im neuen Pfad /imports/{id}/{filename} liegt.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 11:35:43 +01:00

3428 lines
165 KiB
PHP
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 import.php
* \ingroup importzugferd
* \brief Manual ZUGFeRD import page with persistent workflow
*/
// 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/files.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
dol_include_once('/importzugferd/class/zugferdparser.class.php');
dol_include_once('/importzugferd/class/zugferdimport.class.php');
dol_include_once('/importzugferd/class/importline.class.php');
dol_include_once('/importzugferd/class/productmapping.class.php');
dol_include_once('/importzugferd/class/actions_importzugferd.class.php');
dol_include_once('/importzugferd/class/datanorm.class.php');
dol_include_once('/importzugferd/class/datanormparser.class.php');
dol_include_once('/importzugferd/class/importnotification.class.php');
dol_include_once('/importzugferd/lib/importzugferd.lib.php');
// Load translation files
$langs->loadLangs(array("importzugferd@importzugferd", "bills", "products", "companies"));
// Security check
if (!$user->hasRight('importzugferd', 'import', 'write')) {
accessforbidden();
}
// Get parameters
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');
$id = GETPOST('id', 'int'); // Import ID for editing existing imports
$supplier_id = GETPOST('supplier_id', 'int');
$line_id = GETPOST('line_id', 'int');
$product_id = GETPOST('product_id', 'int');
$template_product_id = GETPOST('template_product_id', 'int');
// Zeilenspezifische Produkt-IDs (wegen eindeutiger select2-IDs pro Zeile)
if (empty($product_id) && $line_id > 0) {
$product_id = GETPOST('product_id_'.$line_id, 'int');
}
if (empty($template_product_id) && $line_id > 0) {
$template_product_id = GETPOST('template_product_id_'.$line_id, 'int');
}
// Initialize objects
$form = new Form($db);
$formfile = new FormFile($db);
$actions = new ActionsImportZugferd($db);
$import = new ZugferdImport($db);
$importLine = new ImportLine($db);
$notification = new ImportNotification($db);
$error = 0;
$message = '';
/*
* Helper-Funktionen (DRY)
*/
/**
* Parse Aderanzahl und Querschnitt aus Kabelbezeichnung
* Erkennt Formate wie: NYM-J 3x2,5 / NYM-J 5x1.5 / H07V-K 1x4 / J-Y(ST)Y 2x2x0,8 etc.
*
* @param string $text Kabelbezeichnung (z.B. "NYM-J 3x2,5 Eca Ri100")
* @return array|null Array mit 'aderanzahl', 'querschnitt' oder null wenn kein Kabel
*/
function parseCableSpecsFromText($text)
{
// Spezialfall: Fernmeldekabel wie J-Y(ST)Y 2x2x0,8 (Paare x Adern pro Paar x Querschnitt)
// Pattern: Zahl x Zahl x Zahl (z.B. 2x2x0,8 = 4 Adern mit 0,8mm²)
if (preg_match('/(\d+)\s*[xX]\s*(\d+)\s*[xX]\s*(\d+(?:[,\.]\d+)?)/', $text, $matches)) {
$paare = (int) $matches[1];
$adernProPaar = (int) $matches[2];
$querschnitt = (float) str_replace(',', '.', $matches[3]);
$aderanzahl = $paare * $adernProPaar;
// Plausibilitätsprüfung
if ($aderanzahl >= 1 && $aderanzahl <= 200 && $querschnitt >= 0.14 && $querschnitt <= 400) {
return array(
'aderanzahl' => $aderanzahl,
'querschnitt' => $querschnitt
);
}
}
// Standard: NYM-J 3x2,5 (Adern x Querschnitt)
// Pattern: Zahl x Zahl (mit Komma oder Punkt als Dezimaltrenner)
if (preg_match('/(\d+)\s*[xX]\s*(\d+(?:[,\.]\d+)?)/', $text, $matches)) {
$aderanzahl = (int) $matches[1];
$querschnitt = (float) str_replace(',', '.', $matches[2]);
// Plausibilitätsprüfung
if ($aderanzahl >= 1 && $aderanzahl <= 100 && $querschnitt >= 0.5 && $querschnitt <= 400) {
return array(
'aderanzahl' => $aderanzahl,
'querschnitt' => $querschnitt
);
}
}
return null;
}
/**
* Berechne Kupfergehalt aus Aderanzahl und Querschnitt
* Formel: Aderanzahl × Querschnitt × 8.9 (Dichte Kupfer) = kg/km
*
* @param int $aderanzahl Anzahl der Adern
* @param float $querschnitt Querschnitt in mm²
* @return float Kupfergehalt in kg/km
*/
function calculateKupfergehalt($aderanzahl, $querschnitt)
{
// Kupferdichte: 8.9 g/cm³ = 8.9 kg/dm³
// 1 mm² × 1 km = 1 mm² × 1000m = 1000 mm³ = 1 cm³
// Also: 1 mm² Querschnitt × 1 km Länge = 1000 cm³ = 1 dm³ = 8.9 kg
return $aderanzahl * $querschnitt * 8.9;
}
/**
* Hole aktuellen Kupferpreis aus Metallzuschlag-Modul
*
* @param DoliDB $db Datenbank
* @param int $supplierId Lieferanten-ID (optional, für lieferantenspezifischen Preis)
* @return float CU-Notiz in EUR/100kg oder 0 wenn nicht verfügbar
*/
function getCurrentCopperPrice($db, $supplierId = 0)
{
// Erst prüfen ob Metallzuschlag-Modul aktiv ist
if (!isModEnabled('metallzuschlag')) {
return 0;
}
// Lieferanten-spezifischer CU-Wert (aus societe_extrafields)
if ($supplierId > 0) {
$sql = "SELECT metallzuschlag_cu FROM ".MAIN_DB_PREFIX."societe_extrafields";
$sql .= " WHERE fk_object = ".(int)$supplierId;
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
if (!empty($obj->metallzuschlag_cu) && (float)$obj->metallzuschlag_cu > 0) {
return (float)$obj->metallzuschlag_cu;
}
}
}
// Fallback: Aktuellster CU-Wert aus History
$sql = "SELECT value FROM ".MAIN_DB_PREFIX."metallzuschlag_history";
$sql .= " WHERE metal = 'CU' ORDER BY date_notiz DESC LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
return (float)$obj->value;
}
return 0;
}
/**
* Berechne Kupferzuschlag für eine bestimmte Menge
* Formel: Kupfergehalt (kg/km) × CU (EUR/100kg) / 100000 × Menge
*
* @param float $kupfergehalt Kupfergehalt in kg/km
* @param float $cuPrice CU-Notiz in EUR/100kg
* @param float $quantity Menge (z.B. 100 für 100m)
* @return float Kupferzuschlag in EUR
*/
function calculateKupferzuschlag($kupfergehalt, $cuPrice, $quantity = 1)
{
if ($kupfergehalt <= 0 || $cuPrice <= 0) {
return 0;
}
// kg/km × EUR/100kg / 100000 × m = EUR
return round($kupfergehalt * $cuPrice / 100000 * $quantity, 2);
}
/**
* Prüft ob ein Produkt ein Kabel ist (basierend auf Warengruppe oder Bezeichnung)
*
* @param Datanorm $datanorm Datanorm-Objekt
* @return bool True wenn Kabel
*/
function isCableProduct($datanorm)
{
// Warengruppen die typisch für Kabel sind
$cableGroups = array('KAB', 'KABEL', 'LEI', 'LEIT', 'LEITUNG');
if (!empty($datanorm->product_group)) {
$group = strtoupper(substr($datanorm->product_group, 0, 5));
foreach ($cableGroups as $cg) {
if (strpos($group, $cg) !== false) {
return true;
}
}
}
// Typische Kabelbezeichnungen
$cablePatterns = array(
'/NYM[-\s]?[JYOA]/i',
'/NYY[-\s]?[JO]/i',
'/H0[357]V[-\s]?[KUR]/i',
'/H0[357]RN[-\s]?F/i',
'/NHXH/i',
'/J[-\s]?Y\(ST\)Y/i',
'/LiYCY/i',
'/ÖLFLEX/i',
);
$text = $datanorm->short_text1 . ' ' . $datanorm->short_text2;
foreach ($cablePatterns as $pattern) {
if (preg_match($pattern, $text)) {
return true;
}
}
return false;
}
/**
* Ringgröße aus Kabel-Bezeichnung extrahieren
* Erkennt Muster wie: Ri100, Ri.50, Ri 100, Ring100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m"
*
* WICHTIG: Nur verwenden wenn price_unit = 1!
* Bei price_unit > 1 ist das bereits die korrekte Preiseinheit (z.B. 100 für 100m)
*
* @param string $text Produktbezeichnung
* @return int Ringgröße in Metern (0 wenn nicht gefunden)
*/
function extractCableRingSize($text)
{
// Muster für Ringgröße: Ri100, Ri.50, Ri 100, Ring100, Ring 50
if (preg_match('/Ri(?:ng)?[.\s]?(\d+)/i', $text, $matches)) {
return (int)$matches[1];
}
// Muster für "Ring 100m", "Ring 50 m"
if (preg_match('/Ring\s+(\d+)\s*m/i', $text, $matches)) {
return (int)$matches[1];
}
// Muster für Trommel: Tr500, Tr.500, Trommel500, "Trommel 500m"
if (preg_match('/Tr(?:ommel)?[.\s]?(\d+)/i', $text, $matches)) {
return (int)$matches[1];
}
if (preg_match('/Trommel\s+(\d+)\s*m/i', $text, $matches)) {
return (int)$matches[1];
}
// Muster für Folie/Rolle: Fol.25m, Fol25, Rol.50m
if (preg_match('/(?:Fol|Rol)[.\s]?(\d+)/i', $text, $matches)) {
return (int)$matches[1];
}
return 0;
}
/**
* Berechne Kabelpreis unter Berücksichtigung unterschiedlicher Lieferanten-Formate
*
* Logik:
* - Kluxen/Witte/eltric: price_unit > 1 (z.B. 100) → Preis ist für 100m
* - Sonepar: price_unit = 1 → Preis ist für kompletten Ring (Größe aus Name)
*
* @param Datanorm $datanorm Datanorm-Objekt
* @param float $minQty Mindestbestellmenge (default 1)
* @return array Array mit 'unitPrice', 'totalPrice', 'priceUnit'
*/
function calculateCablePricing($datanorm, $minQty = 1)
{
$priceUnit = $datanorm->price_unit > 0 ? $datanorm->price_unit : 1;
$cableText = $datanorm->short_text1 . ' ' . $datanorm->short_text2;
if ($priceUnit > 1) {
// Kluxen/Witte-Format: price_unit gibt die Preiseinheit an (z.B. 100m)
$unitPrice = $datanorm->price / $priceUnit;
$effectivePriceUnit = $priceUnit;
} else {
// Sonepar-Format: price_unit = 1, aber Preis ist für kompletten Ring
$ringSize = extractCableRingSize($cableText);
if ($ringSize > 0) {
$unitPrice = $datanorm->price / $ringSize;
$effectivePriceUnit = $ringSize;
} else {
// Einzelstück
$unitPrice = $datanorm->price;
$effectivePriceUnit = 1;
}
}
// Schutz gegen Division durch Null
$effectivePriceUnit = max(1, $effectivePriceUnit);
return array(
'unitPrice' => $unitPrice,
'totalPrice' => $unitPrice * $minQty,
'priceUnit' => $effectivePriceUnit
);
}
/**
* Extrafields fuer Lieferantenpreis aus Datanorm-Daten zusammenstellen
*
* @param Datanorm $datanorm Datanorm-Objekt
* @param ImportLine|null $lineObj Import-Zeile (optional, fuer ZUGFeRD-Daten)
* @return array Extrafields-Array
*/
function datanormBuildSupplierPriceExtrafields($datanorm, $lineObj = null)
{
$extrafields = array();
// Produktpreis (reiner Materialpreis ohne Kupferzuschlag) - nur bei Kabeln mit Metallzuschlag
// Der Preis ist bereits auf Mindestmenge (price_unit) bezogen
if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0 && !empty($datanorm->price)) {
$extrafields['options_produktpreis'] = $datanorm->price;
}
// Preiseinheit
if (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) {
$extrafields['options_preiseinheit'] = $datanorm->price_unit;
} elseif ($lineObj && !empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) {
$extrafields['options_preiseinheit'] = $lineObj->basis_quantity;
}
// Warengruppe
if (!empty($datanorm->product_group)) {
$extrafields['options_warengruppe'] = $datanorm->product_group;
}
return $extrafields;
}
/**
* Lieferantenpreis aus Datanorm hinzufuegen
*
* @param DoliDB $db Datenbank
* @param int $productId Produkt-ID
* @param Datanorm $datanorm Datanorm-Objekt
* @param Societe $supplier Lieferant-Objekt
* @param User $user Benutzer
* @param float $purchasePrice Einkaufspreis
* @param float $taxPercent MwSt-Satz
* @param array $extrafields Extrafields
* @return int >0 bei Erfolg, <0 bei Fehler
*/
function datanormAddSupplierPrice($db, $productId, $datanorm, $supplier, $user, $purchasePrice, $taxPercent = 19, $extrafields = array())
{
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
$prodfourn = new ProductFournisseur($db);
$prodfourn->id = $productId;
$supplierEan = !empty($datanorm->ean) ? $datanorm->ean : '';
$supplierEanType = !empty($datanorm->ean) ? 2 : 0;
$description = trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : ''));
// Mindestbestellmenge und Verpackungseinheit vom bestehenden Lieferantenpreis übernehmen
// (gleiches Produkt = gleiche Mengen, nur anderer Lieferant)
$minQty = 1;
$packaging = null;
$sqlExisting = "SELECT quantity, packaging FROM " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlExisting .= " WHERE fk_product = " . (int)$productId;
$sqlExisting .= " AND quantity > 0";
$sqlExisting .= " ORDER BY rowid ASC LIMIT 1";
$resExisting = $db->query($sqlExisting);
if ($resExisting && $db->num_rows($resExisting) > 0) {
$objExisting = $db->fetch_object($resExisting);
if ($objExisting->quantity > 0) {
$minQty = $objExisting->quantity;
}
if (!empty($objExisting->packaging)) {
$packaging = $objExisting->packaging;
}
}
// Preis berechnen mit zentraler Funktion
$pricing = calculateCablePricing($datanorm, $minQty);
$totalPrice = $pricing['totalPrice'];
$result = $prodfourn->update_buyprice(
$minQty, $totalPrice, $user, 'HT', $supplier, 0,
$datanorm->article_number, $taxPercent,
0, 0, 0, 0, 0, 0, array(), '',
0, 'HT', 1, '',
$description, $supplierEan, $supplierEanType,
$extrafields
);
// Verpackungseinheit nachträglich setzen (nicht in update_buyprice verfügbar)
if ($result > 0 && !empty($packaging)) {
$sqlPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlPkg .= " SET packaging = " . (float)$packaging;
$sqlPkg .= " WHERE rowid = " . (int)$result;
$db->query($sqlPkg);
}
return $result;
}
/**
* Extrafields in product_fournisseur_price_extrafields einfuegen
*
* @param DoliDB $db Datenbank
* @param int $priceId ID des Lieferantenpreises
* @param array $extrafields Extrafields-Array
*/
function datanormInsertPriceExtrafields($db, $priceId, $extrafields)
{
if (empty($priceId) || empty($extrafields)) {
return;
}
// Note: kupferzuschlag is NOT set here - it's calculated by a separate module
// based on copper content (kupfergehalt) and current copper price
$produktpreis = !empty($extrafields['options_produktpreis']) ? (float)$extrafields['options_produktpreis'] : 'NULL';
$preiseinheit = !empty($extrafields['options_preiseinheit']) ? (int)$extrafields['options_preiseinheit'] : 1;
$warengruppe = !empty($extrafields['options_warengruppe']) ? "'".$db->escape($extrafields['options_warengruppe'])."'" : 'NULL';
// Pruefen ob bereits vorhanden
$sqlCheck = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields WHERE fk_object = ".(int)$priceId;
$resCheck = $db->query($sqlCheck);
if ($resCheck && $db->num_rows($resCheck) > 0) {
// Update statt Insert wenn bereits vorhanden
$sql = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields SET ";
$sql .= "produktpreis = ".($produktpreis === 'NULL' ? "NULL" : $produktpreis).", ";
$sql .= "preiseinheit = ".$preiseinheit.", ";
$sql .= "warengruppe = ".$warengruppe." ";
$sql .= "WHERE fk_object = ".(int)$priceId;
if (!$db->query($sql)) {
dol_syslog('ImportZugferd: Fehler beim Update der Extrafields: '.$db->lasterror(), LOG_ERR);
}
return;
}
$sql = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields";
$sql .= " (fk_object, produktpreis, preiseinheit, warengruppe) VALUES (";
$sql .= (int)$priceId.", ";
$sql .= ($produktpreis === 'NULL' ? "NULL" : $produktpreis).", ";
$sql .= $preiseinheit.", ";
$sql .= $warengruppe.")";
if (!$db->query($sql)) {
dol_syslog('ImportZugferd: Fehler beim Einfuegen der Extrafields: '.$db->lasterror(), LOG_ERR);
}
}
/*
* Actions
*/
// AJAX: Get raw Datanorm lines for debugging
if ($action == 'get_raw_lines' && GETPOST('article_number', 'alphanohtml')) {
header('Content-Type: application/json');
$article_number = GETPOST('article_number', 'alphanohtml');
$ajax_fk_soc = GETPOSTINT('fk_soc');
$result = array(
'datanorm_line' => '',
'datpreis_line' => '',
'article_number' => $article_number
);
// Get the upload directory for this supplier
$upload_dir = $conf->importzugferd->dir_output.'/datanorm/'.$ajax_fk_soc;
if (is_dir($upload_dir)) {
$allFiles = glob($upload_dir . '/*');
// Search in DATANORM files
foreach ($allFiles as $file) {
$basename = strtoupper(basename($file));
if (preg_match('/^DATANORM\.\d{3}$/', $basename)) {
$handle = fopen($file, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
// A-Satz starts with A; and contains the article number
if (preg_match('/^A;/', $line)) {
$parts = explode(';', $line);
if (isset($parts[2]) && trim($parts[2]) == $article_number) {
$result['datanorm_line'] = trim($line);
break;
}
}
}
fclose($handle);
}
if (!empty($result['datanorm_line'])) break;
}
}
// Search in DATPREIS files
foreach ($allFiles as $file) {
$basename = strtoupper(basename($file));
if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) {
$handle = fopen($file, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
// P-Satz contains article numbers at various positions
if (preg_match('/^P;/', $line) && strpos($line, $article_number) !== false) {
$result['datpreis_line'] = trim($line);
break;
}
}
fclose($handle);
}
if (!empty($result['datpreis_line'])) break;
}
}
$result['upload_dir'] = $upload_dir;
} else {
$result['error'] = 'Upload directory not found: ' . $upload_dir;
}
echo json_encode($result);
exit;
}
// Upload and parse PDF - creates import record immediately
if ($action == 'upload') {
if (!empty($_FILES['zugferd_file']['tmp_name'])) {
$upload_dir = $conf->importzugferd->dir_output.'/temp';
if (!is_dir($upload_dir)) {
dol_mkdir($upload_dir);
}
$filename = dol_sanitizeFileName($_FILES['zugferd_file']['name']);
$destfile = $upload_dir.'/'.$filename;
if (move_uploaded_file($_FILES['zugferd_file']['tmp_name'], $destfile)) {
$force_reimport = GETPOST('force_reimport', 'int');
// Check for duplicate
$file_hash = hash_file('sha256', $destfile);
$isDuplicate = $import->isDuplicate($file_hash);
if ($isDuplicate && !$force_reimport) {
$error++;
$message = $langs->trans('ErrorDuplicateInvoice');
@unlink($destfile);
} else {
// If force reimport, delete the old record first
if ($isDuplicate && $force_reimport) {
$oldImport = new ZugferdImport($db);
$oldImport->fetch(0, null, $file_hash);
if ($oldImport->id > 0) {
$db->begin();
// Alten Import-Datensatz komplett loeschen (Transaktion)
$oldLines = new ImportLine($db);
$oldLines->deleteAllByImport($oldImport->id);
$old_dir = $conf->importzugferd->dir_output.'/imports/'.$oldImport->id;
if (is_dir($old_dir)) {
dol_delete_dir_recursive($old_dir);
}
$oldImport->delete($user);
$db->commit();
}
}
// Parse the file
$parser = new ZugferdParser($db);
$res = $parser->extractFromPdf($destfile);
if ($res > 0) {
$res = $parser->parse();
if ($res > 0) {
$parsed_data = $parser->getInvoiceData();
// Create import record immediately
$import->invoice_number = $parsed_data['invoice_number'];
$import->invoice_date = $parsed_data['invoice_date'];
$import->seller_name = $parsed_data['seller']['name'];
$import->seller_vat = $parsed_data['seller']['vat_id'];
$import->buyer_reference = $parsed_data['buyer']['reference'] ?: $parsed_data['buyer']['id'];
$import->total_ht = $parsed_data['totals']['net'];
$import->total_ttc = $parsed_data['totals']['gross'];
$import->currency = $parsed_data['totals']['currency'];
$import->xml_content = $parser->getXmlContent();
$import->pdf_filename = $filename;
$import->file_hash = $file_hash;
// Find supplier
$supplier_id = $actions->findSupplier($parsed_data);
$import->fk_soc = $supplier_id;
// Process line items to find products
$processed_lines = $actions->processLineItems($parsed_data['lines'], $supplier_id);
// Check if all lines have products
$all_have_products = true;
$has_any_product = false;
$total_lines = count($processed_lines);
foreach ($processed_lines as $line) {
if ($line['fk_product'] <= 0) {
$all_have_products = false;
} else {
$has_any_product = true;
}
}
// Set status based on product matching
// STATUS_IMPORTED only if: supplier found, has lines, ALL lines have products
if ($all_have_products && $supplier_id > 0 && $total_lines > 0 && $has_any_product) {
$import->status = ZugferdImport::STATUS_IMPORTED;
} else {
$import->status = ZugferdImport::STATUS_PENDING;
}
$import->date_creation = dol_now();
$result = $import->create($user);
if ($result > 0) {
// Store line items in database
foreach ($processed_lines as $line) {
$importLineObj = new ImportLine($db);
$importLineObj->fk_import = $import->id;
$importLineObj->line_id = $line['line_id'];
$importLineObj->supplier_ref = $line['supplier_ref'];
$importLineObj->product_name = $line['name'];
$importLineObj->description = $line['description'];
$importLineObj->quantity = $line['quantity'];
$importLineObj->unit_code = $line['unit_code'];
$importLineObj->unit_price = $line['unit_price'];
$importLineObj->unit_price_raw = $line['unit_price_raw'];
$importLineObj->basis_quantity = $line['basis_quantity'];
$importLineObj->basis_quantity_unit = $line['basis_quantity_unit'];
$importLineObj->line_total = $line['line_total'];
$importLineObj->tax_percent = $line['tax_percent'];
$importLineObj->ean = $line['ean'];
$importLineObj->fk_product = $line['fk_product'];
$importLineObj->match_method = $line['match_method'];
$importLineObj->create($user);
}
// Move PDF to permanent storage
$final_dir = $conf->importzugferd->dir_output.'/imports/'.$import->id;
if (!is_dir($final_dir)) {
dol_mkdir($final_dir);
}
if (!@rename($destfile, $final_dir.'/'.$filename)) {
// Fallback: copy + delete (z.B. bei verschiedenen Dateisystemen)
if (@copy($destfile, $final_dir.'/'.$filename)) {
@unlink($destfile);
} else {
dol_syslog('ImportZugferd: Fehler beim Verschieben der PDF nach '.$final_dir, LOG_ERR);
}
}
// Send notification if manual intervention required
if ($import->status == ZugferdImport::STATUS_PENDING) {
$storedLines = $importLine->fetchAllByImport($import->id);
$notification->sendManualInterventionNotification($import, $storedLines);
}
// Check for price differences
if ($import->status == ZugferdImport::STATUS_IMPORTED) {
$storedLines = $importLine->fetchAllByImport($import->id);
$notification->checkAndNotifyPriceDifferences($import, $storedLines);
}
// Redirect to edit page
$id = $import->id;
$action = 'edit';
setEventMessages($langs->trans('ImportRecordCreated'), null, 'mesgs');
} else {
$error++;
$message = $import->error;
@unlink($destfile);
// Send error notification
$notification->sendErrorNotification($import, $message, $filename);
}
} else {
$error++;
$message = $parser->error;
@unlink($destfile);
}
} else {
$error++;
$message = $parser->error;
@unlink($destfile);
}
}
} else {
$error++;
$message = $langs->trans('ErrorFileUploadFailed');
}
} else {
$error++;
$message = $langs->trans('ErrorNoFileUploaded');
}
}
// Load existing import for editing
if ($id > 0 && empty($action)) {
$action = 'edit';
}
if ($action == 'edit' && $id > 0) {
$result = $import->fetch($id);
if ($result <= 0) {
$error++;
$message = $langs->trans('ErrorRecordNotFound');
$action = '';
}
}
// Assign product to line
if ($action == 'assignproduct' && $line_id > 0 && $product_id > 0) {
$lineObj = new ImportLine($db);
$result = $lineObj->fetch($line_id);
if ($result > 0) {
$lineObj->setProduct($product_id, $langs->trans('ManualAssignment'), $user);
setEventMessages($langs->trans('ProductAssigned'), null, 'mesgs');
// Get import ID to reload
$id = $lineObj->fk_import;
// Check if all lines now have products
$allHaveProducts = $importLine->allLinesHaveProducts($id);
if ($allHaveProducts) {
// Update import status
$import->fetch($id);
if ($import->status == ZugferdImport::STATUS_PENDING) {
$import->status = ZugferdImport::STATUS_IMPORTED;
$import->update($user);
// Check for price differences now that all products are assigned
$storedLines = $importLine->fetchAllByImport($id);
$notification->checkAndNotifyPriceDifferences($import, $storedLines);
}
}
}
$action = 'edit';
$import->fetch($id);
}
// Remove product assignment from line
if ($action == 'removeproduct' && $line_id > 0) {
$lineObj = new ImportLine($db);
$result = $lineObj->fetch($line_id);
if ($result > 0) {
$id = $lineObj->fk_import;
$lineObj->setProduct(0, '', $user);
setEventMessages($langs->trans('ProductRemoved'), null, 'mesgs');
// Update import status to pending
$import->fetch($id);
if ($import->status == ZugferdImport::STATUS_IMPORTED) {
$import->status = ZugferdImport::STATUS_PENDING;
$import->update($user);
}
}
$action = 'edit';
$import->fetch($id);
}
// Fehlende Lieferantenpreise aus anderen Katalogen hinzufuegen
if ($action == 'addmissingprices' && $id > 0) {
$import->fetch($id);
$selectedPrices = GETPOST('add_prices', 'array');
if (!empty($selectedPrices)) {
$addedCount = 0;
$errorCount = 0;
$processedKeys = array();
foreach ($selectedPrices as $entry) {
// Duplikate ueberspringen
if (isset($processedKeys[$entry])) {
continue;
}
$processedKeys[$entry] = true;
$parts = explode(',', $entry);
if (count($parts) !== 3) {
continue;
}
$productId = (int) $parts[0];
$socId = (int) $parts[1];
$datanormId = (int) $parts[2];
if ($productId <= 0 || $socId <= 0 || $datanormId <= 0) {
continue;
}
$datanorm = new Datanorm($db);
if ($datanorm->fetch($datanormId) > 0) {
$altSupplier = new Societe($db);
$altSupplier->fetch($socId);
$priceExtrafields = datanormBuildSupplierPriceExtrafields($datanorm);
$result = datanormAddSupplierPrice($db, $productId, $datanorm, $altSupplier, $user, 0, 19, $priceExtrafields);
if ($result > 0) {
datanormInsertPriceExtrafields($db, $result, $priceExtrafields);
$mapping = new ProductMapping($db);
$mapping->fk_soc = $socId;
$mapping->supplier_ref = $datanorm->article_number;
$mapping->fk_product = $productId;
$mapping->ean = $datanorm->ean;
$mapping->manufacturer_ref = $datanorm->manufacturer_ref;
$mapping->description = $datanorm->short_text1;
$mapping->create($user);
$addedCount++;
} else {
$errorCount++;
dol_syslog('ImportZugferd addmissingprices: Fehler bei Lieferantenpreis product='.$productId.' supplier='.$socId, LOG_ERR);
}
}
}
if ($addedCount > 0) {
setEventMessages($langs->trans('SupplierPricesAdded', $addedCount), null, 'mesgs');
}
if ($errorCount > 0) {
setEventMessages($addedCount.' hinzugefuegt, '.$errorCount.' Fehler', null, 'warnings');
}
} else {
setEventMessages('Keine Preise ausgewählt', null, 'warnings');
}
$action = 'edit';
}
// Update supplier
if ($action == 'setsupplier' && $id > 0) {
$import->fetch($id);
$import->fk_soc = $supplier_id;
$import->update($user);
setEventMessages($langs->trans('SupplierUpdated'), null, 'mesgs');
$action = 'edit';
}
// Duplicate product from template
if ($action == 'duplicateproduct' && $template_product_id > 0 && $line_id > 0) {
$lineObj = new ImportLine($db);
$result = $lineObj->fetch($line_id);
if ($result > 0) {
// Load template product
$template = new Product($db);
if ($template->fetch($template_product_id) > 0) {
// Create new product as copy
$newproduct = new Product($db);
// Copy basic properties from template
$newproduct->type = $template->type;
$newproduct->status = $template->status;
$newproduct->status_buy = $template->status_buy;
$newproduct->status_batch = $template->status_batch;
$newproduct->fk_product_type = $template->fk_product_type;
$newproduct->price = $lineObj->unit_price;
$newproduct->price_base_type = 'HT';
$newproduct->tva_tx = $lineObj->tax_percent ?: $template->tva_tx;
$newproduct->weight = $template->weight;
$newproduct->weight_units = $template->weight_units;
$newproduct->fk_unit = $template->fk_unit;
// Set label from ZUGFeRD
$newproduct->label = $lineObj->product_name;
// Generate unique ref
$newproduct->ref = 'NEW-'.dol_print_date(dol_now(), '%Y%m%d%H%M%S');
// Build description with ZUGFeRD data
$zugferd_info = '';
if (!empty($lineObj->supplier_ref)) {
$zugferd_info .= $langs->trans('SupplierRef').': '.$lineObj->supplier_ref."\n";
}
if (!empty($lineObj->unit_code)) {
$zugferd_info .= $langs->trans('Unit').': '.zugferdGetUnitLabel($lineObj->unit_code)."\n";
}
if (!empty($lineObj->ean)) {
$zugferd_info .= 'EAN: '.$lineObj->ean."\n";
}
$zugferd_info .= "---\n";
$newproduct->description = $zugferd_info . ($template->description ?: '');
// Create the product
$result = $newproduct->create($user);
if ($result > 0) {
setEventMessages($langs->trans('ProductCreated'), null, 'mesgs');
// Redirect to product card for editing
header('Location: '.DOL_URL_ROOT.'/product/card.php?id='.$result);
exit;
} else {
setEventMessages($newproduct->error, $newproduct->errors, 'errors');
}
}
$id = $lineObj->fk_import;
}
$action = 'edit';
$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;
// Set default accounting codes from module settings
$newproduct->accountancy_code_sell = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL', '');
$newproduct->accountancy_code_sell_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA', '');
$newproduct->accountancy_code_sell_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT', '');
$newproduct->accountancy_code_buy = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY', '');
$newproduct->accountancy_code_buy_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA', '');
$newproduct->accountancy_code_buy_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT', '');
// 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();
// Preise und Kupferzuschlag
// Datanorm liefert den reinen Materialpreis (ohne Kupferzuschlag)
// WICHTIG: Bei Kabeln ist der Preis bereits für die Ringgröße (z.B. 49,20€ für 100m Ring)
$materialPrice = $datanorm->price;
$priceUnit = $datanorm->price_unit > 0 ? $datanorm->price_unit : 1;
// Prüfen ob es ein Kabel ist
$isCable = isCableProduct($datanorm);
// Preiseinheit bestimmen - unterschiedliche Logik je nach Lieferant-Datenformat:
// - Kluxen/Witte/eltric: price_unit > 1 (z.B. 100) → Preis ist für 100m
// - Sonepar: price_unit = 1, aber Preis ist für kompletten Ring → Größe aus Name extrahieren
$cableText = $datanorm->short_text1 . ' ' . $datanorm->short_text2;
if ($priceUnit == 1) {
// Sonepar-Format: Ringgröße aus Bezeichnung extrahieren
$ringSize = extractCableRingSize($cableText);
if ($ringSize > 0) {
$priceUnit = $ringSize; // z.B. 100 für Ri100
}
}
// Bei price_unit > 1 (Kluxen/Witte) bleibt priceUnit unverändert
$cableSpecs = null;
$kupfergehalt = 0;
$kupferzuschlag = 0;
$cuPrice = 0;
if ($isCable) {
// Parse Aderanzahl und Querschnitt aus Bezeichnung
$cableSpecs = parseCableSpecsFromText($datanorm->short_text1 . ' ' . $datanorm->short_text2);
if ($cableSpecs) {
// Kupfergehalt berechnen
$kupfergehalt = calculateKupfergehalt($cableSpecs['aderanzahl'], $cableSpecs['querschnitt']);
// Aktuellen Kupferpreis holen
$cuPrice = getCurrentCopperPrice($db, $import->fk_soc);
if ($cuPrice > 0 && $kupfergehalt > 0) {
// Kupferzuschlag für die Preiseinheit berechnen (z.B. 100m)
$kupferzuschlag = calculateKupferzuschlag($kupfergehalt, $cuPrice, $priceUnit);
}
}
}
// Einkaufspreis = Materialpreis + Kupferzuschlag (für die Preiseinheit)
$totalPurchasePrice = $materialPrice + $kupferzuschlag;
// Stückpreis (pro 1 Einheit, z.B. pro Meter)
$purchasePricePerUnit = $totalPurchasePrice / $priceUnit;
// Verkaufspreis mit Aufschlag
$sellingPrice = $purchasePricePerUnit * (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) {
// Bei Kabeln: Produkt-Extrafields für Aderanzahl, Querschnitt und Kupfergehalt setzen
if ($isCable && $cableSpecs && $kupfergehalt > 0) {
$sqlProdExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_extrafields";
$sqlProdExtra .= " (fk_object, aderanzahl, querschnitt, kupfergehalt)";
$sqlProdExtra .= " VALUES (".(int)$newproduct->id.", ";
$sqlProdExtra .= (int)$cableSpecs['aderanzahl'].", ";
$sqlProdExtra .= (float)$cableSpecs['querschnitt'].", ";
$sqlProdExtra .= (float)$kupfergehalt.")";
$sqlProdExtra .= " ON DUPLICATE KEY UPDATE";
$sqlProdExtra .= " aderanzahl = ".(int)$cableSpecs['aderanzahl'].",";
$sqlProdExtra .= " querschnitt = ".(float)$cableSpecs['querschnitt'].",";
$sqlProdExtra .= " kupfergehalt = ".(float)$kupfergehalt;
if (!$db->query($sqlProdExtra)) {
dol_syslog("ImportZugferd: Fehler beim Setzen der Kabel-Extrafields: ".$db->lasterror(), LOG_WARNING);
} else {
dol_syslog("ImportZugferd: Kabel-Extrafields gesetzt - Adern: ".$cableSpecs['aderanzahl'].", Querschnitt: ".$cableSpecs['querschnitt'].", Kupfergehalt: ".$kupfergehalt." kg/km", LOG_INFO);
}
}
// 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
}
// Prepare extrafields for supplier price
$supplierPriceExtrafields = array();
// Produktpreis (reiner Materialpreis ohne Kupferzuschlag) - nur bei Kabeln
if ($isCable && $materialPrice > 0) {
$supplierPriceExtrafields['options_produktpreis'] = $materialPrice;
}
// Preiseinheit
if ($priceUnit > 1) {
$supplierPriceExtrafields['options_preiseinheit'] = $priceUnit;
} elseif (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) {
$supplierPriceExtrafields['options_preiseinheit'] = $lineObj->basis_quantity;
}
// Warengruppe aus Datanorm
if (!empty($datanorm->product_group)) {
$supplierPriceExtrafields['options_warengruppe'] = $datanorm->product_group;
}
// Lieferantenpreis speichern (Gesamtpreis inkl. Kupferzuschlag für die Preiseinheit)
$res = $prodfourn->update_buyprice(
$priceUnit, // Quantity (Mindestmenge, z.B. 100 für 100m)
$totalPurchasePrice, // Price (Gesamtpreis für die Mindestmenge inkl. Kupfer)
$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(), // Localtaxes array
'', // Default VAT code
0, // Multicurrency price
'HT', // Multicurrency price base type
1, // Multicurrency tx
'', // Multicurrency code
trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')), // Description from Datanorm
$supplierEan, // Barcode/EAN in supplier price
$supplierEanType, // Barcode type (EAN13)
$supplierPriceExtrafields // Extra fields
);
dol_syslog("ImportZugferd: Lieferantenpreis - Material: ".$materialPrice.", Kupfer: ".$kupferzuschlag.", Gesamt: ".$totalPurchasePrice." (für ".$priceUnit." Einheiten)", LOG_INFO);
// Manually ensure extrafields record exists for supplier price
// (Dolibarr update_buyprice doesn't always create it properly)
$sqlGetPrice = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sqlGetPrice .= " WHERE fk_product = ".(int)$newproduct->id;
$sqlGetPrice .= " AND fk_soc = ".(int)$supplier->id;
$sqlGetPrice .= " ORDER BY rowid DESC LIMIT 1";
$resGetPrice = $db->query($sqlGetPrice);
if ($resGetPrice && $db->num_rows($resGetPrice) > 0) {
$objPrice = $db->fetch_object($resGetPrice);
$priceId = $objPrice->rowid;
// Check if extrafields record exists
$sqlCheckExtra = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields";
$sqlCheckExtra .= " WHERE fk_object = ".(int)$priceId;
$resCheckExtra = $db->query($sqlCheckExtra);
// Werte für Extrafields vorbereiten
$produktpreisVal = $isCable && $materialPrice > 0 ? (float)$materialPrice : 'NULL';
$kupferzuschlagVal = $kupferzuschlag > 0 ? (float)$kupferzuschlag : 'NULL';
$preiseinheitVal = $priceUnit > 1 ? (int)$priceUnit : 1;
$warengruppeVal = !empty($datanorm->product_group) ? "'".$db->escape($datanorm->product_group)."'" : 'NULL';
if ($resCheckExtra && $db->num_rows($resCheckExtra) == 0) {
// Insert extrafields record
$sqlInsertExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields";
$sqlInsertExtra .= " (fk_object, produktpreis, kupferzuschlag, preiseinheit, warengruppe) VALUES (";
$sqlInsertExtra .= (int)$priceId.", ";
$sqlInsertExtra .= ($produktpreisVal === 'NULL' ? "NULL" : $produktpreisVal).", ";
$sqlInsertExtra .= ($kupferzuschlagVal === 'NULL' ? "NULL" : $kupferzuschlagVal).", ";
$sqlInsertExtra .= $preiseinheitVal.", ";
$sqlInsertExtra .= $warengruppeVal.")";
if (!$db->query($sqlInsertExtra)) {
dol_syslog('ImportZugferd: Fehler beim Einfuegen der Extrafields: '.$db->lasterror(), LOG_ERR);
}
} else {
// Update extrafields record
$sqlUpdateExtra = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields SET ";
$sqlUpdateExtra .= "produktpreis = ".($produktpreisVal === 'NULL' ? "NULL" : $produktpreisVal).", ";
$sqlUpdateExtra .= "kupferzuschlag = ".($kupferzuschlagVal === 'NULL' ? "NULL" : $kupferzuschlagVal).", ";
$sqlUpdateExtra .= "preiseinheit = ".$preiseinheitVal.", ";
$sqlUpdateExtra .= "warengruppe = ".$warengruppeVal." ";
$sqlUpdateExtra .= "WHERE fk_object = ".(int)$priceId;
if (!$db->query($sqlUpdateExtra)) {
dol_syslog('ImportZugferd: Fehler beim Update der Extrafields: '.$db->lasterror(), LOG_ERR);
}
}
}
// 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);
}
// "Alle zuordnen" - Assign Datanorm matches to import lines
if ($action == 'assignallfromdatanorm' && $id > 0) {
$import->fetch($id);
if ($import->fk_soc > 0) {
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
// Get all lines without product
$lines = $importLine->fetchAllByImport($import->id);
$datanorm = new Datanorm($db);
$mapping = new ProductMapping($db);
$assignedCount = 0;
$datanormFoundCount = 0;
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)) {
$datanormFoundCount++;
$datanormMatch = $results[0];
// Get Datanorm ID and article number (array access)
$datanormId = isset($datanormMatch['id']) ? $datanormMatch['id'] : (isset($datanormMatch['rowid']) ? $datanormMatch['rowid'] : 0);
$articleNumber = isset($datanormMatch['article_number']) ? $datanormMatch['article_number'] : '';
// Check if product already exists for this supplier ref
$existingProductId = $mapping->findProductBySupplierRef($import->fk_soc, $articleNumber);
if ($existingProductId > 0) {
// Product exists - assign both product and Datanorm to the line
$lineObj->fk_product = $existingProductId;
$lineObj->fk_datanorm = $datanormId;
$lineObj->match_method = 'datanorm_assign';
$lineObj->update($user);
$assignedCount++;
} else {
// No product yet - save Datanorm reference for later product creation
$lineObj->fk_datanorm = $datanormId;
$lineObj->match_method = 'datanorm_pending';
$lineObj->update($user);
}
}
}
if ($assignedCount > 0) {
setEventMessages($langs->trans('ProductsAssignedFromDatanorm', $assignedCount), null, 'mesgs');
}
if ($datanormFoundCount > $assignedCount) {
$pendingCount = $datanormFoundCount - $assignedCount;
setEventMessages($langs->trans('DatanormMatchesFoundNotAssigned', $pendingCount), null, 'mesgs');
}
if ($datanormFoundCount == 0) {
setEventMessages($langs->trans('DatanormBatchNoMatches'), null, 'warnings');
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&id='.$id.'&token='.newToken());
exit;
}
// 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;
}
// Get copper surcharge for selling price calculation
// Priority: 1. Datanorm, 2. ZUGFeRD
$copperSurchargeForPrice = 0;
if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) {
$copperSurchargeForPrice = $datanorm->metal_surcharge;
// Normalize to per-unit if price_unit > 1
if ($datanorm->price_unit > 1) {
$copperSurchargeForPrice = $datanorm->metal_surcharge / $datanorm->price_unit;
}
} elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) {
$copperSurchargeForPrice = $lineObj->copper_surcharge;
// Normalize based on copper_surcharge_basis_qty
if (!empty($lineObj->copper_surcharge_basis_qty) && $lineObj->copper_surcharge_basis_qty > 1) {
$copperSurchargeForPrice = $lineObj->copper_surcharge / $lineObj->copper_surcharge_basis_qty;
}
}
// 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);
// Add additional supplier prices from selected alternatives (for existing products too)
$supplierPricesPost = GETPOST('supplier_prices', 'array');
if (!empty($supplierPricesPost[$lineObj->id])) {
foreach ($supplierPricesPost[$lineObj->id] as $altSocId => $altDatanormId) {
// Check if supplier price already exists for this product/supplier
$sqlCheckSupplier = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sqlCheckSupplier .= " WHERE fk_product = ".(int)$existingProductId;
$sqlCheckSupplier .= " AND fk_soc = ".(int)$altSocId;
$resCheckSupplier = $db->query($sqlCheckSupplier);
if ($resCheckSupplier && $db->num_rows($resCheckSupplier) > 0) {
continue; // Skip if supplier price already exists
}
// Fetch the alternative Datanorm article
$altDatanorm = new Datanorm($db);
if ($altDatanorm->fetch($altDatanormId) > 0) {
$altSupplier = new Societe($db);
$altSupplier->fetch($altSocId);
// Prepare extrafields
$altExtrafields = array();
if (!empty($altDatanorm->metal_surcharge) && $altDatanorm->metal_surcharge > 0 && !empty($altDatanorm->price)) {
$altExtrafields['options_produktpreis'] = $altDatanorm->price;
}
if (!empty($altDatanorm->product_group)) {
$altExtrafields['options_warengruppe'] = $altDatanorm->product_group;
}
// Mindestbestellmenge, Verpackungseinheit, Steuersatz und kaufmenge vom bestehenden Preis übernehmen
$altMinQty = 1;
$altPackaging = null;
$altTvaTx = $lineObj->tax_percent ?: 19;
$altKaufmenge = null;
$sqlAltExisting = "SELECT pf.rowid, pf.quantity, pf.packaging, pf.tva_tx FROM " . MAIN_DB_PREFIX . "product_fournisseur_price pf";
$sqlAltExisting .= " WHERE pf.fk_product = " . (int)$existingProductId;
$sqlAltExisting .= " AND pf.quantity > 0 ORDER BY pf.rowid ASC LIMIT 1";
$resAltExisting = $db->query($sqlAltExisting);
if ($resAltExisting && $db->num_rows($resAltExisting) > 0) {
$objAltExisting = $db->fetch_object($resAltExisting);
if ($objAltExisting->quantity > 0) {
$altMinQty = $objAltExisting->quantity;
}
if (!empty($objAltExisting->packaging)) {
$altPackaging = $objAltExisting->packaging;
}
if ($objAltExisting->tva_tx > 0) {
$altTvaTx = $objAltExisting->tva_tx;
}
// kaufmenge aus Extrafields übernehmen (nur wenn numerisch und > 0)
$sqlKaufmenge = "SELECT kaufmenge FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sqlKaufmenge .= " WHERE fk_object = " . (int)$objAltExisting->rowid;
$resKaufmenge = $db->query($sqlKaufmenge);
if ($resKaufmenge && $db->num_rows($resKaufmenge) > 0) {
$objKaufmenge = $db->fetch_object($resKaufmenge);
$kmValue = trim($objKaufmenge->kaufmenge);
if ($kmValue !== '' && is_numeric($kmValue) && (int)$kmValue > 0) {
$altKaufmenge = (int)$kmValue;
}
}
}
// Preis berechnen - Datanorm-Preis ist für die price_unit Menge!
$altCableText = $altDatanorm->short_text1 . ' ' . $altDatanorm->short_text2;
$altRingSize = extractCableRingSize($altCableText);
$altPriceUnit = 1;
if ($altRingSize > 0) {
// Kabel mit Ringgröße
$altPriceUnit = $altRingSize;
$altExtrafields['options_preiseinheit'] = $altRingSize;
} elseif (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) {
// Nicht-Kabel mit price_unit > 1
$altPriceUnit = $altDatanorm->price_unit;
$altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit;
}
// WICHTIG: Datanorm price ist bereits für die altPriceUnit Menge!
// Nur umrechnen wenn altMinQty != altPriceUnit
if ($altMinQty == $altPriceUnit) {
// Gleiche Menge: Preis direkt übernehmen
$altTotalPrice = $altDatanorm->price;
} else {
// Unterschiedliche Menge: Umrechnen über Stückpreis
$altUnitPrice = $altDatanorm->price / $altPriceUnit;
$altTotalPrice = $altUnitPrice * $altMinQty;
}
// Add supplier price (use tva_tx from existing price)
$altProdfourn = new ProductFournisseur($db);
$altProdfourn->id = $existingProductId;
$altResult = $altProdfourn->update_buyprice(
$altMinQty, $altTotalPrice, $user, 'HT', $altSupplier, 0,
$altDatanorm->article_number, $altTvaTx,
0, 0, 0, 0, 0, 0, array(), '',
0, 'HT', 1, '',
trim($altDatanorm->short_text1 . ($altDatanorm->short_text2 ? ' ' . $altDatanorm->short_text2 : '')),
!empty($altDatanorm->ean) ? $altDatanorm->ean : '',
!empty($altDatanorm->ean) ? 2 : 0,
$altExtrafields
);
// Verpackungseinheit und kaufmenge nachträglich setzen
if ($altResult > 0) {
// Packaging = Mindestmenge (als Standard)
$sqlAltPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlAltPkg .= " SET packaging = " . (float)($altPackaging ?: $altMinQty);
$sqlAltPkg .= " WHERE rowid = " . (int)$altResult;
$db->query($sqlAltPkg);
// kaufmenge in Extrafields übernehmen wenn vorhanden
if (!empty($altKaufmenge)) {
$sqlCheckExtra = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields WHERE fk_object = " . (int)$altResult;
$resCheckExtra = $db->query($sqlCheckExtra);
if ($resCheckExtra && $db->num_rows($resCheckExtra) > 0) {
$sqlUpdateKm = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sqlUpdateKm .= " SET kaufmenge = " . (int)$altKaufmenge;
$sqlUpdateKm .= " WHERE fk_object = " . (int)$altResult;
$db->query($sqlUpdateKm);
} else {
$sqlInsertKm = "INSERT INTO " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sqlInsertKm .= " (fk_object, kaufmenge) VALUES (" . (int)$altResult . ", " . (int)$altKaufmenge . ")";
$db->query($sqlInsertKm);
}
}
}
// Create product mapping
$altMapping = new ProductMapping($db);
$altMapping->fk_soc = $altSocId;
$altMapping->supplier_ref = $altDatanorm->article_number;
$altMapping->fk_product = $existingProductId;
$altMapping->ean = $altDatanorm->ean;
$altMapping->manufacturer_ref = $altDatanorm->manufacturer_ref;
$altMapping->description = $altDatanorm->short_text1;
$altMapping->create($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();
// Set default accounting codes from module settings
$newproduct->accountancy_code_sell = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL', '');
$newproduct->accountancy_code_sell_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA', '');
$newproduct->accountancy_code_sell_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT', '');
$newproduct->accountancy_code_buy = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY', '');
$newproduct->accountancy_code_buy_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA', '');
$newproduct->accountancy_code_buy_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT', '');
// Selling price: (purchase price + copper surcharge) × (1 + markup%)
$sellingPrice = ($purchasePrice + $copperSurchargeForPrice) * (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;
}
// Prepare extrafields for supplier price
$supplierPriceExtrafields = array();
// Produktpreis (reiner Materialpreis) - nur bei Kabeln mit Metallzuschlag
if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0 && !empty($datanorm->price)) {
$supplierPriceExtrafields['options_produktpreis'] = $datanorm->price;
}
// Preiseinheit - Priorität: 1. ZUGFeRD basis_quantity, 2. Datanorm price_unit
if (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) {
$supplierPriceExtrafields['options_preiseinheit'] = $lineObj->basis_quantity;
} elseif (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) {
$supplierPriceExtrafields['options_preiseinheit'] = $datanorm->price_unit;
}
// Warengruppe aus Datanorm
if (!empty($datanorm->product_group)) {
$supplierPriceExtrafields['options_warengruppe'] = $datanorm->product_group;
}
// Mindestbestellmenge und Preis mit zentraler Funktion berechnen
$pricing = calculateCablePricing($datanorm, 1);
$newMinQty = $pricing['priceUnit'];
$newPackaging = $pricing['priceUnit'];
$newTotalPrice = $datanorm->price; // Originalpreis aus Datanorm
// Fallback auf ZUGFeRD basis_quantity wenn keine Ringgröße erkannt
if ($newMinQty == 1 && !empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) {
$newMinQty = $lineObj->basis_quantity;
$newPackaging = $lineObj->basis_quantity;
$newTotalPrice = $purchasePrice * $newMinQty;
}
$newPriceResult = $prodfourn->update_buyprice(
$newMinQty, // Quantity (Mindestbestellmenge)
$newTotalPrice, // Price (Gesamtpreis für die Mindestmenge)
$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(), // Localtaxes array
'', // Default VAT code
0, // Multicurrency price
'HT', // Multicurrency price base type
1, // Multicurrency tx
'', // Multicurrency code
trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')), // Description from Datanorm
$supplierEan, // Barcode/EAN
$supplierEanType, // Barcode type
$supplierPriceExtrafields // Extra fields
);
// Manually ensure extrafields record exists for supplier price
// (Dolibarr update_buyprice doesn't always create it properly)
$sqlGetPrice = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sqlGetPrice .= " WHERE fk_product = ".(int)$newproduct->id;
$sqlGetPrice .= " AND fk_soc = ".(int)$supplier->id;
$sqlGetPrice .= " ORDER BY rowid DESC LIMIT 1";
$resGetPrice = $db->query($sqlGetPrice);
if ($resGetPrice && $db->num_rows($resGetPrice) > 0) {
$objPrice = $db->fetch_object($resGetPrice);
$priceId = $objPrice->rowid;
// Check if extrafields record exists
$sqlCheckExtra = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields";
$sqlCheckExtra .= " WHERE fk_object = ".(int)$priceId;
$resCheckExtra = $db->query($sqlCheckExtra);
$produktpreis = !empty($supplierPriceExtrafields['options_produktpreis']) ? (float)$supplierPriceExtrafields['options_produktpreis'] : 'NULL';
$preiseinheit = !empty($supplierPriceExtrafields['options_preiseinheit']) ? (int)$supplierPriceExtrafields['options_preiseinheit'] : 1;
$warengruppe = !empty($supplierPriceExtrafields['options_warengruppe']) ? "'".$db->escape($supplierPriceExtrafields['options_warengruppe'])."'" : 'NULL';
if ($resCheckExtra && $db->num_rows($resCheckExtra) == 0) {
// Insert extrafields record
$sqlInsertExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields";
$sqlInsertExtra .= " (fk_object, produktpreis, preiseinheit, warengruppe) VALUES (";
$sqlInsertExtra .= (int)$priceId.", ";
$sqlInsertExtra .= ($produktpreis === 'NULL' ? "NULL" : $produktpreis).", ";
$sqlInsertExtra .= $preiseinheit.", ";
$sqlInsertExtra .= $warengruppe.")";
if (!$db->query($sqlInsertExtra)) {
dol_syslog('ImportZugferd: Fehler beim Einfuegen der Extrafields: '.$db->lasterror(), LOG_ERR);
}
} else {
// Update extrafields record
$sqlUpdateExtra = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields SET ";
$sqlUpdateExtra .= "produktpreis = ".($produktpreis === 'NULL' ? "NULL" : $produktpreis).", ";
$sqlUpdateExtra .= "preiseinheit = ".$preiseinheit.", ";
$sqlUpdateExtra .= "warengruppe = ".$warengruppe." ";
$sqlUpdateExtra .= "WHERE fk_object = ".(int)$priceId;
if (!$db->query($sqlUpdateExtra)) {
dol_syslog('ImportZugferd: Fehler beim Update der Extrafields: '.$db->lasterror(), LOG_ERR);
}
}
}
// 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);
// Add additional supplier prices from selected alternatives
$supplierPricesPost = GETPOST('supplier_prices', 'array');
if (!empty($supplierPricesPost[$lineObj->id])) {
// Hole Werte vom gerade angelegten Hauptpreis (Mindestmenge, Verpackung, Steuersatz)
$mainMinQty = $newMinQty;
$mainPackaging = $newPackaging;
$mainTvaTx = $lineObj->tax_percent ?: 19;
$mainKaufmenge = null;
// kaufmenge vom Hauptpreis-Extrafield holen (nur wenn numerisch und > 0)
if (!empty($priceId)) {
$sqlMainKm = "SELECT kaufmenge FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sqlMainKm .= " WHERE fk_object = " . (int)$priceId;
$resMainKm = $db->query($sqlMainKm);
if ($resMainKm && $db->num_rows($resMainKm) > 0) {
$objMainKm = $db->fetch_object($resMainKm);
$kmValue = trim($objMainKm->kaufmenge);
if ($kmValue !== '' && is_numeric($kmValue) && (int)$kmValue > 0) {
$mainKaufmenge = (int)$kmValue;
}
}
}
foreach ($supplierPricesPost[$lineObj->id] as $altSocId => $altDatanormId) {
// Skip the main invoice supplier (already added above)
if ($altSocId == $import->fk_soc) {
continue;
}
// Fetch the alternative Datanorm article
$altDatanorm = new Datanorm($db);
if ($altDatanorm->fetch($altDatanormId) > 0) {
$altSupplier = new Societe($db);
$altSupplier->fetch($altSocId);
// Prepare extrafields for alternative supplier price
$altExtrafields = array();
if (!empty($altDatanorm->metal_surcharge) && $altDatanorm->metal_surcharge > 0 && !empty($altDatanorm->price)) {
$altExtrafields['options_produktpreis'] = $altDatanorm->price;
}
if (!empty($altDatanorm->product_group)) {
$altExtrafields['options_warengruppe'] = $altDatanorm->product_group;
}
// Preis berechnen - Datanorm-Preis ist für die price_unit Menge!
$altCableText = $altDatanorm->short_text1 . ' ' . $altDatanorm->short_text2;
$altRingSize = extractCableRingSize($altCableText);
$altPriceUnit = 1;
if ($altRingSize > 0) {
// Kabel mit Ringgröße
$altPriceUnit = $altRingSize;
$altExtrafields['options_preiseinheit'] = $altRingSize;
} elseif (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) {
// Nicht-Kabel mit price_unit > 1
$altPriceUnit = $altDatanorm->price_unit;
$altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit;
}
// WICHTIG: Datanorm price ist bereits für die altPriceUnit Menge!
// Umrechnen auf mainMinQty (vom Hauptpreis übernommen)
if ($mainMinQty == $altPriceUnit) {
// Gleiche Menge: Preis direkt übernehmen
$altTotalPrice = $altDatanorm->price;
} else {
// Unterschiedliche Menge: Umrechnen über Stückpreis
$altUnitPrice = $altDatanorm->price / $altPriceUnit;
$altTotalPrice = $altUnitPrice * $mainMinQty;
}
// Add supplier price for alternative supplier
$altProdfourn = new ProductFournisseur($db);
$altProdfourn->id = $newproduct->id;
$altResult = $altProdfourn->update_buyprice(
$mainMinQty, // Quantity (vom Hauptpreis übernommen)
$altTotalPrice, // Price (für mainMinQty berechnet)
$user,
'HT', // Price base
$altSupplier, // Alternative supplier
0, // Availability
$altDatanorm->article_number, // Supplier ref
$mainTvaTx, // VAT (vom Hauptpreis übernommen)
0, 0, 0, 0, 0, 0, array(), '',
0, 'HT', 1, '',
trim($altDatanorm->short_text1 . ($altDatanorm->short_text2 ? ' ' . $altDatanorm->short_text2 : '')),
!empty($altDatanorm->ean) ? $altDatanorm->ean : '',
!empty($altDatanorm->ean) ? 2 : 0,
$altExtrafields
);
// Verpackungseinheit und kaufmenge nachträglich setzen (vom Hauptpreis übernommen)
if ($altResult > 0) {
$sqlAltPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price";
$sqlAltPkg .= " SET packaging = " . (float)$mainPackaging;
$sqlAltPkg .= " WHERE rowid = " . (int)$altResult;
$db->query($sqlAltPkg);
// kaufmenge in Extrafields übernehmen wenn vorhanden
if (!empty($mainKaufmenge)) {
$sqlCheckExtra = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields WHERE fk_object = " . (int)$altResult;
$resCheckExtra = $db->query($sqlCheckExtra);
if ($resCheckExtra && $db->num_rows($resCheckExtra) > 0) {
$sqlUpdateKm = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sqlUpdateKm .= " SET kaufmenge = " . (int)$mainKaufmenge;
$sqlUpdateKm .= " WHERE fk_object = " . (int)$altResult;
$db->query($sqlUpdateKm);
} else {
$sqlInsertKm = "INSERT INTO " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
$sqlInsertKm .= " (fk_object, kaufmenge) VALUES (" . (int)$altResult . ", " . (int)$mainKaufmenge . ")";
$db->query($sqlInsertKm);
}
}
}
// Create product mapping for alternative supplier
$altMapping = new ProductMapping($db);
$altMapping->fk_soc = $altSocId;
$altMapping->supplier_ref = $altDatanorm->article_number;
$altMapping->fk_product = $newproduct->id;
$altMapping->ean = $altDatanorm->ean;
$altMapping->manufacturer_ref = $altDatanorm->manufacturer_ref;
$altMapping->description = $altDatanorm->short_text1;
$altMapping->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);
}
// Preview Datanorm matches (step 1 - show what will be created)
$datanormPreviewMatches = array();
if ($action == 'previewdatanorm' && $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);
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 - get ALL supplier alternatives
$results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 10);
if (empty($results) && !empty($lineObj->ean)) {
$results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 10);
}
if (!empty($results)) {
// Process the primary result (first = current supplier or cheapest)
$datanormArticle = $results[0];
$datanorm->fetch($datanormArticle['id']);
$purchasePrice = $datanorm->price;
if ($datanorm->price_unit > 1) {
$purchasePrice = $datanorm->price / $datanorm->price_unit;
}
// Get copper surcharge for selling price calculation
$copperSurchargeForPrice = 0;
if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) {
$copperSurchargeForPrice = $datanorm->metal_surcharge;
if ($datanorm->price_unit > 1) {
$copperSurchargeForPrice = $datanorm->metal_surcharge / $datanorm->price_unit;
}
} elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) {
$copperSurchargeForPrice = $lineObj->copper_surcharge;
if (!empty($lineObj->copper_surcharge_basis_qty) && $lineObj->copper_surcharge_basis_qty > 1) {
$copperSurchargeForPrice = $lineObj->copper_surcharge / $lineObj->copper_surcharge_basis_qty;
}
}
// Calculate selling price
$sellingPrice = ($purchasePrice + $copperSurchargeForPrice) * (1 + $markup / 100);
// Check if product already exists in Dolibarr
$existingProductId = 0;
$productAction = 'create'; // 'create' or 'assign'
// 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;
$productAction = 'assign';
}
// 2. Check by product reference pattern
if ($existingProductId <= 0) {
$expectedRef = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number;
$existingProduct = new Product($db);
$fetchResult = $existingProduct->fetch(0, $expectedRef);
if ($fetchResult > 0) {
$existingProductId = $existingProduct->id;
$productAction = 'assign';
}
}
// 3. Check by EAN if available
if ($existingProductId <= 0 && !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;
$productAction = 'assign';
}
}
// Build supplier alternatives array
// Only show suppliers that don't already have a price for this product
$supplierAlternatives = array();
$existingPriceSuppliers = array();
// If product exists, load existing supplier prices
if ($existingProductId > 0) {
$sqlExisting = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sqlExisting .= " WHERE fk_product = ".(int)$existingProductId;
$resExisting = $db->query($sqlExisting);
if ($resExisting) {
while ($objEx = $db->fetch_object($resExisting)) {
$existingPriceSuppliers[$objEx->fk_soc] = true;
}
}
}
foreach ($results as $altResult) {
// Skip if supplier already has a price for this product
if ($existingProductId > 0 && isset($existingPriceSuppliers[$altResult['fk_soc']])) {
continue;
}
$altSupplier = new Societe($db);
$altSupplier->fetch($altResult['fk_soc']);
$altPurchasePrice = $altResult['price'];
if ($altResult['price_unit'] > 1) {
$altPurchasePrice = $altResult['price'] / $altResult['price_unit'];
}
$supplierAlternatives[] = array(
'datanorm_id' => $altResult['id'],
'fk_soc' => $altResult['fk_soc'],
'supplier_name' => $altSupplier->name,
'article_number' => $altResult['article_number'],
'short_text1' => $altResult['short_text1'],
'price' => $altResult['price'],
'price_unit' => $altResult['price_unit'],
'purchase_price' => $altPurchasePrice,
'ean' => $altResult['ean'],
'manufacturer_ref' => $altResult['manufacturer_ref'],
'is_invoice_supplier' => ($altResult['fk_soc'] == $import->fk_soc),
);
}
// Store match info for preview
$datanormPreviewMatches[] = array(
'line_id' => $lineObj->id,
'line_supplier_ref' => $lineObj->supplier_ref,
'line_product_name' => $lineObj->product_name,
'line_quantity' => $lineObj->quantity,
'line_unit_price' => $lineObj->unit_price,
'datanorm_id' => $datanorm->id,
'datanorm_article_number' => $datanorm->article_number,
'datanorm_short_text1' => $datanorm->short_text1,
'datanorm_short_text2' => $datanorm->short_text2,
'datanorm_price' => $datanorm->price,
'datanorm_price_unit' => $datanorm->price_unit,
'datanorm_ean' => $datanorm->ean,
'purchase_price' => $purchasePrice,
'selling_price' => $sellingPrice,
'copper_surcharge' => $copperSurchargeForPrice,
'existing_product_id' => $existingProductId,
'action' => $productAction,
'new_ref' => 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number,
'supplier_alternatives' => $supplierAlternatives
);
}
}
}
$action = 'edit';
}
// Create supplier invoice
if ($action == 'createinvoice' && $id > 0) {
$import->fetch($id);
// Check prerequisites
if ($import->fk_soc <= 0) {
$error++;
setEventMessages($langs->trans('ErrorSupplierRequired'), null, 'errors');
} else {
// 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');
} else {
// Load supplier to get default values
$supplier = new Societe($db);
$supplier->fetch($import->fk_soc);
// Create invoice
$invoice = new FactureFournisseur($db);
$invoice->socid = $import->fk_soc;
$invoice->ref_supplier = $import->invoice_number;
$invoice->date = $import->invoice_date;
$invoice->note_private = $langs->trans('ImportedFromZugferd').' ('.$import->ref.')';
// Set label to most expensive item (for list display)
$maxTotal = 0;
$mostExpensiveLabel = '';
foreach ($lines as $line) {
$lineTotal = $line->quantity * $line->unit_price;
if ($lineTotal > $maxTotal) {
$maxTotal = $lineTotal;
$mostExpensiveLabel = $line->product_name;
}
}
if (!empty($mostExpensiveLabel)) {
// Truncate to 255 chars (database field limit)
$invoice->label = dol_trunc($mostExpensiveLabel, 255);
}
// Use supplier default values for payment
$invoice->cond_reglement_id = $supplier->cond_reglement_supplier_id ?: 1;
$invoice->mode_reglement_id = $supplier->mode_reglement_supplier_id ?: 0;
$invoice->fk_account = $supplier->fk_account ?: 0;
$db->begin();
$result = $invoice->create($user);
if ($result > 0) {
// Add lines
foreach ($lines as $line) {
$res = $invoice->addline(
$line->product_name,
$line->unit_price,
$line->tax_percent,
0, 0,
$line->quantity,
$line->fk_product,
0, '', '',
0, 0,
'HT', // price_base_type - Netto-Preise aus ZUGFeRD
0 // type (0=product)
);
if ($res < 0) {
$error++;
setEventMessages($invoice->error, $invoice->errors, 'errors');
break;
}
// Update EAN on product if not set
if (!empty($line->ean) && $line->fk_product > 0) {
$product = new Product($db);
$product->fetch($line->fk_product);
if (empty($product->barcode)) {
$product->barcode = $line->ean;
$product->barcode_type = 2; // EAN13
$product->update($product->id, $user);
}
}
}
if (!$error) {
// Invoice stays as draft - user can validate manually
// Copy PDF to invoice and register in ECM
// Neuer Pfad: /imports/{id}/{filename}
$source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename;
// Fallback: Alter Pfad /imports/{ref}_{filename}
if (!file_exists($source_pdf)) {
$source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->ref.'_'.$import->pdf_filename;
}
if (file_exists($source_pdf)) {
// Relativer Pfad für ECM (ohne DOL_DATA_ROOT Prefix)
$rel_dir = 'fournisseur/facture/'.get_exdir($invoice->id, 2, 0, 0, $invoice, 'invoice_supplier').$invoice->ref;
$dest_dir = $conf->fournisseur->facture->dir_output.'/'.get_exdir($invoice->id, 2, 0, 0, $invoice, 'invoice_supplier').$invoice->ref;
if (!is_dir($dest_dir)) {
dol_mkdir($dest_dir);
}
$dest_file = $dest_dir.'/'.$import->pdf_filename;
if (@copy($source_pdf, $dest_file)) {
// In ECM-Datenbank registrieren für korrekte Verknüpfung
require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmfiles.class.php';
$ecmfile = new EcmFiles($db);
$ecmfile->filepath = $rel_dir;
$ecmfile->filename = $import->pdf_filename;
$ecmfile->label = md5_file(dol_osencode($dest_file));
$ecmfile->fullpath_orig = $dest_file;
$ecmfile->gen_or_uploaded = 'uploaded';
$ecmfile->description = 'ZUGFeRD Import - '.$import->invoice_number;
$ecmfile->src_object_type = 'supplier_invoice';
$ecmfile->src_object_id = $invoice->id;
$ecmfile->entity = $conf->entity;
$result = $ecmfile->create($user);
if ($result < 0) {
dol_syslog('ImportZugferd: Fehler beim ECM-Eintrag: '.implode(',', $ecmfile->errors), LOG_ERR);
}
} else {
dol_syslog('ImportZugferd: Fehler beim Kopieren der PDF nach '.$dest_dir, LOG_ERR);
}
}
// Update import record
$import->fk_facture_fourn = $invoice->id;
$import->status = ZugferdImport::STATUS_PROCESSED;
$import->date_import = dol_now();
$import->update($user);
$db->commit();
setEventMessages($langs->trans('InvoiceCreatedSuccessfully'), null, 'mesgs');
// Redirect to invoice
header('Location: '.DOL_URL_ROOT.'/fourn/facture/card.php?facid='.$invoice->id);
exit;
} else {
$db->rollback();
}
} else {
$error++;
setEventMessages($invoice->error, $invoice->errors, 'errors');
$db->rollback();
}
}
}
$action = 'edit';
}
// Finish import - check for existing invoice and update status
if ($action == 'finishimport' && $id > 0) {
$import->fetch($id);
// Check all lines have products
$lines = $importLine->fetchAllByImport($id);
$allHaveProducts = true;
foreach ($lines as $line) {
if ($line->fk_product <= 0) {
$allHaveProducts = false;
break;
}
}
if (!$allHaveProducts) {
$error++;
setEventMessages($langs->trans('ErrorNotAllProductsAssigned'), null, 'errors');
} elseif ($import->fk_soc <= 0) {
$error++;
setEventMessages($langs->trans('ErrorSupplierRequired'), null, 'errors');
} else {
// Search for existing supplier invoice with this ref_supplier
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_fourn";
$sql .= " WHERE fk_soc = ".((int) $import->fk_soc);
$sql .= " AND ref_supplier = '".$db->escape($import->invoice_number)."'";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
// Found existing invoice - link it
$import->fk_facture_fourn = $obj->rowid;
$import->status = ZugferdImport::STATUS_PROCESSED;
$import->date_import = dol_now();
$result = $import->update($user);
if ($result > 0) {
$invoiceLink = '<a href="'.DOL_URL_ROOT.'/fourn/facture/card.php?facid='.$obj->rowid.'">'.$import->invoice_number.'</a>';
setEventMessages($langs->trans('ImportLinkedToExistingInvoice', $invoiceLink), null, 'mesgs');
} else {
setEventMessages($import->error, null, 'errors');
}
} else {
// No existing invoice - mark as imported (ready for invoice creation)
$import->status = ZugferdImport::STATUS_IMPORTED;
$result = $import->update($user);
if ($result > 0) {
setEventMessages($langs->trans('ImportFinished'), null, 'mesgs');
} else {
setEventMessages($import->error, null, 'errors');
}
}
}
$action = 'edit';
}
// Delete import record
if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0 && $user->hasRight('importzugferd', 'import', 'delete')) {
$import->fetch($id);
// Delete lines first
$importLine->deleteAllByImport($id);
// Delete files
$import_dir = $conf->importzugferd->dir_output.'/imports/'.$import->id;
if (is_dir($import_dir)) {
dol_delete_dir_recursive($import_dir);
}
// Delete import record
$import->delete($user);
setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs');
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
/*
* View
*/
$title = $langs->trans('ZugferdImport');
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-import');
print load_fiche_titre($title, '', 'fa-file-import');
// Error message
if ($error && !empty($message)) {
setEventMessages($message, null, 'errors');
}
/*
* Upload form (shown when no import is being edited)
*/
if (empty($action) || ($action == 'upload' && $error)) {
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 '<div class="fichecenter">';
print '<div class="fichethirdleft">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans('UploadZugferdInvoice').'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans('File').' (PDF)</td>';
print '<td>';
print '<input type="file" name="zugferd_file" accept=".pdf" class="flat minwidth300" required>';
print '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.$langs->trans('ForceReimport').'</td>';
print '<td>';
print '<input type="checkbox" name="force_reimport" value="1"> ';
print '<span class="opacitymedium">'.$langs->trans('ForceReimportHelp').'</span>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '<div class="center" style="margin-top: 20px;">';
print '<input type="submit" class="button button-primary" value="'.$langs->trans('Upload').'">';
print '</div>';
print '</div>';
// Show pending imports
print '<div class="fichetwothirdright">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="5">'.$langs->trans('PendingImports').'</td>';
print '</tr>';
$sql = "SELECT i.rowid, i.ref, i.invoice_number, i.seller_name, i.total_ttc, i.status, i.date_creation";
$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_import as i";
$sql .= " WHERE i.entity = ".$conf->entity;
$sql .= " AND i.status IN (".ZugferdImport::STATUS_IMPORTED.", ".ZugferdImport::STATUS_PENDING.")";
$sql .= " ORDER BY i.date_creation DESC LIMIT 10";
$resql = $db->query($sql);
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
print '<tr class="liste_titre">';
print '<td>'.$langs->trans('Ref').'</td>';
print '<td>'.$langs->trans('InvoiceNumber').'</td>';
print '<td>'.$langs->trans('Supplier').'</td>';
print '<td class="right">'.$langs->trans('TotalTTC').'</td>';
print '<td>'.$langs->trans('Status').'</td>';
print '</tr>';
while ($obj = $db->fetch_object($resql)) {
print '<tr class="oddeven">';
print '<td><a href="'.$_SERVER['PHP_SELF'].'?id='.$obj->rowid.'">'.$obj->ref.'</a></td>';
print '<td>'.$obj->invoice_number.'</td>';
print '<td>'.$obj->seller_name.'</td>';
print '<td class="right">'.price($obj->total_ttc).'</td>';
print '<td>';
$tmpimport = new ZugferdImport($db);
print $tmpimport->LibStatut($obj->status, 1);
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="5" class="opacitymedium">'.$langs->trans('NoPendingImports').'</td></tr>';
}
}
print '</table>';
print '</div>';
print '</div>';
print '</div>';
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
*/
if ($action == 'edit' && $import->id > 0) {
// Fetch lines
$lines = $importLine->fetchAllByImport($import->id);
$missingProducts = $importLine->countLinesWithoutProduct($import->id);
$allComplete = ($missingProducts == 0 && $import->fk_soc > 0);
// Header info
print '<div class="fichecenter">';
// Status banner
if ($import->status == ZugferdImport::STATUS_PENDING) {
print '<div class="warning">';
print '<i class="fas fa-exclamation-triangle paddingright"></i>';
print $langs->trans('ManualInterventionRequired');
if ($missingProducts > 0) {
print ' - '.$missingProducts.' '.$langs->trans('ProductsNotAssigned');
}
if ($import->fk_soc <= 0) {
print ' - '.$langs->trans('SupplierNotAssigned');
}
print '</div><br>';
} elseif ($allComplete) {
print '<div class="ok" style="padding: 10px; margin-bottom: 10px;">';
print '<i class="fas fa-check-circle paddingright"></i>';
print $langs->trans('ReadyToCreateInvoice');
print '</div>';
}
// Invoice data
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="4">'.$langs->trans('InvoiceData').' - '.$import->ref.'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans('InvoiceNumber').'</td>';
print '<td><strong>'.dol_escape_htmltag($import->invoice_number).'</strong></td>';
print '<td>'.$langs->trans('InvoiceDate').'</td>';
print '<td>'.dol_print_date($import->invoice_date, 'day').'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.$langs->trans('Supplier').'</td>';
print '<td>'.dol_escape_htmltag($import->seller_name).'</td>';
print '<td>'.$langs->trans('VATIntra').'</td>';
print '<td>'.dol_escape_htmltag($import->seller_vat).'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.$langs->trans('BuyerReference').'</td>';
print '<td>'.dol_escape_htmltag($import->buyer_reference).'</td>';
print '<td>'.$langs->trans('TotalHT').'</td>';
print '<td>'.price($import->total_ht).' '.$import->currency.'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.$langs->trans('Status').'</td>';
print '<td>'.$import->getLibStatut(1).'</td>';
print '<td>'.$langs->trans('TotalTTC').'</td>';
print '<td><strong>'.price($import->total_ttc).' '.$import->currency.'</strong></td>';
print '</tr>';
print '</table>';
print '</div>';
// Supplier selection
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="setsupplier">';
print '<input type="hidden" name="id" value="'.$import->id.'">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans('SupplierAssignment').'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans('SelectSupplier').' <span class="fieldrequired">*</span></td>';
print '<td>';
print $form->select_company($import->fk_soc, 'supplier_id', 's.fournisseur = 1', 'SelectThirdParty', 0, 0, null, 0, 'minwidth300');
print ' <input type="submit" class="button smallpaddingimp" value="'.$langs->trans('Save').'">';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</form>';
// Line items
print '<br>';
// Form fuer fehlende Lieferantenpreise (ausserhalb der Tabelle, um verschachtelte Forms zu vermeiden)
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" id="missing_prices_form" style="display:none;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="addmissingprices">';
print '<input type="hidden" name="id" value="'.$import->id.'">';
print '</form>';
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td>'.$langs->trans('Position').'</td>';
print '<td>'.$langs->trans('SupplierRef').'</td>';
print '<td>'.$langs->trans('ProductDescription').'</td>';
print '<td class="right">'.$langs->trans('Qty').'</td>';
print '<td class="right">'.$langs->trans('UnitPrice').'</td>';
print '<td class="right">'.$langs->trans('DolibarrPrice').'</td>';
print '<td class="right">'.$langs->trans('TotalHT').'</td>';
print '<td>'.$langs->trans('MatchedProduct').'</td>';
print '<td>'.$langs->trans('Action').'</td>';
print '</tr>';
// Initialize totals for summary row
$totalDolibarrHT = 0;
$totalZugferdHT = 0;
$hasDolibarrPrices = false;
$allProductsMatched = true;
$matchedLinesCount = 0;
$totalLinesCount = count($lines);
$allMissingPrices = array(); // Fehlende Lieferantenpreise sammeln
$hasMissingPrices = false; // Flag für globale Buttons
foreach ($lines as $line) {
$hasProduct = ($line->fk_product > 0);
$rowStyle = $hasProduct ? 'background-color: #dff0d8;' : ''; // Green for matched products
print '<tr class="oddeven" style="'.$rowStyle.'">';
print '<td>'.$line->line_id.'</td>';
print '<td>'.dol_escape_htmltag($line->supplier_ref).'</td>';
print '<td>';
print dol_escape_htmltag($line->product_name);
if (!empty($line->ean) && !$hasProduct) {
print '<br><span style="color: #666;">EAN: '.dol_escape_htmltag($line->ean).'</span>';
}
print '</td>';
print '<td class="right">'.price2num($line->quantity, 'MS').' '.zugferdGetUnitLabel($line->unit_code).'</td>';
print '<td class="right">';
print price($line->unit_price);
if (!empty($line->basis_quantity) && $line->basis_quantity != 1) {
print '<br><span style="color: #888; font-size: 0.9em;">('.price($line->unit_price_raw).'/'.price2num($line->basis_quantity, 'MS').zugferdGetUnitLabel($line->basis_quantity_unit).')</span>';
}
print '</td>';
// Dolibarr price column - show supplier price and difference
print '<td class="right nowraponall">';
$lineDolibarrTotal = 0;
if ($hasProduct && $import->fk_soc > 0) {
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
$productFourn = new ProductFournisseur($db);
// Use line quantity to find price for matching quantity tier (e.g. 100m cables)
$searchQty = max(1, $line->quantity);
$result = $productFourn->find_min_price_product_fournisseur($line->fk_product, $searchQty, $import->fk_soc);
if ($result > 0 && $productFourn->fourn_unitprice > 0) {
// Use unit price for comparison (per-unit, not per-quantity-tier)
// Note: fourn_unitprice already INCLUDES copper surcharge - it's the total price from invoice
// The extrafield 'kupferzuschlag' is only informational (shows copper portion of price)
// The extrafield 'produktpreis' is only informational (shows material price without copper)
$dolibarrUnitPrice = $productFourn->fourn_unitprice;
$zugferdPrice = $line->unit_price;
$priceDiff = $zugferdPrice - $dolibarrUnitPrice;
$priceDiffPercent = ($dolibarrUnitPrice > 0) ? (($priceDiff / $dolibarrUnitPrice) * 100) : 0;
// Accumulate for summary
$lineDolibarrTotal = $dolibarrUnitPrice * $line->quantity;
$totalDolibarrHT += $lineDolibarrTotal;
$hasDolibarrPrices = true;
$matchedLinesCount++;
print price($dolibarrUnitPrice);
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>';
if ($hasProduct) {
$product = new Product($db);
$product->fetch($line->fk_product);
print $product->getNomUrl(1);
if (!empty($line->match_method)) {
print '<br><span class="opacitymedium">'.$langs->trans('MatchMethod').': '.$line->match_method.'</span>';
}
if (!empty($line->ean)) {
print '<br><span class="opacitymedium"><i class="fas fa-barcode"></i> '.dol_escape_htmltag($line->ean).'</span>';
}
print ' <i class="fas fa-check-circle" style="color: green;"></i>';
// Alle Einkaufspreise des Produktes anzeigen
$sqlPrices = "SELECT pfp.fk_soc, pfp.price, pfp.unitprice, pfp.ref_fourn, pfp.quantity, s.nom as supplier_name";
$sqlPrices .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price pfp";
$sqlPrices .= " LEFT JOIN ".MAIN_DB_PREFIX."societe s ON s.rowid = pfp.fk_soc";
$sqlPrices .= " WHERE pfp.fk_product = ".(int)$line->fk_product;
$sqlPrices .= " ORDER BY pfp.unitprice ASC";
$resPrices = $db->query($sqlPrices);
if ($resPrices && $db->num_rows($resPrices) > 0) {
print '<div style="margin-top: 4px; font-size: 0.85em; line-height: 1.4;">';
while ($objP = $db->fetch_object($resPrices)) {
$isInvoiceSupplier = ($objP->fk_soc == $import->fk_soc);
$style = $isInvoiceSupplier ? 'font-weight: bold;' : 'color: #666;';
print '<div style="'.$style.'">';
print dol_escape_htmltag($objP->supplier_name);
print ': <strong>'.price($objP->unitprice).'</strong>';
if ($objP->quantity > 1) {
print ' <span class="opacitymedium">('.price($objP->price).'/'.(int)$objP->quantity.'Stk.)</span>';
}
if (!empty($objP->ref_fourn)) {
print ' <span class="opacitymedium">('.dol_escape_htmltag($objP->ref_fourn).')</span>';
}
if ($isInvoiceSupplier) {
print ' <i class="fas fa-file-invoice" style="color: #337ab7;" title="'.$langs->trans('SupplierAssignment').'"></i>';
}
print '</div>';
}
print '</div>';
}
} else {
print '<span class="warning">'.$langs->trans('NoProductMatch').'</span>';
}
print '</td>';
print '<td class="nowraponall">';
if ($hasProduct) {
// Remove assignment button
print '<a href="'.$_SERVER['PHP_SELF'].'?action=removeproduct&line_id='.$line->id.'&id='.$import->id.'&token='.newToken().'" class="button buttongen">';
print '<i class="fas fa-times"></i>';
print '</a>';
// Fehlende Lieferantenpreise aus anderen Katalogen sammeln (Anzeige weiter unten)
if ($import->fk_soc > 0 && getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL')) {
// Alle vorhandenen Lieferantenpreise fuer dieses Produkt laden
$sqlExistingPrices = "SELECT fk_soc, price, unitprice, barcode FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sqlExistingPrices .= " WHERE fk_product = ".(int)$line->fk_product;
$resExistingPrices = $db->query($sqlExistingPrices);
$existingSupplierIds = array();
$currentSupplierPrice = 0;
$supplierEan = '';
if ($resExistingPrices) {
while ($objPrice = $db->fetch_object($resExistingPrices)) {
$existingSupplierIds[$objPrice->fk_soc] = true;
// Stueckpreis und EAN vom Rechnungslieferanten merken
if ($objPrice->fk_soc == $import->fk_soc) {
$currentSupplierPrice = $objPrice->unitprice;
if (!empty($objPrice->barcode)) {
$supplierEan = $objPrice->barcode;
}
}
}
}
// Suche mit Lieferanten-Artikelnummer (die EAN wird intern für Cross-Catalog verwendet)
$datanormSearch = new Datanorm($db);
$allCatalogResults = array();
// Suche mit Artikelnummer - die Funktion nutzt dann die EAN für Cross-Catalog
if (!empty($line->supplier_ref)) {
$allCatalogResults = $datanormSearch->searchByArticleNumber($line->supplier_ref, $import->fk_soc, true, 10);
}
if (!empty($allCatalogResults)) {
$missingSuppliers = array();
foreach ($allCatalogResults as $catalogResult) {
if (!isset($existingSupplierIds[$catalogResult['fk_soc']])) {
$altSupplier = new Societe($db);
$altSupplier->fetch($catalogResult['fk_soc']);
$altPurchasePrice = $catalogResult['price'];
if ($catalogResult['price_unit'] > 1) {
$altPurchasePrice = $catalogResult['price'] / $catalogResult['price_unit'];
}
$missingSuppliers[] = array(
'datanorm_id' => $catalogResult['id'],
'fk_soc' => $catalogResult['fk_soc'],
'supplier_name' => $altSupplier->name,
'article_number' => $catalogResult['article_number'],
'price' => $catalogResult['price'],
'price_unit' => $catalogResult['price_unit'],
'purchase_price' => $altPurchasePrice,
'ean' => $catalogResult['ean'],
);
}
}
if (!empty($missingSuppliers)) {
// Inline-Anzeige der fehlenden Lieferantenpreise direkt bei der Produktzeile
$toggleId = 'missing_inline_'.$line->id;
$missingCount = count($missingSuppliers);
print '<div class="missing-suppliers-inline" style="margin-top: 8px; padding: 8px; background-color: #fcf8e3; border: 1px solid #faebcc; border-radius: 4px;">';
print '<div style="font-weight: bold; cursor: pointer; color: #8a6d3b;" onclick="$(\'#'.$toggleId.'\').toggle(); $(this).find(\'.toggle-icon\').toggleClass(\'fa-chevron-down fa-chevron-up\');">';
print '<i class="fas fa-plus-circle paddingright"></i>';
print $langs->trans('MissingSupplierPrices');
print ' <span class="badge badge-warning">'.$missingCount.'</span>';
print ' <i class="fas fa-chevron-up toggle-icon" style="float: right; color: #999;"></i>';
print '</div>';
// Aufklappbarer Bereich (Standard: sichtbar/aufgeklappt)
print '<div id="'.$toggleId.'" class="missing-suppliers-content" style="margin-top: 5px;">';
foreach ($missingSuppliers as $missing) {
$priceDiffHtml = '';
if ($currentSupplierPrice > 0) {
$pDiff = $missing['purchase_price'] - $currentSupplierPrice;
$pDiffPercent = ($pDiff / $currentSupplierPrice) * 100;
if ($pDiff < 0) {
$priceDiffHtml = ' <span style="color: #5cb85c;"><i class="fas fa-arrow-down"></i> '.number_format(abs($pDiffPercent), 1).'%</span>';
} elseif ($pDiff > 0) {
$priceDiffHtml = ' <span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.number_format($pDiffPercent, 1).'%</span>';
} else {
$priceDiffHtml = ' <span class="opacitymedium">=</span>';
}
}
// Wert: productId,socId,datanormId
$cbValue = $line->fk_product.','.$missing['fk_soc'].','.$missing['datanorm_id'];
print '<div style="padding: 3px 0 3px 10px;">';
print '<label style="cursor: pointer; display: block;">';
print '<input type="checkbox" name="add_prices[]" value="'.dol_escape_htmltag($cbValue).'" checked class="cb-missing-price" form="missing_prices_form" style="margin-right: 5px;">';
print '<strong>'.dol_escape_htmltag($missing['supplier_name']).'</strong>';
print ' <code style="font-size: 0.9em;">'.dol_escape_htmltag($missing['article_number']).'</code>';
print ' @ <strong>'.price($missing['purchase_price']).'</strong>';
print $priceDiffHtml;
print '</label>';
print '</div>';
}
print '</div>'; // End toggleable div
print '</div>'; // End inline box
// Track for global actions
$hasMissingPrices = true;
}
}
}
} else {
// Product selection form
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" class="inline-block">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="assignproduct">';
print '<input type="hidden" name="line_id" value="'.$line->id.'">';
print '<input type="hidden" name="id" value="'.$import->id.'">';
print $form->select_produits('', 'product_id_'.$line->id, '', 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'minwidth150 maxwidth200', 1, '', 0);
print ' <button type="submit" class="button buttongen" title="'.$langs->trans('AssignProduct').'">';
print '<i class="fas fa-link"></i>';
print '</button>';
print '</form>';
// Create new product link
$create_url = DOL_URL_ROOT.'/product/card.php?action=create';
$create_url .= '&label='.urlencode($line->product_name);
$create_url .= '&price='.urlencode($line->unit_price);
$create_desc = '';
if (!empty($line->supplier_ref)) {
$create_desc .= $langs->trans('SupplierRef').': '.$line->supplier_ref."\n";
}
if (!empty($line->unit_code)) {
$create_desc .= $langs->trans('Unit').': '.zugferdGetUnitLabel($line->unit_code)."\n";
}
if (!empty($line->ean)) {
$create_desc .= 'EAN: '.$line->ean."\n";
}
$create_url .= '&description='.urlencode(trim($create_desc));
print '<br><a href="'.$create_url.'" target="_blank" class="button buttongen margintoponlyshort">';
print '<i class="fas fa-plus-circle"></i> '.$langs->trans('CreateProduct');
print '</a>';
// Refresh-Button nach Produktanlage
print ' <a href="'.$_SERVER['PHP_SELF'].'?id='.$import->id.'" class="button buttongen margintoponlyshort" title="'.$langs->trans('RefreshProductListHelp').'">';
print '<i class="fas fa-sync-alt"></i>';
print '</a>';
// Product template
print '<br>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" class="inline-block margintoponlyshort">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="duplicateproduct">';
print '<input type="hidden" name="line_id" value="'.$line->id.'">';
print '<input type="hidden" name="id" value="'.$import->id.'">';
print $form->select_produits('', 'template_product_id_'.$line->id, '', 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'minwidth100 maxwidth150', 1, '', 0);
print ' <button type="submit" class="button buttongen" title="'.$langs->trans('ProductTemplateHelp').'">';
print '<i class="fas fa-copy"></i>';
print '</button>';
print '</form>';
// Datanorm button (only if supplier is set and supplier_ref exists)
if ($import->fk_soc > 0 && !empty($line->supplier_ref)) {
// Check if Datanorm article exists
$datanormCheck = new Datanorm($db);
$searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0);
$datanormResults = $datanormCheck->searchByArticleNumber($line->supplier_ref, $import->fk_soc, $searchAll, 1);
if (!empty($datanormResults)) {
$datanormArticle = $datanormResults[0];
print '<br>';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=createfromdatanorm&line_id='.$line->id.'&id='.$import->id.'&token='.newToken().'" class="button buttongen margintoponlyshort" title="'.$langs->trans('CreateFromDatanormHelp').'">';
print '<i class="fas fa-database paddingright"></i>'.$langs->trans('CreateFromDatanorm');
print '</a>';
// Button to show raw Datanorm data
print ' <a href="#" class="button buttongen margintoponlyshort" onclick="showRawDatanorm(\''.dol_escape_js($line->supplier_ref).'\', '.$import->fk_soc.'); return false;" title="'.$langs->trans('ShowRawDatanorm').'">';
print '<i class="fas fa-file-code"></i>';
print '</a>';
// Show comparison: Invoice name vs Datanorm name
print '<div class="small" style="margin-top: 5px; padding: 5px; background-color: #e8f4fd; border-radius: 3px;">';
print '<table class="noborder" style="width: 100%; font-size: 0.85em;">';
print '<tr><td style="color: #666; width: 70px;"><i class="fas fa-file-invoice"></i> Rechnung:</td>';
print '<td><strong>'.dol_trunc($line->product_name, 50).'</strong></td></tr>';
print '<tr><td style="color: #666;"><i class="fas fa-database"></i> Datanorm:</td>';
print '<td style="color: #2980b9;"><strong>'.dol_trunc($datanormArticle['short_text1'], 50).'</strong>';
print ' <span style="color: #27ae60;">('.price($datanormArticle['price']).')</span></td></tr>';
print '</table>';
print '</div>';
}
}
}
print '</td>';
print '</tr>';
// Accumulate ZUGFeRD total
$totalZugferdHT += $line->line_total;
}
// Summary row with total comparison
// Only show full comparison if ALL products are matched with Dolibarr prices
print '<tr style="background-color: #f5f5f5; font-weight: bold;">';
print '<td colspan="5" class="right"><strong>'.$langs->trans('Total').' '.$langs->trans('TotalHT').'</strong></td>';
if ($allProductsMatched && $hasDolibarrPrices) {
// Full comparison possible - all products matched with prices
$totalDiff = $totalZugferdHT - $totalDolibarrHT;
$totalDiffPercent = ($totalDolibarrHT > 0) ? (($totalDiff / $totalDolibarrHT) * 100) : 0;
// Determine colors: green if close match, red if significant difference
$threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10);
$isMatch = (abs($totalDiffPercent) < 0.5); // Less than 0.5% difference = match
$isSignificant = (abs($totalDiffPercent) >= $threshold);
if ($isMatch) {
$cellStyle = 'background-color: #dff0d8;'; // Green
} elseif ($isSignificant) {
$cellStyle = 'background-color: #f2dede;'; // Red
} else {
$cellStyle = 'background-color: #fcf8e3;'; // Yellow/warning
}
print '<td class="right nowraponall" style="'.$cellStyle.'">';
print '<strong>'.price($totalDolibarrHT).'</strong>';
if (abs($totalDiffPercent) >= 0.01) {
print '<br>';
if ($totalDiff > 0) {
print '<span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.number_format($totalDiffPercent, 1).'%</span>';
} elseif ($totalDiff < 0) {
print '<span style="color: #5cb85c;"><i class="fas fa-arrow-down"></i> '.number_format($totalDiffPercent, 1).'%</span>';
}
}
print '</td>';
print '<td class="right" style="'.$cellStyle.'"><strong>'.price($totalZugferdHT).'</strong></td>';
print '<td colspan="2" class="nowraponall" style="'.$cellStyle.'">';
if ($isMatch) {
print '<span style="color: #3c763d;"><i class="fas fa-check-circle"></i> '.$langs->trans('SumValidationOk').'</span>';
} else {
print '<span style="color: #a94442;"><i class="fas fa-exclamation-triangle"></i> '.$langs->trans('Difference').': '.price($totalDiff).' '.$import->currency.'</span>';
}
print '</td>';
} else {
// Not all products matched - show totals but no comparison
print '<td class="right nowraponall">';
if ($hasDolibarrPrices) {
print '<span class="opacitymedium">'.price($totalDolibarrHT).'</span>';
print '<br><span class="opacitymedium small">('.$matchedLinesCount.'/'.$totalLinesCount.')</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '<td class="right"><strong>'.price($totalZugferdHT).'</strong></td>';
print '<td colspan="2" class="nowraponall">';
print '<span class="opacitymedium"><i class="fas fa-info-circle"></i> '.$langs->trans('ProductsNotAssigned').'</span>';
print '</td>';
}
print '</tr>';
print '</table>';
print '</div>';
// Aktionsbereich für fehlende Lieferantenpreise (wenn vorhanden)
if ($hasMissingPrices) {
print '<div style="margin-top: 10px; padding: 10px; background-color: #fcf8e3; border: 1px solid #faebcc; border-radius: 4px;">';
print '<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 10px;">';
// Toggle Buttons
print '<span style="color: #8a6d3b; font-weight: bold;"><i class="fas fa-plus-circle paddingright"></i>'.$langs->trans('MissingSupplierPrices').'</span>';
print '<a href="#" onclick="$(\'.missing-suppliers-content\').show(); $(\'.missing-suppliers-inline .toggle-icon\').removeClass(\'fa-chevron-down\').addClass(\'fa-chevron-up\'); return false;" class="button buttongen small">';
print '<i class="fas fa-expand-alt paddingright"></i>'.$langs->trans('ExpandAll');
print '</a>';
print '<a href="#" onclick="$(\'.missing-suppliers-content\').hide(); $(\'.missing-suppliers-inline .toggle-icon\').removeClass(\'fa-chevron-up\').addClass(\'fa-chevron-down\'); return false;" class="button buttongen small">';
print '<i class="fas fa-compress-alt paddingright"></i>'.$langs->trans('CollapseAll');
print '</a>';
// Checkbox Buttons
print '<span style="border-left: 1px solid #ccc; padding-left: 10px;">';
print '<a href="#" onclick="$(\'.cb-missing-price\').prop(\'checked\', true); return false;" class="button buttongen small">';
print '<i class="fas fa-check-square paddingright"></i>'.$langs->trans('SelectAll');
print '</a>';
print '</span>';
print '<a href="#" onclick="$(\'.cb-missing-price\').prop(\'checked\', false); return false;" class="button buttongen small">';
print '<i class="fas fa-square paddingright"></i>'.$langs->trans('DeselectAll');
print '</a>';
// Submit Button
print '<span style="border-left: 1px solid #ccc; padding-left: 10px;">';
print '<button type="submit" form="missing_prices_form" class="button buttongen">';
print '<i class="fas fa-plus paddingright"></i>'.$langs->trans('AddSelectedPrices');
print '</button>';
print '</span>';
print '</div>';
print '</div>';
}
// missing_prices_form ist bereits oben geschlossen (ausgelagert wegen verschachtelter Forms)
// Datanorm Preview Section (shown when preview action was triggered)
if (!empty($datanormPreviewMatches)) {
print '<div class="div-table-responsive-no-min" style="margin-top: 20px;">';
print '<div class="titre" style="margin-bottom: 10px;">';
print '<i class="fas fa-database paddingright"></i>'.$langs->trans('DatanormPreview');
print ' <span class="badge badge-info">'.count($datanormPreviewMatches).' '.$langs->trans('Matches').'</span>';
print '</div>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" id="datanorm_confirm_form">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="createallfromdatanorm">';
print '<input type="hidden" name="id" value="'.$import->id.'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th class="center" style="width: 30px;"><input type="checkbox" id="checkall_datanorm" checked></th>';
print '<th>'.$langs->trans('SupplierRef').'</th>';
print '<th>'.$langs->trans('InvoiceProductName').'</th>';
print '<th>'.$langs->trans('DatanormProductName').'</th>';
print '<th class="right">'.$langs->trans('InvoicePrice').'</th>';
print '<th class="right">'.$langs->trans('DatanormPrice').'</th>';
print '<th class="right">'.$langs->trans('SellingPrice').'</th>';
print '<th class="center">'.$langs->trans('Action').'</th>';
print '</tr>';
$countCreate = 0;
$countAssign = 0;
foreach ($datanormPreviewMatches as $match) {
$rowClass = ($match['action'] == 'assign') ? 'background-color: #d9edf7;' : 'background-color: #dff0d8;';
print '<tr class="oddeven" style="'.$rowClass.'">';
print '<td class="center"><input type="checkbox" name="selected_lines[]" value="'.$match['line_id'].'" checked></td>';
print '<td><strong>'.$match['datanorm_article_number'].'</strong>';
if (!empty($match['datanorm_ean'])) {
print '<br><span class="small" style="color: #666;">EAN: '.$match['datanorm_ean'].'</span>';
}
print '</td>';
// Invoice product name
print '<td>';
print '<span style="color: #666;">'.dol_trunc($match['line_product_name'], 40).'</span>';
print '</td>';
// Datanorm product name
print '<td>';
print '<strong style="color: #2980b9;">'.dol_trunc($match['datanorm_short_text1'], 40).'</strong>';
if (!empty($match['datanorm_short_text2'])) {
print '<br><span class="small" style="color: #666;">'.dol_trunc($match['datanorm_short_text2'], 40).'</span>';
}
print '</td>';
// Invoice price (from ZUGFeRD)
print '<td class="right nowraponall">';
print '<strong>'.price($match['line_unit_price']).'</strong>';
print '</td>';
// Datanorm price - show original price and calculated unit price
print '<td class="right nowraponall">';
if ($match['datanorm_price_unit'] > 1) {
// Show original price and price unit
print '<span class="small" style="color: #666;">'.price($match['datanorm_price']).'/'.$match['datanorm_price_unit'].'</span>';
print '<br><strong>= '.price($match['purchase_price']).'</strong>';
} else {
print '<strong>'.price($match['purchase_price']).'</strong>';
}
if ($match['copper_surcharge'] > 0) {
print '<br><span class="small" style="color: #d9534f;">+ '.price($match['copper_surcharge']).' Cu</span>';
}
print '</td>';
// Selling price
print '<td class="right nowraponall"><strong style="color: #27ae60;">'.price($match['selling_price']).'</strong></td>';
// Action
print '<td class="center nowraponall">';
if ($match['action'] == 'assign') {
print '<span class="badge badge-info" title="'.$langs->trans('ProductAlreadyExists').'"><i class="fas fa-link"></i> '.$langs->trans('Assign').'</span>';
$countAssign++;
} else {
print '<span class="badge badge-success"><i class="fas fa-plus"></i> '.$langs->trans('Create').'</span>';
print '<br><span class="small" style="color: #666;">'.$match['new_ref'].'</span>';
$countCreate++;
}
print '</td>';
print '</tr>';
// Show supplier alternatives if available (more than 1 supplier found)
if (!empty($match['supplier_alternatives']) && count($match['supplier_alternatives']) > 1) {
$altCount = count($match['supplier_alternatives']);
$toggleId = 'alt_'.$match['line_id'];
print '<tr class="oddeven" style="background-color: #f9f9f9;">';
print '<td></td>'; // Empty checkbox column
print '<td colspan="7" style="padding: 10px;">';
print '<div style="background-color: #fff; border: 1px solid #ddd; border-radius: 5px; padding: 10px;">';
// Header mit Anzahl und Toggle-Button
print '<div style="font-weight: bold; margin-bottom: 8px; color: #555; cursor: pointer;" onclick="$(\'#'.$toggleId.'\').toggle(); $(this).find(\'.toggle-icon\').toggleClass(\'fa-chevron-down fa-chevron-up\');">';
print '<i class="fas fa-store-alt paddingright"></i>'.$langs->trans('SupplierAlternatives');
print ' <span class="badge" style="background-color: #5bc0de;">'.$altCount.' '.$langs->trans('Suppliers').'</span>';
print ' <i class="fas fa-chevron-up toggle-icon" style="float: right; color: #999;"></i>';
print '</div>';
// Aufklappbarer Bereich (Standard: sichtbar)
print '<div id="'.$toggleId.'">';
print '<table class="noborder" style="width: 100%; font-size: 0.9em;">';
print '<tr style="background-color: #f5f5f5;">';
print '<th class="center" style="width: 40px;">'.$langs->trans('Select').'</th>';
print '<th>'.$langs->trans('Supplier').'</th>';
print '<th>'.$langs->trans('SupplierRef').'</th>';
print '<th>'.$langs->trans('EAN').'</th>';
print '<th>'.$langs->trans('ManufacturerRef').'</th>';
print '<th class="right">'.$langs->trans('UnitPrice').'</th>';
print '<th class="right">'.$langs->trans('Difference').'</th>';
print '</tr>';
$lowestPrice = PHP_FLOAT_MAX;
foreach ($match['supplier_alternatives'] as $alt) {
if ($alt['purchase_price'] < $lowestPrice) {
$lowestPrice = $alt['purchase_price'];
}
}
foreach ($match['supplier_alternatives'] as $altIdx => $alt) {
$isInvoiceSupplier = $alt['is_invoice_supplier'];
$isCheapest = ($alt['purchase_price'] == $lowestPrice);
$rowStyle = '';
if ($isInvoiceSupplier) {
$rowStyle = 'background-color: #d9edf7;'; // Blue for invoice supplier
} elseif ($isCheapest) {
$rowStyle = 'background-color: #dff0d8;'; // Green for cheapest
}
print '<tr style="'.$rowStyle.'">';
// Checkbox for selecting this supplier as purchase source
print '<td class="center">';
$checkboxName = 'supplier_prices['.$match['line_id'].']['.$alt['fk_soc'].']';
$checked = $isInvoiceSupplier ? ' checked' : '';
print '<input type="checkbox" name="'.$checkboxName.'" value="'.$alt['datanorm_id'].'"'.$checked.' title="'.$langs->trans('AddAsPurchasePrice').'">';
print '</td>';
// Supplier name
print '<td>';
print '<strong>'.dol_escape_htmltag($alt['supplier_name']).'</strong>';
if ($isInvoiceSupplier) {
print ' <span class="badge" style="background-color: #337ab7; font-size: 0.75em;"><i class="fas fa-file-invoice"></i></span>';
}
if ($isCheapest) {
print ' <span class="badge" style="background-color: #5cb85c; font-size: 0.75em;"><i class="fas fa-tag"></i></span>';
}
print '</td>';
// Supplier article number
print '<td><code>'.dol_escape_htmltag($alt['article_number']).'</code></td>';
// EAN
print '<td>';
if (!empty($alt['ean'])) {
print '<span class="small">'.dol_escape_htmltag($alt['ean']).'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Manufacturer ref
print '<td>';
if (!empty($alt['manufacturer_ref'])) {
print '<span class="small">'.dol_escape_htmltag($alt['manufacturer_ref']).'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Price
print '<td class="right nowraponall">';
if ($alt['price_unit'] > 1) {
print '<span class="small" style="color: #666;">'.price($alt['price']).'/'.$alt['price_unit'].'</span><br>';
}
print '<strong>'.price($alt['purchase_price']).'</strong>';
print '</td>';
// Difference from invoice supplier price
print '<td class="right nowraponall">';
if (!$isInvoiceSupplier && isset($match['purchase_price'])) {
$diff = $alt['purchase_price'] - $match['purchase_price'];
$diffPercent = ($match['purchase_price'] > 0) ? ($diff / $match['purchase_price'] * 100) : 0;
if ($diff < 0) {
print '<span style="color: #5cb85c;"><i class="fas fa-arrow-down"></i> '.price(abs($diff)).' ('.number_format(abs($diffPercent), 1).'%)</span>';
} elseif ($diff > 0) {
print '<span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.price($diff).' (+'.number_format($diffPercent, 1).'%)</span>';
} else {
print '<span class="opacitymedium">=</span>';
}
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '<div class="small opacitymedium" style="margin-top: 5px;">';
print '<i class="fas fa-info-circle"></i> '.$langs->trans('SelectSuppliersForPurchasePrices');
print '</div>';
print '</div>'; // End toggleable div
print '</div>'; // End white box
print '</td>';
print '</tr>';
}
}
print '</table>';
// Summary and confirm button
print '<div class="center" style="margin-top: 15px; padding: 15px; background-color: #f5f5f5; border-radius: 5px;">';
print '<div style="margin-bottom: 10px;">';
if ($countCreate > 0) {
print '<span class="badge badge-success" style="margin-right: 10px;"><i class="fas fa-plus"></i> '.$countCreate.' '.$langs->trans('ToCreate').'</span>';
}
if ($countAssign > 0) {
print '<span class="badge badge-info"><i class="fas fa-link"></i> '.$countAssign.' '.$langs->trans('ToAssign').'</span>';
}
print '</div>';
print '<button type="submit" class="button button-primary">';
print '<i class="fas fa-check paddingright"></i>'.$langs->trans('ConfirmAndCreateProducts');
print '</button>';
print ' &nbsp; ';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=edit&id='.$import->id.'" class="button">';
print '<i class="fas fa-times paddingright"></i>'.$langs->trans('Cancel');
print '</a>';
print '</div>';
print '</form>';
print '</div>';
// JavaScript for select all checkbox
print '<script>
$(document).ready(function() {
$("#checkall_datanorm").change(function() {
$("input[name=\'selected_lines[]\']").prop("checked", this.checked);
});
});
</script>';
}
// Action buttons
print '<div class="center" style="margin-top: 20px;">';
if ($allComplete) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=createinvoice&id='.$import->id.'&token='.newToken().'" class="button button-primary">';
print '<i class="fas fa-file-invoice paddingright"></i>'.$langs->trans('CreateSupplierInvoice');
print '</a>';
print ' &nbsp; ';
}
// Finish import button - shown when pending status and all products assigned
if ($import->status == ZugferdImport::STATUS_PENDING && $allComplete) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=finishimport&id='.$import->id.'&token='.newToken().'" class="button">';
print '<i class="fas fa-check paddingright"></i>'.$langs->trans('FinishImport');
print '</a>';
print ' &nbsp; ';
}
// Datanorm buttons - show when products are missing and supplier is set
if ($missingProducts > 0 && $import->fk_soc > 0 && empty($datanormPreviewMatches)) {
// "Alle zuordnen" - creates all products from Datanorm
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('AssignAllFromDatanorm');
print '</a>';
print ' &nbsp; ';
// "Datanorm Vorschau" - preview what will be created
print '<a href="'.$_SERVER['PHP_SELF'].'?action=previewdatanorm&id='.$import->id.'&token='.newToken().'" class="button">';
print '<i class="fas fa-search paddingright"></i>'.$langs->trans('PreviewDatanormMatches');
print '</a>';
print ' &nbsp; ';
}
print '<a href="'.dol_buildpath('/importzugferd/list.php', 1).'" class="button">'.$langs->trans('BackToList').'</a>';
// Delete button - show for pending imports or imports without linked invoice
$canDelete = ($import->status == ZugferdImport::STATUS_PENDING) ||
($import->status == ZugferdImport::STATUS_IMPORTED && $import->fk_facture_fourn <= 0);
if ($canDelete) {
print ' &nbsp; ';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&id='.$import->id.'&token='.newToken().'" class="button button-cancel">';
print '<i class="fas fa-trash paddingright"></i>'.$langs->trans('Delete');
print '</a>';
}
print '</div>';
print '</div>';
// Modal CSS and HTML for raw Datanorm data
print '<style>
.datanorm-modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
z-index: 9999;
}
.datanorm-modal {
position: relative;
background: white;
width: 90%;
max-width: 700px;
max-height: 85vh;
margin: 50px auto;
padding: 20px;
border-radius: 5px;
overflow-y: auto;
}
.datanorm-modal h3 {
margin-top: 0;
color: #333;
}
.datanorm-modal h4 {
margin-bottom: 5px;
color: #555;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
}
.datanorm-modal pre {
background: #f5f5f5;
padding: 10px;
font-size: 11px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.datanorm-modal .close-btn {
float: right;
cursor: pointer;
font-size: 20px;
color: #999;
}
.datanorm-modal .close-btn:hover {
color: #333;
}
</style>';
// Modal for raw data
print '<div id="datanormRawModal" class="datanorm-modal-overlay" onclick="closeRawDatanormModal(event)">';
print '<div class="datanorm-modal" onclick="event.stopPropagation()">';
print '<span class="close-btn" onclick="closeRawDatanormModal()">&times;</span>';
print '<h3><i class="fas fa-file-code"></i> Rohdaten: <span id="modalArticleNumber"></span></h3>';
print '<div id="modalContent">';
print '<p><em>Laden...</em></p>';
print '</div>';
print '</div>';
print '</div>';
print '<script>
function showRawDatanorm(articleNumber, fkSoc) {
document.getElementById("datanormRawModal").style.display = "block";
document.getElementById("modalArticleNumber").textContent = articleNumber;
document.getElementById("modalContent").innerHTML = "<p><em>Laden...</em></p>";
// AJAX request
var xhr = new XMLHttpRequest();
xhr.open("GET", "'.dol_escape_js($_SERVER['PHP_SELF']).'?action=get_raw_lines&article_number=" + encodeURIComponent(articleNumber) + "&fk_soc=" + fkSoc + "&token='.newToken().'", true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
var html = "";
html += "<h4>DATANORM (A-Satz)</h4>";
if (data.datanorm_line) {
html += "<pre>" + escapeHtml(data.datanorm_line) + "</pre>";
// Parse and show fields
var parts = data.datanorm_line.split(";");
html += "<table class=\"noborder\" style=\"margin-top:10px;font-size:12px;\">";
html += "<tr><td><b>0: Satzart</b></td><td>" + escapeHtml(parts[0] || "") + "</td></tr>";
html += "<tr><td><b>1: Aktionscode</b></td><td>" + escapeHtml(parts[1] || "") + "</td></tr>";
html += "<tr><td><b>2: Artikelnummer</b></td><td>" + escapeHtml(parts[2] || "") + "</td></tr>";
html += "<tr><td><b>3: Textkennzeichen</b></td><td>" + escapeHtml(parts[3] || "") + "</td></tr>";
html += "<tr><td><b>4: Kurztext 1</b></td><td>" + escapeHtml(parts[4] || "") + "</td></tr>";
html += "<tr><td><b>5: Kurztext 2</b></td><td>" + escapeHtml(parts[5] || "") + "</td></tr>";
html += "<tr><td><b>6: Preiskennzeichen</b></td><td>" + escapeHtml(parts[6] || "") + " (1=Brutto, 2=Netto)</td></tr>";
html += "<tr><td><b>7: PE-Code</b></td><td>" + escapeHtml(parts[7] || "") + " (0=1, 1=10, 2=100, 3=1000)</td></tr>";
html += "<tr><td><b>8: Mengeneinheit</b></td><td>" + escapeHtml(parts[8] || "") + "</td></tr>";
html += "<tr><td><b>9: Preis (Cent)</b></td><td>" + escapeHtml(parts[9] || "") + "</td></tr>";
html += "<tr><td><b>10: Rabattgruppe</b></td><td>" + escapeHtml(parts[10] || "") + "</td></tr>";
html += "<tr><td><b>11: Warengruppe</b></td><td>" + escapeHtml(parts[11] || "") + "</td></tr>";
html += "</table>";
} else {
html += "<p class=\"opacitymedium\">Keine DATANORM-Zeile gefunden</p>";
}
html += "<br><h4>DATPREIS (P-Satz)</h4>";
if (data.datpreis_line) {
html += "<pre>" + escapeHtml(data.datpreis_line) + "</pre>";
// Parse P-Satz
var pparts = data.datpreis_line.split(";");
html += "<table class=\"noborder\" style=\"margin-top:10px;font-size:12px;\">";
html += "<tr><td><b>0: Satzart</b></td><td>" + escapeHtml(pparts[0] || "") + "</td></tr>";
html += "<tr><td><b>1: Aktionscode</b></td><td>" + escapeHtml(pparts[1] || "") + "</td></tr>";
// Find the article in the P-Satz (multiple articles per line)
for (var i = 2; i < pparts.length; i += 9) {
if (pparts[i] && pparts[i].trim() === articleNumber) {
html += "<tr><td colspan=\"2\"><b>--- Artikel " + escapeHtml(pparts[i]) + " ---</b></td></tr>";
html += "<tr><td><b>" + i + ": Artikelnummer</b></td><td>" + escapeHtml(pparts[i] || "") + "</td></tr>";
html += "<tr><td><b>" + (i+1) + ": Preiskennzeichen</b></td><td>" + escapeHtml(pparts[i+1] || "") + " (2=Netto)</td></tr>";
html += "<tr><td><b>" + (i+2) + ": Preis (Cent)</b></td><td>" + escapeHtml(pparts[i+2] || "") + " = " + (parseFloat(pparts[i+2] || 0) / 100).toFixed(2) + " EUR</td></tr>";
html += "<tr><td><b>" + (i+3) + ": Unbekannt</b></td><td>" + escapeHtml(pparts[i+3] || "") + "</td></tr>";
html += "<tr><td><b>" + (i+4) + ": Metallzuschlag (Cent)</b></td><td>" + escapeHtml(pparts[i+4] || "") + " = " + (parseFloat(pparts[i+4] || 0) / 100).toFixed(2) + " EUR</td></tr>";
break;
}
}
html += "</table>";
} else {
html += "<p class=\"opacitymedium\">Keine DATPREIS-Zeile gefunden</p>";
}
if (data.error) {
html += "<br><p class=\"error\">" + escapeHtml(data.error) + "</p>";
}
document.getElementById("modalContent").innerHTML = html;
} catch(e) {
document.getElementById("modalContent").innerHTML = "<p class=\"error\">Fehler beim Parsen: " + e.message + "</p><pre>" + escapeHtml(xhr.responseText) + "</pre>";
}
} else {
document.getElementById("modalContent").innerHTML = "<p class=\"error\">HTTP Fehler: " + xhr.status + "</p>";
}
}
};
xhr.send();
}
function closeRawDatanormModal(event) {
if (!event || event.target.id === "datanormRawModal") {
document.getElementById("datanormRawModal").style.display = "none";
}
}
function escapeHtml(text) {
var div = document.createElement("div");
div.appendChild(document.createTextNode(text || ""));
return div.innerHTML;
}
// Close modal with ESC key
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") {
closeRawDatanormModal();
}
});
</script>';
}
llxFooter();
$db->close();