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>
240 lines
7.8 KiB
PHP
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;
|
|
}
|