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

248 lines
9.5 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* GPL v3 (siehe COPYING).
*/
/**
* \file htdocs/custom/mahnung/ajax/createmahnung.php
* \ingroup mahnung
* \brief AJAX-Endpoint: Mahnung(en) zu Rechnung(en) erzeugen + PDF generieren.
*
* Akzeptiert sowohl klassische Form-POSTs (Browser-Submit aus list.php)
* als auch AJAX-Calls. Antwortet je nach Accept-Header HTML-Redirect
* oder JSON.
*
* Ist die Zielstufe eine kostenlose Zahlungserinnerung (ist_erinnerung = 1),
* wird KEIN Mahn-PDF erzeugt: Anhang ist später die unveränderte
* Original-Rechnung, der Versand läuft per E-Mail über ajax/sendmail.php und
* ausschließlich nach ausdrücklicher Bestätigung.
*
* Hat der Kunde keine (gültige) E-Mail-Adresse, wird die Erinnerung trotzdem
* angelegt — die Rechnung darf nicht aus dem Mahnlauf fallen — und in der Antwort
* unter "ohne_email" namentlich gemeldet (Adresse nachtragen oder per Post senden).
*
* POST:
* - facture_ids[] Array Rechnungs-IDs (oder einzelne facture_id)
* - stufe Optional: Stufe erzwingen (sonst Vorschlag-Logik).
* '0' ist ein gültiger Wert, '' bedeutet "nicht gesetzt".
* - token CSRF
*/
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.'/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.'/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';
global $db, $user, $langs, $conf;
$langs->loadLangs(array('mahnung@mahnung'));
// Schema nach einem reinen Datei-Deploy nachziehen. Kritisch hier: ohne die
// Spalte kosten_vorstufen schlägt Mahnung::create() mit "Unknown column" fehl.
modMahnung::ensureSchema($db);
/**
* @param bool $success
* @param string $message
* @param array $extra
*/
function respond($success, $message, $extra = array())
{
$wantsJson = false;
if (!empty($_SERVER['HTTP_ACCEPT']) && stripos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) {
$wantsJson = true;
}
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
$wantsJson = true;
}
while (ob_get_level() > 0) {
ob_end_clean();
}
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array_merge(array('success' => (bool) $success, 'message' => $message), $extra));
exit;
}
// Klassischer Submit -> Redirect mit Flash-Message.
//
// Wurde GENAU EIN Vorgang erzeugt, geht es direkt auf dessen Karte: der Vorgang
// muss ohnehin bearbeitet werden (Versand anstoßen, Texte prüfen), und in der
// Vorschlagsliste taucht die Rechnung jetzt gar nicht mehr auf — die Wartefrist
// zur Folgestufe läuft. Ohne diesen Sprung wäre die frisch angelegte Mahnung nur
// über den Umweg Archiv auffindbar.
if (function_exists('setEventMessages')) {
setEventMessages($message, null, $success ? 'mesgs' : 'errors');
}
if ($success && !empty($extra['single_id']) && (int) $extra['single_id'] > 0) {
header('Location: '.DOL_URL_ROOT.'/custom/mahnung/card.php?id='.((int) $extra['single_id']));
exit;
}
header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag');
exit;
}
// 1) CSRF
$postedToken = GETPOST('token', 'alphanohtml');
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
respond(false, $langs->trans('MahnungCsrfFehler'), array('code' => 'csrf'));
}
// 2) Permission
if (!$user->hasRight('mahnung', 'write')) {
respond(false, $langs->transnoentities('NotEnoughPermissions') ?: $langs->trans('MahnungNichtBerechtigt'), array('code' => 'forbidden'));
}
// 3) Input
$factureIds = GETPOST('facture_ids', 'array:int');
if (empty($factureIds)) {
$single = GETPOSTINT('facture_id');
if (!empty($single)) {
$factureIds = array($single);
}
}
$factureIds = array_values(array_unique(array_map('intval', $factureIds)));
$factureIds = array_filter($factureIds, function ($v) {
return $v > 0;
});
if (empty($factureIds)) {
respond(false, $langs->trans('MahnungKeineRechnungenAusgewaehlt'), array('code' => 'noinput'));
}
$service = new MahnungVorschlag($db);
// 4) Verarbeitung — Vorschläge einmal holen, dann pro Rechnung Mahnung erzeugen.
// 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) {
respond(false, mahnungVorschlagFehlertext($service), array('code' => 'vorschlagfehler'));
}
// Zielstufe erzwingen? 0 ist gültig (Zahlungserinnerung), '' heißt "nicht gesetzt".
$forceStufe = mahnungGetForceStufe($service);
if ($forceStufe === MAHNUNG_STUFE_UNGUELTIG) {
respond(false, $langs->trans('MahnungStufeUngueltig'), array('code' => 'badstufe'));
}
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
$created = 0;
$createdErinnerung = 0;
$skipped = 0;
$failed = array();
// IDs der tatsächlich angelegten Vorgänge — bei genau einem wird direkt auf dessen
// Karte weitergeleitet (siehe respond()).
$createdIds = array();
// Angelegte Zahlungserinnerungen, die mangels (gültiger) E-Mail-Adresse nicht
// automatisch versendet werden können — siehe Kommentar in der Schleife.
$ohneEmail = array();
foreach ($factureIds as $fid) {
if (!isset($vorschlaege[$fid])) {
// Keine offene Mahnungs-Empfehlung — z.B. weil die Wartefrist noch läuft
$skipped++;
continue;
}
$row = $vorschlaege[$fid];
$stufeNr = ($forceStufe !== MAHNUNG_STUFE_NICHT_GESETZT)
? $forceStufe
: (isset($row['vorgeschlagene_stufe']) ? (int) $row['vorgeschlagene_stufe'] : null);
if ($stufeNr === null) {
$skipped++;
continue;
}
$stufe = $service->getStufe($stufeNr);
if ($stufe === null) {
$failed[] = $langs->trans('MahnungStufeNichtKonfiguriert', $fid, $stufeNr);
continue;
}
$mahnung = mahnungBaueVorgang($db, $row, $stufe, $basiszins);
$newId = $mahnung->create($user);
if ($newId <= 0) {
$failed[] = 'Rechnung #'.$fid.': '.$mahnung->error;
continue;
}
if ($stufe->istErinnerung()) {
// Kostenlose Zahlungserinnerung: kein Mahn-PDF. Versendet wird die
// Original-Rechnung per E-Mail, erst nach Bestätigung auf der Karte.
$createdErinnerung++;
$created++;
// Ohne (gültige) E-Mail-Adresse ist die Erinnerung per Mail nicht zustellbar —
// ajax/sendmail.php bricht dann mit MahnungKundeKeineEmail ab. Trotzdem wird sie
// angelegt und NICHT abgewiesen: die Erinnerung ist in der Regel die kleinste
// aktive Stufe, ein Abweisen würde die Rechnung dauerhaft aus dem Mahnlauf
// werfen (ohne Vorstufe schlägt der Vorschlag immer wieder dieselbe Erinnerung
// vor, die immer wieder scheitert — eine echte Mahnstufe würde nie erreicht).
// Angelegt läuft die Wartefrist zur Folgestufe dagegen ab date_mahnung weiter
// (MahnungVorschlag::ermittleZielstufe() nutzt date_versand nur als Vorzug),
// die Rechnung erreicht also Stufe 1 auch ohne Mailadresse.
// Damit die Erinnerung nicht still liegen bleibt, wird sie hier namentlich
// gemeldet: Adresse nachtragen oder das Schreiben per Post rausschicken.
$kundenMail = trim((string) (isset($row['soc_email']) ? $row['soc_email'] : ''));
if ($kundenMail === '' || !isValidEmail($kundenMail)) {
$refText = trim((string) (isset($row['facture_ref']) ? $row['facture_ref'] : ''));
if ($refText === '') {
$refText = '#'.((int) $fid);
}
$kundeText = trim((string) (isset($row['soc_nom']) ? $row['soc_nom'] : ''));
$ohneEmail[] = ($kundeText !== '') ? $refText.' ('.$kundeText.')' : $refText;
}
$createdIds[] = (int) $newId;
continue;
}
$docResult = $mahnung->generateDocument('', $langs);
if ($docResult <= 0) {
$failed[] = 'Rechnung #'.$fid.' (Mahnung '.$mahnung->ref.'): '.$langs->trans('MahnungDokumentFehler').' '.$mahnung->error;
continue;
}
$created++;
$createdIds[] = (int) $newId;
}
$msg = $langs->trans('MahnungMahnungErstellt', $created);
if ($createdErinnerung > 0) {
$msg .= $langs->trans('MahnungErinnerungenErstelltHinweis', $createdErinnerung);
}
if ($skipped > 0) {
$msg .= $langs->trans('MahnungUebersprungen2', $skipped);
}
// Nicht versendbare Erinnerungen deutlich benennen — sonst wartet Eddy auf einen
// Mailversand, der nie kommt. trans() escapt die eingesetzten Kundennamen selbst,
// deshalb hier KEIN zusätzliches dol_escape_htmltag() (sonst doppeltes Encoding).
if (!empty($ohneEmail)) {
$msg .= $langs->trans('MahnungErinnerungOhneEmailHinweis', count($ohneEmail), implode(', ', $ohneEmail));
}
if (!empty($failed)) {
$msg .= $langs->trans('MahnungFehlerLabel', implode(' | ', $failed));
respond(false, $msg, array('created' => $created, 'erinnerungen' => $createdErinnerung, 'ohne_email' => $ohneEmail, 'failed' => $failed));
}
// single_id nur bei GENAU einem angelegten Vorgang — dann springt respond() direkt
// auf dessen Karte, weil er ohnehin bearbeitet werden muss.
respond(true, $msg, array(
'created' => $created,
'erinnerungen' => $createdErinnerung,
'ohne_email' => $ohneEmail,
'skipped' => $skipped,
'created_ids' => $createdIds,
'single_id' => (count($createdIds) === 1) ? $createdIds[0] : 0,
));