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>
248 lines
9.5 KiB
PHP
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,
|
|
));
|