mahnung/card.php
Eduard Wisch 0244a5b07e feat(mahnung): Mailversand ueber FormMail, Versandprotokoll, HTML-Mails
Phase 13/14 abgeschlossen. Der Mailversand war zuvor KAPUTT: ajax/sendmail.php
war bereits zur Funktionsbibliothek umgebaut, card.php postete aber weiterhin
per JS dagegen.

Mailformular (FormMail)
- card.php nutzt jetzt Dolibarrs Standard-Mailformular (action=presend/send):
  Empfaenger (Firma + alle Ansprechpartner), Betreff, Text und Anhang sichtbar
  und aenderbar. Gesendet wird ausschliesslich ueber mahnungSendeErinnerungsMail().
- Anhang = unveraenderte Original-Rechnungs-PDF, wird bei Bedarf nacherzeugt.
  Eigener Parameter mailinit statt mode=init, weil get_form() bei mode=init die
  Anhangsliste selbst leert.
- HTML-Mails: DolEditor im Setup + withfckeditor=-1 im Formular (folgt
  FCKEDITOR_ENABLE_MAIL wie Dolibarrs eigene Mailvorlagen).
- Klartext bleibt Klartext: GETPOST('restricthtml') jagt jeden Nicht-HTML-Text
  durch dol_nl2br() — mahnungBodyEntkleiden() nimmt nur dieses Artefakt zurueck
  und laesst echte Formatierung unangetastet.
- Platzhalter jetzt auch in Dolibarr-Schreibweise (__REF__, __DATE_YMD__,
  __AMOUNT_FORMATED__, __DATE_DUE_YMD__, __FRIST_TAGE__ ...), Liste sichtbar im Setup.
- Absender-Adresse und -Name konfigurierbar (MAHNUNG_EMAIL_SENDER[_NAME]).
- Erneuter Versand moeglich (force aus dem Status abgeleitet, nicht aus dem
  Request — Doppelversand-Schutz bleibt wirksam).

Versandprotokoll (neue Tabelle llx_mahnung_mailprotokoll)
- Jede versendete Erinnerung wird mit Empfaenger, Kopie, Betreff, Text und
  Anhangsnamen festgehalten, einsehbar unter Versandstatus. Historie statt
  Spalten am Vorgang, weil erneut gesendet werden kann.
- Lazy-Migration legt die Tabelle an (DB_VERSION 0.4.0), kein Reaktivieren noetig.

Haertung nach Code-Review (21 bestaetigte Funde)
- Anhang liess sich nicht abwaehlen (wurde sofort wieder eingehaengt)
- Upload/Entfernen ohne Rechtepruefung; Temp-Verzeichnis pro Vorgang getrennt
- Teilzahlung zwischen Oeffnen und Senden fuehrt zurueck ins Formular
- Empfaenger: Semikolon-Trenner, keine stillen Verwerfungen, Dubletten, CR/LF
- CSRF: presend + Core-Dateiaktionen (confirm_deletefile, renamefile, sendit,
  linkit) token-pflichtig
- Externe Benutzer sehen nur eigene Vorgaenge; Abschreiben verlangt facture.creer

UI
- Mahnstufe nur noch EINE Darstellung (Badge), Farbskala zentral in
  lib/mahnung_ui.lib.php statt doppelt gepflegt
- Zahnrad oben rechts in die Einstellungen (nur mit Recht mahnung.setup)
- Original-Rechnung unter Verknuepfte Dokumente mit Vorschau, Groesse in KB
- PDF-Einleitungstext nur noch, wo ueberhaupt ein PDF entsteht

Sprachdateien de_DE/en_US deckungsgleich, 9 tote Keys entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:31:25 +02:00

1561 lines
72 KiB
PHP

<?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/card.php
* \ingroup mahnung
* \brief Detailansicht eines einzelnen Mahnvorgangs.
*/
$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) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php';
global $langs, $user, $db;
// Schema nach einem reinen Datei-Deploy nachziehen — ohne die Spalte
// kosten_vorstufen scheitert sonst jedes update() auf dieser Karte.
modMahnung::ensureSchema($db);
$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills'));
if (!$user->hasRight('mahnung', 'read')) {
accessforbidden();
}
$id = GETPOSTINT('id');
$action = GETPOST('action', 'aZ09');
// Antwort aus einem formconfirm-Dialog ('yes' | 'no'). Ohne diese Auswertung würde
// auch ein "Nein" die bestätigungspflichtige Aktion ausführen.
$confirm = GETPOST('confirm', 'alpha');
$mahnung = new Mahnung($db);
if ($mahnung->fetch($id) <= 0) {
dol_print_error($db, $langs->trans('MahnungNichtGefunden', $id));
exit;
}
// Externe Benutzer (Kundenkontakte mit eigenem Dolibarr-Zugang) dürfen nur die
// Mahnvorgänge ihrer eigenen Firma sehen. Mahnung::fetch() filtert bloß nach Entity —
// ohne diese Prüfung genügt das Durchzählen der id, um fremde Forderungen samt
// Beträgen zu lesen.
if ((int) $user->socid > 0 && (int) $user->socid !== (int) $mahnung->fk_soc) {
accessforbidden();
}
$canWrite = $user->hasRight('mahnung', 'write');
// --- CSRF-Absicherung aller zustandsändernden Aktionen ----------------------
// Der Core-Check aus main.inc.php greift auf dieser Installation NICHT: in der
// conf.php steht $dolibarr_nocsrfcheck=1, zusätzlich ist
// MAIN_SECURITY_CSRF_WITH_TOKEN=0. Der komplette Prüfblock ab main.inc.php:333
// wird damit übersprungen. Ohne eigene Prüfung genügt deshalb ein Aufruf wie
// card.php?id=42&action=send&… von einer fremden Seite aus, um im eingeloggten
// Kontext eine Aktion auszulösen — beim Mailversand ginge die Zahlungserinnerung
// raus, ohne dass jemand das Formular je gesehen hat.
//
// Verglichen wird gegen BEIDE Session-Slots: main.inc.php rollt das Token bei
// jedem Seitenaufruf ($_SESSION['token'] = das bisherige $_SESSION['newtoken']).
// Ohne MAIN_SECURITY_CSRF_TOKEN_RENEWAL_ON_EACH_CALL sind beide identisch; mit
// der Option landet das mitgeschickte Token in 'token', während 'newtoken' schon
// neu gewürfelt ist. Eine Prüfung nur gegen 'newtoken' würde bei aktivierter
// Option sämtliche Aktionen der Seite blockieren.
// hash_equals() statt ===: konstante Laufzeit, kein Timing-Orakel.
$tokenGesendet = (string) GETPOST('token', 'alphanohtml');
$tokenOk = ($tokenGesendet !== '' && $tokenGesendet !== 'notrequired');
if ($tokenOk) {
$tokenOk = ((!empty($_SESSION['token']) && hash_equals((string) $_SESSION['token'], $tokenGesendet))
|| (!empty($_SESSION['newtoken']) && hash_equals((string) $_SESSION['newtoken'], $tokenGesendet)));
}
// Aktionen mit Seiteneffekt. Reine Anzeige-Actions (edit_versand, confirm_storno,
// ask_uneinbringlich) stehen bewusst NICHT in der Liste: die rendern nur ein
// Formular bzw. einen Bestätigungsdialog und werden teils über Links ohne Token
// aufgerufen.
//
// Bewusst KEIN zusätzlicher Zwang auf REQUEST_METHOD=POST: $form->formconfirm()
// liefert das "Ja" bei aktivem JavaScript per GET aus — $postconfirmas='GET' ist
// in html.form.class.php fest verdrahtet, der Dialog navigiert per location.href.
// Eine POST-Pflicht würde Storno, Uneinbringlich-Klassifikation und den
// Erinnerungsversand komplett lahmlegen. Das Token wird auf beiden Wegen
// mitgeschickt (ajax-Dialog: options="token=…", HTML-Fallback: hidden field) und
// ist für sich genommen ausreichend — erraten kann es ein Angreifer nicht.
$actionsMitToken = array(
'storno',
'regenerate_pdf',
'delete_doc',
'set_versand',
'confirm_uneinbringlich',
'apply_tracking',
'dismiss_tracking',
'scan_belege',
'clear_versand',
// Mailformular: 'send' deckt Absenden, Anhang-Upload und Anhang-Entfernen ab —
// alle drei posten dasselbe Formular und tragen dessen Token.
'send',
// 'presend' rendert zwar nur das Formular, legt dabei aber die Original-Rechnungs-
// PDF als Anhang bereit — und erzeugt sie über Facture::generateDocument() neu,
// falls sie fehlt. Damit ist es keine reine Anzeige-Action mehr.
'presend',
// Core-Dateiaktionen aus core/actions_linkedfiles.inc.php (Sendebelege): das
// Include verarbeitet sie selbst und prüft dabei nur $permissiontoadd, kein Token.
// Ohne diese Einträge könnte ein untergeschobener GET-Aufruf einen hochgeladenen
// Zustellbeleg löschen oder umbenennen.
'confirm_deletefile',
'confirm_updateline',
'renamefile',
);
// 'sendit' und 'linkit' (ebenfalls aus actions_linkedfiles.inc.php) hängen an eigenen
// Parametern statt an $action und lassen sich über die Action-Liste nicht erfassen.
if (GETPOST('sendit', 'alpha') || GETPOST('linkit', 'alpha') || GETPOST('renamefilesave', 'alpha')) {
$actionsMitToken[] = $action;
}
if (in_array($action, $actionsMitToken, true) && !$tokenOk) {
setEventMessages($langs->trans('MahnungCsrfTokenUngueltig'), null, 'errors');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Stufen-Konfiguration laden. Ob es sich um die kostenlose Zahlungserinnerung
// handelt, entscheidet ausschließlich das Flag ist_erinnerung — NICHT die
// Stufennummer. Eine Erinnerung kostet nichts (keine Mahngebühr, keine Pauschale
// nach §288 Abs. 5, keine Verzugszinsen), hat kein eigenes Mahn-PDF und geht
// ausschließlich per E-Mail mit der Original-Rechnung im Anhang raus.
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
$stufeObj = new MahnungStufe($db);
$stufeGeladen = ($stufeObj->fetchByStufe((int) $mahnung->stufe) > 0);
// Rohes Flag der Stufe: gilt für die Stufen-KONFIGURATION, nicht für den Vorgang.
$stufeIstErinnerung = ($stufeGeladen && $stufeObj->istErinnerung());
// Tatsächlich gespeicherte Kostenbestandteile dieses Vorgangs.
// Das Erinnerungs-Flag hängt an der AKTUELLEN Stufenkonfiguration, der Mahnvorgang
// selbst hat dafür (noch) kein eigenes Snapshot-Feld. Setzt jemand ist_erinnerung
// nachträglich auf eine bereits benutzte Stufe, würden ALTE Vorgänge mit echten
// Gebühren rückwirkend als "kostenlos" dargestellt und wären plötzlich per Mail
// versendbar — obwohl im längst erzeugten PDF Beträge stehen.
$hatGespeicherteKosten = (abs((float) $mahnung->mahngebuehr) > 0.001
|| abs((float) $mahnung->pauschale_b2b) > 0.001
|| abs((float) $mahnung->verzugszinsen) > 0.001
|| abs((float) $mahnung->kosten_vorstufen) > 0.001);
// Maßgeblich für die gesamte Karte: als kostenlose Erinnerung gilt ein Vorgang nur,
// wenn Stufen-Flag UND gespeicherte Beträge zusammenpassen. Die Zusatzprüfung verengt
// die Erkennung ausschließlich — eine echte Erinnerung hat über mahnungBaueVorgang()
// immer 0,00 in allen vier Feldern, eine echte Mahnstufe wird dadurch nie fälschlich
// zur Erinnerung. Den umgekehrten Fall (Flag wird von einer Stufe wieder entfernt)
// kann diese Heuristik nicht abdecken, dafür braucht es ein Snapshot-Feld am Vorgang.
$istErinnerung = ($stufeIstErinnerung && !$hatGespeicherteKosten);
// Ende des Eskalationspfads datengetrieben bestimmen — es gibt keine feste Obergrenze
// bei Stufe 3 mehr. Maßgeblich ist, ob nach der Stufe dieses Vorgangs überhaupt noch
// eine aktive Stufe konfiguriert ist.
// naechsteStufeNach() liefert null auch dann, wenn die Stufenkonfiguration gar nicht
// ladbar ist (SQL-Fehler oder keine aktive Stufe). Deshalb wird zusätzlich geprüft,
// dass überhaupt Stufen geladen wurden: ein Konfigurationsfehler darf den praktisch
// unwiderruflichen "Uneinbringlich"-Button nicht freischalten.
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
$stufenService = new MahnungVorschlag($db);
$stufenKonfiguriert = (count($stufenService->getAlleStufen()) > 0);
$istLetzteStufe = ($stufenKonfiguriert && $stufenService->naechsteStufeNach((int) $mahnung->stufe) === null);
// Verzugszinsen mit der aktuellen Konfiguration neu berechnen — aber NUR solange noch
// kein Dokument erzeugt wurde.
// Begründung für die Einschränkung: sobald pdf_path gesetzt ist, existiert ein
// Mahnschreiben mit festen Beträgen, das beim Kunden liegen kann. Der Block schreibt
// bei JEDEM Kartenaufruf; jede Änderung an der Berechnung (z.B. round() -> floor() bei
// den Verzugstagen) oder ein nachträglich gepflegter Zinssatz-Override würde
// Bestandsdaten rückwirkend verbiegen, bis DB und versendetes PDF auseinanderlaufen.
// Status ENTWURF bleibt zusätzlich erlaubt: ein Entwurf ist ausdrücklich noch nicht
// final, ein dort erzeugtes PDF ist nur Vorschau und wird vor dem Versand ohnehin neu
// gebaut. Bei einer Zahlungserinnerung entfällt die Neuberechnung komplett — die
// kostet nichts.
$neuberechnungErlaubt = (empty($mahnung->pdf_path) || (int) $mahnung->status === Mahnung::STATUS_ENTWURF);
if (!$istErinnerung && $stufeGeladen && $neuberechnungErlaubt
&& (int) $mahnung->status <= Mahnung::STATUS_ERSTELLT && !empty($mahnung->fk_facture)) {
$_basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
$_override = $stufeObj->getZinssatzOverride($mahnung->customertype);
// Verzugstage: Differenz Mahndatum - Original-Fälligkeit.
// floor() statt round(): angefangene Tage zählen nicht mit — konsistent zu
// MahnungVorschlag und ajax/createmahnung.php. Mit round() wich die hier
// angezeigte Berechnung sonst um bis zu einen Tag von der beim Anlegen ab.
$_tageVerzug = 0;
if (!empty($mahnung->datec) && !empty($mahnung->date_lim_reglement_alt)) {
$_tageVerzug = max(0, (int) floor(($mahnung->datec - $mahnung->date_lim_reglement_alt) / 86400));
}
$_neueZinsen = Mahnung::berechneVerzugszinsen(
$mahnung->betrag_offen,
$_tageVerzug,
$mahnung->customertype,
$_basiszins,
$_override
);
if (abs((float) $mahnung->verzugszinsen - $_neueZinsen) > 0.001) {
$mahnung->verzugszinsen = $_neueZinsen;
$mahnung->basiszins_snapshot = $_basiszins;
$mahnung->rechneSumme();
// Nur mit Schreibrecht tatsächlich speichern: ein reiner Leser ruft die Karte
// per GET auf und darf dabei keinen DB-Write auslösen. Ohne Recht bleibt die
// Neuberechnung rein in-memory und dient nur der Anzeige.
if ($canWrite) {
$mahnung->update($user);
}
}
}
// Stornieren — nur nach ausdrücklicher Bestätigung im formconfirm-Dialog
if ($action === 'storno' && $confirm === 'yes' && $user->hasRight('mahnung', 'delete')) {
$mahnung->status = Mahnung::STATUS_STORNIERT;
if ($mahnung->update($user) > 0) {
setEventMessages($langs->trans('MahnungStornieren').' OK', null, 'mesgs');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
setEventMessages($mahnung->error, null, 'errors');
}
// Dokument neu generieren
if ($action === 'regenerate_pdf' && $canWrite) {
if ($istErinnerung) {
// Zahlungserinnerungen haben kein eigenes Mahn-PDF — Anhang ist die
// unveränderte Original-Rechnung. Button ist ausgeblendet, der Aufruf per
// URL wird hier zusätzlich abgefangen.
setEventMessages($langs->trans('MahnungErinnerungKeinPdf'), null, 'errors');
} else {
$selectedModel = GETPOST('model', 'alphanohtml');
$result = $mahnung->generateDocument($selectedModel, $langs);
if ($result > 0) {
setEventMessages($langs->trans('MahnungDokumentErstellt'), null, 'mesgs');
} else {
setEventMessages($langs->trans('MahnungDokumentFehler').': '.$mahnung->error, null, 'errors');
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Generiertes Dokument löschen
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($langs->trans('MahnungDateiNichtGefunden'), null, 'errors');
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Versand-Daten speichern (Datum, Weg, optional Tracking)
if ($action === 'set_versand' && $user->hasRight('mahnung', 'write')) {
$y = GETPOSTINT('versand_year');
$m = GETPOSTINT('versand_month');
$d = GETPOSTINT('versand_day');
$dateVersand = ($y && $m && $d) ? dol_mktime(12, 0, 0, $m, $d, $y) : dol_now();
$weg = GETPOST('versandweg', 'aZ09');
$trackNr = trim((string) GETPOST('tracking_nr', 'alphanohtml'));
$trackProv = GETPOST('tracking_provider', 'aZ09');
if (empty($trackProv) && $trackNr !== '') {
$trackProv = Mahnung::defaultProviderForWeg($weg);
}
if ($mahnung->setVersand($user, $dateVersand, $weg, $trackNr ?: null, $trackProv ?: null) > 0) {
setEventMessages($langs->trans('MahnungVersandGespeichert'), null, 'mesgs');
} else {
setEventMessages($mahnung->error ?: $langs->trans('MahnungFehlerSpeichern'), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Rechnung als uneinbringlich klassifizieren (close_code='badcustomer')
// — endgültiger Schritt nach erfolglosem Mahnverfahren / Vollstreckung.
// Zwingend nur mit ausdrücklichem "Ja" aus dem formconfirm-Dialog: der Schritt
// ist praktisch unwiderruflich.
if ($action === 'confirm_uneinbringlich' && $confirm === 'yes' && $user->hasRight('mahnung', 'delete')) {
require_once DOL_DOCUMENT_ROOT.'/core/class/commoninvoice.class.php';
// Der Schritt schreibt auf der RECHNUNG (fk_statut=3, close_code='badcustomer')
// und ist buchhalterisch praktisch unwiderruflich. Ein Mahnungs-Recht allein
// reicht dafür nicht — wer keine Rechnungen ändern darf, darf auch keine
// abschreiben.
if (!$user->hasRight('facture', 'creer')) {
setEventMessages($langs->trans('MahnungUneinbringlichKeinRechnungsrecht'), null, 'errors');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
$note = trim((string) GETPOST('uneinbringlich_note', 'nohtml'));
if ($note === '') {
$note = $langs->trans('MahnungVerfahrenErfolglos').' '.dol_print_date(dol_now(), 'day');
}
$facReload = new Facture($db);
if ($facReload->fetch((int) $mahnung->fk_facture) > 0) {
// Rechnung auf STATUS_ABANDONED (3) + close_code='badcustomer' setzen
$ret = $facReload->setCanceled($user, CommonInvoice::CLOSECODE_BADDEBT, $note);
if ($ret > 0) {
// Mahnung mitstornieren — Vorgang ist damit fachlich abgeschlossen
$mahnung->status = Mahnung::STATUS_STORNIERT;
$mahnung->note_private = trim(($mahnung->note_private ? $mahnung->note_private."\n\n" : '').$langs->trans('MahnungUneinbringlichKlassifiziert').' ('.dol_print_date(dol_now(), 'day').'): '.$note);
$mahnung->update($user);
setEventMessages($langs->trans('MahnungUneinbringlichErfolg'), null, 'mesgs');
} else {
setEventMessages($facReload->error ?: $langs->trans('MahnungFehlerKlassifizieren'), null, 'errors');
}
} else {
setEventMessages($langs->trans('MahnungRechnungNichtLadbar'), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Sendungsnummer aus erkanntem Vorschlag übernehmen
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;
// Versanddaten gleich mitsetzen wenn noch leer
if (empty($mahnung->date_versand)) {
$mahnung->date_versand = dol_now();
}
if (empty($mahnung->versandweg)) {
// Provider → passender Versandweg
$provToWeg = array('dpag' => 'einschreiben', 'dhl' => 'dhl', 'dpd' => 'dpd', 'hermes' => 'hermes', 'ups' => 'ups');
$mahnung->versandweg = $provToWeg[$prov] ?? 'post';
}
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;
}
// Vorschläge 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';
$scanRef = 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'))
.'/'.$scanRef;
$patternService = new MahnungTrackingPattern($db);
$suggestions = array();
$pdftotextAvailable = null;
$ocrmypdfAvailable = null;
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) {
// Erst normales pdftotext versuchen
$cmd = 'pdftotext -layout '.escapeshellarg($file['fullname']).' - 2>/dev/null';
$text = trim((string) @shell_exec($cmd), " \t\n\r\0\x0B\x0C");
// Kein Text? → OCR via ocrmypdf (Bild-PDF lesbar machen)
if ($text === '') {
if ($ocrmypdfAvailable === null) {
$check2 = @shell_exec('command -v ocrmypdf 2>/dev/null');
$ocrmypdfAvailable = !empty(trim((string) $check2));
}
if ($ocrmypdfAvailable) {
$ocrTmp = $file['fullname'].'.ocr.pdf';
$ocrCmd = 'ocrmypdf --skip-text -l deu+eng --jobs 2 '
.escapeshellarg($file['fullname']).' '
.escapeshellarg($ocrTmp).' 2>/dev/null';
@shell_exec($ocrCmd);
if (file_exists($ocrTmp)) {
$text = trim((string) @shell_exec('pdftotext -layout '.escapeshellarg($ocrTmp).' - 2>/dev/null'));
@unlink($ocrTmp);
}
}
}
}
}
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 (empty($suggestions)) {
setEventMessages($langs->trans('MahnungKeineSendungsnummerErkannt'), null, 'warnings');
}
if ($pdftotextAvailable === false) {
setEventMessages($langs->trans('MahnungPdftotextMissing'), null, 'warnings');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// Versand-Daten zurücksetzen (z.B. Korrekturmöglichkeit)
if ($action === 'clear_versand' && $user->hasRight('mahnung', 'write')) {
$mahnung->date_versand = null;
$mahnung->versandweg = null;
$mahnung->tracking_nr = null;
$mahnung->tracking_provider = null;
// Status nicht zurückdrehen — nur Daten löschen
if ($mahnung->update($user) > 0) {
setEventMessages($langs->trans('MahnungVersandGeleert'), null, 'mesgs');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id));
exit;
}
// --- Kontext für die Zahlungserinnerung (Empfänger, Rechnung, Anhang) ---
// Rechnung und Kunde werden hier einmal geladen und weiter unten nur noch angezeigt.
$facture = new Facture($db);
$factureGeladen = ($facture->fetch((int) $mahnung->fk_facture) > 0);
$societe = new Societe($db);
$societeGeladen = ($societe->fetch((int) $mahnung->fk_soc) > 0);
$kundeEmail = $societeGeladen ? trim((string) $societe->email) : '';
// Dateiname der Original-Rechnungs-PDF, die als Anhang mitgeht. Ermittelt und bei
// Bedarf erzeugt wird sie in mahnungHoleRechnungsPdf(); hier dient der Name nur dem
// Hinweistext in der Dokumenten-Sektion, damit auf der Karte sichtbar ist, was bei
// einer Zahlungserinnerung statt eines Mahn-PDFs rausgeht.
$anhangDatei = '';
if ($factureGeladen) {
$anhangDatei = !empty($facture->last_main_doc)
? basename($facture->last_main_doc)
: dol_sanitizeFileName($facture->ref).'.pdf';
}
// Funktionsbibliothek des Mailversands — liefert unter anderem die Empfängerliste,
// die schon für die Frage gebraucht wird, ob überhaupt versendet werden kann.
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/ajax/sendmail.php';
// Versand der Zahlungserinnerung ist nur erlaubt, wenn es wirklich eine Erinnerung
// ist, ein Empfänger existiert und das Recht passt.
// $istErinnerung schließt Vorgänge mit gespeicherten Gebühren/Zinsen aus: für die liegt
// ein Mahnschreiben mit Beträgen vor, das darf nicht als "freundliche Erinnerung" mit
// der blanken Original-Rechnung rausgehen.
//
// Ein bereits versendeter Vorgang bleibt ausdrücklich versendbar: die Mail kann als
// unzustellbar zurückkommen, im Spam hängen bleiben oder schlicht an die falsche
// Adresse gegangen sein. Gesperrt sind nur erledigte (= bezahlte) und stornierte
// Vorgänge — die prüft zusätzlich mahnungVersandErlaubt().
//
// Empfänger: nicht nur die Firmenadresse, auch ein Ansprechpartner mit Mailadresse
// genügt; im Formular lässt sich außerdem eine beliebige Adresse eintragen.
$erinnerungEmpfaengerMoeglich = ($kundeEmail !== '');
if (!$erinnerungEmpfaengerMoeglich && $societeGeladen) {
foreach (mahnungEmpfaengerListe($societe) as $kandidat) {
if (mahnungAdresseNormieren($kandidat) !== null) {
$erinnerungEmpfaengerMoeglich = true;
break;
}
}
}
$canSendErinnerung = ($istErinnerung
&& (int) $mahnung->status !== Mahnung::STATUS_ERLEDIGT
&& (int) $mahnung->status !== Mahnung::STATUS_STORNIERT
&& $erinnerungEmpfaengerMoeglich
&& $user->hasRight('mahnung', 'send'));
// Erneuter Versand? Wird ausschließlich aus dem Status abgeleitet, nicht aus einem
// Request-Parameter — so kann niemand den Doppelversand-Schutz per URL aushebeln.
$istErneuterVersand = ($canSendErinnerung && (int) $mahnung->status >= Mahnung::STATUS_VERSENDET);
// ---------------------------------------------------------------------------
// Mailformular für die Zahlungserinnerung (Dolibarr-Standard FormMail)
// ---------------------------------------------------------------------------
// Verschickt wird über das gewohnte Dolibarr-Mailformular: Empfänger (inklusive
// aller Ansprechpartner des Kunden), Betreff, Text und Anhang stehen sichtbar im
// Formular und sind vor dem Absenden änderbar. Der Versand selbst läuft
// ausschließlich über mahnungSendeErinnerungsMail() — dort hängen Statuswächter,
// die fachliche Sperre (nur Erinnerungsstufen) und die atomare Reservierung gegen
// Doppelversand. ajax/sendmail.php stellt diese Bausteine nur noch bereit, es ist
// kein eigener Endpoint mehr.
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formmail.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
// Die Anhangsliste liegt in der Session; der trackid trennt sie pro Mahnvorgang,
// sonst sähe eine zweite offene Karte die Anhänge der ersten.
$mahnungTrackId = 'mah'.((int) $mahnung->id);
$formmail = new FormMail($db);
$formmail->trackid = $mahnungTrackId;
// Merker "für diesen Vorgang wurde die Anhangsliste schon einmal vorbelegt".
// Er wird zusammen mit der Liste geleert, damit ein später neu geöffnetes Formular
// die Rechnung wieder eingehängt bekommt.
$mahnungInitMarker = 'mahnung_mailinit_'.((int) $mahnung->id);
$urlKarte = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id);
// "Abbrechen" im Mailformular: hochgeladene Anhänge verwerfen, zurück zur Karte.
if ($action === 'send' && GETPOST('cancel', 'alpha')) {
$formmail->clear_attached_files();
unset($_SESSION[$mahnungInitMarker]);
header('Location: '.$urlKarte);
exit;
}
// Anhang hinzufügen / entfernen — gleiches Muster wie core/actions_sendmails.inc.php.
// Beide Buttons posten dasselbe Formular (action=send); danach wird das Formular
// erneut angezeigt und NICHT gesendet.
//
// $canSendErinnerung ist Pflicht: ohne die Prüfung könnte jeder eingeloggte Benutzer
// (das Leserecht ist Default-Recht) mit einem gültigen Token Dateien in sein
// Temp-Verzeichnis hochladen, obwohl er hier gar nichts versenden darf.
if ($action === 'send' && (GETPOST('addfile', 'alpha') || GETPOST('removedfile'))) {
if (!$canSendErinnerung) {
setEventMessages($langs->trans('MahnungMailNichtErlaubt'), null, 'errors');
header('Location: '.$urlKarte);
exit;
}
// Temp-Verzeichnis pro Mahnvorgang: dol_add_file_process() speichert mit
// allowoverwrite, ein gleichnamiger Anhang eines anderen Vorgangs würde sonst
// überschrieben — und beide Vorgänge zeigten anschließend auf dieselbe Datei.
$uploadTmpDir = $conf->user->dir_output.'/'.((int) $user->id).'/temp/'.$mahnungTrackId;
if (GETPOST('addfile', 'alpha')) {
dol_add_file_process($uploadTmpDir, 1, 0, 'addedfile', '', null, $mahnungTrackId, 0);
} else {
// Nur aus der Liste nehmen, Datei NICHT von der Platte löschen: der
// Standardanhang ist die echte Original-Rechnungs-PDF im Dokumentenordner.
dol_remove_file_process(GETPOSTINT('removedfile'), 0, 1, $mahnungTrackId);
}
$action = 'presend';
}
// --- Absenden -------------------------------------------------------------
if ($action === 'send' && $tokenOk) {
$sendFehler = '';
// Recht, Erinnerungs-Flag, Status und Kunden-Mailadresse hängen alle an
// $canSendErinnerung. Die feinkörnige Begründung liefert zusätzlich
// mahnungVersandErlaubt() innerhalb von mahnungSendeErinnerungsMail().
if (!$canSendErinnerung) {
$sendFehler = $langs->trans('MahnungMailNichtErlaubt');
} elseif (!$factureGeladen || !$societeGeladen) {
$sendFehler = $langs->trans('MahnungRechnungNichtLadbar');
}
// --- Empfänger ---------------------------------------------------------
// Aus der Auswahlliste kommen nur Schlüssel; die zugehörige Adresse wird über
// mahnungEmpfaengerAdresse() aufgelöst. Vorher wird geprüft, dass der Schlüssel
// wirklich aus der Liste DIESES Kunden stammt — Societe::contact_get_property()
// validiert die Firmenzugehörigkeit nicht, ein manipuliertes receiver[] könnte
// die Erinnerung sonst an einen fremden Kontakt schicken.
$adressen = array();
if ($sendFehler === '') {
$empfaengerListe = mahnungEmpfaengerListe($societe);
foreach (GETPOST('receiver', 'array') as $key) {
if (!array_key_exists((string) $key, $empfaengerListe) && !array_key_exists((int) $key, $empfaengerListe)) {
continue;
}
// Über dieselbe Normierung wie der Freitextpfad schlüsseln (kleingeschriebene
// Mailadresse). Der rohe String "Name <mail>" als Schlüssel würde denselben
// Empfänger zweimal in den To-Header lassen, sobald er einmal ausgewählt und
// einmal von Hand eingetippt wurde.
$norm = mahnungAdresseNormieren(mahnungEmpfaengerAdresse($societe, $key));
if ($norm !== null) {
$adressen[$norm['mail']] = $norm['voll'];
}
}
// Zusätzlich frei eingetragene Adressen (Komma oder Semikolon getrennt).
$verworfen = array();
foreach (mahnungAdressenAusFreitext(GETPOST('sendto', 'alphawithlgt'), $verworfen) as $mail => $voll) {
$adressen[$mail] = $voll;
}
if (!empty($verworfen)) {
// Nicht kommentarlos weniger Empfänger beliefern als im Formular standen.
$sendFehler = $langs->trans('MahnungMailEmpfaengerUngueltig', implode(', ', $verworfen));
}
if ($sendFehler === '' && empty($adressen)) {
$sendFehler = $langs->trans('MahnungMailKeinEmpfaenger');
}
}
// Kopie / Blindkopie — gleiche Prüfung, und wer schon im An-Feld steht, bekommt
// die Mail nicht zusätzlich als Kopie.
$adressenCc = array();
$adressenBcc = array();
if ($sendFehler === '') {
foreach (array('sendtocc' => 'cc', 'sendtoccc' => 'bcc') as $feld => $ziel) {
$verworfenKopie = array();
$liste = mahnungAdressenAusFreitext(GETPOST($feld, 'alphawithlgt'), $verworfenKopie);
if (!empty($verworfenKopie)) {
$sendFehler = $langs->trans('MahnungMailEmpfaengerUngueltig', implode(', ', $verworfenKopie));
break;
}
foreach ($liste as $mail => $voll) {
if (isset($adressen[$mail])) {
continue;
}
if ($ziel === 'cc') {
$adressenCc[$mail] = $voll;
} else {
$adressenBcc[$mail] = $voll;
}
}
}
}
// --- Rechnung zum Versandzeitpunkt prüfen ------------------------------
// Zwischen Anlage und Versand kann bezahlt, teilbezahlt, storniert oder
// abgeschrieben worden sein. Ändert sich der offene Betrag, zieht die Prüfung
// betrag_offen/summe_mahnung am Objekt nach, damit Mail und Datensatz denselben
// Betrag nennen.
$betragAngepasst = false;
if ($sendFehler === '') {
$pruefFehler = '';
if (!mahnungRechnungNochOffen($mahnung, $facture, $pruefFehler, $betragAngepasst)) {
$sendFehler = $pruefFehler;
} elseif ($betragAngepasst) {
// Der Kunde hat zwischen dem Öffnen des Formulars und dem Klick auf "Senden"
// eine Teilzahlung geleistet. Im Textfeld steht noch der alte Betrag — die
// Platzhalter wurden beim Aufbau des Formulars bereits ersetzt, ein erneutes
// Ersetzen findet keine Marke mehr. Statt eine Mail mit falschem Betrag zu
// verschicken, wird zurück ins Formular geleitet: es baut Betreff und Text
// mit dem jetzt gültigen Betrag neu auf, und Eddy schickt bewusst ab.
$sendFehler = $langs->trans('MahnungMailBetragAngepasst', price($mahnung->summe_mahnung));
// Die geposteten Werte verwerfen: FormMail zeigt ein vorhandenes subject/message
// bevorzugt an und würde sonst genau den veralteten Text wieder einsetzen.
unset($_POST['subject'], $_POST['message'], $_GET['subject'], $_GET['message']);
}
}
// --- Absender ----------------------------------------------------------
// Bewusst die Firmenadresse, nicht die des angemeldeten Benutzers: eine
// Zahlungserinnerung geht im Namen des Betriebs raus. Ein trotzdem geposteter
// frommail-Wert wird ignoriert.
$absender = array('voll' => '');
if ($sendFehler === '') {
$absenderFehler = '';
$absender = mahnungAbsender($absenderFehler);
if ($absenderFehler !== '') {
$sendFehler = $absenderFehler;
}
}
// --- Senden ------------------------------------------------------------
if ($sendFehler === '') {
$anhaenge = $formmail->get_attached_files();
$neueFrist = mahnungBerechneNeueFrist($stufeObj, dol_now());
$werte = mahnungPlatzhalterWerte($mahnung, $facture, $societe, $neueFrist, $stufeObj);
// Betreff und Text kommen aus dem Formular. Platzhalter werden noch einmal
// aufgelöst — so wirken auch {…}-Marken, die im Formular ergänzt wurden.
//
// mahnungBodyEntkleiden() macht die nl2br-Umwandlung rückgängig, die GETPOST
// mit 'restricthtml' auf jedem Klartext anrichtet. Ohne das ginge eine als
// Klartext gepflegte Erinnerung als HTML-Mail mit sichtbaren <br>-Tags raus.
$vorlageIshtml = (int) mahnungMailVorlage($stufeObj, $werte)['ishtml'];
$bodyRoh = mahnungBodyEntkleiden(GETPOST('message', 'restricthtml'), $vorlageIshtml);
$isHtml = dol_textishtml($bodyRoh) ? 1 : 0;
$betreff = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter((string) GETPOST('subject', 'restricthtml'), $werte, 0));
if ($betreff === '') {
$sendFehler = $langs->trans('MahnungMailKeinBetreff');
}
if ($sendFehler === '') {
$mailFehler = '';
$versandOk = mahnungSendeErinnerungsMail(
$mahnung,
$stufeObj,
array(
'to' => implode(', ', $adressen),
'cc' => implode(', ', $adressenCc),
'bcc' => implode(', ', $adressenBcc),
'from' => $absender['voll'],
'subject' => $betreff,
'body' => mahnungErsetzePlatzhalter($bodyRoh, $werte, $isHtml),
'ishtml' => $isHtml,
'paths' => $anhaenge['paths'],
'names' => $anhaenge['names'],
'mimes' => $anhaenge['mimes'],
'deliveryreceipt' => GETPOSTINT('deliveryreceipt'),
'trackid' => 'mah'.((int) $mahnung->id),
'frist' => $neueFrist,
// Erneuter Versand eines bereits versendeten Vorgangs (Mail kam nicht
// an, ging an die falsche Adresse …). Der Wert kommt aus dem Status,
// nicht aus dem Request — der Doppelversand-Schutz greift weiterhin:
// die Reservierung nagelt beim erneuten Versand zusätzlich das
// bisherige Versanddatum fest, sodass zwei Parallelklicks nicht
// beide durchkommen.
'force' => $istErneuterVersand ? 1 : 0,
),
$user,
$mailFehler
);
if ($versandOk) {
$formmail->clear_attached_files();
unset($_SESSION[$mahnungInitMarker]);
setEventMessages($langs->trans('MahnungErinnerungGesendet'), null, 'mesgs');
header('Location: '.$urlKarte);
exit;
}
$sendFehler = $mailFehler;
}
}
// Fehlgeschlagen: Meldung zeigen und zurück ins Formular, damit die Eingaben
// nicht verloren gehen.
setEventMessages($sendFehler ?: $langs->trans('MahnungErinnerungSendenFehler'), null, 'errors');
$action = 'presend';
}
// --- Anhang vorbelegen ----------------------------------------------------
// Beim Öffnen des Formulars (Button trägt mailinit=1) wird die Anhangsliste
// zurückgesetzt und die UNVERÄNDERTE Original-Rechnungs-PDF hineingelegt.
//
// Die Vorbelegung hängt ausschließlich an `mailinit`, NICHT daran, ob die Liste
// leer ist: wer die Rechnung bewusst abwählt, hat danach eine leere Liste — sie
// würde sonst im selben Request sofort wieder eingehängt und ließe sich überhaupt
// nicht entfernen. Dass schon einmal initialisiert wurde, merkt sich ein
// Session-Marker; damit bekommt auch ein Direktaufruf von `action=presend`
// (Lesezeichen, Legacy-Redirect) seine Vorbelegung genau einmal.
//
// Bewusst ein eigener Parameter statt des Dolibarr-üblichen mode=init: FormMail::
// get_form() räumt bei mode=init selbst die Session-Liste leer und würde die hier
// eingehängte Rechnung gleich wieder entfernen.
//
// Der Block läuft vor llxHeader(), damit eine fehlende Rechnungs-PDF sofort als
// Warnung erscheint und nicht erst beim nächsten Seitenaufruf.
if ($action === 'presend' && $canSendErinnerung && $factureGeladen) {
if (GETPOSTINT('mailinit') === 1 || empty($_SESSION[$mahnungInitMarker])) {
$formmail->clear_attached_files();
$_SESSION[$mahnungInitMarker] = 1;
$pdfFehler = '';
$pdfDatei = mahnungHoleRechnungsPdf($facture, $user, $pdfFehler);
if ($pdfDatei !== '') {
$formmail->add_attached_files($pdfDatei, basename($pdfDatei), dol_mimetype($pdfDatei));
} elseif ($pdfFehler !== '') {
// Ohne Anhang kann trotzdem verschickt werden — der offene Betrag steht im
// Mailtext. Deshalb nur eine Warnung, keine Sperre.
setEventMessages($pdfFehler, null, 'warnings');
}
}
}
// Upload-Verzeichnis für Sendebelege (muss VOR llxHeader stehen für 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);
print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, mahnungSetupLink(), 'fa-envelope-open-text');
print '<div class="fichecenter">';
print '<div class="underbanner clearboth"></div>';
print '<table class="border centpercent tableforfield">';
print '<tr><td class="titlefield fieldname_ref">'.$langs->trans('MahnungRef').'</td><td class="valuefield fieldname_ref">'.dol_escape_htmltag($mahnung->ref).'</td></tr>';
// Stufe als EIN Badge (Nummer + Bezeichnung). Vorher stand die Bezeichnung als
// Text da und daneben noch ein Badge mit demselben Wort.
print '<tr><td>'.$langs->trans('MahnungStufe').'</td><td>';
print mahnungStufeBadge((int) $mahnung->stufe, $stufeGeladen ? $stufeObj->label : '', $istErinnerung);
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungDatum').'</td><td>'.dol_print_date($mahnung->date_mahnung, 'day').'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungFaelligkeitAlt').'</td><td>'.dol_print_date($mahnung->date_lim_reglement_alt, 'day').'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungFaelligkeitNeu').'</td><td>'.dol_print_date($mahnung->date_lim_reglement_neu, 'day').'</td></tr>';
// Rechnung
if ($factureGeladen) {
print '<tr><td>'.$langs->trans('MahnungRechnung').'</td>';
print '<td><a href="'.DOL_URL_ROOT.'/compta/facture/card.php?id='.((int) $facture->id).'">'.dol_escape_htmltag($facture->ref).'</a></td></tr>';
}
// Kunde
if ($societeGeladen) {
print '<tr><td>'.$langs->trans('MahnungKunde').'</td>';
print '<td><a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.((int) $societe->id).'">'.dol_escape_htmltag($societe->name).'</a> ('.dol_escape_htmltag((string) $mahnung->customertype).')</td></tr>';
}
print '<tr><td>'.$langs->trans('MahnungBetragOffen').'</td><td>'.price($mahnung->betrag_offen).'</td></tr>';
if ($istErinnerung) {
// Kostenlose Zahlungserinnerung: keine Gebühren-, Pauschalen- oder Zinszeilen
// anzeigen — es fällt bewusst nichts an. Ein Altvorgang mit gespeicherten Beträgen
// behält seine Kostenzeilen (siehe $hatGespeicherteKosten weiter oben), auch wenn
// die Stufe inzwischen als Erinnerung markiert wurde.
print '<tr><td>'.$langs->trans('MahnungKosten').'</td><td class="opacitymedium">'.dol_escape_htmltag($langs->trans('MahnungErinnerungKostenfrei')).'</td></tr>';
} else {
print '<tr><td>'.$langs->trans('MahnungGebuehr').'</td><td>'.price($mahnung->mahngebuehr).'</td></tr>';
if ((float) $mahnung->pauschale_b2b > 0) {
print '<tr><td>'.$langs->trans('MahnungPauschaleB2B').'</td><td>'.price($mahnung->pauschale_b2b).'</td></tr>';
}
if ((float) $mahnung->kosten_vorstufen > 0) {
print '<tr><td>'.$langs->trans('MahnungKostenVorstufen').'</td><td>'.price($mahnung->kosten_vorstufen).'</td></tr>';
}
// Ausgewiesener Zinssatz exakt wie im Schreiben (siehe
// pdf_standard_mahnung::getAusgewiesenerZinssatz()): der Stufen-Override hat
// Vorrang, nur ohne Override gilt Basiszins-Snapshot + Aufschlag. Vorher stand
// hier bloß der Basiszins — bei gesetztem Override wich die Karte damit vom
// tatsächlich berechneten und im PDF ausgewiesenen Satz ab.
$zinsOverride = $stufeGeladen ? $stufeObj->getZinssatzOverride($mahnung->customertype) : null;
$basisSnapshot = ($mahnung->basiszins_snapshot !== null) ? (float) $mahnung->basiszins_snapshot : 0.0;
if ($zinsOverride !== null) {
$zinssatzAusgewiesen = (float) $zinsOverride;
$zinsHerkunft = $langs->trans('MahnungZinssatzOverrideStufe');
} else {
$zinsAufschlag = ($mahnung->customertype === Mahnung::KUNDENTYP_B2B)
? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')
: (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0');
$zinssatzAusgewiesen = $basisSnapshot + $zinsAufschlag;
$zinsHerkunft = $langs->trans('MahnungZinssatzAusBasiszins', number_format($basisSnapshot, 2, ',', '.'));
}
// $langs->trans() kodiert sein Ergebnis selbst per htmlentities — kein zweites
// Escaping, das würde Sonderzeichen doppelt kodieren.
print '<tr><td>'.$langs->trans('MahnungVerzugszinsen').'</td><td>'.price($mahnung->verzugszinsen)
.' ('.number_format($zinssatzAusgewiesen, 2, ',', '.').' % p.a. — '.$zinsHerkunft.')</td></tr>';
print '<tr><td>'.$langs->trans('MahnungSumme').'</td><td><strong>'.price($mahnung->summe_mahnung).'</strong></td></tr>';
}
print '<tr><td>'.$langs->trans('Status').'</td><td>'.dol_escape_htmltag($mahnung->getStatusLabel()).'</td></tr>';
print '</table>';
print '</div>'; // Ende fichecenter Stammdaten
// --- Generierte Dokumente (wie bei Rechnungen) ---
print '<br>';
print load_fiche_titre($langs->trans('Documents'), '', 'fa-file');
if ($istErinnerung) {
// Für eine Zahlungserinnerung wird bewusst KEIN Mahn-PDF erzeugt. Angehängt
// wird die unveränderte Original-Rechnung.
// Kein dol_escape_htmltag drumherum: $langs->trans() setzt den Parameter per
// sprintf ein und kodiert das Ergebnis danach selbst per htmlentities.
// Ein zweites Escaping würde Sonderzeichen im Dateinamen doppelt kodieren.
print '<div class="opacitymedium" style="margin-bottom:8px;">';
print $langs->trans('MahnungErinnerungAnhangHinweis', $anhangDatei !== '' ? $anhangDatei : '-');
print '</div>';
}
// Dokumente im Rechnungsordner suchen die zur Mahnung gehoeren
$docDir = '';
if ($facture->id > 0) {
$docDir = !empty($conf->facture->multidir_output[$facture->entity])
? $conf->facture->multidir_output[$facture->entity]
: $conf->facture->dir_output;
$docDir .= '/'.dol_sanitizeFileName($facture->ref);
}
$fileList = array();
if (!empty($docDir) && is_dir($docDir)) {
$allFiles = dol_dir_list($docDir, 'files', 0, preg_quote($mahnungSafeRef, '/'));
foreach ($allFiles as $f) {
$fileList[] = $f;
}
}
// Bei einer Zahlungserinnerung gehört die Original-Rechnung mit in die Liste: sie ist
// das Dokument, das tatsächlich rausgeht. Ohne sie stünde hier "keine Dokumente", und
// zum Nachschauen müsste man erst auf die Rechnungskarte wechseln.
//
// Bewusst mahnungFindeRechnungsPdf() statt mahnungHoleRechnungsPdf(): das bloße
// Anschauen der Karte darf keine PDF-Erzeugung auslösen. Fehlt die Datei noch, wird
// sie beim Öffnen des Mailformulars erzeugt.
$rechnungsPdf = null;
if ($istErinnerung && $factureGeladen) {
$rechnungsPdfPfad = mahnungFindeRechnungsPdf($facture);
if ($rechnungsPdfPfad !== '') {
$rechnungsPdf = array(
'name' => basename($rechnungsPdfPfad),
'fullname' => $rechnungsPdfPfad,
'relativ' => dol_sanitizeFileName($facture->ref).'/'.basename($rechnungsPdfPfad),
);
}
}
if (!empty($fileList) || $rechnungsPdf !== null) {
$canDeleteDoc = $user->hasRight('mahnung', 'write');
// Eigener Lang-Key statt trans('Document'): den Singular gibt es in Dolibarrs
// deutschem Sprachpaket nicht, dort stand deshalb "Document" auf Englisch.
// Vorschau bekommt eine eigene, schmale Spalte — direkt neben dem Dateinamen
// klebte die Lupe am Text.
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('MahnungDokument').'</th>';
print '<th class="center" style="width:70px;">'.$langs->trans('MahnungVorschau').'</th>';
print '<th class="right" style="width:90px;">'.$langs->trans('Size').'</th>';
print '<th class="center" style="width:140px;">'.$langs->trans('Date').'</th>';
print '<th class="right" style="width:90px;"></th>';
print '</tr>';
foreach ($fileList as $f) {
$fname = $f['name'];
$relativePath = dol_sanitizeFileName($facture->ref).'/'.$fname;
$dlUrl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode($relativePath);
$viewUrl = $dlUrl.'&attachment=0';
$ext = strtolower(pathinfo($fname, PATHINFO_EXTENSION));
$icon = ($ext === 'pdf') ? 'pdf' : (($ext === 'odt') ? 'ooffice' : 'generic');
$filesize = !empty($f['size']) ? $f['size'] : filesize($f['fullname']);
$filedate = !empty($f['date']) ? $f['date'] : filemtime($f['fullname']);
print '<tr class="oddeven">';
// Dateiname mit Icon
print '<td class="nowraponall">';
print '<a href="'.$dlUrl.'">';
print img_picto('', $icon, 'class="pictofixedwidth"');
print dol_escape_htmltag($fname);
print '</a>';
print '</td>';
// Vorschau (nur PDF)
print '<td class="center nowraponall">';
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>';
// Größe — mit shortvalue/shortunit, sonst stünde dort die rohe Byte-Zahl
print '<td class="right nowraponall">'.dol_print_size($filesize, 1, 1).'</td>';
// Datum
print '<td class="center nowraponall">'.dol_print_date($filedate, 'dayhour').'</td>';
// Aktionen: Download + Löschen
print '<td class="right nowraponall">';
print '<a href="'.$dlUrl.'" title="'.dol_escape_htmltag($langs->trans('Download')).'">'.img_picto($langs->trans('Download'), 'download').'</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 '</td>';
print '</tr>';
}
// Original-Rechnung der Zahlungserinnerung — gleiche Darstellung wie oben, aber
// ohne Löschen-Knopf: die Datei gehört der Rechnung, nicht dem Mahnvorgang.
if ($rechnungsPdf !== null) {
$rDlUrl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode($rechnungsPdf['relativ']);
$rViewUrl = $rDlUrl.'&attachment=0';
$rSize = (int) @filesize($rechnungsPdf['fullname']);
$rDate = (int) @filemtime($rechnungsPdf['fullname']);
print '<tr class="oddeven">';
print '<td class="nowraponall">';
print '<a href="'.$rDlUrl.'">';
print img_picto('', 'pdf', 'class="pictofixedwidth"');
print dol_escape_htmltag($rechnungsPdf['name']);
print '</a>';
// Als Badge statt als grauer Fließtext — sonst verschwimmt der Hinweis
// mit dem Dateinamen.
print ' <span class="badge badge-status4 badge-status" style="margin-left:6px;">';
print dol_escape_htmltag($langs->trans('MahnungErinnerungAnhangOriginal'));
print '</span>';
print '</td>';
print '<td class="center nowraponall">';
print '<a href="'.$rViewUrl.'" target="_blank" title="'.dol_escape_htmltag($langs->trans('Preview')).'">'.img_picto($langs->trans('Preview'), 'search').'</a>';
print '</td>';
print '<td class="right nowraponall">'.($rSize > 0 ? dol_print_size($rSize, 1, 1) : '').'</td>';
print '<td class="center nowraponall">'.($rDate > 0 ? dol_print_date($rDate, 'dayhour') : '').'</td>';
print '<td class="right nowraponall">';
print '<a href="'.$rDlUrl.'" title="'.dol_escape_htmltag($langs->trans('Download')).'">'.img_picto($langs->trans('Download'), 'download').'</a>';
print '</td>';
print '</tr>';
}
print '</table>';
} elseif ($istErinnerung) {
// Erinnerung ohne auffindbare Rechnungs-PDF: sie wird beim Öffnen des
// Mailformulars erzeugt, hier gibt es deshalb noch nichts zu zeigen.
print '<div class="opacitymedium">'.$langs->trans('MahnungErinnerungRechnungsPdfNochNicht').'</div>';
} else {
print '<div class="opacitymedium">'.$langs->trans('NoDocuments').'</div>';
}
// Aktionen
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php';
$form = new Form($db);
$formfile = new FormFile($db);
// Postalischer Versand (Versandweg, Sendungsverfolgung, Belege) ist nur sinnvoll,
// wenn die Mahnung tatsächlich auf dem Postweg rausgeht. Eine Zahlungserinnerung —
// und generell jeder Vorgang mit Versandart "E-Mail" — braucht weder Einschreiben-
// Nummer noch Belegscan. Statt der kompletten Sektion wird dann nur der
// Mail-Versandstatus angezeigt.
$istMailVersand = ($mahnung->versandart === Mahnung::VERSAND_MAIL) || $istErinnerung;
if ($istMailVersand) {
print '<br>';
print load_fiche_titre($langs->trans('MahnungVersandStatus'), '', 'fa-envelope');
print '<div class="fichecenter">';
print '<div class="underbanner clearboth"></div>';
print '<table class="border centpercent">';
print '<tr><td class="titlefield">'.$langs->trans('MahnungVersandart').'</td><td>';
print dol_escape_htmltag($langs->trans('MahnungVersandwegEmail'));
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungEmpfaenger').'</td><td>';
$empfaenger = '';
if (!empty($mahnung->fk_soc)) {
$socMail = new Societe($db);
if ($socMail->fetch((int) $mahnung->fk_soc) > 0) {
$empfaenger = trim((string) $socMail->email);
}
}
if ($empfaenger !== '') {
print dol_escape_htmltag($empfaenger);
} else {
print '<span class="warning">'.dol_escape_htmltag($langs->trans('MahnungKundeKeineEmail')).'</span>';
}
print '</td></tr>';
print '<tr><td>'.$langs->trans('MahnungDateVersand').'</td><td>';
if (!empty($mahnung->date_versand)) {
print dol_print_date($mahnung->date_versand, 'dayhour');
} else {
print '<span class="opacitymedium">'.dol_escape_htmltag($langs->trans('MahnungNochNichtVersendet')).'</span>';
}
print '</td></tr>';
print '</table>';
// --- Versandprotokoll ---------------------------------------------------
// Was ist wann an wen rausgegangen? Festgehalten wird der Stand zum
// Sendezeitpunkt; eine später geänderte Stufen-Vorlage ändert das Protokoll
// nicht. Mehrere Einträge entstehen beim erneuten Versand.
$mailProtokoll = mahnungHoleMailProtokoll((int) $mahnung->id);
if (!empty($mailProtokoll)) {
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('MahnungMailProtokoll').'</th>';
print '</tr>';
foreach ($mailProtokoll as $eintrag) {
print '<tr class="oddeven"><td>';
// Kopfzeile: zugeklappt sieht man Zeitpunkt und Empfänger, mehr braucht
// es im Regelfall nicht.
print '<details>';
print '<summary style="cursor:pointer;">';
print '<strong>'.dol_print_date($eintrag->date_versand, 'dayhour').'</strong>';
print ' &nbsp; '.dol_escape_htmltag($eintrag->mail_to);
print '</summary>';
print '<div style="margin:8px 0 4px 18px;">';
print '<table class="border centpercent">';
print '<tr><td class="titlefield">'.$langs->trans('MahnungMailVon').'</td><td>'.dol_escape_htmltag((string) $eintrag->mail_from).'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungMailAn').'</td><td>'.dol_escape_htmltag((string) $eintrag->mail_to).'</td></tr>';
if (trim((string) $eintrag->mail_cc) !== '') {
print '<tr><td>'.$langs->trans('MahnungMailKopie').'</td><td>'.dol_escape_htmltag((string) $eintrag->mail_cc).'</td></tr>';
}
if (trim((string) $eintrag->mail_bcc) !== '') {
print '<tr><td>'.$langs->trans('MahnungMailBlindkopie').'</td><td>'.dol_escape_htmltag((string) $eintrag->mail_bcc).'</td></tr>';
}
print '<tr><td>'.$langs->trans('MahnungMailBetreff').'</td><td>'.dol_escape_htmltag((string) $eintrag->subject).'</td></tr>';
if (trim((string) $eintrag->anhaenge) !== '') {
print '<tr><td>'.$langs->trans('MahnungMailAnhaenge').'</td><td>'.dol_escape_htmltag((string) $eintrag->anhaenge).'</td></tr>';
}
print '</table>';
// Der Text so, wie er verschickt wurde. Bei HTML durch
// dol_string_onlythesehtmltags() — der Inhalt kam zwar schon gefiltert
// herein, aber ausgegeben wird er hier ungefragt im Browser.
print '<div style="margin-top:8px; padding:8px; border:1px solid var(--colorbacklinebreak, #ddd); border-radius:4px; background:var(--colorbacklinepair1, #fff);">';
if (!empty($eintrag->ishtml)) {
print dol_string_onlythesehtmltags((string) $eintrag->body);
} else {
print nl2br(dol_escape_htmltag((string) $eintrag->body));
}
print '</div>';
print '</div>';
print '</details>';
print '</td></tr>';
}
print '</table>';
}
print '</div>';
} else {
// --- Versand & Belege ---
print '<br>';
print load_fiche_titre($langs->trans('MahnungVersandBelege'), '', 'fa-truck');
print '<div class="fichecenter">';
print '<div class="underbanner clearboth"></div>';
// Versandwege (Dropdown-Optionen, Label kommt aus Lang-File MahnungVersandweg*)
$versandwege = array(
'post' => $langs->trans('MahnungVersandwegPost'),
'einschreiben' => $langs->trans('MahnungVersandwegEinschreiben'),
'dhl' => $langs->trans('MahnungVersandwegDhl'),
'dpd' => $langs->trans('MahnungVersandwegDpd'),
'hermes' => $langs->trans('MahnungVersandwegHermes'),
'ups' => $langs->trans('MahnungVersandwegUps'),
'fax' => $langs->trans('MahnungVersandwegFax'),
'email' => $langs->trans('MahnungVersandwegEmail'),
'persoenlich' => $langs->trans('MahnungVersandwegPersoenlich'),
'eigen' => $langs->trans('MahnungVersandwegEigen'),
);
// $canWrite wird bereits oben (vor den Action-Handlern) gesetzt
$editVersand = ($action === 'edit_versand') || empty($mahnung->date_versand);
if (!empty($mahnung->date_versand) && $action !== 'edit_versand') {
// Anzeige der bereits erfassten Versanddaten
print '<table class="border centpercent">';
print '<tr><td class="titlefield">'.$langs->trans('MahnungVersanddatum').'</td><td>'.dol_print_date($mahnung->date_versand, 'day').'</td></tr>';
print '<tr><td>'.$langs->trans('MahnungVersandweg').'</td><td>'
.($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)) {
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)) {
print ' <a href="'.dol_escape_htmltag($trackUrl).'" target="_blank" rel="noopener" class="butAction" style="margin-left:8px;">';
print img_picto('', 'fa-external-link-alt', 'class="pictofixedwidth"');
print dol_escape_htmltag($langs->trans('MahnungSendungVerfolgen')).'</a>';
}
print '</td></tr>';
}
print '</table>';
if ($canWrite) {
print '<div style="margin-top:8px;">';
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=edit_versand">'.img_picto('', 'edit').' '.dol_escape_htmltag($langs->trans('MahnungVersandBearbeiten')).'</a> ';
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=clear_versand&token='.newToken().'" onclick="return confirm(\''.dol_escape_js($langs->trans('MahnungVersandLeerenConfirm')).'\');">'.dol_escape_htmltag($langs->trans('MahnungVersandLeeren')).'</a>';
print '</div>';
}
} elseif ($canWrite) {
// Versand-Formular (Erfassung oder Bearbeitung)
$dateInit = !empty($mahnung->date_versand) ? $mahnung->date_versand : dol_now();
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="set_versand">';
print '<table class="border centpercent">';
// Versanddatum
print '<tr><td class="titlefield">'.$langs->trans('MahnungVersanddatum').'</td><td>';
print $form->selectDate($dateInit, 'versand_', 0, 0, 0, '', 1, 0);
print '</td></tr>';
// Versandweg
print '<tr><td>'.$langs->trans('MahnungVersandweg').'</td><td>';
print '<select name="versandweg" id="versandweg">';
foreach ($versandwege as $k => $label) {
print '<option value="'.dol_escape_htmltag($k).'"'.((string) $mahnung->versandweg === $k ? ' selected' : '').'>'.dol_escape_htmltag($label).'</option>';
}
print '</select>';
print '</td></tr>';
// Tracking-Nr
print '<tr><td>'.$langs->trans('MahnungTrackingNr').'</td><td>';
print '<input type="text" name="tracking_nr" value="'.dol_escape_htmltag((string) $mahnung->tracking_nr).'" size="30" placeholder="'.dol_escape_htmltag($langs->trans('MahnungTrackingNrHint')).'">';
print ' <span class="opacitymedium">('.dol_escape_htmltag($langs->trans('MahnungTrackingProviderAuto')).')</span>';
print '</td></tr>';
// Optional: Tracking-Provider override
print '<tr><td>'.$langs->trans('MahnungTrackingProvider').'</td><td>';
print '<select name="tracking_provider">';
print '<option value="">'.dol_escape_htmltag($langs->trans('MahnungTrackingProviderAuto')).'</option>';
foreach (array('dhl' => 'DHL', 'dpag' => 'Deutsche Post', 'dpd' => 'DPD', 'hermes' => 'Hermes', 'ups' => 'UPS') as $k => $label) {
print '<option value="'.dol_escape_htmltag($k).'"'.((string) $mahnung->tracking_provider === $k ? ' selected' : '').'>'.$label.'</option>';
}
print '</select>';
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 (!empty($mahnung->date_versand)) {
print '<a class="butActionRefused" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'">'.dol_escape_htmltag($langs->trans('Cancel')).'</a>';
}
print '</div>';
print '</form>';
}
// --- Sendebelege (Beleg-Upload via Dolibarr-Standard) ---
print '<br>';
print load_fiche_titre($langs->trans('MahnungSendebelege'), '', 'fa-paperclip');
print '<div class="opacitymedium" style="margin-bottom:8px;">'.$langs->trans('MahnungSendebelegeHint').'</div>';
// Tracking-Vorschläge 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>';
}
$urlSelf = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id);
// Upload-Formular (Durchsuchen + Upload-Button)
$formfile->form_attach_new_file(
$urlSelf,
'', // title
0, // addcancel
0, // sectionid
(int) $canWrite, // perm
50, // size
$mahnung, // object
'', // options
1, // useajax
'', // savingdocmask
0, // linkfiles
'formuserfile', // htmlname
'', // accept
'', // sectiondir
0, // usewithoutform
0, // capture
0 // disablemulti
);
// Dateiliste der bereits hochgeladenen Belege
print $formfile->showdocuments(
'mahnung', // $modulepart
$mahnungSafeRef, // $modulesubdir
$upload_dir, // $filedir
$urlSelf, // $urlsource
0, // $genallowed (kein PDF-Gen-Button hier)
(int) $canWrite, // $delallowed
'', // $modelselected
0, // $allowgenifempty
0, // $forcenomultilang
0, // $iconPDF
0, // $notused
0, // $noform
'', // $param
'', // $title
'', // $buttonlabel
'', // $codelang
'', // $morepicto
$mahnung, // $object
0 // $hideifempty
);
print '</div>'; // Ende fichecenter Versand & Belege
}
if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) {
if ($action === 'confirm_storno') {
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id),
$langs->trans('MahnungStornieren'),
$langs->trans('MahnungStornieren').' — '.dol_escape_htmltag($mahnung->ref).'?',
'storno',
'',
0,
1
);
}
if ($action === 'ask_uneinbringlich') {
// Bestätigungsdialog mit Eingabefeld für Begründung
$formquestion = array(
array(
'type' => 'textarea',
'name' => 'uneinbringlich_note',
'label' => $langs->trans('MahnungUneinbringlichBegruendung'),
'value' => $langs->trans('MahnungVerfahrenErfolglos').' '.dol_print_date(dol_now(), 'day'),
'morecss' => 'minwidth500 quatrevingtpercent',
),
);
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id),
$langs->trans('MahnungUneinbringlichTitel'),
$langs->trans('MahnungUneinbringlichWarnung').' '.dol_escape_htmltag($mahnung->ref).'?',
'confirm_uneinbringlich',
$formquestion,
0,
1,
300
);
}
}
print '<br><div class="tabsAction">';
// Mahn-PDF: für Zahlungserinnerungen gibt es keins — dort wird die unveränderte
// Original-Rechnung angehängt, kein eigenes Dokument erzeugt.
if ($canWrite && !$istErinnerung) {
// Modellauswahl
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/mahnung/modules_mahnung.php';
$modellist = ModelePDFMahnung::liste_modeles($db);
$defaultModel = getDolGlobalString('MAHNUNG_ADDON_PDF', 'standard_mahnung');
if (is_array($modellist) && count($modellist) > 1) {
print '<select name="model" id="selectmodel" style="margin-right: 5px;">';
foreach ($modellist as $k => $v) {
print '<option value="'.dol_escape_htmltag($k).'"'.($k === $defaultModel ? ' selected' : '').'>'.dol_escape_htmltag($v).'</option>';
}
print '</select>';
}
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=regenerate_pdf&token='.newToken().'" onclick="if(document.getElementById(\'selectmodel\')){this.href+=\'&model=\'+document.getElementById(\'selectmodel\').value;}">';
print $langs->trans('MahnungGenerate');
print '</a> ';
}
// "Zahlungserinnerung per E-Mail senden" — ausschließlich für Erinnerungs-Stufen.
// Echte Mahnungen (Stufe ohne ist_erinnerung) werden bewusst NICHT per Mail
// verschickt, weil der Zugang dort nicht beweisbar ist.
// mailinit=1 setzt die Anhangsliste zurück und legt die Original-Rechnung frisch
// hinein — sonst hingen bei jedem erneuten Öffnen die alten Dateien noch dran.
if ($canSendErinnerung) {
// Nach einem bereits erfolgten Versand heißt der Button "erneut senden": die Mail
// kann unzustellbar zurückgekommen sein, im Spam hängen oder an die falsche
// Adresse gegangen sein. Der Empfänger lässt sich im Formular ändern.
$sendeLabel = $istErneuterVersand ? 'MahnungErinnerungErneutSenden' : 'MahnungErinnerungSenden';
$sendeHint = $istErneuterVersand ? 'MahnungErinnerungErneutSendenHint' : 'MahnungErinnerungSendenHint';
print '<a class="butAction" href="'.$urlKarte.'&action=presend&mailinit=1&token='.newToken().'#formmail" title="'.dol_escape_htmltag($langs->trans($sendeHint)).'">';
print img_picto('', 'email', 'class="pictofixedwidth"');
print $langs->trans($sendeLabel);
print '</a> ';
} elseif ($istErinnerung
&& (int) $mahnung->status !== Mahnung::STATUS_ERLEDIGT
&& (int) $mahnung->status !== Mahnung::STATUS_STORNIERT
&& $user->hasRight('mahnung', 'send')
&& !$erinnerungEmpfaengerMoeglich
) {
// Grund für den deaktivierten Button sichtbar machen
print '<span class="butActionRefused classfortooltip" title="'.dol_escape_htmltag($langs->trans('MahnungKundeKeineEmail')).'">';
print $langs->trans('MahnungErinnerungSenden');
print '</span> ';
}
// "Als uneinbringlich klassifizieren" — der letzte Schritt im Eskalationspfad.
// Angeboten wird er, wenn nach der Stufe dieses Vorgangs keine weitere aktive Stufe
// mehr konfiguriert ist ($istLetzteStufe, siehe MahnungVorschlag::naechsteStufeNach()),
// der Vorgang keine kostenlose Zahlungserinnerung ist, Status >= ERSTELLT gilt (also
// kein Entwurf) und nichts storniert wurde. Früher hing das an der harten Grenze
// stufe === 3 — mit frei konfigurierbaren Stufen war der Button damit bei jeder
// abweichenden Stufennummer unerreichbar.
// Setzt die Rechnung auf STATUS_ABANDONED mit close_code='badcustomer'.
if ($mahnung->status !== Mahnung::STATUS_STORNIERT
&& $istLetzteStufe
&& !$istErinnerung
&& (int) $mahnung->status >= Mahnung::STATUS_ERSTELLT
&& $user->hasRight('mahnung', 'delete')
// Der Schritt schreibt auf der Rechnung — ohne Rechnungsrecht würde der Button
// nur in eine Fehlermeldung führen (siehe Handler oben).
&& $user->hasRight('facture', 'creer')
) {
// Prüfen ob Rechnung schon abandoned ist — dann Button verstecken
$facStatus = 0;
$facRes = $db->query("SELECT fk_statut, close_code FROM ".MAIN_DB_PREFIX."facture WHERE rowid = ".((int) $mahnung->fk_facture));
if ($facRes && ($facObj = $db->fetch_object($facRes))) {
$facStatus = (int) $facObj->fk_statut;
$db->free($facRes);
}
if ($facStatus !== 3) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=ask_uneinbringlich" title="'.dol_escape_htmltag($langs->trans('MahnungUneinbringlichHint')).'">';
print img_picto('', 'warning', 'class="pictofixedwidth"');
print $langs->trans('MahnungUneinbringlich');
print '</a>';
} else {
// Rechnung ist schon als abandoned markiert — Info anzeigen
print '<span class="opacitymedium" style="margin-left:8px;">'.dol_escape_htmltag($langs->trans('MahnungRechnungBereitsUneinbringlich')).'</span>';
}
}
if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=confirm_storno">';
print $langs->trans('MahnungStornieren');
print '</a>';
}
print '</div>';
// ---------------------------------------------------------------------------
// Mailformular für die Zahlungserinnerung
// ---------------------------------------------------------------------------
// Standard-Dolibarr-Formular: Empfänger (Firmenadresse + alle Ansprechpartner),
// Betreff, Text und Anhang stehen sichtbar da und sind änderbar. Abgeschickt wird
// gegen action=send auf dieser Karte — der Versand selbst läuft ausschließlich über
// mahnungSendeErinnerungsMail() (siehe oben).
if ($action === 'presend' && $canSendErinnerung) {
print '<br>';
print load_fiche_titre($langs->trans($istErneuterVersand ? 'MahnungErinnerungErneutSenden' : 'MahnungErinnerungSenden'), '', 'fa-envelope');
if ($istErneuterVersand) {
// Deutlich sichtbar, dass diese Erinnerung schon einmal raus war — sonst
// verschickt man sie versehentlich ein zweites Mal an denselben Empfänger.
print '<div class="warning" style="margin-bottom:8px;">';
print $langs->trans(
'MahnungErinnerungErneutSendenWarnung',
$mahnung->date_versand ? dol_print_date($mahnung->date_versand, 'dayhour') : '-'
);
print '</div>';
}
print '<div class="opacitymedium" style="margin-bottom:8px;">'.$langs->trans('MahnungErinnerungMailHint').'</div>';
// Betreff und Text aus der Stufen-Konfiguration, Platzhalter bereits ersetzt.
// Die Frist wird zum jetzigen Zeitpunkt gerechnet; beim Absenden passiert das
// erneut, damit eine später abgeschickte Mail keine verstrichene Frist nennt.
$mailFrist = mahnungBerechneNeueFrist($stufeObj, dol_now());
$mailWerte = mahnungPlatzhalterWerte($mahnung, $facture, $societe, $mailFrist, $stufeObj);
$mailVorlage = mahnungMailVorlage($stufeObj, $mailWerte);
// Absender ist die Firmenadresse, nicht der angemeldete Benutzer — deshalb nur
// anzeigen (withfromreadonly), nicht zur Auswahl stellen.
$absenderAnzeigeFehler = '';
$absenderAnzeige = mahnungAbsender($absenderAnzeigeFehler);
if ($absenderAnzeigeFehler !== '') {
print '<div class="error">'.dol_escape_htmltag($absenderAnzeigeFehler).'</div>';
}
// param-Einträge werden von FormMail als hidden inputs ausgegeben und landen
// damit im POST des Formulars.
$formmail->param['action'] = 'send';
$formmail->param['id'] = (int) $mahnung->id;
$formmail->param['returnurl'] = $urlKarte;
// 'none' = keine Vorlagen aus c_email_templates anbieten. Betreff und Text
// kommen aus der Stufen-Konfiguration des Moduls; eine zweite Textquelle
// daneben würde nur auseinanderlaufen.
$formmail->param['models'] = 'none';
$formmail->withform = 1;
$formmail->fromtype = 'special';
$formmail->fromname = $absenderAnzeige['name'];
$formmail->frommail = $absenderAnzeige['mail'];
$formmail->withfrom = 1;
$formmail->withfromreadonly = 1;
// Auswahlliste: Firmenadresse + alle aktiven Ansprechpartner des Kunden.
$formmail->withto = mahnungEmpfaengerListe($societe);
// Nach einem fehlgeschlagenen Versand den eingetippten Freitext-Empfänger
// zurückschreiben. FormMail füllt das Feld sonst nur aus GETPOST('sendto'), wenn
// withtofree numerisch ist — bei einem nicht-numerischen Wert gilt dieser als
// Feldinhalt, genau das brauchen wir hier.
$formmail->withtofree = GETPOSTISSET('sendto') ? GETPOST('sendto', 'alphawithlgt') : 1;
$formmail->withtocc = 1;
$formmail->withtopic = $mailVorlage['subject'];
// Eine bereits getippte Nutzereingabe hat Vorrang vor der Vorlage — sie kommt
// aber <br>-verseucht aus GETPOST(…, 'restricthtml') zurück (siehe
// mahnungBodyEntkleiden). Deshalb: bereinigen, als Vorgabewert setzen und den
// Rohwert aus dem Request nehmen, sonst zeigt FormMail wieder die Tag-Fassung.
$formmail->withbody = $mailVorlage['body'];
if (GETPOSTISSET('message')) {
$formmail->withbody = mahnungBodyEntkleiden(GETPOST('message', 'restricthtml'), (int) $mailVorlage['ishtml']);
unset($_POST['message'], $_GET['message']);
}
// -1 = Dolibarr entscheidet anhand von FCKEDITOR_ENABLE_MAIL, genau wie beim
// Mailversand aus Rechnungen und Aufträgen. Damit lässt sich der Text hier mit
// dem gewohnten Editor formatieren (fett, Listen, Links); ist der WYSIWYG global
// abgeschaltet, bleibt es ein einfaches Textfeld.
$formmail->withfckeditor = -1;
// 2 = Anhänge anzeigen UND weitere hinzufügen/entfernen können.
$formmail->withfile = 2;
$formmail->withdeliveryreceipt = 1;
$formmail->withcancel = 1;
print $formmail->get_form();
}
llxFooter();
$db->close();