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('MahnungTrackingPatternLabel').' | '; +print ''.$langs->trans('MahnungTrackingPatternProvider').' | '; +print ''.$langs->trans('MahnungTrackingPatternRegex').' | '; +print ''.$langs->trans('MahnungTrackingPatternUrlTemplate').' | '; +print ''.$langs->trans('MahnungTrackingPatternPriority').' | '; +print ''.$langs->trans('Status').' | '; +print ''; +print ' |
|---|---|---|---|---|---|---|
| '.$langs->trans('MahnungTrackingPatternsEmpty').' | ||||||
| '.dol_escape_htmltag($p['label']).' | '; + print ''.dol_escape_htmltag($p['provider']).' | '; + print ''.dol_escape_htmltag($p['regex']).' | ';
+ print ''.dol_escape_htmltag($p['url_template']).' | '; + print ''.((int) $p['priority']).' | '; + print ''; + $toggleLabel = $p['active'] ? $langs->trans('Active') : $langs->trans('Disabled'); + $toggleColor = $p['active'] ? 'badge-status4' : 'badge-status8'; + print ''.dol_escape_htmltag($toggleLabel).''; + print ' | '; + print ''; + print ''.img_picto($langs->trans('Edit'), 'edit').' '; + print ''.img_picto($langs->trans('Delete'), 'delete').''; + print ' | '; + print '
' + 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 ''.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{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;