mahnung/ajax/sammelbrief.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

240 lines
7.8 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* GPL v3 (siehe COPYING).
*/
/**
* \file htdocs/custom/mahnung/ajax/sammelbrief.php
* \ingroup mahnung
* \brief AJAX/Form-Endpoint: Sammelbrief — für eine Auswahl von Rechnungen
* Mahnungen erzeugen und alle Einzel-PDFs in EIN PDF zusammenfassen.
*
* Kostenlose Zahlungserinnerungen (ist_erinnerung = 1) sind hier
* ausgeschlossen: sie erzeugen kein Mahn-PDF und werden ausschließlich
* per E-Mail mit der Original-Rechnung als Anhang versendet.
*
* POST:
* facture_ids[] Rechnungs-IDs
* stufe (opt) Stufe erzwingen (sonst Vorschlag)
* token CSRF
*
* Response: PDF-Download "sammelbrief-YYYYMMDD.pdf".
*/
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
ob_start();
require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_anlage.lib.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php';
global $db, $user, $langs, $conf;
$langs->loadLangs(array('mahnung@mahnung'));
// Schema nach einem reinen Datei-Deploy nachziehen (der Sammelbrief legt
// Mahnvorgänge an und braucht dafür die Spalte kosten_vorstufen).
modMahnung::ensureSchema($db);
// CSRF
$postedToken = GETPOST('token', 'alphanohtml');
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
httpExitError(403, $langs->trans('MahnungSammelbriefCsrfFehler'));
}
// Permission: der Sammelbrief LEGT Mahnvorgänge an (mahnungBaueVorgang() +
// Mahnung::create()) und erzeugt die zugehörigen PDFs — das ist eine schreibende
// Aktion. 'send' allein reicht dafür nicht: dieses Recht deckt nur das Versenden
// bereits vorhandener Mahnungen ab. Die Liste blendet den Sammelbrief-Button
// ohnehin nur bei mahnung.write ein (list.php: $canWrite), der Endpoint muss
// dieselbe Grenze ziehen.
if (!$user->hasRight('mahnung', 'write')) {
httpExitError(403, $langs->trans('MahnungNichtBerechtigtWrite'));
}
$factureIds = GETPOST('facture_ids', 'array:int');
$factureIds = array_values(array_filter(array_unique(array_map('intval', $factureIds)), function ($v) {
return $v > 0;
}));
if (empty($factureIds)) {
httpExitError(400, $langs->trans('MahnungKeineRechnungenAusgewaehlt'));
}
$service = new MahnungVorschlag($db);
// Bewusst VOR der Stufen-Validierung: liefert die Stufenkonfiguration einen
// Fehler, soll dieser gemeldet werden und nicht "Stufe ungültig".
$vorschlaege = mahnungVorschlagsIndex($service);
if ($vorschlaege === false) {
httpExitError(500, mahnungVorschlagFehlertext($service));
}
// Zielstufe erzwingen? 0 ist gültig (Zahlungserinnerung), '' heißt "nicht gesetzt".
$forceStufe = mahnungGetForceStufe($service);
if ($forceStufe === MAHNUNG_STUFE_UNGUELTIG) {
httpExitError(400, $langs->trans('MahnungStufeUngueltig'));
}
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
$paths = array();
$skippedErinnerung = 0;
foreach ($factureIds as $fid) {
if (!isset($vorschlaege[$fid])) {
continue;
}
$row = $vorschlaege[$fid];
$stufeNr = ($forceStufe !== MAHNUNG_STUFE_NICHT_GESETZT)
? $forceStufe
: (isset($row['vorgeschlagene_stufe']) ? (int) $row['vorgeschlagene_stufe'] : null);
if ($stufeNr === null) {
continue;
}
$stufe = $service->getStufe($stufeNr);
if ($stufe === null) {
continue;
}
// Zahlungserinnerungen gehören nicht in den Sammelbrief — sie erzeugen kein
// Mahn-PDF und werden per E-Mail mit der Original-Rechnung verschickt.
if ($stufe->istErinnerung()) {
$skippedErinnerung++;
continue;
}
$mahnung = mahnungBaueVorgang($db, $row, $stufe, $basiszins, Mahnung::VERSAND_DRUCK);
if ($mahnung->create($user) <= 0) {
dol_syslog('Mahnung Sammelbrief: Anlage für Rechnung #'.((int) $fid).' fehlgeschlagen: '.$mahnung->error, LOG_ERR);
continue;
}
// Dokumentenmodell-System statt eigener PDF-Klasse. Bewusst hart auf das
// PDF-Modell: ein ODT-Dokument ließe sich nicht in den Sammelbrief mischen.
$docResult = $mahnung->generateDocument('standard_mahnung', $langs);
if ($docResult <= 0) {
dol_syslog('Mahnung Sammelbrief: Dokument für '.$mahnung->ref.' fehlgeschlagen: '.$mahnung->error, LOG_ERR);
continue;
}
// pdf_path setzt das Dokumentenmodell beim Schreiben, last_main_doc die
// Dolibarr-Basis. Beides prüfen, damit ein Modellwechsel nichts bricht.
$pdfPath = '';
if (!empty($mahnung->pdf_path)) {
$pdfPath = (string) $mahnung->pdf_path;
} elseif (!empty($mahnung->last_main_doc)) {
$pdfPath = (string) $mahnung->last_main_doc;
}
if ($pdfPath === '' || !file_exists($pdfPath)) {
dol_syslog('Mahnung Sammelbrief: PDF zu '.$mahnung->ref.' nicht auffindbar ('.$pdfPath.')', LOG_ERR);
continue;
}
$paths[] = $pdfPath;
}
if (empty($paths)) {
if ($skippedErinnerung > 0) {
httpExitError(400, $langs->trans('MahnungSammelbriefNurErinnerungen'));
}
httpExitError(500, $langs->trans('MahnungSammelbriefKeinePdfs'));
}
// Alle Einzel-PDFs zu einem Dokument zusammenfassen (TCPDI).
$absOut = mahnungBaueSammelbriefPdf($paths);
if ($absOut === null || !file_exists($absOut)) {
httpExitError(500, $langs->trans('MahnungSammelbriefFehler'));
}
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="sammelbrief-'.dol_print_date(dol_now(), 'dayxcard').'.pdf"');
header('Content-Length: '.filesize($absOut));
readfile($absOut);
@unlink($absOut);
exit;
// ----------------------------------------------------------------
/**
* Konkateniert mehrere PDF-Dateien zu einer temporären Datei. Gibt deren
* absoluten Pfad zurück oder null bei Fehler.
*
* Der Rückgabewert ist IMMER eine Wegwerf-Kopie im Temp-Verzeichnis — der
* Aufrufer löscht sie nach dem Download, und dabei darf niemals das Original-PDF
* einer Mahnung erwischt werden.
*
* @param string[] $paths Absolute Pfade der Einzel-PDFs
* @return string|null
*/
function mahnungBaueSammelbriefPdf(array $paths)
{
$out = sys_get_temp_dir().'/mahnung-sammelbrief-'.uniqid('', true).'.pdf';
if (!class_exists('TCPDI')) {
// Dolibarr liefert TCPDI über tcpdf/tcpdi.php aus
$tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php';
if (file_exists($tcpdiPath)) {
require_once $tcpdiPath;
}
}
if (!class_exists('TCPDI')) {
dol_syslog('Mahnung Sammelbrief: TCPDI-Klasse nicht verfügbar — nur erstes PDF wird zurückgeliefert', LOG_WARNING);
$first = reset($paths);
if (empty($first) || !file_exists($first)) {
return null;
}
return @copy($first, $out) ? $out : null;
}
$pdf = new TCPDI();
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
foreach ($paths as $src) {
if (!file_exists($src)) {
continue;
}
$pageCount = $pdf->setSourceFile($src);
for ($p = 1; $p <= $pageCount; $p++) {
$tplIdx = $pdf->importPage($p);
$size = $pdf->getTemplateSize($tplIdx);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tplIdx);
}
}
$pdf->Output($out, 'F');
if (!file_exists($out) || filesize($out) <= 0) {
return null;
}
return $out;
}
/**
* Bricht mit HTTP-Status und Klartext-Meldung ab.
*
* @param int $code
* @param string $message
* @return void
*/
function httpExitError($code, $message)
{
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}