Fix: Menü-Navigation, GlobalNotify-URL, Dokument-Upload + -Löschen [deploy]
All checks were successful
Deploy mahnung / deploy (push) Successful in 13s

- GlobalNotify-URL: relativer Pfad statt absoluter (DOL_MAIN_URL_ROOT ohne
  Protokoll führte zu kaputten Seiten-URLs im Browser); buildAbsoluteUrl()
  prüft jetzt auf fehlendes http(s)://
- Menü-Navigation: mainmenu=billing&leftmenu=mahnung in allen list.php-Links
  ergänzt (createmahnung.php Redirect, Filter-Form, Hook-Links, Cron-Pfade)
- card.php Dokumente: Lupe direkt neben Dateinamen, Löschen-Button pro Datei
- card.php Upload: actions_linkedfiles.inc.php eingebunden (vor llxHeader),
  upload_dir korrekt gesetzt — showdocuments() zeigt jetzt Upload-Formular
- Redundante Variablen-Definitionen (mahnungSafeRef, files.lib require) entfernt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-13 15:36:58 +02:00
parent 5e91c87c99
commit 80d92042bc
8 changed files with 103 additions and 37 deletions

View file

@ -64,7 +64,7 @@ function respond($success, $message, $extra = array())
if (function_exists('setEventMessages')) { if (function_exists('setEventMessages')) {
setEventMessages($message, null, $success ? 'mesgs' : 'errors'); setEventMessages($message, null, $success ? 'mesgs' : 'errors');
} }
header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mode=vorschlag'); header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag');
exit; exit;
} }

View file

@ -79,6 +79,30 @@ if ($action === 'regenerate_pdf' && $user->hasRight('mahnung', 'write')) {
exit; exit;
} }
// Generiertes Dokument loeschen
if ($action === 'delete_doc' && $user->hasRight('mahnung', 'write')) {
$docfile = GETPOST('file', 'alphanohtml');
if (!empty($docfile) && !empty($mahnung->fk_facture)) {
$facTmp = new Facture($db);
if ($facTmp->fetch((int) $mahnung->fk_facture) > 0) {
$baseDir = !empty($conf->facture->multidir_output[$facTmp->entity])
? $conf->facture->multidir_output[$facTmp->entity]
: $conf->facture->dir_output;
$fullpath = $baseDir.'/'.dol_sanitizeFileName($facTmp->ref).'/'.dol_sanitizeFileName($docfile);
// Sicherstellen dass die Datei zur Mahnung gehoert (Ref im Dateinamen)
if (file_exists($fullpath) && strpos($docfile, dol_sanitizeFileName($mahnung->ref)) !== false) {
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
dol_delete_file($fullpath);
setEventMessages($langs->trans('FileWasRemoved'), null, 'mesgs');
} else {
setEventMessages('Datei nicht gefunden oder nicht zugehoerig', null, 'errors');
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Versand-Daten speichern (Datum, Weg, optional Tracking) // Versand-Daten speichern (Datum, Weg, optional Tracking)
if ($action === 'set_versand' && $user->hasRight('mahnung', 'write')) { if ($action === 'set_versand' && $user->hasRight('mahnung', 'write')) {
$y = GETPOSTINT('versand_year'); $y = GETPOSTINT('versand_year');
@ -156,13 +180,8 @@ if ($action === 'dismiss_tracking' && $user->hasRight('mahnung', 'write')) {
// Belege scannen: pdftotext + Pattern-Matching // Belege scannen: pdftotext + Pattern-Matching
if ($action === 'scan_belege' && $user->hasRight('mahnung', 'write')) { if ($action === 'scan_belege' && $user->hasRight('mahnung', 'write')) {
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; 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 = $upload_dir;
$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); $patternService = new MahnungTrackingPattern($db);
$suggestions = array(); $suggestions = array();
@ -225,6 +244,20 @@ if ($action === 'clear_versand' && $user->hasRight('mahnung', 'write')) {
exit; exit;
} }
// Upload-Verzeichnis fuer Sendebelege (muss VOR llxHeader stehen fuer actions_linkedfiles)
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
$mahnungSafeRef = dol_sanitizeFileName($mahnung->ref);
$upload_dir = (!empty($conf->mahnung->multidir_output[$mahnung->entity])
? $conf->mahnung->multidir_output[$mahnung->entity]
: $conf->mahnung->dir_output ?? (DOL_DATA_ROOT.'/mahnung'))
.'/'.$mahnungSafeRef;
if (!is_dir($upload_dir)) {
dol_mkdir($upload_dir);
}
$permissiontoadd = $user->hasRight('mahnung', 'write');
$permissiontodelete = $user->hasRight('mahnung', 'write');
include DOL_DOCUMENT_ROOT.'/core/actions_linkedfiles.inc.php';
llxHeader('', $langs->trans('MahnungRef').' '.$mahnung->ref); llxHeader('', $langs->trans('MahnungRef').' '.$mahnung->ref);
print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, '', 'fa-envelope-open-text'); print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, '', 'fa-envelope-open-text');
@ -265,7 +298,6 @@ print '<br>';
print load_fiche_titre($langs->trans('Documents'), '', 'fa-file'); print load_fiche_titre($langs->trans('Documents'), '', 'fa-file');
// Dokumente im Rechnungsordner suchen die zur Mahnung gehoeren // Dokumente im Rechnungsordner suchen die zur Mahnung gehoeren
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
$docDir = ''; $docDir = '';
if ($facture->id > 0) { if ($facture->id > 0) {
$docDir = !empty($conf->facture->multidir_output[$facture->entity]) $docDir = !empty($conf->facture->multidir_output[$facture->entity])
@ -274,16 +306,16 @@ if ($facture->id > 0) {
$docDir .= '/'.dol_sanitizeFileName($facture->ref); $docDir .= '/'.dol_sanitizeFileName($facture->ref);
} }
$mahnungRef = dol_sanitizeFileName($mahnung->ref);
$fileList = array(); $fileList = array();
if (!empty($docDir) && is_dir($docDir)) { if (!empty($docDir) && is_dir($docDir)) {
$allFiles = dol_dir_list($docDir, 'files', 0, preg_quote($mahnungRef, '/')); $allFiles = dol_dir_list($docDir, 'files', 0, preg_quote($mahnungSafeRef, '/'));
foreach ($allFiles as $f) { foreach ($allFiles as $f) {
$fileList[] = $f; $fileList[] = $f;
} }
} }
if (!empty($fileList)) { if (!empty($fileList)) {
$canDeleteDoc = $user->hasRight('mahnung', 'write');
print '<table class="noborder centpercent">'; print '<table class="noborder centpercent">';
print '<tr class="liste_titre">'; print '<tr class="liste_titre">';
print '<th>'.$langs->trans('Document').'</th>'; print '<th>'.$langs->trans('Document').'</th>';
@ -295,30 +327,36 @@ if (!empty($fileList)) {
$fname = $f['name']; $fname = $f['name'];
$relativePath = dol_sanitizeFileName($facture->ref).'/'.$fname; $relativePath = dol_sanitizeFileName($facture->ref).'/'.$fname;
$dlUrl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode($relativePath); $dlUrl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode($relativePath);
$viewUrl = $dlUrl.'&attachment=0'; // Inline-Ansicht (keine Download-Erzwingung) $viewUrl = $dlUrl.'&attachment=0';
$ext = strtolower(pathinfo($fname, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($fname, PATHINFO_EXTENSION));
$icon = ($ext === 'pdf') ? 'pdf' : (($ext === 'odt') ? 'ooffice' : 'generic'); $icon = ($ext === 'pdf') ? 'pdf' : (($ext === 'odt') ? 'ooffice' : 'generic');
$filesize = !empty($f['size']) ? $f['size'] : filesize($f['fullname']); $filesize = !empty($f['size']) ? $f['size'] : filesize($f['fullname']);
$filedate = !empty($f['date']) ? $f['date'] : filemtime($f['fullname']); $filedate = !empty($f['date']) ? $f['date'] : filemtime($f['fullname']);
print '<tr class="oddeven">'; print '<tr class="oddeven">';
// Dateiname mit Icon // Dateiname mit Icon + Lupe direkt daneben
print '<td class="nowraponall">'; print '<td class="nowraponall">';
print '<a href="'.$dlUrl.'">'; print '<a href="'.$dlUrl.'">';
print img_picto('', $icon, 'class="pictofixedwidth"'); print img_picto('', $icon, 'class="pictofixedwidth"');
print dol_escape_htmltag($fname); print dol_escape_htmltag($fname);
print '</a>'; print '</a>';
if ($ext === 'pdf') {
print ' <a href="'.$viewUrl.'" target="_blank" title="'.dol_escape_htmltag($langs->trans('Preview')).'">'.img_picto($langs->trans('Preview'), 'search').'</a>';
}
print '</td>'; print '</td>';
// Groesse // Groesse
print '<td class="right nowraponall">'.dol_print_size($filesize, 0, 0).'</td>'; print '<td class="right nowraponall">'.dol_print_size($filesize, 0, 0).'</td>';
// Datum // Datum
print '<td class="center nowraponall">'.dol_print_date($filedate, 'dayhour').'</td>'; print '<td class="center nowraponall">'.dol_print_date($filedate, 'dayhour').'</td>';
// Aktionen: Vorschau + Download // Aktionen: Download + Loeschen
print '<td class="right nowraponall">'; print '<td class="right nowraponall">';
if ($ext === 'pdf') { print '<a href="'.$dlUrl.'">'.img_picto($langs->trans('Download'), 'download').'</a>';
print '<a href="'.$viewUrl.'" target="_blank" title="'.dol_escape_htmltag($langs->trans('Preview')).'">'.img_picto($langs->trans('Preview'), 'search', 'class="pictofixedwidth"').'</a> '; if ($canDeleteDoc) {
$delUrl = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=delete_doc&file='.urlencode($fname).'&token='.newToken();
print ' <a href="'.$delUrl.'" onclick="return confirm(\''.dol_escape_js($langs->trans('MahnungDokumentLoeschenConfirm', $fname)).'\');">';
print img_picto($langs->trans('Delete'), 'delete');
print '</a>';
} }
print '<a href="'.$dlUrl.'">'.img_picto($langs->trans('Download'), 'download', 'class="pictofixedwidth"').'</a>';
print '</td>'; print '</td>';
print '</tr>'; print '</tr>';
} }
@ -473,21 +511,11 @@ if ($canWrite) {
print '</div>'; print '</div>';
} }
$mahnungSafeRef = dol_sanitizeFileName($mahnung->ref);
$mahnungFileDir = (!empty($conf->mahnung->multidir_output[$mahnung->entity])
? $conf->mahnung->multidir_output[$mahnung->entity]
: $conf->mahnung->dir_output ?? (DOL_DATA_ROOT.'/mahnung'))
.'/'.$mahnungSafeRef;
// Verzeichnis bei Bedarf anlegen, damit FormFile->showdocuments() das Upload-Formular zeigt
if (!is_dir($mahnungFileDir)) {
dol_mkdir($mahnungFileDir);
}
$urlSelf = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id); $urlSelf = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id);
$formfile->showdocuments( $formfile->showdocuments(
'mahnung', // $modulepart 'mahnung', // $modulepart
$mahnungSafeRef, // $modulesubdir $mahnungSafeRef, // $modulesubdir
$mahnungFileDir, // $filedir $upload_dir, // $filedir
$urlSelf, // $urlsource $urlSelf, // $urlsource
0, // $genallowed (kein PDF-Gen-Button hier) 0, // $genallowed (kein PDF-Gen-Button hier)
(int) $canWrite, // $delallowed (int) $canWrite, // $delallowed

View file

@ -63,7 +63,7 @@ class ActionsMahnung
$label = $langs->trans('MahnungErstellen'); $label = $langs->trans('MahnungErstellen');
if ($ueberfaellig) { if ($ueberfaellig) {
$url = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=vorschlag&search_socid='.((int) $object->socid); $url = DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag&search_socid='.((int) $object->socid);
print dolGetButtonAction($label, '', 'default', $url, 'btn-mahnung-create', 1); print dolGetButtonAction($label, '', 'default', $url, 'btn-mahnung-create', 1);
} else { } else {
$attr = array('title' => $langs->trans('MahnungKeineUeberfaelligen')); $attr = array('title' => $langs->trans('MahnungKeineUeberfaelligen'));
@ -114,10 +114,10 @@ class ActionsMahnung
if ($onInvoice) { if ($onInvoice) {
$count = $this->countMahnungen($db, 'fk_facture', (int) $object->id); $count = $this->countMahnungen($db, 'fk_facture', (int) $object->id);
$tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=archiv&fk_facture='.((int) $object->id); $tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv&fk_facture='.((int) $object->id);
} else { } else {
$count = $this->countMahnungen($db, 'fk_soc', (int) $object->id); $count = $this->countMahnungen($db, 'fk_soc', (int) $object->id);
$tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mode=archiv&search_socid='.((int) $object->id); $tabUrl = DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv&search_socid='.((int) $object->id);
} }
$head = &$parameters['head']; $head = &$parameters['head'];

View file

@ -74,7 +74,8 @@ class MahnungCron
} }
$dolUrl = trim((string) getDolGlobalString('MAIN_INFO_SOCIETE_NOM', '')); $dolUrl = trim((string) getDolGlobalString('MAIN_INFO_SOCIETE_NOM', ''));
$listUrl = self::buildAbsoluteUrl('/custom/mahnung/list.php?mode=vorschlag'); $relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag';
$absUrl = self::buildAbsoluteUrl($relPath);
$title = 'Mahnwesen: '.$count.' offene Vorschlaege'; $title = 'Mahnwesen: '.$count.' offene Vorschlaege';
$message = "Stufe 1 (Erinnerung): {$counts[1]}\n"; $message = "Stufe 1 (Erinnerung): {$counts[1]}\n";
@ -82,9 +83,10 @@ class MahnungCron
$message .= "Stufe 3 (Letzte Mahnung): {$counts[3]}\n"; $message .= "Stufe 3 (Letzte Mahnung): {$counts[3]}\n";
$message .= 'Offener Betrag: '.number_format($summe, 2, ',', '.').' EUR'; $message .= 'Offener Betrag: '.number_format($summe, 2, ',', '.').' EUR';
MahnungNtfy::send($title, $message, $listUrl, array('envelope_with_arrow', 'warning')); MahnungNtfy::send($title, $message, $absUrl, array('envelope_with_arrow', 'warning'));
// Optional: GlobalNotify-Badge ins Dolibarr-UI (wenn Modul aktiv) // Optional: GlobalNotify-Badge ins Dolibarr-UI (wenn Modul aktiv)
// Relativer Pfad — wird im Browser-Kontext korrekt aufgeloest
if (isModEnabled('globalnotify') && class_exists('GlobalNotify') === false) { if (isModEnabled('globalnotify') && class_exists('GlobalNotify') === false) {
$gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php'; $gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php';
if (file_exists($gnPath)) { if (file_exists($gnPath)) {
@ -96,7 +98,7 @@ class MahnungCron
'mahnung', 'mahnung',
'Mahnwesen: '.$count.' Vorschlaege', 'Mahnwesen: '.$count.' Vorschlaege',
$message, $message,
$listUrl, $relPath,
'Vorschlagsliste oeffnen' 'Vorschlagsliste oeffnen'
); );
} }
@ -176,7 +178,8 @@ class MahnungCron
return 0; return 0;
} }
$listUrl = self::buildAbsoluteUrl('/custom/mahnung/list.php?mode=archiv'); $relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv';
$absUrl = self::buildAbsoluteUrl($relPath);
$title = 'Mahnwesen: '.count($pending).' Mahnung(en) unversendet'; $title = 'Mahnwesen: '.count($pending).' Mahnung(en) unversendet';
$lines = array(); $lines = array();
foreach ($pending as $p) { foreach ($pending as $p) {
@ -191,9 +194,9 @@ class MahnungCron
} }
$message = implode("\n", $lines); $message = implode("\n", $lines);
MahnungNtfy::send($title, $message, $listUrl, array('envelope_with_arrow', 'warning')); MahnungNtfy::send($title, $message, $absUrl, array('envelope_with_arrow', 'warning'));
// Optional: GlobalNotify-Badge // Optional: GlobalNotify-Badge — relativer Pfad fuer Browser-Kontext
if (isModEnabled('globalnotify') && !class_exists('GlobalNotify')) { if (isModEnabled('globalnotify') && !class_exists('GlobalNotify')) {
$gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php'; $gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php';
if (file_exists($gnPath)) { if (file_exists($gnPath)) {
@ -205,7 +208,7 @@ class MahnungCron
'mahnung_versand', 'mahnung_versand',
$title, $title,
$message, $message,
$listUrl, $relPath,
'Archiv oeffnen' 'Archiv oeffnen'
); );
} }
@ -230,6 +233,10 @@ class MahnungCron
if (empty($base)) { if (empty($base)) {
return $relPath; return $relPath;
} }
// Protokoll sicherstellen — ohne Protokoll wird die URL im Browser als relativ interpretiert
if (!preg_match('/^https?:\/\//', $base)) {
$base = 'http://'.$base;
}
return rtrim($base, '/').$relPath; return rtrim($base, '/').$relPath;
} }
} }

View file

@ -95,6 +95,14 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
} }
} }
$this->db->free($resql); $this->db->free($resql);
// Wenn Mahnungen erledigt wurden: pruefen ob noch offene uebrig sind.
// Falls nicht, GlobalNotify-Badge raeumen.
if ($count > 0 && !$this->hatOffeneMahnungen()) {
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungcron.class.php';
MahnungCron::clearGlobalNotify();
}
return $count; return $count;
} }
@ -128,6 +136,25 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
return $total; return $total;
} }
/**
* Prueft ob noch offene Mahnvorgaenge existieren (Status weder erledigt noch storniert).
*
* @return bool true wenn mindestens eine offene Mahnung existiert
*/
private function hatOffeneMahnungen()
{
$sql = "SELECT COUNT(*) AS nb FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
$sql .= " WHERE status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")";
$resql = $this->db->query($sql);
if (!$resql) {
return true; // Im Zweifel Badge stehen lassen
}
$obj = $this->db->fetch_object($resql);
$this->db->free($resql);
return (int) $obj->nb > 0;
}
/** /**
* @param int $factureId * @param int $factureId
* @return bool * @return bool

View file

@ -211,3 +211,4 @@ MahnungDokumentModelle = Dokumentenmodelle
MahnungPdfStandard = Standard-PDF (DIN 5008) MahnungPdfStandard = Standard-PDF (DIN 5008)
MahnungGenerate = Dokument generieren MahnungGenerate = Dokument generieren
NoDocuments = Keine Dokumente vorhanden. NoDocuments = Keine Dokumente vorhanden.
MahnungDokumentLoeschenConfirm = Dokument '%s' wirklich loeschen?

View file

@ -199,3 +199,4 @@ MahnungCronBuildVorschlag = Dunning — build proposal list
MahnungCronBuildVorschlagDesc = Daily scan for overdue invoices, sends a Ntfy push with the count of new proposals. MahnungCronBuildVorschlagDesc = Daily scan for overdue invoices, sends a Ntfy push with the count of new proposals.
MahnungCronVersandReminder = Dunning — shipment reminder (unsent dunnings) MahnungCronVersandReminder = Dunning — shipment reminder (unsent dunnings)
MahnungCronVersandReminderDesc = Daily check for dunnings in status ERSTELLT that have not been sent for more than N days (MAHNUNG_VERSAND_REMINDER_DAYS, default 2). MahnungCronVersandReminderDesc = Daily check for dunnings in status ERSTELLT that have not been sent for more than N days (MAHNUNG_VERSAND_REMINDER_DAYS, default 2).
MahnungDokumentLoeschenConfirm = Really delete document '%s'?

View file

@ -93,6 +93,8 @@ print load_fiche_titre(
// --- Filter-Form --- // --- Filter-Form ---
print '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">'; print '<form method="GET" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="mainmenu" value="billing">';
print '<input type="hidden" name="leftmenu" value="mahnung">';
print '<input type="hidden" name="mode" value="'.dol_escape_htmltag($mode).'">'; print '<input type="hidden" name="mode" value="'.dol_escape_htmltag($mode).'">';
print '<table class="noborder centpercent"><tr class="liste_titre">'; print '<table class="noborder centpercent"><tr class="liste_titre">';
print '<th>'.$langs->trans('MahnungStufe').'</th>'; print '<th>'.$langs->trans('MahnungStufe').'</th>';