Compare commits

...

No commits in common. "main" and "v3.3" have entirely different histories.
main ... v3.3

32 changed files with 19585 additions and 2134 deletions

Binary file not shown.

40
.claude/settings.local.json Executable file
View file

@ -0,0 +1,40 @@
{
"permissions": {
"allow": [
"Bash(pdfdetach:*)",
"Bash(python3:*)",
"Bash(xmllint:*)",
"Bash(php -r:*)",
"Bash(chmod:*)",
"Bash(cut:*)",
"Bash(grep:*)",
"Bash(mysql:*)",
"Bash(ls:*)",
"Bash(php:*)",
"Bash(for module in /srv/http/dolibarr/custom/*/core/modules/mod*.php)",
"Bash(do echo \"=== $module ===\" grep -A5 \"''js''\" \"$module\")",
"Bash(done)",
"Bash(find:*)",
"Bash(chown:*)",
"Bash(sudo chown:*)",
"Bash(composer show:*)",
"Bash(journalctl:*)",
"Bash(sudo tail:*)",
"Bash(strings:*)",
"Bash(qpdf --show-object=all:*)",
"Bash(pdftk:*)",
"Bash(pdfinfo:*)",
"Bash(exiftool:*)",
"Bash(pacman:*)",
"Bash(git init:*)",
"Bash(git remote add:*)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git tag:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)"
]
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,145 +0,0 @@
# Changelog
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## [5.7] - 2026-03-04
### Behoben
- **PDF-Pfad bei Cron/Batch-Import**: PDFs werden jetzt korrekt nach `/imports/{id}/{filename}` gespeichert
- Problem: Cron speicherte nach `/imports/{ref}_{filename}`, aber beim Erstellen der Rechnung wurde nach `/imports/{id}/{filename}` gesucht
- Lösung: Einheitlicher Pfad für manuellen und Cron-Import
- **Fallback für alte PDF-Pfade**: Beim Anhängen an Lieferantenrechnung wird auch im alten Pfadformat gesucht
- Ermöglicht korrekte Verarbeitung von Imports die vor dem Fix erstellt wurden
- Sucht zuerst `/imports/{id}/{filename}`, dann `/imports/{ref}_{filename}`
## [5.5] - 2026-03-03
### Behoben
- **Kupferzuschlag-Skalierung in Massenaktualisierung**: Kupferzuschlag wird jetzt korrekt skaliert wenn Dolibarr-Mindestmenge von Datanorm-Preiseinheit abweicht
- Problem: Cu für 50m wurde direkt zu Datanorm-Preis für 100m addiert
- Lösung: Cu wird erst auf Stückpreis umgerechnet (`cu_per_unit = Cu / quantity`), dann auf Datanorm-PE skaliert
- **Steuersatz bei Preisübernahme**: `tva_tx` wird jetzt korrekt beibehalten statt auf 0 gesetzt
- Direktes SQL-UPDATE statt `update_buyprice()` um alle Felder zu erhalten
- **Preise auf 2 Dezimalstellen**: Gesamtpreis und Stückpreis werden auf 2 Nachkommastellen gerundet
### Hinzugefügt
- **Filter für Preisrichtung**: Neue Checkboxen "Preise rauf" und "Preise runter" in Massenaktualisierung
- Ermöglicht gezieltes Filtern nach Preiserhöhungen oder -senkungen
- **Filter-Persistenz**: Alle Filter (inkl. hide_cables, filter_price_up, filter_price_down) bleiben nach Preisübernahme erhalten
- **Alternative Datanorm-Preise verbessert**: Beim Import von Rechnungen mit alternativen Datanorm-Katalogen werden jetzt übernommen:
- Mindestmenge vom vorhandenen/Hauptpreis
- Verpackungseinheit vom vorhandenen/Hauptpreis
- Steuersatz vom vorhandenen/Hauptpreis
- kaufmenge-Extrafield (nur wenn numerisch und > 0)
- **Extrafield kaufmenge sichtbar**: Feld wird jetzt in Formularen angezeigt (`list = 1`)
### Geändert
- **Kupferzuschlag nicht automatisch gesetzt**: Bei Datanorm-Import wird kupferzuschlag NICHT mehr gesetzt - wird von separatem Modul berechnet
### Technisch
- Kupferzuschlag-Berechnung: `cu_for_price_unit = (kupferzuschlag / effective_quantity) * price_unit`
- kaufmenge-Validierung: `trim() !== '' && is_numeric() && (int) > 0`
## [4.2] - 2026-03-02
### Behoben
- **PDF-Anhänge**: ZUGFeRD-PDFs werden jetzt korrekt an Lieferantenrechnungen angehängt
- Problem: PDF wurde nur ins Dateisystem kopiert, nicht in ECM-Datenbank registriert
- Lösung: `EcmFiles`-Eintrag wird erstellt für korrekte Verknüpfung mit Rechnung
- Wichtig: Bei Rechnungsvalidierung wird PDF automatisch mitverschoben
### Hinzugefügt
- **Bezeichnung in Rechnungsliste**: Teuerster Artikel wird als Bezeichnung der Lieferantenrechnung gesetzt
- Erleichtert schnelle Identifikation in der Rechnungsliste
- Spalte "Bezeichnung" muss in Liste aktiviert sein
## [4.0] - 2026-03-01
### Behoben
- **Verschachtelte HTML-Forms**: "Ausgewählte Preise hinzufügen" funktionierte nicht, weil Browser verschachtelte `<form>`-Elemente nicht unterstützen. Lösung: HTML5 `form`-Attribut
- **Stückpreis-Anzeige**: Einkaufspreise zeigen jetzt den Stückpreis statt Gesamtpreis (z.B. 0,16 statt 16,32 bei 100 Stk.)
- **Preisvergleich**: Fehlende Lieferantenpreise werden korrekt auf Stückpreis-Basis verglichen
- **DATPREIS-Kommentare**: Feld korrekt als Rabattkennzeichen dokumentiert (war fälschlich als PE-Code beschrieben)
### Verbessert
- **Feedback bei Preishinzufügen**: Zeigt Erfolgs- und Fehlermeldungen nach dem Hinzufügen von Lieferantenpreisen
- **Mengenkontext**: Bei Mengenstaffel wird zusätzlich der Gesamtpreis mit Stückzahl angezeigt (z.B. "0,16 (16,32/100Stk.)")
### Hinweis
- `uk_product_barcode` UNIQUE KEY auf `product_fournisseur_price` muss entfernt werden falls vorhanden (mehrere Lieferanten dürfen gleichen EAN haben)
## [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
- **GlobalNotify Integration**: Benachrichtigungen über das zentrale GlobalNotify-Modul
- Import-Fehler: Warnung bei fehlgeschlagenen Importen
- Rechnungen zur Prüfung: Aktion wenn neue Rechnungen warten
- IMAP-Fehler: Warnung wenn E-Mail Postfach nicht erreichbar
- Exception/Fatal: Sofortige Benachrichtigung bei Abstürzen
- **Helper-Funktion**: `notify()` für sichere GlobalNotify-Nutzung mit Fallback
### Hinweis
GlobalNotify ist optional. Ohne das Modul werden Benachrichtigungen ins Dolibarr-Log geschrieben.
## [3.6] - 2026-02-23
### Behoben
- **Cron-Job Fix**: Fehlendes `require_once` für `admin.lib.php` hinzugefügt - verhinderte das Speichern des letzten Laufzeitpunkts
- Cron-Job lief in Endlosschleife weil `dolibarr_set_const()` nicht gefunden wurde
### Hinzugefügt
- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/importzugferd/logs/cron_importzugferd.log`
- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie
- **Detailliertes Logging**: Zeigt jeden Schritt des Import-Prozesses (Ordner-Zugriff, PDF-Scan, IMAP-Status)
### Verbessert
- Robustere Fehlerbehandlung mit try/catch für Exceptions und Throwables
- IMAP-Import wird nur ausgeführt wenn tatsächlich konfiguriert
## [3.5] - 2026-02-15
### Hinzugefügt
- Automatischer Cron-Import aus Watch-Folder
- IMAP-Mailbox-Unterstützung für E-Mail-Rechnungen
- Konfigurierbare Import-Frequenz (stündlich, täglich, wöchentlich)
- Archiv- und Fehler-Ordner für verarbeitete Dateien
## [3.0] - 2026-02-01
### Hinzugefügt
- ZUGFeRD/Factur-X PDF-Parsing
- Automatische Lieferanten-Erkennung
- Rechnungsvorschau vor Import
- Datanorm-Integration für Artikelpreise
## [2.0] - 2026-01-15
### Hinzugefügt
- Basis-Import von ZUGFeRD-Rechnungen
- Manuelle Datei-Auswahl
- Integration in Lieferantenrechnungen
## [1.0] - 2026-01-01
### Erste Version
- Grundlegende ZUGFeRD-Erkennung
- XML-Extraktion aus PDF

View file

@ -1,107 +1,43 @@
# Changelog
# CHANGELOG MODULE IMPORTZUGFERD FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## 3.2
## [4.2] - 2026-03-02
### 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
### Behoben
- **PDF-Anhänge**: ZUGFeRD-PDFs werden jetzt korrekt an Lieferantenrechnungen angehängt
- Problem: PDF wurde nur ins Dateisystem kopiert, nicht in ECM-Datenbank registriert
- Lösung: `EcmFiles`-Eintrag wird erstellt für korrekte Verknüpfung mit Rechnung
- Wichtig: Bei Rechnungsvalidierung wird PDF automatisch mitverschoben
### 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)
### Hinzugefügt
- **Bezeichnung in Rechnungsliste**: Teuerster Artikel wird als Bezeichnung der Lieferantenrechnung gesetzt
- Erleichtert schnelle Identifikation in der Rechnungsliste
- Spalte "Bezeichnung" muss in Liste aktiviert sein
### Hinweise
- Kluxen-Katalog enthaelt nur Listenpreise (UVP), keine Netto-Einkaufspreise
- Cross-Katalog-Suche erfordert aktivierte Einstellung "In allen Lieferanten-Katalogen suchen"
## [4.0] - 2026-03-01
## 2.1
### Behoben
- **Verschachtelte HTML-Forms**: "Ausgewählte Preise hinzufügen" funktionierte nicht, weil Browser verschachtelte `<form>`-Elemente nicht unterstützen. Lösung: HTML5 `form`-Attribut
- **Stückpreis-Anzeige**: Einkaufspreise zeigen jetzt den Stückpreis statt Gesamtpreis (z.B. 0,16 statt 16,32 bei 100 Stk.)
- **Preisvergleich**: Fehlende Lieferantenpreise werden korrekt auf Stückpreis-Basis verglichen
- **DATPREIS-Kommentare**: Feld korrekt als Rabattkennzeichen dokumentiert (war fälschlich als PE-Code beschrieben)
### Bugfixes
- Rechnungsimport: Preise wurden falsch als Brutto (TTC) statt Netto (HT) behandelt - korrigierte Parameterreihenfolge in addline()
- Datanorm Massenaktualisierung: Lieferantenauswahl ging nach Aktionen verloren - Redirects hinzugefuegt
- Datanorm Massenaktualisierung: "Alle Aenderungen uebernehmen" Button war nicht sichtbar ohne Suchergebnisse
- Datanorm Massenaktualisierung: Filter-Auswahl (Preis/Beschreibung/Bezeichnung) wurde bei "Alle hinzufuegen" ignoriert
- ProductFournisseur::update_buyprice erwartet Societe-Objekt, nicht Integer-ID
### Verbessert
- **Feedback bei Preishinzufügen**: Zeigt Erfolgs- und Fehlermeldungen nach dem Hinzufügen von Lieferantenpreisen
- **Mengenkontext**: Bei Mengenstaffel wird zusätzlich der Gesamtpreis mit Stückzahl angezeigt (z.B. "0,16 (16,32/100Stk.)")
### Verbesserungen
- Bestaetungsdialog fuer Massenaktionen verwendet jetzt Dolibarr jQuery UI Dialog statt JavaScript confirm()
- Manuelles Metallzuschlag-Eingabefeld entfernt (nicht mehr benoetigt - Kupferzuschlag wird aus ZUGFeRD XML extrahiert)
- Ausstehende Aenderungen werden immer angezeigt wenn vorhanden, unabhaengig von Suchergebnissen
### Hinweis
- `uk_product_barcode` UNIQUE KEY auf `product_fournisseur_price` muss entfernt werden falls vorhanden (mehrere Lieferanten dürfen gleichen EAN haben)
## 2.0
## [3.8] - 2026-02-25
- Datanorm 4.0/5.0 Katalog-Import
- Kupferzuschlag-Extraktion aus ZUGFeRD XML (AllowanceCharge)
- Automatischer Preisvergleich zwischen Datanorm und aktuellen Einkaufspreisen
- Massenaktualisierung von Produktpreisen und Beschreibungen
- Aenderungsprotokoll fuer Preisanpassungen
### 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
## 1.0
### 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
- **GlobalNotify Integration**: Benachrichtigungen über das zentrale GlobalNotify-Modul
- Import-Fehler: Warnung bei fehlgeschlagenen Importen
- Rechnungen zur Prüfung: Aktion wenn neue Rechnungen warten
- IMAP-Fehler: Warnung wenn E-Mail Postfach nicht erreichbar
- Exception/Fatal: Sofortige Benachrichtigung bei Abstürzen
- **Helper-Funktion**: `notify()` für sichere GlobalNotify-Nutzung mit Fallback
### Hinweis
GlobalNotify ist optional. Ohne das Modul werden Benachrichtigungen ins Dolibarr-Log geschrieben.
## [3.6] - 2026-02-23
### Behoben
- **Cron-Job Fix**: Fehlendes `require_once` für `admin.lib.php` hinzugefügt - verhinderte das Speichern des letzten Laufzeitpunkts
- Cron-Job lief in Endlosschleife weil `dolibarr_set_const()` nicht gefunden wurde
### Hinzugefügt
- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/importzugferd/logs/cron_importzugferd.log`
- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie
- **Detailliertes Logging**: Zeigt jeden Schritt des Import-Prozesses (Ordner-Zugriff, PDF-Scan, IMAP-Status)
### Verbessert
- Robustere Fehlerbehandlung mit try/catch für Exceptions und Throwables
- IMAP-Import wird nur ausgeführt wenn tatsächlich konfiguriert
## [3.5] - 2026-02-15
### Hinzugefügt
- Automatischer Cron-Import aus Watch-Folder
- IMAP-Mailbox-Unterstützung für E-Mail-Rechnungen
- Konfigurierbare Import-Frequenz (stündlich, täglich, wöchentlich)
- Archiv- und Fehler-Ordner für verarbeitete Dateien
## [3.0] - 2026-02-01
### Hinzugefügt
- ZUGFeRD/Factur-X PDF-Parsing
- Automatische Lieferanten-Erkennung
- Rechnungsvorschau vor Import
- Datanorm-Integration für Artikelpreise
## [2.0] - 2026-01-15
### Hinzugefügt
- Basis-Import von ZUGFeRD-Rechnungen
- Manuelle Datei-Auswahl
- Integration in Lieferantenrechnungen
## [1.0] - 2026-01-01
### Erste Version
- Grundlegende ZUGFeRD-Erkennung
- XML-Extraktion aus PDF
Initial version

View file

@ -102,39 +102,23 @@ Available in:
## Version History
See [CHANGELOG.md](CHANGELOG.md) for detailed version history.
### 5.7 (Current)
- Fixed PDF path for Cron/Batch imports - now correctly saved to `/imports/{id}/{filename}`
- Added fallback for old PDF paths when attaching to supplier invoices
### 5.5
- Fixed copper surcharge scaling in mass update (different quantities between Dolibarr and Datanorm)
- Fixed VAT rate preservation when updating prices
- New filters for price direction (up/down) in mass update
- Alternative Datanorm prices inherit min quantity, packaging, VAT, and kaufmenge from existing prices
- Copper surcharge is NOT set by import - calculated by separate module
### 4.2
- PDF attachments properly linked to supplier invoices via ECM
- Most expensive item shown as invoice description
### 3.8
- 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
### 3.7
- GlobalNotify integration for import notifications
### 3.5
- Automatic cron import from watch folder and IMAP
### 3.0
- Datanorm integration for article prices
### 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)
### 1.0
- Initial release
- Basic ZUGFeRD/Factur-X import
- Automatic product matching
- Supplier detection
- Duplicate detection
## License

BIN
bin/module_importzugferd-2.2.zip Executable file

Binary file not shown.

BIN
bin/module_importzugferd-2.3.zip Executable file

Binary file not shown.

BIN
bin/module_importzugferd-2.4.zip Executable file

Binary file not shown.

BIN
bin/module_importzugferd-2.5.zip Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -244,13 +244,6 @@ if (!empty($object->xml_content)) {
// Format XML for better readability using class method
$formattedXml = ZugferdImport::formatXmlForDisplay($object->xml_content);
// XML Syntax-Highlighting
$highlightedXml = dol_escape_htmltag($formattedXml);
// Tag-Namen (oeffnend und schliessend)
$highlightedXml = preg_replace('/(&lt;\/?)([\w:.-]+)/', '$1<span style="color:#2271b1;font-weight:bold;">$2</span>', $highlightedXml);
// Attribut-Namen und -Werte
$highlightedXml = preg_replace('/ ([\w:.-]+)(=)(&quot;)(.*?)(&quot;)/', ' <span style="color:#a83a32;">$1</span>$2<span style="color:#2e7d32;">$3$4$5</span>', $highlightedXml);
print '<br>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
@ -261,8 +254,8 @@ if (!empty($object->xml_content)) {
print '<td colspan="2">';
print '<a href="#" onclick="jQuery(\'#xmlcontent\').toggle(); return false;" class="butAction">'.$langs->trans('ClickToExpand').'</a>';
print '<div id="xmlcontent" style="display: none; margin-top: 10px;">';
print '<pre style="max-height: 600px; overflow: auto; background: #f8f9fa; padding: 12px; border: 1px solid #dee2e6; border-radius: 4px; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word;">';
print $highlightedXml;
print '<pre style="max-height: 500px; overflow: auto; background: #f5f5f5; padding: 10px; font-size: 11px; white-space: pre-wrap; word-wrap: break-word;">';
print dol_escape_htmltag($formattedXml);
print '</pre>';
print '</div>';
print '</td>';

View file

@ -316,32 +316,6 @@ 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;
}

View file

@ -14,7 +14,6 @@
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
dol_include_once('/importzugferd/class/zugferdparser.class.php');
dol_include_once('/importzugferd/class/zugferdimport.class.php');
@ -61,67 +60,6 @@ class CronImportZugferd
*/
public $error_count = 0;
/**
* @var string Path to cron log file
*/
private $cronLogFile = '';
/**
* @var float Start time of cron execution
*/
private $startTime = 0;
/**
* Send notification via GlobalNotify (if available)
*
* @param string $type 'error', 'warning', 'info', 'action'
* @param string $title Title
* @param string $message Message
* @param string $actionUrl URL for action button
* @param string $actionLabel Label for action button
* @return bool True if sent via GlobalNotify
*/
protected function notify($type, $title, $message, $actionUrl = '', $actionLabel = '')
{
if (!isModEnabled('globalnotify')) {
dol_syslog("ImportZugferd [{$type}]: {$title} - {$message}", LOG_WARNING);
return false;
}
$classFile = dol_buildpath('/globalnotify/class/globalnotify.class.php', 0);
if (!file_exists($classFile)) {
dol_syslog("ImportZugferd [{$type}]: {$title} - {$message}", LOG_WARNING);
return false;
}
require_once $classFile;
if (!class_exists('GlobalNotify')) {
dol_syslog("ImportZugferd [{$type}]: {$title} - {$message}", LOG_WARNING);
return false;
}
try {
switch ($type) {
case 'error':
GlobalNotify::error('importzugferd', $title, $message, $actionUrl, $actionLabel);
break;
case 'warning':
GlobalNotify::warning('importzugferd', $title, $message, $actionUrl, $actionLabel);
break;
case 'action':
GlobalNotify::actionRequired('importzugferd', $title, $message, $actionUrl, $actionLabel ?: 'Aktion erforderlich');
break;
default:
GlobalNotify::info('importzugferd', $title, $message, $actionUrl, $actionLabel);
}
return true;
} catch (Exception $e) {
dol_syslog("GlobalNotify error: ".$e->getMessage(), LOG_ERR);
return false;
}
}
/**
* Constructor
*
@ -129,45 +67,7 @@ class CronImportZugferd
*/
public function __construct($db)
{
global $conf;
$this->db = $db;
// Set up dedicated log file for cron jobs
$logDir = $conf->importzugferd->dir_output.'/logs';
if (!is_dir($logDir)) {
dol_mkdir($logDir);
}
$this->cronLogFile = $logDir.'/cron_importzugferd.log';
}
/**
* Write to dedicated cron log file
*
* @param string $message Log message
* @param string $level Log level (INFO, WARNING, ERROR, DEBUG)
* @return void
*/
private function cronLog($message, $level = 'INFO')
{
$timestamp = date('Y-m-d H:i:s');
$elapsed = $this->startTime > 0 ? round(microtime(true) - $this->startTime, 2).'s' : '0s';
$logLine = "[{$timestamp}] [{$level}] [{$elapsed}] {$message}\n";
@file_put_contents($this->cronLogFile, $logLine, FILE_APPEND | LOCK_EX);
dol_syslog("CronImportZugferd: ".$message, $level === 'ERROR' ? LOG_ERR : ($level === 'WARNING' ? LOG_WARNING : LOG_INFO));
}
/**
* Shutdown handler to catch fatal errors
*/
public function handleShutdown()
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE))) {
$message = "FATAL SHUTDOWN: {$error['message']} in {$error['file']}:{$error['line']}";
$this->cronLog($message, 'ERROR');
$this->cronLog("========== CRON END (fatal shutdown) ==========");
}
}
/**
@ -227,16 +127,15 @@ class CronImportZugferd
*/
public function runScheduledImport()
{
global $langs;
// Initialize timing and shutdown handler
$this->startTime = microtime(true);
register_shutdown_function(array($this, 'handleShutdown'));
global $conf, $user, $langs;
$langs->load('importzugferd@importzugferd');
$this->cronLog("========== CRON START ==========");
$this->cronLog("PHP Version: ".PHP_VERSION.", Memory Limit: ".ini_get('memory_limit'));
// Check if we should run based on frequency
if (!$this->shouldRunImport()) {
$this->output = 'Skipped - not scheduled to run (frequency: '.getDolGlobalString('IMPORTZUGFERD_IMPORT_FREQUENCY', 'manual').')';
return 0;
}
// Reset counters
$this->imported_count = 0;
@ -244,69 +143,25 @@ class CronImportZugferd
$this->error_count = 0;
$this->errors = array();
try {
$this->cronLog("Starting folder import...");
$this->importFromFolder();
$this->cronLog("Folder import completed");
$folderResult = $this->importFromFolder();
$mailboxResult = $this->fetchFromMailbox();
// IMAP nur wenn konfiguriert
if (!empty(getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'))) {
$this->cronLog("Starting IMAP import...");
$this->fetchFromMailbox();
$this->cronLog("IMAP import completed");
} else {
$this->cronLog("IMAP not configured - skipping");
}
// Update last run time
$this->updateLastRunTime();
// Update last run time
$this->updateLastRunTime();
// Build combined output
$this->output = sprintf(
"Scheduled import complete. Imported: %d, Skipped: %d, Errors: %d",
$this->imported_count,
$this->skipped_count,
$this->error_count
);
// Build combined output
$this->output = sprintf(
"Scheduled import complete. Imported: %d, Skipped: %d, Errors: %d",
$this->imported_count,
$this->skipped_count,
$this->error_count
);
if ($this->error_count > 0 && !empty($this->errors)) {
$this->output .= "\nErrors: " . implode(", ", array_slice($this->errors, 0, 5));
}
$duration = round(microtime(true) - $this->startTime, 2);
$this->cronLog("Completed: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}, duration={$duration}s");
$this->cronLog("========== CRON END (success) ==========");
// Send GlobalNotify notifications
$this->sendImportNotifications();
return ($this->error_count > 0) ? -1 : 0;
} catch (Exception $e) {
$this->error = 'Exception: '.$e->getMessage();
$this->cronLog("EXCEPTION: ".$e->getMessage()."\n".$e->getTraceAsString(), 'ERROR');
$this->cronLog("========== CRON END (exception) ==========");
$this->notify(
'error',
'ZUGFeRD Import fehlgeschlagen',
'Exception: '.$e->getMessage(),
dol_buildpath('/importzugferd/admin/setup.php', 1),
'Einstellungen prüfen'
);
return -1;
} catch (Throwable $t) {
$this->error = 'Fatal: '.$t->getMessage();
$this->cronLog("FATAL: ".$t->getMessage()."\n".$t->getTraceAsString(), 'ERROR');
$this->cronLog("========== CRON END (fatal) ==========");
$this->notify(
'error',
'ZUGFeRD Import Absturz',
'Fatal: '.$t->getMessage(),
dol_buildpath('/importzugferd/admin/setup.php', 1),
'Einstellungen prüfen'
);
return -1;
if ($this->error_count > 0 && !empty($this->errors)) {
$this->output .= "\nErrors: " . implode(", ", array_slice($this->errors, 0, 5));
}
return ($this->error_count > 0) ? -1 : 0;
}
/**
@ -316,7 +171,7 @@ class CronImportZugferd
*/
public function importFromFolder()
{
global $langs;
global $conf, $user, $langs;
$langs->load('importzugferd@importzugferd');
@ -325,45 +180,24 @@ class CronImportZugferd
$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER');
$autoCreate = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE');
$this->cronLog("Watch folder: {$watchFolder}");
// Validate settings
if (empty($watchFolder)) {
$this->cronLog("Watch folder not configured - skipping");
$this->output = 'Watch folder not configured';
return 0;
if (empty($watchFolder) || !is_dir($watchFolder)) {
$this->output = 'Watch folder not configured or not accessible';
return 0; // Not an error, just not configured
}
if (!is_dir($watchFolder)) {
$this->cronLog("Watch folder not accessible: {$watchFolder}", 'WARNING');
$this->output = 'Watch folder not accessible';
return 0;
}
$this->cronLog("Watch folder accessible, scanning for PDFs...");
// Load admin user for import actions
$admin_user = new User($this->db);
$admin_user->fetch(1);
// Find PDF files
$this->cronLog("Running glob for *.pdf...");
$files = glob($watchFolder . '/*.pdf');
$this->cronLog("Found ".count($files)." .pdf files");
$this->cronLog("Running glob for *.PDF...");
$filesUpper = glob($watchFolder . '/*.PDF');
$this->cronLog("Found ".count($filesUpper)." .PDF files");
$files = array_merge($files, $filesUpper);
$files = array_merge($files, glob($watchFolder . '/*.PDF'));
if (empty($files)) {
$this->cronLog("No PDF files found in watch folder");
return 0;
}
$this->cronLog("Total ".count($files)." PDF files to process");
// Ensure archive folder exists if configured
if (!empty($archiveFolder) && !is_dir($archiveFolder)) {
dol_mkdir($archiveFolder);
@ -375,22 +209,20 @@ class CronImportZugferd
}
foreach ($files as $file) {
$this->cronLog("Processing: ".basename($file));
// Use ZugferdImport::importFromFile for consistent handling
$import = new ZugferdImport($this->db);
$result = $import->importFromFile($admin_user, $file, !empty($autoCreate));
$result = $import->importFromFile($admin_user, $file, $autoCreate);
if ($result > 0) {
$this->imported_count++;
$this->cronLog("Imported: ".basename($file)." -> ID {$result}");
dol_syslog("CronImportZugferd: Imported invoice from folder: " . basename($file), LOG_INFO);
// Archive the file
$this->moveFile($file, $archiveFolder, 'imported_');
} elseif ($result == -2) {
// Duplicate - already imported
$this->skipped_count++;
$this->cronLog("Skipped (duplicate): ".basename($file));
dol_syslog("CronImportZugferd: Skipped duplicate invoice: " . basename($file), LOG_INFO);
// Archive duplicates - delete if no archive folder
if (!$this->moveFile($file, $archiveFolder, 'duplicate_')) {
@ -399,7 +231,7 @@ class CronImportZugferd
} else {
$this->error_count++;
$this->errors[] = basename($file) . ': ' . $import->error;
$this->cronLog("Error importing ".basename($file).": ".$import->error, 'ERROR');
dol_syslog("CronImportZugferd: Error importing: " . basename($file) . " - " . $import->error, LOG_WARNING);
// Try error folder first, fall back to archive folder
if (!$this->moveFile($file, $errorFolder, 'error_')) {
@ -409,7 +241,6 @@ class CronImportZugferd
}
}
$this->cronLog("Folder import finished: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}");
return 0;
}
@ -666,17 +497,7 @@ class CronImportZugferd
}
if (is_dir($targetFolder) && is_writable($targetFolder)) {
// Originalen Dateinamen beibehalten, bei Namenskollision Zaehler anhaengen
$baseName = basename($file);
$targetPath = $targetFolder . '/' . $baseName;
if (file_exists($targetPath)) {
$pathInfo = pathinfo($baseName);
$counter = 1;
do {
$targetPath = $targetFolder . '/' . $pathInfo['filename'] . '_' . $counter . '.' . $pathInfo['extension'];
$counter++;
} while (file_exists($targetPath));
}
$targetPath = $targetFolder . '/' . $prefix . date('Y-m-d_His') . '_' . basename($file);
if (@rename($file, $targetPath)) {
dol_syslog("CronImportZugferd: Moved file to: " . $targetPath, LOG_INFO);
@ -713,79 +534,4 @@ class CronImportZugferd
dol_syslog("CronImportZugferd: Keeping error file in watch folder: " . $file, LOG_INFO);
return true;
}
/**
* Send notifications based on import results
*
* @return void
*/
protected function sendImportNotifications()
{
// Check for errors
if ($this->error_count > 0) {
$errorSummary = count($this->errors) > 0 ? implode(', ', array_slice($this->errors, 0, 3)) : 'Siehe Log';
$this->notify(
'warning',
$this->error_count.' ZUGFeRD Import-Fehler',
$errorSummary,
dol_buildpath('/importzugferd/list.php?status=error', 1),
'Fehler anzeigen'
);
}
// Check for imported invoices that need review
if ($this->imported_count > 0) {
// Count pending invoices (drafts needing approval)
$pendingCount = $this->countPendingInvoices();
if ($pendingCount > 0) {
$this->notify(
'action',
$this->imported_count.' ZUGFeRD Rechnungen importiert',
"{$pendingCount} Lieferantenrechnungen warten auf Prüfung und Freigabe",
dol_buildpath('/fourn/facture/list.php?search_status=0', 1),
'Rechnungen prüfen'
);
} else {
// All auto-created and validated
$this->notify(
'info',
$this->imported_count.' ZUGFeRD Rechnungen importiert',
'Alle Rechnungen wurden erfolgreich verarbeitet',
dol_buildpath('/fourn/facture/list.php', 1),
'Anzeigen'
);
}
}
// IMAP connection issues
if (strpos($this->error, 'IMAP connection failed') !== false) {
$this->notify(
'error',
'IMAP Verbindung fehlgeschlagen',
'E-Mail Postfach für ZUGFeRD-Import nicht erreichbar',
dol_buildpath('/importzugferd/admin/setup.php', 1),
'IMAP prüfen'
);
}
}
/**
* Count pending (draft) supplier invoices
*
* @return int Number of draft supplier invoices
*/
protected function countPendingInvoices()
{
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_fourn";
$sql .= " WHERE fk_statut = 0"; // Draft status
$sql .= " AND entity IN (".getEntity('facture_fourn').")";
$resql = $this->db->query($sql);
if ($resql) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->cnt;
}
return 0;
}
}

View file

@ -520,22 +520,9 @@ class Datanorm extends CommonObject
if ($result > 0) {
$results[] = $this->toArray();
$foundIds[$this->id] = true;
// Store EAN from Datanorm
// Store EAN and manufacturer_ref for cross-catalog search
$foundEan = $this->ean;
// 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;
}
}
$foundManufacturerRef = $this->manufacturer_ref;
// If not searching all catalogs, return immediately
if (!$searchAll) {
@ -544,15 +531,24 @@ class Datanorm extends CommonObject
}
}
// 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)) {
// 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 ean = '" . $this->db->escape($foundEan) . "'";
$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
@ -592,44 +588,55 @@ class Datanorm extends CommonObject
}
}
// 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";
// 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;
$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;
}
if ($fk_soc > 0 && !$searchAll) {
$sql .= " AND fk_soc = " . (int) $fk_soc;
}
// 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;
}
$this->db->free($resql);
}
$this->db->free($resql);
}
return $results;
@ -975,13 +982,13 @@ class Datanorm extends CommonObject
// P;A format - multiple articles per line
// Format: P;A;ArtNr;PreisKz;Preis;PE;Zuschlag;x;x;x;ArtNr2;...
// Rabattkennzeichen aus DATPREIS (wird gespeichert aber nicht fuer price_unit verwendet)
// PE is the price unit code from DATPREIS (may differ from A-record!)
if ($recordType === 'P' && isset($parts[1]) && $parts[1] === 'A') {
$i = 2;
while ($i < count($parts) - 2) {
$articleNumber = trim($parts[$i] ?? '');
$priceRaw = trim($parts[$i + 2] ?? '0');
$datpreisPeCode = (int)trim($parts[$i + 3] ?? '0'); // Rabattkennzeichen (nicht PE!)
$datpreisPeCode = (int)trim($parts[$i + 3] ?? '0'); // PE code from DATPREIS
$metalSurchargeRaw = trim($parts[$i + 4] ?? '0');
$price = (float)$priceRaw / 100; // Convert cents to euros
$metalSurcharge = (float)$metalSurchargeRaw / 100; // Convert cents to euros
@ -1000,7 +1007,7 @@ class Datanorm extends CommonObject
// Simple format: P;ArtNr;PreisKz;Preis;PE;...
$articleNumber = trim($parts[1] ?? '');
$priceRaw = trim($parts[3] ?? '0');
$datpreisPeCode = (int)trim($parts[4] ?? '0'); // Rabattkennzeichen (nicht PE!)
$datpreisPeCode = (int)trim($parts[4] ?? '0'); // PE code if available
if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) {
$price = (float)$priceRaw / 100;

View file

@ -624,32 +624,6 @@ 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);
}
}
}
@ -675,12 +649,12 @@ class ZugferdImport extends CommonObject
$this->status = self::STATUS_IMPORTED;
}
// Copy PDF to documents (in subfolder by import ID)
$destdir = $conf->importzugferd->dir_output . '/imports/' . $this->id;
// Copy PDF to documents
$destdir = $conf->importzugferd->dir_output . '/imports';
if (!is_dir($destdir)) {
dol_mkdir($destdir);
}
$destfile = $destdir . '/' . $this->pdf_filename;
$destfile = $destdir . '/' . $this->ref . '_' . basename($file_path);
copy($file_path, $destfile);
// Update status

View file

@ -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 = '5.5';
$this->version = '3.3';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -573,173 +573,113 @@ class modImportZugferd extends DolibarrModules
$extrafields = new ExtraFields($this->db);
// Add extrafield for supplier customer number (our customer ID at the supplier)
// Signature: addExtraField($attrname, $label, $type, $pos, $size, $elementtype, $unique, $required, $default_value, $param, $alwayseditable, $perms, $list, $help, $computed, $entity, $langfile, $enabled, $totalizable, $printable)
$extrafields->addExtraField(
'supplier_customer_number', // 1. attribute code
'SupplierCustomerNumber', // 2. label (translation key)
'varchar', // 3. type
100, // 4. position
64, // 5. size
'thirdparty', // 6. element type
0, // 7. unique
0, // 8. required
'', // 9. default value
'', // 10. param
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in list)
'', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
'supplier_customer_number', // attribute code
'SupplierCustomerNumber', // label (translation key)
'varchar', // type
100, // position
64, // size
'thirdparty', // 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 metal surcharge (Kupferzuschlag) on supplier prices
$extrafields->addExtraField(
'kupferzuschlag', // 1. attribute code
'Kupferzuschlag', // 2. label
'price', // 3. type (price field)
110, // 4. position
'24,8', // 5. size
'product_fournisseur_price', // 6. element type
0, // 7. unique
0, // 8. required
'', // 9. default value
'', // 10. param
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in list)
'Metallzuschlag (Kupfer) für diesen Einkaufspreis', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
);
// Add extrafield for product price without copper surcharge (only for cables)
$extrafields->addExtraField(
'produktpreis', // 1. attribute code
'Produktpreis', // 2. label
'price', // 3. type (price field)
115, // 4. position
'24,8', // 5. size
'product_fournisseur_price', // 6. element type
0, // 7. unique
0, // 8. required
'', // 9. default value
'', // 10. param
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in list)
'Materialpreis ohne Kupferzuschlag (nur bei Kabeln)', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
'kupferzuschlag', // attribute code
'Kupferzuschlag', // label (translation key)
'price', // type (price field)
110, // 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', // 1. attribute code
'Preiseinheit', // 2. label
'int', // 3. type
120, // 4. position
11, // 5. size
'product_fournisseur_price', // 6. element type
0, // 7. unique
0, // 8. required
'1', // 9. default value
'', // 10. param
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in list)
'Preiseinheit aus Datanorm (z.B. 100 für Preis pro 100m)', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
'preiseinheit', // attribute code
'Preiseinheit', // label (translation key)
'int', // type
120, // position
11, // size
'product_fournisseur_price', // element type
0, // unique
0, // required
'1', // 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 product group (Warengruppe) on supplier prices
$extrafields->addExtraField(
'warengruppe', // 1. attribute code
'Warengruppe', // 2. label
'varchar', // 3. type
125, // 4. position
32, // 5. size
'product_fournisseur_price', // 6. element type
0, // 7. unique
0, // 8. required
'', // 9. default value
'', // 10. param
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in list)
'Datanorm-Warengruppe', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
);
// Add extrafield for purchase quantity (Kaufmenge) on supplier prices
// Signature: addExtraField($attrname, $label, $type, $pos, $size, $elementtype, $unique, $required, $default_value, $param, $alwayseditable, $perms, $list, $help, $computed, $entity, $langfile, $enabled, $totalizable, $printable)
$extrafields->addExtraField(
'kaufmenge', // 1. attribute code
'Kaufmenge (Datanorm-Vergleich)', // 2. label
'int', // 3. type
127, // 4. position
11, // 5. size
'product_fournisseur_price', // 6. element type
0, // 7. unique
0, // 8. required
'', // 9. default value
'', // 10. param (empty)
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in forms)
'Tatsächliche Kaufmenge für Preisvergleiche. Beispiele: Lüsterklemme mit 12 Positionen = 1, Kabel 100m = leer lassen.', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
'warengruppe', // attribute code
'Warengruppe', // label (translation key)
'varchar', // type
125, // position
32, // 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 copper content (Kupfergehalt) on product - kg per km for cables
$extrafields->addExtraField(
'kupfergehalt', // 1. attribute code
'Kupfergehalt', // 2. label
'double', // 3. type (decimal number)
130, // 4. position
'24,4', // 5. size (precision,scale)
'product', // 6. element type
0, // 7. unique
0, // 8. required
'', // 9. default value
'', // 10. param
1, // 11. always editable
'', // 12. permission
1, // 13. list (show in list)
'Kupfergehalt in kg/km (für Kupferzuschlag-Berechnung bei Kabeln)', // 14. help
'', // 15. computed
'', // 16. entity
'importzugferd@importzugferd', // 17. langfile
'isModEnabled("importzugferd")', // 18. enabled condition
0, // 19. totalizable
0 // 20. printable
'kupfergehalt', // attribute code
'Kupfergehalt', // label (translation key)
'double', // type (decimal number)
130, // position
'24,4', // size (precision,scale)
'product', // 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
);
// Permissions

View file

@ -79,18 +79,12 @@ if ($isFormSubmitted) {
$filter_description = GETPOSTINT('filter_description');
$filter_label = GETPOSTINT('filter_label');
$only_differences = GETPOSTINT('only_differences');
$hide_cables = GETPOSTINT('hide_cables');
$filter_price_up = GETPOSTINT('filter_price_up');
$filter_price_down = GETPOSTINT('filter_price_down');
} else {
// Defaults for first page load
$filter_price = 1;
$filter_description = 1;
$filter_label = 0;
$only_differences = 0;
$hide_cables = 0;
$filter_price_up = 0;
$filter_price_down = 0;
}
// Initialize objects
@ -123,8 +117,8 @@ if ($action == 'apply_single' && GETPOSTINT('product_id') && GETPOST('datanorm_k
setEventMessages($langs->trans('ErrorUpdatingProduct'), null, 'errors');
}
// Redirect to same page with same parameters (preserve all filters)
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&hide_cables='.$hide_cables.'&filter_price_up='.$filter_price_up.'&filter_price_down='.$filter_price_down.'&action=search');
// Redirect to same page with same parameters
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences);
exit;
}
@ -143,8 +137,8 @@ if ($action == 'add_pending') {
setEventMessages($langs->trans('AddedToPendingChanges'), null, 'mesgs');
}
// Redirect back with same parameters to preserve supplier selection and filters
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&hide_cables='.$hide_cables.'&filter_price_up='.$filter_price_up.'&filter_price_down='.$filter_price_down.'&action=search');
// Redirect back with same parameters to preserve supplier selection
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&action=search');
exit;
}
@ -198,8 +192,8 @@ if ($action == 'add_all_pending') {
}
}
// Redirect back with same parameters to preserve supplier selection and filters
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&hide_cables='.$hide_cables.'&filter_price_up='.$filter_price_up.'&filter_price_down='.$filter_price_down.'&action=search');
// Redirect back with same parameters to preserve supplier selection
header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&action=search');
exit;
}
@ -328,7 +322,8 @@ if ($obj->cnt == 0) {
}
// Search form
print '<form method="GET" action="'.$_SERVER['PHP_SELF'].'" name="searchform">';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" name="searchform">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="search">';
print '<div class="fichecenter">';
@ -419,21 +414,6 @@ print '<td>'.$langs->trans('Display').'</td>';
print '<td colspan="3">';
print '<input type="checkbox" name="only_differences" value="1" id="only_differences" '.($only_differences ? 'checked' : '').'>';
print '<label for="only_differences"> '.$langs->trans('OnlyShowDifferences').'</label>';
print ' &nbsp;&nbsp;&nbsp; ';
print '<input type="checkbox" name="hide_cables" value="1" id="hide_cables" '.($hide_cables ? 'checked' : '').'>';
print '<label for="hide_cables"> <i class="fas fa-eye-slash"></i> Keine Kabel anzeigen</label>';
print '</td>';
print '</tr>';
// Price direction filter
print '<tr class="oddeven">';
print '<td>Preisfilter</td>';
print '<td colspan="3">';
print '<input type="checkbox" name="filter_price_up" value="1" id="filter_price_up" '.($filter_price_up ? 'checked' : '').'>';
print '<label for="filter_price_up"> <i class="fas fa-arrow-up" style="color: #d9534f;"></i> Nur Preise rauf</label>';
print ' &nbsp;&nbsp;&nbsp; ';
print '<input type="checkbox" name="filter_price_down" value="1" id="filter_price_down" '.($filter_price_down ? 'checked' : '').'>';
print '<label for="filter_price_down"> <i class="fas fa-arrow-down" style="color: #5cb85c;"></i> Nur Preise runter</label>';
print '</td>';
print '</tr>';
@ -609,45 +589,6 @@ if ($fk_soc > 0 && ($action == 'search' || GETPOST('search_mode'))) {
print '</tr>';
foreach ($comparison_results as $item) {
// Filter cables if requested
// Datanorm groups: 01-19, 101-119, 202, 205 are cables/wires
if ($hide_cables && !empty($item['datanorm_product_group'])) {
$pg = (int)$item['datanorm_product_group'];
if (($pg >= 1 && $pg <= 19) || ($pg >= 101 && $pg <= 119) || $pg == 202 || $pg == 205) {
continue; // Skip cables
}
}
// Calculate price difference for filtering
$price_diff = 0;
if ($item['product_id'] > 0 && $item['price_differs']) {
$datanorm_pe = isset($item['datanorm_price_unit']) ? $item['datanorm_price_unit'] : 1;
$datanorm_raw = isset($item['datanorm_price_raw']) ? $item['datanorm_price_raw'] : 0;
$current_cu = isset($item['current_kupferzuschlag']) ? $item['current_kupferzuschlag'] : 0;
$effective_qty = isset($item['current_effective_quantity']) ? $item['current_effective_quantity'] : 1;
$current_total = isset($item['current_total_price']) ? $item['current_total_price'] : 0;
// Scale Cu to Datanorm's price_unit
$cu_per_unit = ($current_cu > 0 && $effective_qty > 0) ? $current_cu / $effective_qty : 0;
$cu_for_pe = $cu_per_unit * $datanorm_pe;
$datanorm_total = $datanorm_raw + $cu_for_pe;
$current_unit = $effective_qty > 0 ? $current_total / $effective_qty : $current_total;
$datanorm_unit = $datanorm_total / $datanorm_pe;
$price_diff = $datanorm_unit - $current_unit;
}
// Filter by price direction
if ($filter_price_up && !$filter_price_down && $price_diff <= 0) {
continue; // Only show price increases
}
if ($filter_price_down && !$filter_price_up && $price_diff >= 0) {
continue; // Only show price decreases
}
if ($filter_price_up && $filter_price_down && $price_diff == 0) {
continue; // Both checked: show any change, skip unchanged
}
$has_difference = ($filter_price && $item['price_differs']) ||
($filter_description && $item['description_differs']) ||
($filter_label && $item['label_differs']);
@ -682,72 +623,50 @@ if ($fk_soc > 0 && ($action == 'search' || GETPOST('search_mode'))) {
$priceStyle = $item['price_differs'] ? 'background-color: #fcf8e3;' : '';
print '<td class="right nowraponall" style="'.$priceStyle.'">';
if ($item['product_id'] > 0) {
$dolibarr_total = isset($item['current_total_price']) ? $item['current_total_price'] : $item['current_price'];
$dolibarr_qty = isset($item['current_quantity']) ? $item['current_quantity'] : 1;
$dolibarr_cu = isset($item['current_kupferzuschlag']) ? $item['current_kupferzuschlag'] : 0;
// IMPORTANT: Dolibarr price already includes Cu! Show as info only
if ($dolibarr_cu > 0) {
print '<span class="opacitymedium small">(davon '.price($dolibarr_cu).' Cu)</span><br>';
}
// Total price for minimum quantity (already includes Cu!)
print '<strong>'.price($dolibarr_total);
if ($dolibarr_qty > 1) {
print '/'.$dolibarr_qty;
}
print '</strong>';
// Unit price as secondary info
if ($dolibarr_qty > 1) {
$dolibarr_unit = $dolibarr_total / $dolibarr_qty;
print '<br><span class="opacitymedium small">('.price($dolibarr_unit).'/Stk.)</span>';
print price($item['current_price']);
// Show copper surcharge from invoice if available
if (!empty($item['current_kupferzuschlag']) && $item['current_kupferzuschlag'] > 0) {
print '<br><span class="opacitymedium small" title="Kupferzuschlag aus Rechnung">';
print '<i class="fas fa-plus-circle" style="color:#f0ad4e;"></i> '.price($item['current_kupferzuschlag']);
print '</span>';
// Show total price (material + surcharge)
$totalWithSurcharge = $item['current_price'] + $item['current_kupferzuschlag'];
print '<br><span class="small" style="color:#337ab7;" title="Gesamt (Material+CU)">';
print '<strong>='.price($totalWithSurcharge).'</strong>';
print '</span>';
}
} else {
print '-';
}
print '</td>';
print '<td class="right nowraponall" style="'.$priceStyle.'">';
$datanorm_pe = isset($item['datanorm_price_unit']) ? $item['datanorm_price_unit'] : 1;
$datanorm_raw = isset($item['datanorm_price_raw']) ? $item['datanorm_price_raw'] : $item['datanorm_price'];
$current_cu = isset($item['current_kupferzuschlag']) ? $item['current_kupferzuschlag'] : 0;
$effective_qty = isset($item['current_effective_quantity']) ? $item['current_effective_quantity'] : 1;
// Scale Cu from Dolibarr's quantity to Datanorm's price_unit
// Example: Cu 254,55€ for 50m → for 100m = 509,10€
$cu_per_unit = ($current_cu > 0 && $effective_qty > 0) ? $current_cu / $effective_qty : 0;
$cu_for_pe = $cu_per_unit * $datanorm_pe;
// Show breakdown if copper exists
if ($current_cu > 0) {
print '<span class="opacitymedium small">'.price($datanorm_raw).' + '.price($cu_for_pe).' Cu</span><br>';
print price($item['datanorm_price']);
// Show original price and unit if price_unit > 1
if (!empty($item['datanorm_price_unit']) && $item['datanorm_price_unit'] > 1) {
print '<br><span class="opacitymedium small">('.price($item['datanorm_price_raw']).'/'.$item['datanorm_price_unit'].')</span>';
}
// Show effective surcharge (from invoice/datanorm)
if (!empty($item['effective_surcharge']) && $item['effective_surcharge'] > 0) {
// Determine surcharge source
$surchargeSource = isset($item['surcharge_source']) ? $item['surcharge_source'] : 'datanorm';
$sourceLabels = array('invoice' => 'Rechnung', 'datanorm' => 'Datanorm');
$sourceColors = array('invoice' => '#f0ad4e', 'datanorm' => '#95a5a6');
$sourceLabel = isset($sourceLabels[$surchargeSource]) ? $sourceLabels[$surchargeSource] : $surchargeSource;
$sourceColor = isset($sourceColors[$surchargeSource]) ? $sourceColors[$surchargeSource] : '#f0ad4e';
// Total price for Datanorm price_unit (with scaled Cu)
$datanorm_total = $datanorm_raw + $cu_for_pe;
print '<strong>'.price($datanorm_total);
if ($datanorm_pe > 1) {
print '/'.$datanorm_pe;
}
print '</strong>';
// Unit price as secondary info
if ($datanorm_pe > 1) {
$datanorm_unit = $datanorm_total / $datanorm_pe;
print '<br><span class="opacitymedium small">('.price($datanorm_unit).'/Stk.)</span>';
print '<br><span class="opacitymedium small" title="Kupferzuschlag ('.$sourceLabel.')">';
print '<i class="fas fa-plus-circle" style="color:'.$sourceColor.';"></i> '.price($item['effective_surcharge']);
print '</span>';
// Show total price with surcharge
if (!empty($item['datanorm_price_with_surcharge'])) {
print '<br><span class="small" style="color:#337ab7;" title="Gesamt (Material+CU)">';
print '<strong>='.price($item['datanorm_price_with_surcharge']).'</strong>';
print '</span>';
}
}
if ($item['price_differs'] && $item['product_id'] > 0) {
// Calculate percentage difference using UNIT PRICE basis
$current_total = isset($item['current_total_price']) ? $item['current_total_price'] : $item['current_price'];
// Dolibarr: unit price (already includes Cu)
$current_compare = $effective_qty > 0 ? $current_total / $effective_qty : $current_total;
// Datanorm: unit price (material + scaled Cu)
$datanorm_compare = $datanorm_total / $datanorm_pe;
$diff = $datanorm_compare - $current_compare;
$diffPercent = ($current_compare > 0) ? ($diff / $current_compare * 100) : 0;
$diff = $item['datanorm_price'] - $item['current_price'];
$diffPercent = ($item['current_price'] > 0) ? ($diff / $item['current_price'] * 100) : 0;
print '<br>';
if ($diff > 0) {
print '<span style="color: #d9534f;"><i class="fas fa-arrow-up"></i> +'.number_format($diffPercent, 1).'%</span>';
@ -796,9 +715,6 @@ if ($fk_soc > 0 && ($action == 'search' || GETPOST('search_mode'))) {
print '<input type="hidden" name="filter_description" value="'.$filter_description.'">';
print '<input type="hidden" name="filter_label" value="'.$filter_label.'">';
print '<input type="hidden" name="only_differences" value="'.$only_differences.'">';
print '<input type="hidden" name="hide_cables" value="'.$hide_cables.'">';
print '<input type="hidden" name="filter_price_up" value="'.$filter_price_up.'">';
print '<input type="hidden" name="filter_price_down" value="'.$filter_price_down.'">';
// Checkboxes for what to apply
if ($filter_price && $item['price_differs']) {
@ -1185,23 +1101,14 @@ function searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name = 0,
$datanorm_metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0;
// Get current price and copper surcharge from extrafield
$current_total_price = 0;
$current_quantity = 1;
$current_kaufmenge = 0;
$current_price = 0;
$current_kupferzuschlag = 0;
$datanorm_price_unit_code = isset($datanorm->price_unit_code) ? $datanorm->price_unit_code : -1;
if ($product) {
$priceDetails = getSupplierPriceDetails($db, $product->rowid, $fk_soc);
$current_total_price = $priceDetails['price'];
$current_quantity = $priceDetails['quantity'];
$current_kaufmenge = $priceDetails['kaufmenge'];
$current_price = $priceDetails['unitprice'];
$current_kupferzuschlag = $priceDetails['kupferzuschlag'];
}
// Use kaufmenge if set, otherwise fall back to quantity
$effective_quantity = ($current_kaufmenge > 0) ? $current_kaufmenge : $current_quantity;
// Priority for surcharge: 1) Invoice extrafield, 2) Datanorm
if ($current_kupferzuschlag > 0) {
$effective_surcharge = $current_kupferzuschlag;
@ -1211,43 +1118,14 @@ function searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name = 0,
$surcharge_source = 'datanorm';
}
// Calculate prices for comparison - UNIT PRICE basis
// IMPORTANT: Dolibarr price already INCLUDES kupferzuschlag! Don't add it again!
// Datanorm price is WITHOUT kupferzuschlag, so add SCALED Cu for comparison
//
// Example: Kabel NYM-J 5x10
// - Dolibarr: 331,27€ for 50m (includes 254,55€ Cu for 50m) → 6,63€/m
// - Datanorm: 168,50€ for 100m (PE=100)
// - Cu per unit: 254,55€ / 50m = 5,09€/m → for 100m = 509,10€
// - Datanorm total: 168,50€ + 509,10€ = 677,60€ → 6,78€/m
// Calculate Cu per unit (from Dolibarr's quantity basis)
$cu_per_unit = ($current_kupferzuschlag > 0 && $effective_quantity > 0)
? $current_kupferzuschlag / $effective_quantity
: 0;
// Scale Cu to Datanorm's price_unit basis
$cu_for_price_unit = $cu_per_unit * $price_unit;
// Dolibarr: unit price (already includes Cu)
$current_compare_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price;
// Datanorm: material price + scaled Cu, then to unit price
$datanorm_compare_price = ($datanorm->price + $cu_for_price_unit) / $price_unit;
// For display: always show unit prices
// Calculate prices
$datanorm_material_unit_price = $datanorm->price / $price_unit;
$total_price_with_surcharge = $datanorm->price + $cu_for_price_unit;
$total_price_with_surcharge = $datanorm->price + ($effective_surcharge * $price_unit);
$datanorm_total_unit_price = $total_price_with_surcharge / $price_unit;
$current_unit_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price;
$results[] = array(
'product_id' => $product ? $product->rowid : 0,
'current_price' => $current_unit_price,
'current_total_price' => $current_total_price,
'current_quantity' => $current_quantity,
'current_kaufmenge' => $current_kaufmenge,
'current_effective_quantity' => $effective_quantity,
'current_price' => $current_price,
'current_kupferzuschlag' => $current_kupferzuschlag,
'current_description' => $product ? $product->description : '',
'current_label' => $product ? $product->label : '',
@ -1259,14 +1137,12 @@ function searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name = 0,
'datanorm_price_raw' => $datanorm->price,
'datanorm_material_price' => $datanorm->price,
'datanorm_metal_surcharge' => $datanorm_metal_surcharge,
'datanorm_price_unit_code' => $datanorm_price_unit_code,
'effective_surcharge' => $effective_surcharge,
'surcharge_source' => $surcharge_source,
'datanorm_price_unit' => $price_unit,
'datanorm_product_group' => isset($datanorm->product_group) ? $datanorm->product_group : '',
'datanorm_description' => trim($datanorm->short_text1.' '.$datanorm->short_text2),
'datanorm_label' => $datanorm->short_text1,
'price_differs' => $product && abs($current_compare_price - $datanorm_compare_price) > 0.01,
'price_differs' => $product && abs($current_price - $datanorm_material_unit_price) > 0.01,
'description_differs' => $product && $product->description != trim($datanorm->short_text1.' '.$datanorm->short_text2),
'label_differs' => $product && $product->label != $datanorm->short_text1,
);
@ -1400,17 +1276,14 @@ function getSupplierPrice($db, $product_id, $fk_soc)
function getSupplierPriceDetails($db, $product_id, $fk_soc)
{
$result = array(
'price' => 0,
'quantity' => 1,
'kaufmenge' => 0,
'unitprice' => 0,
'kupferzuschlag' => 0,
'preiseinheit' => 1,
'price_id' => 0,
);
// Get base price - ALWAYS load price + quantity, NOT unitprice alone!
$sql = "SELECT pf.rowid, pf.price, pf.quantity";
// Get base price
$sql = "SELECT pf.rowid, pf.unitprice, pf.price, pf.quantity";
$sql .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price pf";
$sql .= " WHERE pf.fk_product = ".((int)$product_id);
$sql .= " AND pf.fk_soc = ".((int)$fk_soc);
@ -1420,14 +1293,18 @@ function getSupplierPriceDetails($db, $product_id, $fk_soc)
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
$result['price_id'] = $obj->rowid;
$result['price'] = (float)$obj->price;
$result['quantity'] = max(1, (int)$obj->quantity);
// Calculate unit price from price / quantity
$result['unitprice'] = $result['quantity'] > 0 ? $result['price'] / $result['quantity'] : $result['price'];
// Calculate unit price
if (!empty($obj->unitprice) && $obj->unitprice > 0) {
$result['unitprice'] = $obj->unitprice;
} elseif (!empty($obj->quantity) && $obj->quantity > 0) {
$result['unitprice'] = $obj->price / $obj->quantity;
} else {
$result['unitprice'] = $obj->price;
}
// Get extrafields (Kupferzuschlag, Preiseinheit, Kaufmenge)
$sql_extra = "SELECT kupferzuschlag, preiseinheit, kaufmenge";
// Get extrafields (Kupferzuschlag, Preiseinheit)
$sql_extra = "SELECT kupferzuschlag, preiseinheit";
$sql_extra .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields";
$sql_extra .= " WHERE fk_object = ".((int)$obj->rowid);
@ -1436,7 +1313,6 @@ function getSupplierPriceDetails($db, $product_id, $fk_soc)
$extra = $db->fetch_object($res_extra);
$result['kupferzuschlag'] = !empty($extra->kupferzuschlag) ? (float)$extra->kupferzuschlag : 0;
$result['preiseinheit'] = !empty($extra->preiseinheit) ? (int)$extra->preiseinheit : 1;
$result['kaufmenge'] = !empty($extra->kaufmenge) ? (int)$extra->kaufmenge : 0;
}
}
@ -1517,21 +1393,14 @@ function buildComparisonResult($product, $datanorm)
global $db;
$fk_soc = $datanorm->fk_soc;
// Get supplier price details including extrafields (Kupferzuschlag, Kaufmenge)
// Get supplier price details including extrafields (Kupferzuschlag)
$priceDetails = getSupplierPriceDetails($db, $product->fk_product, $fk_soc);
$current_total_price = $priceDetails['price'];
$current_quantity = $priceDetails['quantity'];
$current_kaufmenge = $priceDetails['kaufmenge']; // Actual purchase quantity (if set)
$current_price = $priceDetails['unitprice'];
$current_kupferzuschlag = $priceDetails['kupferzuschlag'];
// Use kaufmenge if set, otherwise fall back to quantity
$effective_quantity = ($current_kaufmenge > 0) ? $current_kaufmenge : $current_quantity;
$current_unit_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price;
// Calculate unit price (Datanorm price may be per price_unit pieces)
// Datanorm metal_surcharge is usually 0 for Sonepar - use extrafield from invoice instead
$price_unit = (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) ? $datanorm->price_unit : 1;
$datanorm_price_unit_code = isset($datanorm->price_unit_code) ? $datanorm->price_unit_code : -1;
$datanorm_metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0;
// Priority for surcharge: 1) Invoice extrafield, 2) Datanorm
@ -1543,43 +1412,14 @@ function buildComparisonResult($product, $datanorm)
$surcharge_source = 'datanorm';
}
// Calculate prices for comparison
// IMPORTANT: Dolibarr price already INCLUDES kupferzuschlag! Don't add it again!
// Datanorm price is WITHOUT kupferzuschlag, so add it for comparison
// Compare on UNIT PRICE basis (per 1 piece/meter)
//
// Example: Kabel NYM-J 5x10
// - Dolibarr: 331,27€ for 50m (includes 254,55€ Cu for 50m) → 6,63€/m
// - Datanorm: 168,50€ for 100m (PE=100) + Cu must be scaled to 100m
// - Cu per unit: 254,55€ / 50m = 5,09€/m → for 100m = 509,10€
// - Datanorm total for 100m: 168,50€ + 509,10€ = 677,60€ → 6,78€/m
// Calculate Cu per unit (from Dolibarr's quantity basis)
$cu_per_unit = ($current_kupferzuschlag > 0 && $effective_quantity > 0)
? $current_kupferzuschlag / $effective_quantity
: 0;
// Scale Cu to Datanorm's price_unit basis
$cu_for_price_unit = $cu_per_unit * $price_unit;
// Dolibarr: unit price (already includes Cu)
$current_compare_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price;
// Datanorm: material price + scaled Cu, then to unit price
$datanorm_compare_price = ($datanorm->price + $cu_for_price_unit) / $price_unit;
// For display: always show unit prices
// Calculate prices
$datanorm_material_unit_price = $datanorm->price / $price_unit;
$total_price_with_surcharge = $datanorm->price + $cu_for_price_unit;
$total_price_with_surcharge = $datanorm->price + ($effective_surcharge * $price_unit);
$datanorm_total_unit_price = $total_price_with_surcharge / $price_unit;
return array(
'product_id' => $product->fk_product,
'current_price' => $current_unit_price,
'current_total_price' => $current_total_price,
'current_quantity' => $current_quantity,
'current_kaufmenge' => $current_kaufmenge,
'current_effective_quantity' => $effective_quantity,
'current_price' => $current_price,
'current_kupferzuschlag' => $current_kupferzuschlag,
'current_description' => $product->description,
'current_label' => $product->label,
@ -1591,14 +1431,12 @@ function buildComparisonResult($product, $datanorm)
'datanorm_price_raw' => $datanorm->price, // Raw price from DATPREIS
'datanorm_material_price' => $datanorm->price,
'datanorm_metal_surcharge' => $datanorm_metal_surcharge, // From Datanorm (usually 0)
'datanorm_price_unit_code' => $datanorm_price_unit_code,
'effective_surcharge' => $effective_surcharge, // From invoice or Datanorm
'surcharge_source' => $surcharge_source, // Source of surcharge (invoice/datanorm)
'datanorm_price_unit' => $price_unit,
'datanorm_product_group' => isset($datanorm->product_group) ? $datanorm->product_group : '',
'datanorm_description' => trim($datanorm->short_text1.' '.$datanorm->short_text2),
'datanorm_label' => $datanorm->short_text1,
'price_differs' => abs($current_compare_price - $datanorm_compare_price) > 0.01,
'price_differs' => abs($current_price - $datanorm_material_unit_price) > 0.01,
'description_differs' => $product->description != trim($datanorm->short_text1.' '.$datanorm->short_text2),
'label_differs' => $product->label != $datanorm->short_text1,
);
@ -1626,31 +1464,9 @@ function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $a
// Calculate unit price (Datanorm price may be per price_unit pieces)
// Total price = material price + metal surcharge (for cables)
$price_unit = (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) ? $datanorm->price_unit : 1;
// Get existing supplier price details to get kupferzuschlag and quantity from extrafield
$priceDetails = getSupplierPriceDetails($db, $product_id, $fk_soc);
$current_kupferzuschlag = $priceDetails['kupferzuschlag'];
$current_quantity = $priceDetails['quantity'];
$current_kaufmenge = $priceDetails['kaufmenge'];
$effective_quantity = ($current_kaufmenge > 0) ? $current_kaufmenge : max(1, $current_quantity);
// Priority for surcharge: 1) Dolibarr extrafield (from invoice), 2) Datanorm metal_surcharge
$datanorm_metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0;
// Scale Cu from Dolibarr's quantity to Datanorm's price_unit
// Example: Cu 152,73€ for 50m → per meter = 3,05€ → for 100m = 305,46€
$cu_per_unit = ($current_kupferzuschlag > 0 && $effective_quantity > 0)
? $current_kupferzuschlag / $effective_quantity
: 0;
$cu_for_price_unit = $cu_per_unit * $price_unit;
// Use scaled Cu, or fallback to Datanorm metal_surcharge
$effective_surcharge = ($cu_for_price_unit > 0) ? $cu_for_price_unit : $datanorm_metal_surcharge;
// Total price for price_unit includes scaled surcharge
$total_price_for_pe = $datanorm->price + $effective_surcharge;
// Unit price (per 1 piece/meter)
$datanorm_unit_price = $total_price_for_pe / $price_unit;
$metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0;
$total_price = $datanorm->price + $metal_surcharge;
$datanorm_unit_price = $total_price / $price_unit;
// Load product
$product = new Product($db);
@ -1678,7 +1494,20 @@ function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $a
$updated = true;
}
// Update label only (description goes to supplier price desc_fourn below)
// Update description
if ($apply_description) {
$new_desc = trim($datanorm->short_text1.' '.$datanorm->short_text2);
if ($product->description != $new_desc) {
$changes[] = array(
'field' => 'description',
'old' => $old_description,
'new' => $new_desc
);
$product->description = $new_desc;
$updated = true;
}
}
// Save product changes
if ($updated) {
$result = $product->update($product->id, $user);
@ -1687,8 +1516,8 @@ function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $a
}
}
// Update supplier price and/or description
if ($apply_price || $apply_description) {
// Update supplier price
if ($apply_price) {
$productFourn = new ProductFournisseur($db);
$productFourn->fetch($product_id);
@ -1697,7 +1526,7 @@ function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $a
$supplier->fetch($fk_soc);
// Find existing supplier price
$sql = "SELECT rowid, quantity, price, unitprice, desc_fourn FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sql = "SELECT rowid, quantity, price, unitprice FROM ".MAIN_DB_PREFIX."product_fournisseur_price";
$sql .= " WHERE fk_product = ".((int)$product_id);
$sql .= " AND fk_soc = ".((int)$fk_soc);
$sql .= " ORDER BY rowid DESC LIMIT 1";
@ -1705,66 +1534,54 @@ function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $a
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$priceObj = $db->fetch_object($resql);
$price_rowid = $priceObj->rowid;
// Use effective_quantity (kaufmenge if set, otherwise quantity) for price comparison
// This ensures consistent comparison with buildComparisonResult()
$effective_qty = ($priceDetails['kaufmenge'] > 0) ? $priceDetails['kaufmenge'] : max(1, $priceObj->quantity);
// Get the actual unit price from Dolibarr (price per 1 piece)
$current_unit_price = (!empty($priceObj->unitprice) && $priceObj->unitprice > 0)
? $priceObj->unitprice
: ($priceObj->quantity > 0 ? $priceObj->price / $priceObj->quantity : $priceObj->price);
// Get the actual unit price from Dolibarr (price per 1 effective piece)
$current_unit_price = $effective_qty > 0 ? $priceObj->price / $effective_qty : $priceObj->price;
// Prepare new description if requested
$new_desc_fourn = null;
if ($apply_description) {
$new_desc_fourn = trim($datanorm->short_text1.' '.$datanorm->short_text2);
if ($priceObj->desc_fourn != $new_desc_fourn) {
$changes[] = array(
'field' => 'desc_fourn',
'old' => $priceObj->desc_fourn,
'new' => $new_desc_fourn
);
} else {
$new_desc_fourn = null; // No change needed
}
}
// Check if price needs update
$price_changed = $apply_price && (abs($current_unit_price - $datanorm_unit_price) > 0.01);
if ($price_changed) {
// Only update if unit price differs
if (abs($current_unit_price - $datanorm_unit_price) > 0.01) {
$changes[] = array(
'field' => 'price',
'old' => $current_unit_price,
'new' => $datanorm_unit_price
);
}
// Update only the fields that need changing (preserves all other fields!)
if ($price_changed || $new_desc_fourn !== null) {
$update_fields = array();
// Calculate total price for the quantity (Dolibarr expects total price, not unit price)
// Dolibarr will calculate: unitprice = price / quantity
$total_price_for_qty = $datanorm_unit_price * $priceObj->quantity;
if ($price_changed) {
// Calculate total price for the quantity, round to 2 decimals
$total_price_for_qty = round($datanorm_unit_price * $priceObj->quantity, 2);
$rounded_unit_price = round($datanorm_unit_price, 2);
$update_fields[] = "price = ".((float)$total_price_for_qty);
$update_fields[] = "unitprice = ".((float)$rounded_unit_price);
}
// Update existing price - $supplier must be Societe object, not integer ID
$result = $productFourn->update_buyprice(
$priceObj->quantity,
$total_price_for_qty,
$user,
'HT',
$supplier, // Societe object, not integer
0, // availability
$datanorm->article_number, // ref_fourn
0, // tva_tx
0, // charges
0, // remise_percent
0, // remise
0, // newnpr
0, // delivery_time_days
'', // supplier_reputation
array(), // localtaxes
'', // newdefaultvatcode
0, // multicurrency_buyprice
'', // multicurrency_price_base_type
0, // multicurrency_tx
'', // multicurrency_code
'', // desc_fourn
'', // barcode
0, // fk_barcode_type
array() // options
);
if ($new_desc_fourn !== null) {
$update_fields[] = "desc_fourn = '".$db->escape($new_desc_fourn)."'";
}
if (!empty($update_fields)) {
$sql_update = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price";
$sql_update .= " SET ".implode(", ", $update_fields);
$sql_update .= " WHERE rowid = ".(int)$price_rowid;
$result = $db->query($sql_update);
if (!$result) {
return -4;
}
if ($result < 0) {
return -4;
}
}
}

1187
import.php

File diff suppressed because it is too large Load diff

View file

@ -54,9 +54,6 @@ $langs->loadLangs(array("importzugferd@importzugferd"));
if (!isModEnabled('importzugferd')) {
accessforbidden('Module not enabled');
}
if (!$user->hasRight('importzugferd', 'import', 'read')) {
accessforbidden();
}
/*
* View

View file

@ -451,8 +451,6 @@ 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
@ -488,10 +486,3 @@ AddSelectedPrices = Ausgewählte hinzufügen
SupplierPricesAdded = %s Lieferantenpreise hinzugefügt
CheaperBy = %s%% günstiger
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

View file

@ -389,8 +389,6 @@ 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
@ -419,10 +417,3 @@ AddSelectedPrices = Add Selected
SupplierPricesAdded = %s supplier prices added
CheaperBy = %s%% cheaper
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

View file

@ -15,5 +15,3 @@ ALTER TABLE llx_importzugferd_datanorm ADD COLUMN price_type tinyint DEFAULT 1 A
ALTER TABLE llx_importzugferd_datanorm ADD COLUMN metal_surcharge double(24,8) DEFAULT 0 AFTER price_type;
ALTER TABLE llx_importzugferd_datanorm ADD COLUMN vpe integer DEFAULT NULL AFTER metal_surcharge;
ALTER TABLE llx_importzugferd_datanorm ADD COLUMN action_code char(1) DEFAULT 'N' AFTER datanorm_version;
-- Note: kaufmenge extrafield is created programmatically in modImportZugferd.class.php init()