From 745fc68fc93e1d107ff64b5d09efec41a7e4cfc3 Mon Sep 17 00:00:00 2001 From: data Date: Wed, 25 Feb 2026 13:43:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Kabel-Preislogik=20f=C3=BCr=20verschied?= =?UTF-8?q?ene=20Lieferanten-Formate=20(v3.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue zentrale Funktion calculateCablePricing() für einheitliche Preislogik - Unterschiedliche Lieferanten-Formate: Sonepar (price_unit=1, Ring im Namen) vs Kluxen/Witte (price_unit=100) - Ringgröße-Erkennung: Ri100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m" - Cross-Catalog-Suche nur noch über EAN (verhindert Fehlzuordnungen) - EAN-Auto-Update aus ZUGFeRD mit automatischer Barcode-Typ-Erkennung (EAN8/13/UPC-A) - Neues Extrafield "produktpreis" für Materialpreis ohne Kupferzuschlag - Kupfergehalt-Berechnung: Aderanzahl × Querschnitt × 8.9 - Division durch Null abgesichert - Besseres Error-Handling für Extrafields Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 19 + README.md | 34 +- class/actions_importzugferd.class.php | 26 + class/datanorm.class.php | 115 ++-- class/zugferdimport.class.php | 26 + core/modules/modImportZugferd.class.php | 24 +- import.php | 782 ++++++++++++++++++------ langs/de_DE/importzugferd.lang | 6 + langs/en_US/importzugferd.lang | 6 + 9 files changed, 782 insertions(+), 256 deletions(-) mode change 100644 => 100755 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 index 70ce32b..da68b11 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. +## [3.8] - 2026-02-25 + +### Hinzugefügt +- **Kabel-Preisberechnung**: Zentrale Funktion `calculateCablePricing()` für einheitliche Preislogik +- **Kupfergehalt-Berechnung**: Automatische Berechnung aus Aderanzahl × Querschnitt × 8.9 +- **Ringgröße-Erkennung**: Unterstützt Ri100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m" +- **Extrafield "produktpreis"**: Speichert reinen Materialpreis ohne Kupferzuschlag (nur Kabel) +- **EAN-Auto-Update**: Barcodes aus ZUGFeRD-Rechnungen werden automatisch in Lieferantenpreise übernommen + +### Verbessert +- **Lieferanten-Formate**: Korrekte Unterscheidung zwischen Sonepar (price_unit=1, Ring im Namen) und Kluxen/Witte (price_unit=100) +- **Cross-Catalog-Suche**: Nur noch über EAN, nicht mehr über Artikelnummern (verhindert Fehlzuordnungen) +- **EAN-Barcode-Typ**: Automatische Erkennung (EAN8, EAN13, UPC-A) statt hardcoded EAN13 +- **Error-Handling**: Besseres Logging bei Extrafield-Fehlern + +### Behoben +- Division durch Null bei Preisberechnung abgesichert +- Mindestbestellmenge und Verpackungseinheit werden von existierenden Lieferantenpreisen übernommen + ## [3.7] - 2026-02-23 ### Hinzugefügt diff --git a/README.md b/README.md index 19f0cd5..c051d58 100755 --- a/README.md +++ b/README.md @@ -102,23 +102,29 @@ Available in: ## Version History -### 1.1 -- New persistent import workflow with database storage -- Manual product assignment via dropdown -- Product removal/reassignment -- Status "Pending" for imports requiring manual intervention -- Pending imports overview on upload page -- UN/ECE unit code translation (C62 → Stk., MTR → m, etc.) -- Batch import from folder or IMAP mailbox -- IMAP connection test with folder selection -- Product template feature (duplicate existing product) +See [CHANGELOG.md](CHANGELOG.md) for detailed version history. + +### 3.8 (Current) +- Improved cable pricing for different supplier formats (Sonepar vs Kluxen/Witte) +- Automatic ring size detection from product names (Ri100, Tr500, etc.) +- EAN auto-update from ZUGFeRD invoices with automatic barcode type detection +- New extrafield "produktpreis" for material price without copper surcharge +- Cross-catalog search now EAN-only (prevents mismatches) + +### 3.7 +- GlobalNotify integration for import notifications + +### 3.6 +- Cron job stability fixes and dedicated logging + +### 3.5 +- Automatic cron import from watch folder and IMAP + +### 3.0 +- Datanorm integration for article prices ### 1.0 - Initial release -- Basic ZUGFeRD/Factur-X import -- Automatic product matching -- Supplier detection -- Duplicate detection ## License diff --git a/class/actions_importzugferd.class.php b/class/actions_importzugferd.class.php index 8243f7d..fb77881 100755 --- a/class/actions_importzugferd.class.php +++ b/class/actions_importzugferd.class.php @@ -316,6 +316,32 @@ class ActionsImportZugferd $processed_line['product_ref'] = $product->ref; $processed_line['product_label'] = $product->label; } + + // Update supplier price with EAN from invoice if empty + $invoiceEan = !empty($line['product']['global_id']) ? trim($line['product']['global_id']) : ''; + $supplierRef = !empty($line['product']['seller_id']) ? $line['product']['seller_id'] : ''; + if (!empty($invoiceEan) && !empty($supplierRef) && ctype_digit($invoiceEan)) { + // Barcode-Typ basierend auf Länge bestimmen + $eanLen = strlen($invoiceEan); + if ($eanLen == 13) { + $barcodeType = 2; // EAN13 + } elseif ($eanLen == 8) { + $barcodeType = 1; // EAN8 + } elseif ($eanLen == 12) { + $barcodeType = 3; // UPC-A + } else { + $barcodeType = 0; // Unbekannt + } + + $sqlEan = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlEan .= " SET barcode = '" . $this->db->escape($invoiceEan) . "'"; + $sqlEan .= ", fk_barcode_type = " . (int)$barcodeType; + $sqlEan .= " WHERE fk_product = " . (int)$match['fk_product']; + $sqlEan .= " AND fk_soc = " . (int)$supplier_id; + $sqlEan .= " AND ref_fourn = '" . $this->db->escape($supplierRef) . "'"; + $sqlEan .= " AND (barcode IS NULL OR barcode = '')"; + $this->db->query($sqlEan); + } } else { $processed_line['needs_creation'] = true; } diff --git a/class/datanorm.class.php b/class/datanorm.class.php index 28806f6..50c1148 100755 --- a/class/datanorm.class.php +++ b/class/datanorm.class.php @@ -520,9 +520,22 @@ class Datanorm extends CommonObject if ($result > 0) { $results[] = $this->toArray(); $foundIds[$this->id] = true; - // Store EAN and manufacturer_ref for cross-catalog search + // Store EAN from Datanorm $foundEan = $this->ean; - $foundManufacturerRef = $this->manufacturer_ref; + + // If Datanorm has no EAN, try to get it from supplier price (barcode field) + if (empty($foundEan)) { + $sqlEan = "SELECT barcode FROM " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlEan .= " WHERE fk_soc = " . (int)$fk_soc; + $sqlEan .= " AND ref_fourn = '" . $this->db->escape($article_number) . "'"; + $sqlEan .= " AND barcode IS NOT NULL AND barcode != ''"; + $sqlEan .= " LIMIT 1"; + $resEan = $this->db->query($sqlEan); + if ($resEan && $this->db->num_rows($resEan) > 0) { + $objEan = $this->db->fetch_object($resEan); + $foundEan = $objEan->barcode; + } + } // If not searching all catalogs, return immediately if (!$searchAll) { @@ -531,24 +544,15 @@ class Datanorm extends CommonObject } } - // 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))) { + // If searchAll is enabled and we found article with EAN, + // search other catalogs using EAN ONLY (cross-catalog search) + // Note: Artikelnummern-Vergleich macht keinen Sinn über Kataloge hinweg + if ($searchAll && $fk_soc > 0 && !empty($foundEan)) { $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 .= " WHERE ean = '" . $this->db->escape($foundEan) . "'"; $sql .= " AND active = 1"; $sql .= " AND entity = " . (int) $conf->entity; $sql .= " AND fk_soc != " . (int) $fk_soc; // Exclude already found supplier @@ -588,55 +592,44 @@ class Datanorm extends CommonObject } } - // 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"; - $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element; - $sql .= " WHERE (article_number LIKE '" . $this->db->escape($article_number) . "%'"; - $sql .= " OR ean = '" . $this->db->escape($article_number) . "'"; - $sql .= " OR manufacturer_ref LIKE '" . $this->db->escape($article_number) . "%')"; - $sql .= " AND active = 1"; - $sql .= " AND entity = " . (int) $conf->entity; - - if ($fk_soc > 0 && !$searchAll) { + // Fallback: Search by EXACT article number match for the specified supplier only + // No LIKE search - cross-catalog comparisons only work via EAN + if ($fk_soc > 0 && empty($results)) { + $sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2,"; + $sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,"; + $sql .= " price, price_unit, discount_group, product_group, matchcode"; + $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE article_number = '" . $this->db->escape($article_number) . "'"; $sql .= " AND fk_soc = " . (int) $fk_soc; - } + $sql .= " AND active = 1"; + $sql .= " AND entity = " . (int) $conf->entity; + $sql .= " LIMIT 1"; - // ORDER BY clause - if ($fk_soc > 0 && $searchAll) { - // 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"; - } - - $sql .= " LIMIT " . (int) $limit; - - $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; + $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); } - $this->db->free($resql); } return $results; diff --git a/class/zugferdimport.class.php b/class/zugferdimport.class.php index 100e849..bfdda55 100755 --- a/class/zugferdimport.class.php +++ b/class/zugferdimport.class.php @@ -624,6 +624,32 @@ class ZugferdImport extends CommonObject if (!empty($match) && $match['fk_product'] > 0) { $fk_product = $match['fk_product']; $match_method = $match['method']; + + // Update supplier price with EAN from invoice if empty + $invoiceEan = !empty($line_data['product']['global_id']) ? trim($line_data['product']['global_id']) : ''; + $supplierRef = !empty($line_data['product']['seller_id']) ? $line_data['product']['seller_id'] : ''; + if (!empty($invoiceEan) && !empty($supplierRef) && ctype_digit($invoiceEan)) { + // Barcode-Typ basierend auf Länge bestimmen + $eanLen = strlen($invoiceEan); + if ($eanLen == 13) { + $barcodeType = 2; // EAN13 + } elseif ($eanLen == 8) { + $barcodeType = 1; // EAN8 + } elseif ($eanLen == 12) { + $barcodeType = 3; // UPC-A + } else { + $barcodeType = 0; // Unbekannt + } + + $sqlEan = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlEan .= " SET barcode = '" . $this->db->escape($invoiceEan) . "'"; + $sqlEan .= ", fk_barcode_type = " . (int)$barcodeType; + $sqlEan .= " WHERE fk_product = " . (int)$fk_product; + $sqlEan .= " AND fk_soc = " . (int)$supplier_id; + $sqlEan .= " AND ref_fourn = '" . $this->db->escape($supplierRef) . "'"; + $sqlEan .= " AND (barcode IS NULL OR barcode = '')"; + $this->db->query($sqlEan); + } } } diff --git a/core/modules/modImportZugferd.class.php b/core/modules/modImportZugferd.class.php index 836585c..bc1da66 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.7'; + $this->version = '3.8'; // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; @@ -616,6 +616,28 @@ class modImportZugferd extends DolibarrModules 'isModEnabled("importzugferd")' // enabled condition ); + // Add extrafield for product price without copper surcharge (only for cables) + $extrafields->addExtraField( + 'produktpreis', // attribute code + 'Produktpreis', // label (translation key) + 'price', // type (price field) + 115, // position + '24,8', // size + 'product_fournisseur_price', // element type + 0, // unique + 0, // required + '', // default value + '', // param + 1, // always editable + '', // permission + 1, // list (show in list) + 0, // printable + '', // totalizable + '', // langfile + 'importzugferd@importzugferd', // module + 'isModEnabled("importzugferd")' // enabled condition + ); + // Add extrafield for price unit (Preiseinheit) on supplier prices $extrafields->addExtraField( 'preiseinheit', // attribute code diff --git a/import.php b/import.php index 3cf83be..39ced3f 100755 --- a/import.php +++ b/import.php @@ -100,6 +100,241 @@ $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 * @@ -110,12 +345,13 @@ $message = ''; function datanormBuildSupplierPriceExtrafields($datanorm, $lineObj = null) { $extrafields = array(); - // Kupferzuschlag - if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) { - $extrafields['options_kupferzuschlag'] = $datanorm->metal_surcharge; - } elseif ($lineObj && !empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) { - $extrafields['options_kupferzuschlag'] = $lineObj->copper_surcharge; + + // 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; @@ -153,14 +389,48 @@ function datanormAddSupplierPrice($db, $productId, $datanorm, $supplier, $user, $supplierEanType = !empty($datanorm->ean) ? 2 : 0; $description = trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')); - return $prodfourn->update_buyprice( - 1, $purchasePrice, $user, 'HT', $supplier, 0, + // 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; } /** @@ -175,7 +445,7 @@ function datanormInsertPriceExtrafields($db, $priceId, $extrafields) if (empty($priceId) || empty($extrafields)) { return; } - $kupferzuschlag = !empty($extrafields['options_kupferzuschlag']) ? (float)$extrafields['options_kupferzuschlag'] : 'NULL'; + $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'; @@ -183,13 +453,22 @@ function datanormInsertPriceExtrafields($db, $priceId, $extrafields) $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) { - return; // Bereits vorhanden + // 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, kupferzuschlag, preiseinheit, warengruppe) VALUES ("; + $sql .= " (fk_object, produktpreis, preiseinheit, warengruppe) VALUES ("; $sql .= (int)$priceId.", "; - $sql .= ($kupferzuschlag === 'NULL' ? "NULL" : $kupferzuschlag).", "; + $sql .= ($produktpreis === 'NULL' ? "NULL" : $produktpreis).", "; $sql .= $preiseinheit.", "; $sql .= $warengruppe.")"; if (!$db->query($sql)) { @@ -701,33 +980,58 @@ if ($action == 'createfromdatanorm' && $line_id > 0) { // Description $newproduct->description = $datanorm->getFullDescription(); - // Prices - $purchasePrice = $datanorm->price; - if ($datanorm->price_unit > 1) { - $purchasePrice = $datanorm->price / $datanorm->price_unit; - } + // 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; - // 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 basis quantity differs - if ($datanorm->price_unit > 1) { - $copperSurchargeForPrice = $datanorm->metal_surcharge / $datanorm->price_unit; + // 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 } - } elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) { - $copperSurchargeForPrice = $lineObj->copper_surcharge; - // Normalize to per-unit if basis quantity differs - if (!empty($lineObj->copper_surcharge_basis_qty) && $lineObj->copper_surcharge_basis_qty > 1) { - $copperSurchargeForPrice = $lineObj->copper_surcharge / $lineObj->copper_surcharge_basis_qty; - } elseif (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { - $copperSurchargeForPrice = $lineObj->copper_surcharge / $lineObj->basis_quantity; + } + // 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); + } } } - // Selling price with markup: (purchase price + copper surcharge) × (1 + markup%) - $sellingPrice = ($purchasePrice + $copperSurchargeForPrice) * (1 + $markup / 100); + // 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; @@ -748,6 +1052,25 @@ if ($action == 'createfromdatanorm' && $line_id > 0) { $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); @@ -767,20 +1090,14 @@ if ($action == 'createfromdatanorm' && $line_id > 0) { // Prepare extrafields for supplier price $supplierPriceExtrafields = array(); - // Kupferzuschlag (metal surcharge) - händlerspezifisch - // Priorität: 1. Datanorm, 2. ZUGFeRD Import Line - if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) { - $supplierPriceExtrafields['options_kupferzuschlag'] = $datanorm->metal_surcharge; - } elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) { - // Kupferzuschlag aus ZUGFeRD-Rechnung - $supplierPriceExtrafields['options_kupferzuschlag'] = $lineObj->copper_surcharge; + // Produktpreis (reiner Materialpreis ohne Kupferzuschlag) - nur bei Kabeln + if ($isCable && $materialPrice > 0) { + $supplierPriceExtrafields['options_produktpreis'] = $materialPrice; } - // Preiseinheit - händlerspezifisch - // Priorität: 1. Datanorm, 2. ZUGFeRD basis_quantity - if (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) { - $supplierPriceExtrafields['options_preiseinheit'] = $datanorm->price_unit; + // Preiseinheit + if ($priceUnit > 1) { + $supplierPriceExtrafields['options_preiseinheit'] = $priceUnit; } elseif (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { - // Preiseinheit aus ZUGFeRD-Rechnung (z.B. 100 für "pro 100 Meter") $supplierPriceExtrafields['options_preiseinheit'] = $lineObj->basis_quantity; } // Warengruppe aus Datanorm @@ -788,10 +1105,10 @@ if ($action == 'createfromdatanorm' && $line_id > 0) { $supplierPriceExtrafields['options_warengruppe'] = $datanorm->product_group; } - // Add supplier price entry with EAN and extrafields + // Lieferantenpreis speichern (Gesamtpreis inkl. Kupferzuschlag für die Preiseinheit) $res = $prodfourn->update_buyprice( - 1, // Quantity - $purchasePrice, // Price + $priceUnit, // Quantity (Mindestmenge, z.B. 100 für 100m) + $totalPurchasePrice, // Price (Gesamtpreis für die Mindestmenge inkl. Kupfer) $user, 'HT', // Price base $supplier, // Supplier @@ -813,9 +1130,11 @@ if ($action == 'createfromdatanorm' && $line_id > 0) { 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 (kupferzuschlag, preiseinheit) + $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"; @@ -831,21 +1150,36 @@ if ($action == 'createfromdatanorm' && $line_id > 0) { $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 - $kupferzuschlag = !empty($supplierPriceExtrafields['options_kupferzuschlag']) ? (float)$supplierPriceExtrafields['options_kupferzuschlag'] : 'NULL'; - $preiseinheit = !empty($supplierPriceExtrafields['options_preiseinheit']) ? (int)$supplierPriceExtrafields['options_preiseinheit'] : 1; - $warengruppe = !empty($supplierPriceExtrafields['options_warengruppe']) ? "'".$db->escape($supplierPriceExtrafields['options_warengruppe'])."'" : 'NULL'; - $sqlInsertExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; - $sqlInsertExtra .= " (fk_object, kupferzuschlag, preiseinheit, warengruppe) VALUES ("; + $sqlInsertExtra .= " (fk_object, produktpreis, kupferzuschlag, preiseinheit, warengruppe) VALUES ("; $sqlInsertExtra .= (int)$priceId.", "; - $sqlInsertExtra .= ($kupferzuschlag === 'NULL' ? "NULL" : $kupferzuschlag).", "; - $sqlInsertExtra .= $preiseinheit.", "; - $sqlInsertExtra .= $warengruppe.")"; + $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); + } } } @@ -1085,28 +1419,55 @@ if ($action == 'createallfromdatanorm' && $id > 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->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 und Verpackungseinheit vom bestehenden Preis übernehmen + $altMinQty = 1; + $altPackaging = null; + $sqlAltExisting = "SELECT quantity, packaging FROM " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlAltExisting .= " WHERE fk_product = " . (int)$existingProductId; + $sqlAltExisting .= " AND quantity > 0 ORDER BY 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; + } + } + + // Preis berechnen - bei Kabeln ist Datanorm-Preis bereits Ringpreis! + $altCableText = $altDatanorm->short_text1 . ' ' . $altDatanorm->short_text2; + $altRingSize = extractCableRingSize($altCableText); + if ($altRingSize > 0) { + // Kabel: Stückpreis aus Ringpreis berechnen, dann auf minQty hochrechnen + $altUnitPrice = $altDatanorm->price / $altRingSize; + $altTotalPrice = $altUnitPrice * $altMinQty; + $altExtrafields['options_preiseinheit'] = $altRingSize; + } elseif (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) { + // Nicht-Kabel mit price_unit > 1 + $altUnitPrice = $altDatanorm->price / $altDatanorm->price_unit; + $altTotalPrice = $altUnitPrice * $altMinQty; + $altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit; + } else { + // Einzelstück + $altTotalPrice = $altDatanorm->price * $altMinQty; + } + // Add supplier price $altProdfourn = new ProductFournisseur($db); $altProdfourn->id = $existingProductId; - $altProdfourn->update_buyprice( - 1, $altPurchasePrice, $user, 'HT', $altSupplier, 0, + $altResult = $altProdfourn->update_buyprice( + $altMinQty, $altTotalPrice, $user, 'HT', $altSupplier, 0, $altDatanorm->article_number, $lineObj->tax_percent ?: 19, 0, 0, 0, 0, 0, 0, array(), '', 0, 'HT', 1, '', @@ -1116,6 +1477,14 @@ if ($action == 'createallfromdatanorm' && $id > 0) { $altExtrafields ); + // Verpackungseinheit nachträglich setzen + if ($altResult > 0 && !empty($altPackaging)) { + $sqlAltPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlAltPkg .= " SET packaging = " . (float)$altPackaging; + $sqlAltPkg .= " WHERE rowid = " . (int)$altResult; + $db->query($sqlAltPkg); + } + // Create product mapping $altMapping = new ProductMapping($db); $altMapping->fk_soc = $altSocId; @@ -1186,26 +1555,37 @@ if ($action == 'createallfromdatanorm' && $id > 0) { // Prepare extrafields for supplier price $supplierPriceExtrafields = array(); - // Kupferzuschlag - Priorität: 1. Datanorm, 2. ZUGFeRD - if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) { - $supplierPriceExtrafields['options_kupferzuschlag'] = $datanorm->metal_surcharge; - } elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) { - $supplierPriceExtrafields['options_kupferzuschlag'] = $lineObj->copper_surcharge; + // 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. Datanorm, 2. ZUGFeRD basis_quantity - if (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) { - $supplierPriceExtrafields['options_preiseinheit'] = $datanorm->price_unit; - } elseif (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { + // 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; } - $prodfourn->update_buyprice( - 1, // Quantity - $purchasePrice, // Price + // 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 @@ -1245,21 +1625,32 @@ if ($action == 'createallfromdatanorm' && $id > 0) { $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 - $kupferzuschlag = !empty($supplierPriceExtrafields['options_kupferzuschlag']) ? (float)$supplierPriceExtrafields['options_kupferzuschlag'] : 'NULL'; - $preiseinheit = !empty($supplierPriceExtrafields['options_preiseinheit']) ? (int)$supplierPriceExtrafields['options_preiseinheit'] : 1; - $warengruppe = !empty($supplierPriceExtrafields['options_warengruppe']) ? "'".$db->escape($supplierPriceExtrafields['options_warengruppe'])."'" : 'NULL'; - $sqlInsertExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; - $sqlInsertExtra .= " (fk_object, kupferzuschlag, preiseinheit, warengruppe) VALUES ("; + $sqlInsertExtra .= " (fk_object, produktpreis, preiseinheit, warengruppe) VALUES ("; $sqlInsertExtra .= (int)$priceId.", "; - $sqlInsertExtra .= ($kupferzuschlag === 'NULL' ? "NULL" : $kupferzuschlag).", "; + $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); + } } } @@ -1295,8 +1686,8 @@ if ($action == 'createallfromdatanorm' && $id > 0) { // 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->metal_surcharge) && $altDatanorm->metal_surcharge > 0 && !empty($altDatanorm->price)) { + $altExtrafields['options_produktpreis'] = $altDatanorm->price; } if (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) { $altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit; @@ -1479,8 +1870,28 @@ if ($action == 'previewdatanorm' && $id > 0) { } // 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']); @@ -1945,6 +2356,10 @@ if ($action == 'edit' && $import->id > 0) { // Line items print '
'; + print '
'; + print ''; + print ''; + print ''; print '
'; print ''; print ''; @@ -1967,6 +2382,7 @@ if ($action == 'edit' && $import->id > 0) { $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); @@ -1995,21 +2411,27 @@ if ($action == 'edit' && $import->id > 0) { if ($hasProduct && $import->fk_soc > 0) { require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; $productFourn = new ProductFournisseur($db); - $result = $productFourn->find_min_price_product_fournisseur($line->fk_product, 1, $import->fk_soc); + // 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_price > 0) { - $dolibarrPrice = $productFourn->fourn_price; + 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 - $dolibarrPrice; - $priceDiffPercent = ($dolibarrPrice > 0) ? (($priceDiff / $dolibarrPrice) * 100) : 0; + $priceDiff = $zugferdPrice - $dolibarrUnitPrice; + $priceDiffPercent = ($dolibarrUnitPrice > 0) ? (($priceDiff / $dolibarrUnitPrice) * 100) : 0; // Accumulate for summary - $lineDolibarrTotal = $dolibarrPrice * $line->quantity; + $lineDolibarrTotal = $dolibarrUnitPrice * $line->quantity; $totalDolibarrHT += $lineDolibarrTotal; $hasDolibarrPrices = true; $matchedLinesCount++; - print price($dolibarrPrice); + print price($dolibarrUnitPrice); if (abs($priceDiffPercent) >= 0.01) { $threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10); @@ -2116,18 +2538,12 @@ if ($action == 'edit' && $import->id > 0) { } } - // EAN-Quellen: 1. Lieferantenpreis-Barcode, 2. Import-Zeile EAN - $searchEan = !empty($supplierEan) ? $supplierEan : (!empty($line->ean) ? $line->ean : ''); - + // Suche mit Lieferanten-Artikelnummer (die EAN wird intern für Cross-Catalog verwendet) $datanormSearch = new Datanorm($db); $allCatalogResults = array(); - // Primaer: EAN-Suche in Datanorm-Katalogen (zuverlaessigste Methode) - if (!empty($searchEan)) { - $allCatalogResults = $datanormSearch->searchByArticleNumber($searchEan, $import->fk_soc, true, 10); - } - // Fallback: Lieferanten-Artikelnummer wenn EAN nichts fand - if (empty($allCatalogResults) && !empty($line->supplier_ref)) { + // 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); } @@ -2158,19 +2574,52 @@ if ($action == 'edit' && $import->id > 0) { } if (!empty($missingSuppliers)) { - // Hinweis in der Zeile anzeigen - print ' '.count($missingSuppliers).''; + // Inline-Anzeige der fehlenden Lieferantenpreise direkt bei der Produktzeile + $toggleId = 'missing_inline_'.$line->id; + $missingCount = count($missingSuppliers); - // Duplikate vermeiden (gleiches Produkt auf mehreren Rechnungszeilen) - if (!isset($allMissingPrices[$line->fk_product])) { - $allMissingPrices[$line->fk_product] = array( - 'product_id' => $line->fk_product, - 'product_ref' => $product->ref, - 'product_label' => $product->label, - 'current_price' => $currentSupplierPrice, - 'missing' => $missingSuppliers, - ); + print '
'; + print '
'; + print ''; + print $langs->trans('MissingSupplierPrices'); + print ' '.$missingCount.''; + print ' '; + print '
'; + + // Aufklappbarer Bereich (Standard: sichtbar/aufgeklappt) + print '
'; + + foreach ($missingSuppliers as $missing) { + $priceDiffHtml = ''; + if ($currentSupplierPrice > 0) { + $pDiff = $missing['purchase_price'] - $currentSupplierPrice; + $pDiffPercent = ($pDiff / $currentSupplierPrice) * 100; + if ($pDiff < 0) { + $priceDiffHtml = ' '.number_format(abs($pDiffPercent), 1).'%'; + } elseif ($pDiff > 0) { + $priceDiffHtml = ' +'.number_format($pDiffPercent, 1).'%'; + } else { + $priceDiffHtml = ' ='; + } + } + + // Wert: productId,socId,datanormId + $cbValue = $line->fk_product.','.$missing['fk_soc'].','.$missing['datanorm_id']; + print '
'; + print ''; + print '
'; } + print '
'; // End toggleable div + print '
'; // End inline box + + // Track for global actions + $hasMissingPrices = true; } } } @@ -2324,77 +2773,43 @@ if ($action == 'edit' && $import->id > 0) { print '
'; print '
'; - // Fehlende Lieferantenpreise - konsolidierter Bereich - if (!empty($allMissingPrices)) { - print '
'; - print '
'; - print ''.$langs->trans('MissingSupplierPrices'); - $totalMissing = 0; - foreach ($allMissingPrices as $mp) { - $totalMissing += count($mp['missing']); - } - print ' '.$totalMissing.''; - print '
'; + // Aktionsbereich für fehlende Lieferantenpreise (wenn vorhanden) + if ($hasMissingPrices) { + print '
'; + print '
'; - print ''; - print ''; - print ''; - print ''; + // Toggle Buttons + print ''.$langs->trans('MissingSupplierPrices').''; + print ''; + print ''.$langs->trans('ExpandAll'); + print ''; + print ''; + print ''.$langs->trans('CollapseAll'); + print ''; - foreach ($allMissingPrices as $mpData) { - print '
'; - print '
'; - print ''; - print dol_escape_htmltag($mpData['product_ref'].' - '.$mpData['product_label']); - if ($mpData['current_price'] > 0) { - print ' ('.price($mpData['current_price']).')'; - } - print '
'; + // Checkbox Buttons + print ''; + print ''; + print ''.$langs->trans('SelectAll'); + print ''; + print ''; + print ''; + print ''.$langs->trans('DeselectAll'); + print ''; - foreach ($mpData['missing'] as $missing) { - $priceDiffHtml = ''; - if ($mpData['current_price'] > 0) { - $pDiff = $missing['purchase_price'] - $mpData['current_price']; - $pDiffPercent = ($pDiff / $mpData['current_price']) * 100; - if ($pDiff < 0) { - $priceDiffHtml = ' '.number_format(abs($pDiffPercent), 1).'%'; - } elseif ($pDiff > 0) { - $priceDiffHtml = ' +'.number_format($pDiffPercent, 1).'%'; - } else { - $priceDiffHtml = ' ='; - } - } - - // Wert: productId,socId,datanormId - $cbValue = $mpData['product_id'].','.$missing['fk_soc'].','.$missing['datanorm_id']; - print '
'; - print ''; - print '
'; - } - print '
'; - } - - // Alle auswaehlen / Keine auswaehlen + Submit - print '
'; - print ''.$langs->trans('SelectAll').''; - print ' / '; - print ''.$langs->trans('DeselectAll').''; - print '   '; + // Submit Button + print ''; print ''; - print '
'; + print ''; - print ''; + print '
'; print '
'; } + print ''; // End missing_prices_form + // Datanorm Preview Section (shown when preview action was triggered) if (!empty($datanormPreviewMatches)) { print '
'; @@ -2483,14 +2898,20 @@ if ($action == 'edit' && $import->id > 0) { // 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 ''; print ''; // Empty checkbox column print ''; print '
'; - print '
'; + // Header mit Anzahl und Toggle-Button + print '
'; print ''.$langs->trans('SupplierAlternatives'); - print ' '.count($match['supplier_alternatives']).' '.$langs->trans('Suppliers').''; + print ' '.$altCount.' '.$langs->trans('Suppliers').''; + print ' '; print '
'; + // Aufklappbarer Bereich (Standard: sichtbar) + print '
'; print ''; print ''; @@ -2593,7 +3014,8 @@ if ($action == 'edit' && $import->id > 0) { print '
'; print ' '.$langs->trans('SelectSuppliersForPurchasePrices'); print '
'; - print ''; + print ''; // End toggleable div + print ''; // End white box print ''; print ''; } diff --git a/langs/de_DE/importzugferd.lang b/langs/de_DE/importzugferd.lang index 17ababb..297951f 100755 --- a/langs/de_DE/importzugferd.lang +++ b/langs/de_DE/importzugferd.lang @@ -451,6 +451,8 @@ LabelChange = Namensänderung # Kupferzuschlag = Kupferzuschlag KupferzuschlagHelp = Metallzuschlag pro Einheit (wird aus Rechnungen extrahiert) +Produktpreis = Produktpreis +ProduktpreisHelp = Reiner Materialpreis ohne Kupferzuschlag (nur bei Kabeln) Preiseinheit = Preiseinheit PreiseinheitHelp = Anzahl Einheiten pro Preis (z.B. 100 = Preis pro 100 Stück) Warengruppe = Warengruppe @@ -489,3 +491,7 @@ MoreExpensiveBy = %s%% teurer RefreshProductListHelp = Produktlisten neu laden (nach Anlage neuer Produkte) SelectAll = Alle auswählen DeselectAll = Keine auswählen + +# UI Buttons +ExpandAll = Alle aufklappen +CollapseAll = Alle zuklappen diff --git a/langs/en_US/importzugferd.lang b/langs/en_US/importzugferd.lang index a3252c0..aa7a7b8 100755 --- a/langs/en_US/importzugferd.lang +++ b/langs/en_US/importzugferd.lang @@ -389,6 +389,8 @@ NotifyEmail = Recipient email # Kupferzuschlag = Copper Surcharge KupferzuschlagHelp = Metal surcharge per unit (extracted from invoices) +Produktpreis = Material Price +ProduktpreisHelp = Material price without copper surcharge (cables only) Preiseinheit = Price Unit PreiseinheitHelp = Number of units per price (e.g. 100 = price per 100 pieces) Warengruppe = Product Group @@ -420,3 +422,7 @@ MoreExpensiveBy = %s%% more expensive RefreshProductListHelp = Refresh product lists (after creating new products) SelectAll = Select all DeselectAll = Deselect all + +# UI Buttons +ExpandAll = Expand all +CollapseAll = Collapse all