diff --git a/CHANGELOG.md b/CHANGELOG.md index 391a68f..0003eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Konfigurierbare Tracking-Patterns (Setup-Seite) +- Neue Tabelle `llx_mahnung_trackingpattern` (Pro Eintrag: provider, label, regex, url_template, priority, active). Auto-Migration + Default-Seed beim Setup-Aufruf. +- Default-Patterns: DHL Paket (20-stellig), DPAG Einschreiben (`RR123456789DE`), UPS (1Z…), DHL 11-stellig, Hermes 14-stellig, DPD 14-stellig — Prioritaeten so gesetzt dass spezifischere Patterns zuerst greifen. +- Neue Setup-Seite `admin/tracking_patterns.php` mit CRUD: Pattern anlegen/bearbeiten/aktivieren-deaktivieren/loeschen. +- **Live-Vorschau**: Beim Tippen von Regex/URL/Beispieltext wird via AJAX-Endpoint `ajax/regex_preview.php` direkt gezeigt ob der Regex syntaktisch gueltig ist, was er aus dem Beispieltext matcht und wie die finale Tracking-URL aussieht. +- ReDoS-Schutz im AJAX-Endpoint: max 10 KB Sample, `pcre.backtrack_limit=100k`, Whitelist Delimiter `/ # ~`. +- `Mahnung::trackingUrl()` (hardcoded Fallback) bleibt — primaer wird `MahnungTrackingPattern::urlFor()` aus DB-Patterns benutzt. +- Setup-Page-Link: Button "Tracking-Muster (Regex)" oben rechts auf der Modul-Setup-Seite. + ### Versand & Belege (Mahnungs-Karte) - Neue Felder `date_versand`, `versandweg`, `tracking_nr`, `tracking_provider` an `llx_mahnung_mahnung` — idempotente Migration laeuft beim ersten Setup-Aufruf nach dem Deploy. - Neuer Block "Versand & Belege" auf der Mahnungs-Karte: diff --git a/admin/setup.php b/admin/setup.php index 68fc542..4277032 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -58,6 +58,30 @@ if (!$user->admin && !$user->hasRight('mahnung', 'setup')) { // Schema-Migration bei jedem Setup-Aufruf (idempotent — fehlende Spalten ergaenzen) (new modMahnung($db))->migrateVersandFelder(); +// Tracking-Pattern-Tabelle anlegen (falls noch nicht da) + Default-Patterns seeden +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; +$check = $db->query("SHOW TABLES LIKE '".$db->escape(MAIN_DB_PREFIX.'mahnung_trackingpattern')."'"); +if ($check && $db->num_rows($check) == 0) { + $db->query("CREATE TABLE ".MAIN_DB_PREFIX."mahnung_trackingpattern (" + ." rowid INTEGER AUTO_INCREMENT PRIMARY KEY," + ." entity INTEGER DEFAULT 1 NOT NULL," + ." provider VARCHAR(20) NOT NULL," + ." label VARCHAR(80) NOT NULL," + ." regex VARCHAR(255) NOT NULL," + ." url_template VARCHAR(255) NOT NULL," + ." priority INTEGER DEFAULT 100," + ." active TINYINT DEFAULT 1," + ." datec DATETIME," + ." tms TIMESTAMP," + ." INDEX idx_trackingpattern_active (active, priority)," + ." INDEX idx_trackingpattern_entity (entity)" + .") ENGINE=InnoDB"); +} +if ($check) { + $db->free($check); +} +MahnungTrackingPattern::seedDefaults($db); + $action = GETPOST('action', 'aZ09'); // --------------------------------------------------------------- @@ -212,7 +236,8 @@ if ($action === 'del' && $user->hasRight('mahnung', 'setup')) { llxHeader('', $langs->trans('MahnungSetupPage')); -print load_fiche_titre($langs->trans('MahnungSetupPage'), '', 'fa-envelope-open-text'); +$setupExtra = ''.dol_escape_htmltag($langs->trans('MahnungTrackingPatternsSetup')).''; +print load_fiche_titre($langs->trans('MahnungSetupPage'), $setupExtra, 'fa-envelope-open-text'); print ''.$langs->trans('MahnungSetupDescription').'

'; // --- Block: Konstanten ------------------------------------------------------- diff --git a/admin/tracking_patterns.php b/admin/tracking_patterns.php new file mode 100644 index 0000000..45e4f27 --- /dev/null +++ b/admin/tracking_patterns.php @@ -0,0 +1,320 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file mahnung/admin/tracking_patterns.php + * \ingroup mahnung + * \brief Konfigurations-Seite fuer Tracking-Pattern (Regex + URL-Template). + * Live-Vorschau via /custom/mahnung/ajax/regex_preview.php. + */ + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; + +global $langs, $user, $db; +$langs->loadLangs(array('admin', 'mahnung@mahnung')); + +if (!$user->admin && !$user->hasRight('mahnung', 'setup')) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); +$rowid = GETPOSTINT('rowid'); + +// POST: Speichern (Neu oder Update) +if (($action === 'save_new' || $action === 'save_edit') && $user->hasRight('mahnung', 'setup')) { + $p = new MahnungTrackingPattern($db); + if ($action === 'save_edit' && $rowid > 0) { + if ($p->fetch($rowid) <= 0) { + setEventMessages('Pattern nicht gefunden', null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + } + $p->provider = trim(GETPOST('provider', 'aZ09')); + $p->label = trim(GETPOST('label', 'alphanohtml')); + $p->regex = (string) GETPOST('regex', 'nohtml'); // Regex kann Sonderzeichen enthalten + $p->url_template = trim(GETPOST('url_template', 'alphanohtml')); + $p->priority = GETPOSTINT('priority'); + if ($p->priority <= 0) { + $p->priority = 100; + } + $p->active = GETPOST('active', 'int') ? 1 : 0; + + // Basis-Validierung + $errors = array(); + if ($p->provider === '') { + $errors[] = $langs->trans('MahnungTrackingPatternProviderRequired'); + } + if ($p->label === '') { + $errors[] = $langs->trans('MahnungTrackingPatternLabelRequired'); + } + if (!MahnungTrackingPattern::isValidRegex($p->regex)) { + $errors[] = $langs->trans('MahnungTrackingPatternRegexInvalid'); + } + if (strpos($p->url_template, 'https://') !== 0) { + $errors[] = $langs->trans('MahnungTrackingPatternUrlMustHttps'); + } + if (strpos($p->url_template, '{nr}') === false) { + $errors[] = $langs->trans('MahnungTrackingPatternUrlMissingPlaceholder'); + } + if (!empty($errors)) { + setEventMessages('', $errors, 'errors'); + } else { + $ret = ($action === 'save_new') ? $p->create() : $p->update(); + if ($ret > 0) { + setEventMessages($langs->trans('MahnungTrackingPatternSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } else { + setEventMessages($p->error ?: 'Speichern fehlgeschlagen', null, 'errors'); + } + } +} + +// POST/GET: Loeschen +if ($action === 'delete' && $rowid > 0 && $user->hasRight('mahnung', 'setup')) { + $p = new MahnungTrackingPattern($db); + if ($p->fetch($rowid) > 0 && $p->delete() > 0) { + setEventMessages($langs->trans('MahnungTrackingPatternDeleted'), null, 'mesgs'); + } + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +// Toggle active +if ($action === 'toggle_active' && $rowid > 0 && $user->hasRight('mahnung', 'setup')) { + $p = new MahnungTrackingPattern($db); + if ($p->fetch($rowid) > 0) { + $p->active = $p->active ? 0 : 1; + $p->update(); + } + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +llxHeader('', $langs->trans('MahnungTrackingPatternsSetup')); + +print load_fiche_titre($langs->trans('MahnungTrackingPatternsSetup'), ''.dol_escape_htmltag($langs->trans('Back')).'', 'fa-route'); + +print '
'.$langs->trans('MahnungTrackingPatternsIntro').'
'; + +$service = new MahnungTrackingPattern($db); +$all = $service->fetchAll(false); + +// Edit-Modus laden +$editP = null; +if ($action === 'edit' && $rowid > 0) { + $tmp = new MahnungTrackingPattern($db); + if ($tmp->fetch($rowid) > 0) { + $editP = $tmp; + } +} + +// Liste der bestehenden Patterns +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +if (empty($all)) { + print ''; +} +foreach ($all as $p) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; +} +print '
'.$langs->trans('MahnungTrackingPatternLabel').''.$langs->trans('MahnungTrackingPatternProvider').''.$langs->trans('MahnungTrackingPatternRegex').''.$langs->trans('MahnungTrackingPatternUrlTemplate').''.$langs->trans('MahnungTrackingPatternPriority').''.$langs->trans('Status').'
'.$langs->trans('MahnungTrackingPatternsEmpty').'
'.dol_escape_htmltag($p['label']).''.dol_escape_htmltag($p['provider']).''.dol_escape_htmltag($p['regex']).''.dol_escape_htmltag($p['url_template']).''.((int) $p['priority']).''; + $toggleLabel = $p['active'] ? $langs->trans('Active') : $langs->trans('Disabled'); + $toggleColor = $p['active'] ? 'badge-status4' : 'badge-status8'; + print ''.dol_escape_htmltag($toggleLabel).''; + print ''; + print ''.img_picto($langs->trans('Edit'), 'edit').' '; + print ''.img_picto($langs->trans('Delete'), 'delete').''; + print '
'; + +// Formular: Neu / Edit +$isEdit = ($editP !== null); +$formAction = $isEdit ? 'save_edit' : 'save_new'; +$valProv = $editP ? $editP->provider : ''; +$valLabel = $editP ? $editP->label : ''; +$valRegex = $editP ? $editP->regex : '/\\b(\\d{14})\\b/'; +$valUrl = $editP ? $editP->url_template : 'https://example.com/track?nr={nr}'; +$valPrio = $editP ? (int) $editP->priority : 100; +$valActive = $editP ? (int) $editP->active : 1; + +print '

'.dol_escape_htmltag($isEdit ? $langs->trans('MahnungTrackingPatternEditTitle') : $langs->trans('MahnungTrackingPatternNewTitle')).'

'; +print '
'; +print ''; +print ''; +if ($isEdit) { + print ''; +} + +print ''; +print ''; + +print ''; + +print ''; + +print ''; + +print ''; + +print ''; + +print ''; +print '
'.$langs->trans('MahnungTrackingPatternLabel').''; +print ''; +print '
'.$langs->trans('MahnungTrackingPatternProvider').''; +print ''; +print '
'.$langs->trans('MahnungTrackingPatternRegex').''; +print ''; +print '
 
'; +print '
'.$langs->trans('MahnungTrackingPatternUrlTemplate').''; +print ''; +print '
'.$langs->trans('MahnungTrackingPatternUrlHint').'
'; +print '
'.$langs->trans('MahnungTrackingPatternPriority').''; +print ' '; +print ''.$langs->trans('MahnungTrackingPatternPriorityHint').''; +print '
'.$langs->trans('Status').''; +print ' '.$langs->trans('Active'); +print '
'.$langs->trans('MahnungTrackingPatternSample').''; +print ''; +print '
 
'; +print '
'; + +print '
'; +print ' '; +if ($isEdit) { + print ''.dol_escape_htmltag($langs->trans('Cancel')).''; +} +print '
'; +print '
'; + +// Live-Vorschau-JS +$ajaxUrl = DOL_URL_ROOT.'/custom/mahnung/ajax/regex_preview.php'; +$labelInvalid = dol_escape_js($langs->trans('MahnungTrackingPatternRegexInvalid')); +$labelValid = dol_escape_js($langs->trans('MahnungTrackingPatternRegexValid')); +$labelMatch = dol_escape_js($langs->trans('MahnungTrackingPatternMatch')); +$labelNoMatch = dol_escape_js($langs->trans('MahnungTrackingPatternNoMatch')); + +print << +(function() { + var regexEl = document.getElementById('pat_regex'); + var urlEl = document.getElementById('pat_url'); + var sampleEl = document.getElementById('pat_sample'); + var statusEl = document.getElementById('regex_status'); + var previewEl = document.getElementById('pat_preview'); + var debounceTimer = null; + + function update() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function() { + var regex = regexEl.value; + var sample = sampleEl.value; + var url = urlEl.value; + if (!regex) { + statusEl.textContent = ''; + previewEl.innerHTML = ' '; + return; + } + var fd = new FormData(); + fd.append('regex', regex); + fd.append('sample', sample); + fd.append('url_template', url); + fetch('$ajaxUrl', { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.valid) { + statusEl.textContent = '✗ ' + '$labelInvalid' + (data.error ? ' — ' + data.error : ''); + statusEl.style.color = '#c33'; + previewEl.innerHTML = ' '; + return; + } + statusEl.textContent = '✓ ' + '$labelValid'; + statusEl.style.color = '#2a8'; + if (data.match) { + var html = '$labelMatch' + ': ' + escapeHtml(data.match) + ''; + if (data.preview_url) { + html += ' → ' + escapeHtml(data.preview_url) + ''; + } + previewEl.innerHTML = html; + } else if (sample.trim() !== '') { + previewEl.innerHTML = '$labelNoMatch'; + } else { + previewEl.innerHTML = ' '; + } + }) + .catch(function() { statusEl.textContent = '(Preview-Fehler)'; }); + }, 300); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function(c) { + return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; + }); + } + function escapeAttr(s) { return escapeHtml(s); } + + if (regexEl && urlEl && sampleEl) { + [regexEl, urlEl, sampleEl].forEach(function(el) { + el.addEventListener('input', update); + el.addEventListener('change', update); + }); + // Initial-Preview falls Sample schon befuellt + update(); + } +})(); + +EOT; + +llxFooter(); +$db->close(); diff --git a/ajax/regex_preview.php b/ajax/regex_preview.php new file mode 100644 index 0000000..0fcd007 --- /dev/null +++ b/ajax/regex_preview.php @@ -0,0 +1,89 @@ + + * + * GPL v3 (siehe COPYING). + */ + +/** + * \file htdocs/custom/mahnung/ajax/regex_preview.php + * \ingroup mahnung + * \brief AJAX-Endpoint: Live-Vorschau fuer Tracking-Regex auf der Setup-Seite. + * + * POST: regex (string), sample (string, max 10 KB), url_template (string) + * Response (JSON): { valid: bool, match: string|null, preview_url: string|null, error: string|null } + */ + +if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1'); +if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1'); + +require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; + +global $user, $langs; +$langs->load('mahnung@mahnung'); + +header('Content-Type: application/json; charset=utf-8'); + +if (!$user->admin && !$user->hasRight('mahnung', 'setup')) { + echo json_encode(array('valid' => false, 'error' => 'access denied')); + exit; +} + +// ReDoS-Schutz: Eingaben begrenzen + Zeitlimit +$regex = (string) GETPOST('regex', 'nohtml'); +$sample = (string) GETPOST('sample', 'nohtml'); +$urlTemplate = (string) GETPOST('url_template', 'alphanohtml'); + +if (strlen($regex) > 255) { + echo json_encode(array('valid' => false, 'error' => 'regex too long (max 255)')); + exit; +} +if (strlen($sample) > 10240) { + $sample = substr($sample, 0, 10240); +} + +// Whitelist: Delimiter / # ~ +if ($regex === '' || !in_array($regex[0], array('/', '#', '~'), true)) { + echo json_encode(array('valid' => false, 'error' => 'allowed delimiters: / # ~')); + exit; +} + +// ReDoS-Schutz via PCRE-Backtrack-Limit (klein halten) +$prevBacktrack = ini_get('pcre.backtrack_limit'); +$prevRecursion = ini_get('pcre.recursion_limit'); +@ini_set('pcre.backtrack_limit', '100000'); +@ini_set('pcre.recursion_limit', '10000'); + +$matches = array(); +$ret = @preg_match($regex, '', $matches); // erst leerer String — testet Syntax +if ($ret === false) { + @ini_set('pcre.backtrack_limit', (string) $prevBacktrack); + @ini_set('pcre.recursion_limit', (string) $prevRecursion); + $err = preg_last_error_msg(); + echo json_encode(array('valid' => false, 'error' => 'invalid regex'.($err && $err !== 'No error' ? ': '.$err : ''))); + exit; +} + +// Echtes Sample matchen +$match = null; +if ($sample !== '') { + $ret = @preg_match($regex, $sample, $matches); + if ($ret === 1) { + $match = !empty($matches[1]) ? $matches[1] : $matches[0]; + } +} + +@ini_set('pcre.backtrack_limit', (string) $prevBacktrack); +@ini_set('pcre.recursion_limit', (string) $prevRecursion); + +$previewUrl = null; +if ($match !== null && strpos($urlTemplate, 'https://') === 0 && strpos($urlTemplate, '{nr}') !== false) { + $previewUrl = str_replace('{nr}', rawurlencode($match), $urlTemplate); +} + +echo json_encode(array( + 'valid' => true, + 'match' => $match, + 'preview_url' => $previewUrl, + 'error' => null, +)); +exit; diff --git a/card.php b/card.php index 78fc4da..54b0350 100644 --- a/card.php +++ b/card.php @@ -251,7 +251,8 @@ if (!empty($mahnung->date_versand) && $action !== 'edit_versand') { .($mahnung->versandweg && isset($versandwege[$mahnung->versandweg]) ? dol_escape_htmltag($versandwege[$mahnung->versandweg]) : dol_escape_htmltag((string) $mahnung->versandweg)) .''; if (!empty($mahnung->tracking_nr)) { - $trackUrl = Mahnung::trackingUrl((string) $mahnung->tracking_provider, (string) $mahnung->tracking_nr); + require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; + $trackUrl = (new MahnungTrackingPattern($db))->urlFor((string) $mahnung->tracking_provider, (string) $mahnung->tracking_nr); print ''.$langs->trans('MahnungTrackingNr').''; print ''.dol_escape_htmltag($mahnung->tracking_nr).''; if (!empty($trackUrl)) { diff --git a/class/mahnungtrackingpattern.class.php b/class/mahnungtrackingpattern.class.php new file mode 100644 index 0000000..e9000b7 --- /dev/null +++ b/class/mahnungtrackingpattern.class.php @@ -0,0 +1,337 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3. + */ + +/** + * \file htdocs/custom/mahnung/class/mahnungtrackingpattern.class.php + * \ingroup mahnung + * \brief CRUD + Lookup fuer konfigurierbare Tracking-Patterns (Regex + URL-Template). + * + * Patterns werden in llx_mahnung_trackingpattern gehalten. Beim Upload eines + * Sendebelegs werden alle aktiven Patterns nach priority DESC durchprobiert; + * der erste Match gewinnt. + */ + +class MahnungTrackingPattern +{ + /** @var DoliDB */ + public $db; + + /** @var int */ + public $id; + + /** @var int */ + public $entity; + + /** @var string Provider-Slug, z.B. 'dhl', 'dpag', 'hermes', 'dpd', 'ups', 'custom' */ + public $provider; + + /** @var string Anzeige-Label, z.B. "DHL Paket 20-stellig" */ + public $label; + + /** @var string Regex inkl. Delimiter, z.B. '/\b(\d{20})\b/' */ + public $regex; + + /** @var string URL-Template mit Platzhalter {nr}, z.B. 'https://www.dhl.de/...?piececode={nr}' */ + public $url_template; + + /** @var int hoeher = wichtiger (zuerst geprueft) */ + public $priority = 100; + + /** @var int 0/1 */ + public $active = 1; + + /** @var int Unix-Zeit */ + public $datec; + + /** @var int Unix-Zeit */ + public $tms; + + /** + * @param DoliDB $db + */ + public function __construct($db) + { + global $conf; + $this->db = $db; + $this->entity = $conf->entity; + } + + /** + * Pattern anlegen. + * + * @return int <0 Fehler, sonst neue rowid + */ + public function create() + { + if (!self::isValidRegex($this->regex)) { + $this->error = 'Invalid regex'; + return -2; + } + + $now = dol_now(); + $sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_trackingpattern "; + $sql .= "(entity, provider, label, regex, url_template, priority, active, datec) VALUES ("; + $sql .= ((int) $this->entity).","; + $sql .= "'".$this->db->escape((string) $this->provider)."',"; + $sql .= "'".$this->db->escape((string) $this->label)."',"; + $sql .= "'".$this->db->escape((string) $this->regex)."',"; + $sql .= "'".$this->db->escape((string) $this->url_template)."',"; + $sql .= ((int) $this->priority).","; + $sql .= ((int) $this->active).","; + $sql .= "'".$this->db->idate($now)."'"; + $sql .= ")"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.'mahnung_trackingpattern'); + return $this->id; + } + + /** + * @param int $id + * @return int -1 Fehler, 0 nicht gefunden, >0 OK + */ + public function fetch($id) + { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."mahnung_trackingpattern WHERE rowid = ".((int) $id); + $resql = $this->db->query($sql); + if (!$resql) { + return -1; + } + if (!$this->db->num_rows($resql)) { + $this->db->free($resql); + return 0; + } + $obj = $this->db->fetch_object($resql); + $this->id = (int) $obj->rowid; + $this->entity = (int) $obj->entity; + $this->provider = $obj->provider; + $this->label = $obj->label; + $this->regex = $obj->regex; + $this->url_template = $obj->url_template; + $this->priority = (int) $obj->priority; + $this->active = (int) $obj->active; + $this->datec = $this->db->jdate($obj->datec); + $this->tms = $this->db->jdate($obj->tms); + $this->db->free($resql); + return 1; + } + + /** + * Pattern aktualisieren. + * + * @return int <0 Fehler, sonst id + */ + public function update() + { + if (empty($this->id)) { + return -1; + } + if (!self::isValidRegex($this->regex)) { + $this->error = 'Invalid regex'; + return -2; + } + $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_trackingpattern SET "; + $sql .= "provider = '".$this->db->escape((string) $this->provider)."'"; + $sql .= ", label = '".$this->db->escape((string) $this->label)."'"; + $sql .= ", regex = '".$this->db->escape((string) $this->regex)."'"; + $sql .= ", url_template = '".$this->db->escape((string) $this->url_template)."'"; + $sql .= ", priority = ".((int) $this->priority); + $sql .= ", active = ".((int) $this->active); + $sql .= " WHERE rowid = ".((int) $this->id); + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + return $this->id; + } + + /** + * Pattern loeschen. + * + * @return int <0 Fehler, sonst 1 + */ + public function delete() + { + if (empty($this->id)) { + return -1; + } + $resql = $this->db->query("DELETE FROM ".MAIN_DB_PREFIX."mahnung_trackingpattern WHERE rowid = ".((int) $this->id)); + return $resql ? 1 : -1; + } + + /** + * Alle Patterns laden (sortiert nach priority DESC, label ASC). + * + * @param bool $onlyActive + * @return array + */ + public function fetchAll($onlyActive = false) + { + $sql = "SELECT rowid, entity, provider, label, regex, url_template, priority, active"; + $sql .= " FROM ".MAIN_DB_PREFIX."mahnung_trackingpattern"; + $sql .= " WHERE entity IN (".getEntity('mahnung').")"; + if ($onlyActive) { + $sql .= " AND active = 1"; + } + $sql .= " ORDER BY priority DESC, label ASC"; + + $resql = $this->db->query($sql); + if (!$resql) { + return array(); + } + $out = array(); + while ($obj = $this->db->fetch_object($resql)) { + $out[] = array( + 'rowid' => (int) $obj->rowid, + 'provider' => $obj->provider, + 'label' => $obj->label, + 'regex' => $obj->regex, + 'url_template' => $obj->url_template, + 'priority' => (int) $obj->priority, + 'active' => (int) $obj->active, + ); + } + $this->db->free($resql); + return $out; + } + + /** + * Sucht in einem Beispieltext nach der ersten passenden Sendungsnummer. + * + * @param string $haystack + * @return array|null array{provider:string, nr:string, label:string, url:string} oder null + */ + public function detectFromText($haystack) + { + $haystack = (string) $haystack; + if ($haystack === '') { + return null; + } + foreach ($this->fetchAll(true) as $p) { + if (!self::isValidRegex($p['regex'])) { + continue; + } + $matches = array(); + $ret = @preg_match($p['regex'], $haystack, $matches); + if ($ret === 1) { + $nr = !empty($matches[1]) ? $matches[1] : $matches[0]; + return array( + 'provider' => $p['provider'], + 'nr' => $nr, + 'label' => $p['label'], + 'url' => str_replace('{nr}', rawurlencode($nr), $p['url_template']), + ); + } + } + return null; + } + + /** + * Robuste Regex-Validierung (kein ReDoS-Schutz, aber Syntax-Check). + * Erlaubt nur Delimiter /, #, ~. URL-Template wird hier NICHT geprueft. + * + * @param string $regex + * @return bool + */ + public static function isValidRegex($regex) + { + if (!is_string($regex) || strlen($regex) < 3 || strlen($regex) > 255) { + return false; + } + // Erlaubte Delimiter + $first = $regex[0]; + if (!in_array($first, array('/', '#', '~'), true)) { + return false; + } + // @ unterdrueckt Warning, false bei ungueltigem Regex + $ret = @preg_match($regex, ''); + return $ret !== false; + } + + /** + * URL aus Provider + Sendungsnummer bauen — sucht passendes Pattern aus DB. + * Fallback: Mahnung::trackingUrl() (hardcoded URLs). + * + * @param string $provider + * @param string $nr + * @return string + */ + public function urlFor($provider, $nr) + { + $nr = trim((string) $nr); + if ($nr === '') { + return ''; + } + // Erstes aktives Pattern mit passendem Provider nutzen + foreach ($this->fetchAll(true) as $p) { + if ($p['provider'] === $provider) { + return str_replace('{nr}', rawurlencode($nr), $p['url_template']); + } + } + // Fallback auf Hardcoded (in Mahnung::trackingUrl) + require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; + return Mahnung::trackingUrl($provider, $nr); + } + + /** + * Default-Patterns seeden (idempotent). + * + * @param DoliDB $db + * @return int Anzahl eingefuegter Patterns + */ + public static function seedDefaults($db) + { + global $conf; + $entity = (int) $conf->entity; + + // Anzahl bestehender Patterns pruefen + $resql = $db->query("SELECT COUNT(*) AS nb FROM ".MAIN_DB_PREFIX."mahnung_trackingpattern WHERE entity = ".$entity); + if (!$resql) { + return 0; + } + $obj = $db->fetch_object($resql); + $db->free($resql); + if ((int) $obj->nb > 0) { + return 0; // Schon befuellt + } + + // Defaults — priority hoeher = spezifischer (zuerst gepruef) + $defaults = array( + array('dhl', 'DHL Paket (20-stellig)', '/\b(\d{20})\b/', 'https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode={nr}', 90), + array('dpag', 'Deutsche Post Einschreiben', '/\b(R[A-Z]\d{9}DE)\b/', 'https://www.deutschepost.de/sendung/simpleQuery.html?form.sendungsnummer={nr}', 85), + array('ups', 'UPS (1Z + 16 Zeichen)', '/\b(1Z[A-HJ-NP-Z0-9]{16})\b/i', 'https://www.ups.com/track?tracknum={nr}', 80), + array('dhl', 'DHL Paket Online-Frankierung (11-stellig)','/\b(\d{11})\b/', 'https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode={nr}', 30), + array('hermes', 'Hermes (14-stellig)', '/\b([Hh]?\d{14})\b/', 'https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#{nr}', 25), + array('dpd', 'DPD (14-stellig)', '/\b(\d{14})\b/', 'https://tracking.dpd.de/status/de_DE/parcel/{nr}', 20), + ); + $now = dol_now(); + $count = 0; + foreach ($defaults as $row) { + list($provider, $label, $regex, $url, $priority) = $row; + $sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_trackingpattern "; + $sql .= "(entity, provider, label, regex, url_template, priority, active, datec) VALUES ("; + $sql .= $entity.","; + $sql .= "'".$db->escape($provider)."',"; + $sql .= "'".$db->escape($label)."',"; + $sql .= "'".$db->escape($regex)."',"; + $sql .= "'".$db->escape($url)."',"; + $sql .= ((int) $priority).","; + $sql .= "1,"; + $sql .= "'".$db->idate($now)."'"; + $sql .= ")"; + if ($db->query($sql)) { + $count++; + } + } + return $count; + } +} diff --git a/core/modules/modMahnung.class.php b/core/modules/modMahnung.class.php index 2593054..b5ba6fc 100644 --- a/core/modules/modMahnung.class.php +++ b/core/modules/modMahnung.class.php @@ -319,6 +319,10 @@ class modMahnung extends DolibarrModules // Migration: Versand-Felder ergaenzen, falls Tabelle aus alter Version stammt $this->migrateVersandFelder(); + // Default-Tracking-Patterns seeden (idempotent — nur beim ersten Mal) + require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; + MahnungTrackingPattern::seedDefaults($this->db); + // Dokumentenmodelle registrieren $sql = array(); $sql[] = "DELETE FROM ".MAIN_DB_PREFIX."document_model WHERE nom = 'standard_mahnung' AND type = 'mahnung' AND entity = ".((int) $conf->entity); diff --git a/langs/de_DE/mahnung.lang b/langs/de_DE/mahnung.lang index 1af1875..5b4eb72 100644 --- a/langs/de_DE/mahnung.lang +++ b/langs/de_DE/mahnung.lang @@ -88,6 +88,34 @@ MahnungVersandGeleert = Versanddaten zurueckgesetzt MahnungSendebelege = Sendebelege MahnungSendebelegeHint = Hier Beleg von Post/DHL/Fax/Mail hochladen (PDF, Foto). Bleibt am Mahnvorgang fuer spaetere Nachweise. +# +# Tracking-Patterns (Phase 3) +# +MahnungTrackingPatternsSetup = Tracking-Muster (Regex) +MahnungTrackingPatternsIntro = Hier konfigurierst du, welche Regex auf den Text eines hochgeladenen Sendebelegs angewendet werden, um die Sendungsnummer + den passenden Provider automatisch zu erkennen. Reihenfolge: hoechste Prioritaet zuerst. +MahnungTrackingPatternsEmpty = Keine Tracking-Muster konfiguriert. Beim ersten Aufruf werden Standard-Muster eingesetzt. +MahnungTrackingPatternLabel = Bezeichnung +MahnungTrackingPatternProvider = Provider-Schluessel +MahnungTrackingPatternRegex = Regex (mit Delimiter / # ~) +MahnungTrackingPatternUrlTemplate = URL-Template +MahnungTrackingPatternUrlHint = https-URL mit Platzhalter {nr} — wird mit der erkannten Sendungsnummer ersetzt. +MahnungTrackingPatternPriority = Prioritaet +MahnungTrackingPatternPriorityHint = Hoeher = wird zuerst gepruef (z.B. 90 fuer spezifisch, 20 fuer generisch). +MahnungTrackingPatternSample = Beispieltext (Live-Vorschau) +MahnungTrackingPatternSamplePlaceholder = Hier ein Beispiel-Beleg-Text einfuegen — Treffer + URL erscheinen live unten. +MahnungTrackingPatternMatch = Treffer +MahnungTrackingPatternNoMatch = Kein Treffer im Beispieltext. +MahnungTrackingPatternRegexValid = Regex syntaktisch gueltig. +MahnungTrackingPatternRegexInvalid = Regex ungueltig. +MahnungTrackingPatternNewTitle = Neues Muster anlegen +MahnungTrackingPatternEditTitle = Muster bearbeiten +MahnungTrackingPatternSaved = Muster gespeichert. +MahnungTrackingPatternDeleted = Muster geloescht. +MahnungTrackingPatternProviderRequired = Provider-Schluessel fehlt. +MahnungTrackingPatternLabelRequired = Bezeichnung fehlt. +MahnungTrackingPatternUrlMustHttps = URL-Template muss mit https:// beginnen. +MahnungTrackingPatternUrlMissingPlaceholder = URL-Template muss den Platzhalter {nr} enthalten. + # # Liste / Karte # diff --git a/langs/en_US/mahnung.lang b/langs/en_US/mahnung.lang index e296c48..ef03c33 100644 --- a/langs/en_US/mahnung.lang +++ b/langs/en_US/mahnung.lang @@ -88,6 +88,34 @@ MahnungVersandGeleert = Shipment data reset MahnungSendebelege = Shipment receipts MahnungSendebelegeHint = Upload receipt from postal carrier/DHL/fax/mail (PDF or photo). Stays attached to the dunning case for later verification. +# +# Tracking patterns (Phase 3) +# +MahnungTrackingPatternsSetup = Tracking patterns (regex) +MahnungTrackingPatternsIntro = Configure which regex is applied to the text of an uploaded shipment receipt to detect tracking number + provider automatically. Higher priority is checked first. +MahnungTrackingPatternsEmpty = No tracking patterns configured. Defaults will be seeded on first call. +MahnungTrackingPatternLabel = Label +MahnungTrackingPatternProvider = Provider slug +MahnungTrackingPatternRegex = Regex (delimiters / # ~) +MahnungTrackingPatternUrlTemplate = URL template +MahnungTrackingPatternUrlHint = https URL with placeholder {nr} — replaced with the detected tracking number. +MahnungTrackingPatternPriority = Priority +MahnungTrackingPatternPriorityHint = Higher = checked first (e.g. 90 for specific, 20 for generic). +MahnungTrackingPatternSample = Sample text (live preview) +MahnungTrackingPatternSamplePlaceholder = Paste a sample receipt text here — match + URL appear live below. +MahnungTrackingPatternMatch = Match +MahnungTrackingPatternNoMatch = No match in sample. +MahnungTrackingPatternRegexValid = Regex syntactically valid. +MahnungTrackingPatternRegexInvalid = Invalid regex. +MahnungTrackingPatternNewTitle = Add new pattern +MahnungTrackingPatternEditTitle = Edit pattern +MahnungTrackingPatternSaved = Pattern saved. +MahnungTrackingPatternDeleted = Pattern deleted. +MahnungTrackingPatternProviderRequired = Provider slug is required. +MahnungTrackingPatternLabelRequired = Label is required. +MahnungTrackingPatternUrlMustHttps = URL template must start with https://. +MahnungTrackingPatternUrlMissingPlaceholder = URL template must contain the {nr} placeholder. + # # List / card # diff --git a/sql/llx_mahnung_trackingpattern.key.sql b/sql/llx_mahnung_trackingpattern.key.sql new file mode 100644 index 0000000..623c0e2 --- /dev/null +++ b/sql/llx_mahnung_trackingpattern.key.sql @@ -0,0 +1,3 @@ +-- Indizes fuer llx_mahnung_trackingpattern +ALTER TABLE llx_mahnung_trackingpattern ADD INDEX idx_trackingpattern_active (active, priority); +ALTER TABLE llx_mahnung_trackingpattern ADD INDEX idx_trackingpattern_entity (entity); diff --git a/sql/llx_mahnung_trackingpattern.sql b/sql/llx_mahnung_trackingpattern.sql new file mode 100644 index 0000000..979ed2d --- /dev/null +++ b/sql/llx_mahnung_trackingpattern.sql @@ -0,0 +1,21 @@ +-- Copyright (C) 2026 Eduard Wisch +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. + +-- Konfigurierbare Tracking-Pattern (Regex + URL-Template) pro Versand-Provider. + +CREATE TABLE llx_mahnung_trackingpattern ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + entity INTEGER DEFAULT 1 NOT NULL, + provider VARCHAR(20) NOT NULL, + label VARCHAR(80) NOT NULL, + regex VARCHAR(255) NOT NULL, + url_template VARCHAR(255) NOT NULL, + priority INTEGER DEFAULT 100, + active TINYINT DEFAULT 1, + datec DATETIME, + tms TIMESTAMP +) ENGINE=InnoDB;