Konfigurierbare Tracking-Patterns mit Live-Vorschau [deploy]
Some checks failed
Deploy mahnung / deploy (push) Failing after 4s

Datenmodell:
- Neue Tabelle llx_mahnung_trackingpattern (provider, label, regex,
  url_template, priority, active). Auto-Anlage + Default-Seed im setup.php
  und in modMahnung::init() — idempotent.

Default-Patterns (priority hoeher = spezifischer, zuerst gepruef):
- DHL Paket 20-stellig (90), DPAG Einschreiben RR...DE (85), UPS 1Z... (80),
- DHL 11-stellig Online-Frankierung (30), Hermes 14-stellig (25), DPD (20).

Setup-Seite admin/tracking_patterns.php:
- CRUD (Anlegen/Bearbeiten/Aktivieren-Deaktivieren/Loeschen)
- Live-Vorschau: Regex + Beispieltext + URL-Template werden waehrend
  des Tippens (debounce 300ms) via ajax/regex_preview.php ausgewertet.
  UI zeigt: Regex-Syntax-Status, gefundene Sendungsnummer, vollstaendige
  Tracking-URL (anklickbar).
- Validierung: Delimiter / # ~ Whitelist, https://-Pflicht, {nr}-Platzhalter,
  max 255 Zeichen Regex.

AJAX-Endpoint ajax/regex_preview.php:
- ReDoS-Schutz: max 10 KB Sample, pcre.backtrack_limit=100k.
- POST-only (mit Setup-Recht), JSON-Response.

card.php:
- tracking-URL kommt jetzt aus MahnungTrackingPattern::urlFor() (DB-Lookup
  nach Provider) statt hardcoded Mahnung::trackingUrl() — letztere bleibt
  als Fallback.

Setup-Seite: neuer Button "Tracking-Muster (Regex)" oben rechts.

Lang-Keys: 23 neue (de_DE + en_US) fuer Pattern-CRUD + Live-Vorschau.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-11 12:04:42 +02:00
parent bb610a7594
commit 56954d68f3
11 changed files with 867 additions and 2 deletions

View file

@ -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:

View file

@ -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 = '<a href="tracking_patterns.php" class="button smallpaddingimp">'.dol_escape_htmltag($langs->trans('MahnungTrackingPatternsSetup')).'</a>';
print load_fiche_titre($langs->trans('MahnungSetupPage'), $setupExtra, 'fa-envelope-open-text');
print '<span class="opacitymedium">'.$langs->trans('MahnungSetupDescription').'</span><br><br>';
// --- Block: Konstanten -------------------------------------------------------

320
admin/tracking_patterns.php Normal file
View file

@ -0,0 +1,320 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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'), '<a class="butAction" href="'.DOL_URL_ROOT.'/custom/mahnung/admin/setup.php">'.dol_escape_htmltag($langs->trans('Back')).'</a>', 'fa-route');
print '<div class="opacitymedium" style="margin-bottom:12px;">'.$langs->trans('MahnungTrackingPatternsIntro').'</div>';
$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 '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('MahnungTrackingPatternLabel').'</th>';
print '<th>'.$langs->trans('MahnungTrackingPatternProvider').'</th>';
print '<th>'.$langs->trans('MahnungTrackingPatternRegex').'</th>';
print '<th>'.$langs->trans('MahnungTrackingPatternUrlTemplate').'</th>';
print '<th class="center">'.$langs->trans('MahnungTrackingPatternPriority').'</th>';
print '<th class="center">'.$langs->trans('Status').'</th>';
print '<th></th>';
print '</tr>';
if (empty($all)) {
print '<tr><td colspan="7" class="opacitymedium center">'.$langs->trans('MahnungTrackingPatternsEmpty').'</td></tr>';
}
foreach ($all as $p) {
print '<tr class="oddeven">';
print '<td>'.dol_escape_htmltag($p['label']).'</td>';
print '<td>'.dol_escape_htmltag($p['provider']).'</td>';
print '<td><code>'.dol_escape_htmltag($p['regex']).'</code></td>';
print '<td><span class="opacitymedium small">'.dol_escape_htmltag($p['url_template']).'</span></td>';
print '<td class="center">'.((int) $p['priority']).'</td>';
print '<td class="center">';
$toggleLabel = $p['active'] ? $langs->trans('Active') : $langs->trans('Disabled');
$toggleColor = $p['active'] ? 'badge-status4' : 'badge-status8';
print '<a class="badge '.$toggleColor.'" href="'.$_SERVER['PHP_SELF'].'?action=toggle_active&rowid='.((int) $p['rowid']).'&token='.newToken().'">'.dol_escape_htmltag($toggleLabel).'</a>';
print '</td>';
print '<td class="right nowrap">';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=edit&rowid='.((int) $p['rowid']).'#editform">'.img_picto($langs->trans('Edit'), 'edit').'</a> ';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&rowid='.((int) $p['rowid']).'&token='.newToken().'" onclick="return confirm(\''.dol_escape_js($langs->trans('ConfirmDelete')).'\');">'.img_picto($langs->trans('Delete'), 'delete').'</a>';
print '</td>';
print '</tr>';
}
print '</table>';
// 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 '<br><h3 id="editform">'.dol_escape_htmltag($isEdit ? $langs->trans('MahnungTrackingPatternEditTitle') : $langs->trans('MahnungTrackingPatternNewTitle')).'</h3>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" id="patternForm">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="'.dol_escape_htmltag($formAction).'">';
if ($isEdit) {
print '<input type="hidden" name="rowid" value="'.((int) $editP->id).'">';
}
print '<table class="border centpercent">';
print '<tr><td class="titlefield">'.$langs->trans('MahnungTrackingPatternLabel').'</td><td>';
print '<input type="text" name="label" id="pat_label" value="'.dol_escape_htmltag($valLabel).'" size="50" required>';
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungTrackingPatternProvider').'</td><td>';
print '<input type="text" name="provider" id="pat_provider" value="'.dol_escape_htmltag($valProv).'" size="20" required ';
print 'placeholder="dhl|dpag|dpd|hermes|ups|custom">';
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungTrackingPatternRegex').'</td><td>';
print '<input type="text" name="regex" id="pat_regex" value="'.dol_escape_htmltag($valRegex).'" size="60" required ';
print 'style="font-family:monospace;">';
print '<div id="regex_status" class="opacitymedium small" style="margin-top:4px;">&nbsp;</div>';
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungTrackingPatternUrlTemplate').'</td><td>';
print '<input type="text" name="url_template" id="pat_url" value="'.dol_escape_htmltag($valUrl).'" size="60" required ';
print 'placeholder="https://...?nr={nr}">';
print '<div class="opacitymedium small">'.$langs->trans('MahnungTrackingPatternUrlHint').'</div>';
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungTrackingPatternPriority').'</td><td>';
print '<input type="number" name="priority" value="'.((int) $valPrio).'" size="4"> ';
print '<span class="opacitymedium small">'.$langs->trans('MahnungTrackingPatternPriorityHint').'</span>';
print '</td></tr>';
print '<tr><td>'.$langs->trans('Status').'</td><td>';
print '<input type="checkbox" name="active" value="1"'.($valActive ? ' checked' : '').'> '.$langs->trans('Active');
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungTrackingPatternSample').'</td><td>';
print '<textarea id="pat_sample" rows="3" style="width:100%; font-family:monospace;" placeholder="'.dol_escape_htmltag($langs->trans('MahnungTrackingPatternSamplePlaceholder')).'"></textarea>';
print '<div id="pat_preview" class="info" style="margin-top:6px; padding:6px; min-height:20px;">&nbsp;</div>';
print '</td></tr>';
print '</table>';
print '<div style="margin-top:8px;">';
print '<button type="submit" class="button">'.dol_escape_htmltag($langs->trans('Save')).'</button> ';
if ($isEdit) {
print '<a class="butActionRefused" href="'.$_SERVER['PHP_SELF'].'">'.dol_escape_htmltag($langs->trans('Cancel')).'</a>';
}
print '</div>';
print '</form>';
// 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 <<<EOT
<script>
(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 = '&nbsp;';
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 = '&nbsp;';
return;
}
statusEl.textContent = '✓ ' + '$labelValid';
statusEl.style.color = '#2a8';
if (data.match) {
var html = '$labelMatch' + ': <code><strong>' + escapeHtml(data.match) + '</strong></code>';
if (data.preview_url) {
html += ' → <a href="' + escapeAttr(data.preview_url) + '" target="_blank" rel="noopener">' + escapeHtml(data.preview_url) + '</a>';
}
previewEl.innerHTML = html;
} else if (sample.trim() !== '') {
previewEl.innerHTML = '<span class="opacitymedium">$labelNoMatch</span>';
} else {
previewEl.innerHTML = '&nbsp;';
}
})
.catch(function() { statusEl.textContent = '(Preview-Fehler)'; });
}, 300);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function(c) {
return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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();
}
})();
</script>
EOT;
llxFooter();
$db->close();

89
ajax/regex_preview.php Normal file
View file

@ -0,0 +1,89 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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;

View file

@ -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))
.'</td></tr>';
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 '<tr><td>'.$langs->trans('MahnungTrackingNr').'</td><td>';
print '<code>'.dol_escape_htmltag($mahnung->tracking_nr).'</code>';
if (!empty($trackUrl)) {

View file

@ -0,0 +1,337 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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<int, array{rowid:int, provider:string, label:string, regex:string, url_template:string, priority:int, active:int}>
*/
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;
}
}

View file

@ -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);

View file

@ -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 <code>{nr}</code> — 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
#

View file

@ -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 <code>{nr}</code> — 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
#

View file

@ -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);

View file

@ -0,0 +1,21 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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;