Beleg-Scan: Sendungsnummer aus hochgeladenen PDFs erkennen [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 14s

Neuer Button "Belege scannen" im Versand-Block der Mahnungs-Karte:
- Action scan_belege durchlaeuft alle Files in DOL_DATA_ROOT/mahnung/<MAHN-Ref>/
- PDFs werden via pdftotext (CLI, mit Verfuegbarkeits-Check) extrahiert
- txt/html werden direkt eingelesen (HTML mit strip_tags)
- Pro Datei wird MahnungTrackingPattern::detectFromText() angewendet —
  matched gegen alle aktiven Patterns nach priority DESC
- Treffer landen in $_SESSION als Vorschlag (file, provider, nr, url, label)

UX:
- Vorschlags-Banner mit gruener Linke ueber dem Beleg-Bereich
- Pro Vorschlag: Datei-Icon, Pattern-Label, Sendungsnummer als <code>,
  externer Link zur Sendungsverfolgung, "Uebernehmen"-Button
- "Uebernehmen" (action=apply_tracking) speichert tracking_nr +
  tracking_provider an der Mahnung und leert Session
- "Verwerfen" (action=dismiss_tracking) entfernt nur Session-Eintrag

Fallback:
- Wenn pdftotext nicht im Container verfuegbar: Warnmeldung im UI,
  txt/html werden trotzdem verarbeitet.

OCR fuer Bilder (PNG/JPG) bewusst noch nicht enthalten — separater Schritt
mit Container-Anpassung (Tesseract) wenn gewuenscht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-11 12:07:09 +02:00
parent 56954d68f3
commit 216c185fb7
4 changed files with 153 additions and 0 deletions

View file

@ -2,6 +2,14 @@
## [Unreleased]
### Beleg-Scan mit Sendungsnummer-Erkennung
- Neuer Button "Belege scannen" im Versand-Block der Mahnungs-Karte.
- Beim Klick werden alle hochgeladenen Belege (PDF via `pdftotext`, sonst txt/html) durchsucht und gegen die konfigurierten Tracking-Patterns gematcht.
- Erkannte Sendungsnummern werden als Vorschlag ueber dem Beleg-Bereich angezeigt (mit Dateiname, Provider-Label, Sendungsnummer + Deep-Link).
- Per "Uebernehmen"-Button werden `tracking_nr` + `tracking_provider` in einem Klick gespeichert. "Verwerfen" entfernt den Vorschlag aus der Session.
- Fallback: wenn `pdftotext` im Container fehlt, wird das im UI klar gemeldet — txt/html werden trotzdem durchsucht.
- OCR (Tesseract fuer Bilder) bewusst noch nicht enthalten — kommt separat falls gewuenscht, ist Container-Aufwand.
### 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.

123
card.php
View file

@ -100,6 +100,88 @@ if ($action === 'set_versand' && $user->hasRight('mahnung', 'write')) {
exit;
}
// Sendungsnummer aus erkanntem Vorschlag uebernehmen
if ($action === 'apply_tracking' && $user->hasRight('mahnung', 'write')) {
$nr = trim((string) GETPOST('nr', 'alphanohtml'));
$prov = GETPOST('provider', 'aZ09');
if ($nr !== '' && $prov !== '') {
$mahnung->tracking_nr = $nr;
$mahnung->tracking_provider = $prov;
if ($mahnung->update($user) > 0) {
setEventMessages($langs->trans('MahnungTrackingUebernommen'), null, 'mesgs');
}
}
// Vorschlag aus Session entfernen
unset($_SESSION['mahnung_tracking_suggestions_'.((int) $mahnung->id)]);
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Vorschlaege verwerfen
if ($action === 'dismiss_tracking' && $user->hasRight('mahnung', 'write')) {
unset($_SESSION['mahnung_tracking_suggestions_'.((int) $mahnung->id)]);
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Belege scannen: pdftotext + Pattern-Matching
if ($action === 'scan_belege' && $user->hasRight('mahnung', 'write')) {
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
$mahnungSafeRef = dol_sanitizeFileName($mahnung->ref);
$scanDir = (!empty($conf->mahnung->multidir_output[$mahnung->entity])
? $conf->mahnung->multidir_output[$mahnung->entity]
: ($conf->mahnung->dir_output ?? (DOL_DATA_ROOT.'/mahnung')))
.'/'.$mahnungSafeRef;
$patternService = new MahnungTrackingPattern($db);
$suggestions = array();
$pdftotextAvailable = null; // einmalig pruefen
if (is_dir($scanDir)) {
foreach (dol_dir_list($scanDir, 'files', 0) as $file) {
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$text = '';
if ($ext === 'txt' || $ext === 'html' || $ext === 'htm') {
$text = @file_get_contents($file['fullname']);
if ($ext === 'html' || $ext === 'htm') {
$text = strip_tags((string) $text);
}
} elseif ($ext === 'pdf') {
if ($pdftotextAvailable === null) {
$check = @shell_exec('command -v pdftotext 2>/dev/null');
$pdftotextAvailable = !empty(trim((string) $check));
}
if ($pdftotextAvailable) {
$cmd = 'pdftotext -layout '.escapeshellarg($file['fullname']).' - 2>/dev/null';
$text = (string) @shell_exec($cmd);
}
}
if ($text === '') {
continue;
}
$hit = $patternService->detectFromText($text);
if ($hit !== null) {
$suggestions[] = array(
'file' => $file['name'],
'provider' => $hit['provider'],
'nr' => $hit['nr'],
'url' => $hit['url'],
'label' => $hit['label'],
);
}
}
}
$_SESSION['mahnung_tracking_suggestions_'.((int) $mahnung->id)] = $suggestions;
if ($pdftotextAvailable === false) {
setEventMessages($langs->trans('MahnungPdftotextMissing'), null, 'warnings');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Versand-Daten zuruecksetzen (z.B. Korrekturmoeglichkeit)
if ($action === 'clear_versand' && $user->hasRight('mahnung', 'write')) {
$mahnung->date_versand = null;
@ -321,6 +403,47 @@ if (!empty($mahnung->date_versand) && $action !== 'edit_versand') {
print '<br><h3>'.$langs->trans('MahnungSendebelege').'</h3>';
print '<div class="opacitymedium" style="margin-bottom:8px;">'.$langs->trans('MahnungSendebelegeHint').'</div>';
// Tracking-Vorschlaege aus Session-Flash (vom Scan) anzeigen
$suggKey = 'mahnung_tracking_suggestions_'.((int) $mahnung->id);
$suggestions = $_SESSION[$suggKey] ?? null;
if (is_array($suggestions) && !empty($suggestions)) {
print '<div class="info" style="padding:8px; margin-bottom:8px; border-left:3px solid #2a8;">';
print '<strong>'.$langs->trans('MahnungTrackingErkannt').'</strong> <span class="opacitymedium">('.count($suggestions).')</span>';
print '<table class="noborder" style="margin-top:6px;">';
foreach ($suggestions as $sg) {
print '<tr>';
print '<td class="nowrap">'.img_picto('', 'pdf', 'class="pictofixedwidth"').dol_escape_htmltag($sg['file']).'</td>';
print '<td>'.dol_escape_htmltag($sg['label']).'</td>';
print '<td><code><strong>'.dol_escape_htmltag($sg['nr']).'</strong></code></td>';
print '<td><a href="'.dol_escape_htmltag($sg['url']).'" target="_blank" rel="noopener" class="opacitymedium">'.img_picto('', 'fa-external-link-alt').'</a></td>';
print '<td>';
if ($canWrite) {
$applyUrl = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=apply_tracking'
.'&nr='.urlencode((string) $sg['nr'])
.'&provider='.urlencode((string) $sg['provider'])
.'&token='.newToken();
print '<a class="button smallpaddingimp" href="'.$applyUrl.'">'.dol_escape_htmltag($langs->trans('MahnungTrackingUebernehmen')).'</a>';
}
print '</td>';
print '</tr>';
}
print '</table>';
if ($canWrite) {
print '<div style="margin-top:6px;"><a class="opacitymedium" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=dismiss_tracking&token='.newToken().'">'.dol_escape_htmltag($langs->trans('MahnungTrackingVerwerfen')).'</a></div>';
}
print '</div>';
}
// Scan-Button (Belege durchsuchen)
if ($canWrite) {
print '<div style="margin-bottom:8px;">';
print '<a class="button smallpaddingimp" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=scan_belege&token='.newToken().'">';
print img_picto('', 'fa-search', 'class="pictofixedwidth"').dol_escape_htmltag($langs->trans('MahnungBelegeScannen'));
print '</a> ';
print '<span class="opacitymedium small">'.$langs->trans('MahnungBelegeScannenHint').'</span>';
print '</div>';
}
$mahnungSafeRef = dol_sanitizeFileName($mahnung->ref);
$mahnungFileDir = (!empty($conf->mahnung->multidir_output[$mahnung->entity])
? $conf->mahnung->multidir_output[$mahnung->entity]

View file

@ -116,6 +116,17 @@ MahnungTrackingPatternLabelRequired = Bezeichnung fehlt.
MahnungTrackingPatternUrlMustHttps = URL-Template muss mit https:// beginnen.
MahnungTrackingPatternUrlMissingPlaceholder = URL-Template muss den Platzhalter {nr} enthalten.
#
# Beleg-Scan (Phase 4)
#
MahnungBelegeScannen = Belege scannen
MahnungBelegeScannenHint = Hochgeladene PDFs nach Sendungsnummer durchsuchen (pdftotext + Regex)
MahnungTrackingErkannt = Sendungsnummern erkannt
MahnungTrackingUebernehmen = Uebernehmen
MahnungTrackingUebernommen = Sendungsnummer uebernommen
MahnungTrackingVerwerfen = Vorschlaege verwerfen
MahnungPdftotextMissing = pdftotext nicht im Container verfuegbar — PDFs koennen nicht durchsucht werden. Nur txt/html werden gescannt.
#
# Liste / Karte
#

View file

@ -116,6 +116,17 @@ MahnungTrackingPatternLabelRequired = Label is required.
MahnungTrackingPatternUrlMustHttps = URL template must start with https://.
MahnungTrackingPatternUrlMissingPlaceholder = URL template must contain the {nr} placeholder.
#
# Receipt scan (Phase 4)
#
MahnungBelegeScannen = Scan receipts
MahnungBelegeScannenHint = Search uploaded PDFs for tracking numbers (pdftotext + regex)
MahnungTrackingErkannt = Tracking numbers detected
MahnungTrackingUebernehmen = Apply
MahnungTrackingUebernommen = Tracking number applied
MahnungTrackingVerwerfen = Dismiss suggestions
MahnungPdftotextMissing = pdftotext not available in container — PDFs cannot be scanned. Only txt/html will be processed.
#
# List / card
#