diff --git a/ChangeLog.md b/ChangeLog.md index 13c70e8..c9ebc82 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,21 @@ # CHANGELOG MODULE IMPORTZUGFERD FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) +## 3.2 + +### Neue Funktionen +- Cross-Katalog-Suche: Artikel werden ueber EAN/Hersteller-Artikelnummer in allen Lieferanten-Katalogen gefunden +- Multi-Lieferanten-Anzeige: Bei Produktzuordnung werden alle verfuegbaren Lieferanten mit Preisen angezeigt +- Fehlende Lieferantenpreise: Bei zugeordneten Produkten werden fehlende EK-Preise anderer Lieferanten angeboten +- Preisvergleich mit Prozentangabe (guenstiger/teurer) fuer Lieferanten-Alternativen + +### Bugfixes +- Datanorm Import: Kluxen-Format (Preise im A-Record in Cent) wird jetzt korrekt verarbeitet +- Datanorm Import: Preise aus A-Record werden von Cent in Euro umgerechnet (geteilt durch 100) + +### Hinweise +- Kluxen-Katalog enthaelt nur Listenpreise (UVP), keine Netto-Einkaufspreise +- Cross-Katalog-Suche erfordert aktivierte Einstellung "In allen Lieferanten-Katalogen suchen" + ## 2.1 ### Bugfixes diff --git a/class/datanorm.class.php b/class/datanorm.class.php index a4a45cb..28806f6 100644 --- a/class/datanorm.class.php +++ b/class/datanorm.class.php @@ -510,17 +510,85 @@ class Datanorm extends CommonObject global $conf; $results = array(); + $foundEan = ''; + $foundManufacturerRef = ''; + $foundIds = array(); // Track found IDs to avoid duplicates // First try exact match with specified supplier if ($fk_soc > 0) { $result = $this->fetchByArticleNumber($fk_soc, $article_number); if ($result > 0) { $results[] = $this->toArray(); + $foundIds[$this->id] = true; + // Store EAN and manufacturer_ref for cross-catalog search + $foundEan = $this->ean; + $foundManufacturerRef = $this->manufacturer_ref; + + // If not searching all catalogs, return immediately + if (!$searchAll) { + return $results; + } + } + } + + // If searchAll is enabled and we found article with EAN/manufacturer_ref, + // search other catalogs using these identifiers (cross-catalog search) + if ($searchAll && $fk_soc > 0 && (!empty($foundEan) || !empty($foundManufacturerRef))) { + $sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2,"; + $sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,"; + $sql .= " price, price_unit, discount_group, product_group, matchcode"; + $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE ("; + + $conditions = array(); + if (!empty($foundEan)) { + $conditions[] = "ean = '" . $this->db->escape($foundEan) . "'"; + } + if (!empty($foundManufacturerRef)) { + $conditions[] = "manufacturer_ref = '" . $this->db->escape($foundManufacturerRef) . "'"; + } + $sql .= implode(' OR ', $conditions) . ")"; + + $sql .= " AND active = 1"; + $sql .= " AND entity = " . (int) $conf->entity; + $sql .= " AND fk_soc != " . (int) $fk_soc; // Exclude already found supplier + + $sql .= " ORDER BY price ASC"; // Show cheapest alternatives first + $sql .= " LIMIT " . (int) ($limit - count($results)); + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + if (!isset($foundIds[$obj->rowid])) { + $results[] = array( + 'id' => $obj->rowid, + 'fk_soc' => $obj->fk_soc, + 'article_number' => $obj->article_number, + 'short_text1' => $obj->short_text1, + 'short_text2' => $obj->short_text2, + 'ean' => $obj->ean, + 'manufacturer_ref' => $obj->manufacturer_ref, + 'manufacturer_name' => $obj->manufacturer_name, + 'unit_code' => $obj->unit_code, + 'price' => $obj->price, + 'price_unit' => $obj->price_unit, + 'discount_group' => $obj->discount_group, + 'product_group' => $obj->product_group, + 'matchcode' => $obj->matchcode, + ); + $foundIds[$obj->rowid] = true; + } + } + $this->db->free($resql); + } + + // If we found results via cross-catalog search, return them + if (!empty($results)) { return $results; } } - // Search partial match + // Fallback: Search by partial match on article_number, ean, or manufacturer_ref $sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2,"; $sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,"; $sql .= " price, price_unit, discount_group, product_group, matchcode"; @@ -537,8 +605,8 @@ class Datanorm extends CommonObject // ORDER BY clause if ($fk_soc > 0 && $searchAll) { - // Order by matching supplier first - $sql .= " ORDER BY CASE WHEN fk_soc = " . (int) $fk_soc . " THEN 0 ELSE 1 END, article_number"; + // Order by matching supplier first, then by price + $sql .= " ORDER BY CASE WHEN fk_soc = " . (int) $fk_soc . " THEN 0 ELSE 1 END, price ASC"; } else { $sql .= " ORDER BY article_number"; } @@ -548,22 +616,25 @@ class Datanorm extends CommonObject $resql = $this->db->query($sql); if ($resql) { while ($obj = $this->db->fetch_object($resql)) { - $results[] = array( - 'id' => $obj->rowid, - 'fk_soc' => $obj->fk_soc, - 'article_number' => $obj->article_number, - 'short_text1' => $obj->short_text1, - 'short_text2' => $obj->short_text2, - 'ean' => $obj->ean, - 'manufacturer_ref' => $obj->manufacturer_ref, - 'manufacturer_name' => $obj->manufacturer_name, - 'unit_code' => $obj->unit_code, - 'price' => $obj->price, - 'price_unit' => $obj->price_unit, - 'discount_group' => $obj->discount_group, - 'product_group' => $obj->product_group, - 'matchcode' => $obj->matchcode, - ); + if (!isset($foundIds[$obj->rowid])) { + $results[] = array( + 'id' => $obj->rowid, + 'fk_soc' => $obj->fk_soc, + 'article_number' => $obj->article_number, + 'short_text1' => $obj->short_text1, + 'short_text2' => $obj->short_text2, + 'ean' => $obj->ean, + 'manufacturer_ref' => $obj->manufacturer_ref, + 'manufacturer_name' => $obj->manufacturer_name, + 'unit_code' => $obj->unit_code, + 'price' => $obj->price, + 'price_unit' => $obj->price_unit, + 'discount_group' => $obj->discount_group, + 'product_group' => $obj->product_group, + 'matchcode' => $obj->matchcode, + ); + $foundIds[$obj->rowid] = true; + } } $this->db->free($resql); } diff --git a/class/datanormparser.class.php b/class/datanormparser.class.php index 5187754..d066fd8 100644 --- a/class/datanormparser.class.php +++ b/class/datanormparser.class.php @@ -435,13 +435,23 @@ class DatanormParser $firstField = trim($parts[0] ?? ''); if ($firstField === 'A' && isset($parts[1]) && strlen(trim($parts[1])) <= 2) { - // Sonepar format with action code (N=New, L=Delete, A=Update) + // Sonepar/Kluxen format with action code (N=New, L=Delete, A=Update) // A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;LangTextKey // PE is at index 7 and is a CODE (0=1, 1=10, 2=100, 3=1000) + // Preis is at index 9 - in CENTS (e.g., 27800 = 278,00 €) $actionCode = strtoupper(trim($parts[1] ?? 'N')); $peCode = (int)trim($parts[7] ?? '0'); $priceUnit = self::convertPriceUnitCode($peCode); + // Price from A-record (for formats without separate DATPREIS file like Kluxen) + // Price is in cents, convert to euros + $priceRaw = trim($parts[9] ?? '0'); + $price = 0.0; + if (!empty($priceRaw) && is_numeric($priceRaw)) { + // Price is in cents (integer without decimal), convert to euros + $price = (float)$priceRaw / 100; + } + $article = array( 'article_number' => trim($parts[2] ?? ''), 'action_code' => $actionCode, // N=New, A=Update, L=Delete @@ -458,7 +468,7 @@ class DatanormParser 'manufacturer_name' => '', 'ean' => '', 'long_text' => '', - 'price' => 0, + 'price' => $price, // Price from A-record (in euros) ); } else { // Standard format: A;ArtNr;Matchcode;Kurztext1;Kurztext2;ME;PE;RabGrp;WG;... diff --git a/core/modules/modImportZugferd.class.php b/core/modules/modImportZugferd.class.php index 495e906..7c0ca62 100755 --- a/core/modules/modImportZugferd.class.php +++ b/core/modules/modImportZugferd.class.php @@ -76,7 +76,7 @@ class modImportZugferd extends DolibarrModules $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@importzugferd' // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '3.0'; + $this->version = '3.2'; // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; diff --git a/import.php b/import.php index c797175..529d1c1 100644 --- a/import.php +++ b/import.php @@ -391,6 +391,80 @@ if ($action == 'removeproduct' && $line_id > 0) { $import->fetch($id); } +// Add missing supplier prices from other catalogs +if ($action == 'addmissingprices' && $id > 0) { + $import->fetch($id); + $fk_product = GETPOSTINT('fk_product'); + $addSupplierPrices = GETPOST('add_supplier_prices', 'array'); + + if ($fk_product > 0 && !empty($addSupplierPrices)) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + require_once './class/datanorm.class.php'; + require_once './class/productmapping.class.php'; + + $addedCount = 0; + + foreach ($addSupplierPrices as $socId => $datanormId) { + // Fetch the Datanorm article + $datanorm = new Datanorm($db); + if ($datanorm->fetch($datanormId) > 0) { + $altSupplier = new Societe($db); + $altSupplier->fetch($socId); + + $purchasePrice = $datanorm->price; + if ($datanorm->price_unit > 1) { + $purchasePrice = $datanorm->price / $datanorm->price_unit; + } + + // Prepare extrafields + $extrafields = array(); + if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) { + $extrafields['options_kupferzuschlag'] = $datanorm->metal_surcharge; + } + if (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) { + $extrafields['options_preiseinheit'] = $datanorm->price_unit; + } + if (!empty($datanorm->product_group)) { + $extrafields['options_warengruppe'] = $datanorm->product_group; + } + + // Add supplier price + $prodfourn = new ProductFournisseur($db); + $prodfourn->id = $fk_product; + $result = $prodfourn->update_buyprice( + 1, $purchasePrice, $user, 'HT', $altSupplier, 0, + $datanorm->article_number, 19, + 0, 0, 0, 0, 0, 0, array(), '', + 0, 'HT', 1, '', + trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')), + !empty($datanorm->ean) ? $datanorm->ean : '', + !empty($datanorm->ean) ? 2 : 0, + $extrafields + ); + + if ($result > 0) { + // Create product mapping + $mapping = new ProductMapping($db); + $mapping->fk_soc = $socId; + $mapping->supplier_ref = $datanorm->article_number; + $mapping->fk_product = $fk_product; + $mapping->ean = $datanorm->ean; + $mapping->manufacturer_ref = $datanorm->manufacturer_ref; + $mapping->description = $datanorm->short_text1; + $mapping->create($user); + + $addedCount++; + } + } + } + + if ($addedCount > 0) { + setEventMessages($langs->trans('SupplierPricesAdded', $addedCount), null, 'mesgs'); + } + } + $action = 'edit'; +} + // Update supplier if ($action == 'setsupplier' && $id > 0) { $import->fetch($id); @@ -883,6 +957,70 @@ if ($action == 'createallfromdatanorm' && $id > 0) { 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); + + $altPurchasePrice = $altDatanorm->price; + if ($altDatanorm->price_unit > 1) { + $altPurchasePrice = $altDatanorm->price / $altDatanorm->price_unit; + } + + // Prepare extrafields + $altExtrafields = array(); + if (!empty($altDatanorm->metal_surcharge) && $altDatanorm->metal_surcharge > 0) { + $altExtrafields['options_kupferzuschlag'] = $altDatanorm->metal_surcharge; + } + if (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) { + $altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit; + } + if (!empty($altDatanorm->product_group)) { + $altExtrafields['options_warengruppe'] = $altDatanorm->product_group; + } + + // Add supplier price + $altProdfourn = new ProductFournisseur($db); + $altProdfourn->id = $existingProductId; + $altProdfourn->update_buyprice( + 1, $altPurchasePrice, $user, 'HT', $altSupplier, 0, + $altDatanorm->article_number, $lineObj->tax_percent ?: 19, + 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 + ); + + // 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 @@ -1025,6 +1163,71 @@ if ($action == 'createallfromdatanorm' && $id > 0) { $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])) { + 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); + + $altPurchasePrice = $altDatanorm->price; + if ($altDatanorm->price_unit > 1) { + $altPurchasePrice = $altDatanorm->price / $altDatanorm->price_unit; + } + + // Prepare extrafields for alternative supplier price + $altExtrafields = array(); + if (!empty($altDatanorm->metal_surcharge) && $altDatanorm->metal_surcharge > 0) { + $altExtrafields['options_kupferzuschlag'] = $altDatanorm->metal_surcharge; + } + if (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) { + $altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit; + } + if (!empty($altDatanorm->product_group)) { + $altExtrafields['options_warengruppe'] = $altDatanorm->product_group; + } + + // Add supplier price for alternative supplier + $altProdfourn = new ProductFournisseur($db); + $altProdfourn->id = $newproduct->id; + $altProdfourn->update_buyprice( + 1, // Quantity + $altPurchasePrice, // Price + $user, + 'HT', // Price base + $altSupplier, // Alternative supplier + 0, // Availability + $altDatanorm->article_number, // Supplier ref + $lineObj->tax_percent ?: 19, // VAT + 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 + ); + + // 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++; @@ -1091,14 +1294,15 @@ if ($action == 'previewdatanorm' && $id > 0) { continue; } - // Search in Datanorm database - $results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1); + // 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, 1); + $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']); @@ -1164,6 +1368,32 @@ if ($action == 'previewdatanorm' && $id > 0) { } } + // Build supplier alternatives array + $supplierAlternatives = array(); + foreach ($results as $altResult) { + $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, @@ -1183,7 +1413,8 @@ if ($action == 'previewdatanorm' && $id > 0) { 'copper_surcharge' => $copperSurchargeForPrice, 'existing_product_id' => $existingProductId, 'action' => $productAction, - 'new_ref' => 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number + 'new_ref' => 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number, + 'supplier_alternatives' => $supplierAlternatives ); } } @@ -1723,6 +1954,117 @@ if ($action == 'edit' && $import->id > 0) { print ''; print ''; print ''; + + // Check for missing supplier prices from other catalogs + if ($import->fk_soc > 0 && getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL')) { + // Get existing supplier prices for this product + $sqlExistingPrices = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlExistingPrices .= " WHERE fk_product = ".(int)$line->fk_product; + $resExistingPrices = $db->query($sqlExistingPrices); + $existingSupplierIds = array(); + if ($resExistingPrices) { + while ($objPrice = $db->fetch_object($resExistingPrices)) { + $existingSupplierIds[$objPrice->fk_soc] = true; + } + } + + // Get current supplier price for comparison + $currentSupplierPrice = 0; + if (isset($existingSupplierIds[$import->fk_soc])) { + $sqlCurrentPrice = "SELECT price FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlCurrentPrice .= " WHERE fk_product = ".(int)$line->fk_product; + $sqlCurrentPrice .= " AND fk_soc = ".(int)$import->fk_soc; + $sqlCurrentPrice .= " ORDER BY unitprice ASC LIMIT 1"; + $resCurrentPrice = $db->query($sqlCurrentPrice); + if ($resCurrentPrice && $db->num_rows($resCurrentPrice) > 0) { + $objCurrentPrice = $db->fetch_object($resCurrentPrice); + $currentSupplierPrice = $objCurrentPrice->price; + } + } + + // Search in all Datanorm catalogs for this article + $datanormSearch = new Datanorm($db); + $searchRef = !empty($line->supplier_ref) ? $line->supplier_ref : (!empty($line->ean) ? $line->ean : ''); + if (!empty($searchRef)) { + $allCatalogResults = $datanormSearch->searchByArticleNumber($searchRef, $import->fk_soc, true, 10); + + // Filter to only show suppliers without existing price + $missingSuppliers = array(); + foreach ($allCatalogResults as $catalogResult) { + if (!isset($existingSupplierIds[$catalogResult['fk_soc']])) { + // Load supplier name + $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'], + ); + } + } + + // Show missing suppliers with checkboxes + if (!empty($missingSuppliers)) { + print '