mahnung/class/mahnungcron.class.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

295 lines
9.5 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, version 3.
*/
/**
* \file htdocs/custom/mahnung/class/mahnungcron.class.php
* \ingroup mahnung
* \brief Cron-Job: Vorschlagsliste überfälliger Rechnungen einsammeln,
* Ntfy-Push mit Kennzahl an Eddy.
*/
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungntfy.class.php';
class MahnungCron
{
/** @var DoliDB */
public $db;
/** @var string */
public $error = '';
/** @var string[] */
public $errors = array();
/** @var string */
public $output = '';
/** @var int|string */
public $lastresult = 0;
/**
* @param DoliDB $db
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Sucht überfällige Rechnungen, ermittelt Vorschläge je Stufe,
* sendet Ntfy-Push mit Anzahl je Stufe und Gesamtwert.
*
* Fehler und Leerstand werden strikt getrennt: nur wenn die Vorschlagsliste
* erfolgreich ermittelt wurde UND leer ist, werden die Notifications geräumt.
* Bei einem Fehler bleibt alles stehen und der Cron-Job wird als fehlerhaft
* markiert (Rückgabe < 0).
*
* @return int 0 bei Erfolg, < 0 bei Fehler
*/
public function buildVorschlagsliste()
{
global $langs;
$langs->load('mahnung@mahnung');
$service = new MahnungVorschlag($this->db);
$vorschlaege = $service->getVorschlaege(array());
// false = Vorschläge konnten gar nicht ermittelt werden (SQL-Fehler oder
// keine Mahnstufe konfiguriert). Dann NICHTS aufräumen — sonst verschwindet
// die Notification, obwohl die offenen Vorgänge weiter existieren.
if ($vorschlaege === false || !is_array($vorschlaege)) {
$detail = (isset($service->error) && $service->error !== '') ? (string) $service->error : '';
if ($detail === '') {
$detail = $langs->transnoentities('MahnungCronFehlerUnbekannt');
}
$this->error = $langs->transnoentities('MahnungCronVorschlaegeFehler', $detail);
$this->errors = (isset($service->errors) && is_array($service->errors)) ? $service->errors : array();
$this->output = $this->error;
$this->lastresult = -1;
dol_syslog('MahnungCron::buildVorschlagsliste '.$this->error, LOG_ERR);
return -1;
}
$count = count($vorschlaege);
// Zähler dynamisch über die tatsächlich konfigurierten Stufen aufbauen.
// Stufennummern sind frei wählbar (0 = kostenlose Zahlungserinnerung),
// eine feste 1/2/3-Liste wäre falsch.
$stufeObj = new MahnungStufe($this->db);
$counts = array();
$labels = array();
foreach ($stufeObj->fetchAllActive() as $s) {
$counts[(int) $s->stufe] = 0;
$labels[(int) $s->stufe] = (string) $s->label;
}
$summe = 0.0;
foreach ($vorschlaege as $v) {
$stufe = (int) $v['vorgeschlagene_stufe'];
if (!isset($counts[$stufe])) {
// Stufe zwischenzeitlich deaktiviert — trotzdem mitzählen statt schlucken
$counts[$stufe] = 0;
$labels[$stufe] = (string) ($v['vorgeschlagene_stufe_label'] ?? '');
}
$counts[$stufe]++;
$summe += (float) $v['betrag_offen'];
}
ksort($counts);
$summe = round($summe, 2);
if ($count === 0) {
// Echter Leerstand: alte Notifications räumen — es gibt nichts mehr zu tun
self::clearGlobalNotify();
$this->output = $langs->transnoentities('MahnungCronKeineUeberfaellige');
$this->lastresult = 0;
return 0;
}
$relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag';
$absUrl = self::buildAbsoluteUrl($relPath);
// transnoentities statt trans: der Ntfy-Push ist Klartext, HTML-Entities
// (&uuml; etc.) würden dort wörtlich auftauchen.
$title = $langs->transnoentities('MahnungCronOffeneVorschlaege', $count);
$zeilen = array();
foreach ($counts as $stufe => $anzahl) {
$label = (isset($labels[$stufe]) && $labels[$stufe] !== '')
? $labels[$stufe]
: $langs->transnoentities('MahnungCronStufeOhneLabel');
$zeilen[] = $langs->transnoentities('MahnungCronStufeAnzahl', $stufe, $label, $anzahl);
}
$zeilen[] = $langs->transnoentities('MahnungCronOffenerBetrag', number_format($summe, 2, ',', '.'));
$message = implode("\n", $zeilen);
MahnungNtfy::send($title, $message, $absUrl, array('envelope_with_arrow', 'warning'));
// Optional: GlobalNotify-Badge ins Dolibarr-UI (wenn Modul aktiv)
// Relativer Pfad — wird im Browser-Kontext korrekt aufgeloest
if (isModEnabled('globalnotify') && class_exists('GlobalNotify') === false) {
$gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php';
if (file_exists($gnPath)) {
require_once $gnPath;
}
}
if (class_exists('GlobalNotify')) {
GlobalNotify::actionRequired(
'mahnung',
$langs->trans('MahnungCronVorschlaege', $count),
$message,
$relPath,
$langs->trans('MahnungCronVorschlagslisteOeffnen')
);
}
$this->output = $title.' — '.$message;
$this->lastresult = $count;
return 0;
}
/**
* Räumt alle GlobalNotify-Notifications für das Mahnung-Modul auf.
* Wird aufgerufen wenn keine offenen Vorschläge mehr existieren.
*
* @return void
*/
public static function clearGlobalNotify()
{
if (!isModEnabled('globalnotify')) {
return;
}
if (!class_exists('GlobalNotify')) {
$gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php';
if (!file_exists($gnPath)) {
return;
}
require_once $gnPath;
}
global $db;
$gn = new GlobalNotify($db);
$gn->clearModuleNotifications('mahnung');
}
/**
* Versand-Reminder: Mahnungen mit Status ERSTELLT, deren PDF schon
* laenger als N Tage erstellt wurde, aber noch nicht versendet ist,
* werden gesammelt und per Ntfy gepusht.
*
* Schwellenwert konfigurierbar via MAHNUNG_VERSAND_REMINDER_DAYS (Default 2).
*
* @return int 0 bei Erfolg, < 0 bei Fehler
*/
public function versandReminder()
{
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
$tageSchwelle = (int) getDolGlobalString('MAHNUNG_VERSAND_REMINDER_DAYS', '2');
if ($tageSchwelle <= 0) {
$tageSchwelle = 2;
}
$sql = "SELECT m.rowid, m.ref, m.stufe, m.datec, m.tms, m.fk_facture, m.fk_soc,";
$sql .= " s.nom AS soc_nom, f.ref AS facture_ref";
$sql .= " FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = m.fk_soc";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture as f ON f.rowid = m.fk_facture";
$sql .= " WHERE m.status = ".Mahnung::STATUS_ERSTELLT;
$sql .= " AND m.date_versand IS NULL";
$sql .= " AND m.datec < DATE_SUB(NOW(), INTERVAL ".$tageSchwelle." DAY)";
$sql .= " ORDER BY m.datec ASC";
$resql = $this->db->query($sql);
if (!$resql) {
// Fehler: nichts aufräumen, Job als fehlerhaft melden
$this->error = $this->db->lasterror();
$this->errors[] = $this->error;
$this->output = $this->error;
$this->lastresult = -1;
dol_syslog('MahnungCron::versandReminder SQL-Fehler: '.$this->error, LOG_ERR);
return -1;
}
$pending = array();
while ($obj = $this->db->fetch_object($resql)) {
$pending[] = $obj;
}
$this->db->free($resql);
global $langs;
$langs->load('mahnung@mahnung');
if (empty($pending)) {
$this->output = $langs->transnoentities('MahnungCronKeineUnversendet', $tageSchwelle);
$this->lastresult = 0;
return 0;
}
$relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv';
$absUrl = self::buildAbsoluteUrl($relPath);
// transnoentities: Ntfy-Push ist Klartext, keine HTML-Entities
$title = $langs->transnoentities('MahnungCronUnversendetTitel', count($pending));
$lines = array();
foreach ($pending as $p) {
$tage = (int) floor((time() - strtotime((string) $p->datec)) / 86400);
$lines[] = $langs->transnoentities('MahnungCronStufeAlter', $p->ref, $p->stufe, $tage, $p->soc_nom);
}
// Auf 8 Zeilen kürzen, Rest als "+N weitere"
if (count($lines) > 8) {
$rest = count($lines) - 8;
$lines = array_slice($lines, 0, 8);
$lines[] = $langs->transnoentities('MahnungCronWeitere', $rest);
}
$message = implode("\n", $lines);
MahnungNtfy::send($title, $message, $absUrl, array('envelope_with_arrow', 'warning'));
// Optional: GlobalNotify-Badge — relativer Pfad für Browser-Kontext
if (isModEnabled('globalnotify') && !class_exists('GlobalNotify')) {
$gnPath = DOL_DOCUMENT_ROOT.'/custom/globalnotify/class/globalnotify.class.php';
if (file_exists($gnPath)) {
require_once $gnPath;
}
}
if (class_exists('GlobalNotify')) {
GlobalNotify::actionRequired(
'mahnung_versand',
$title,
$message,
$relPath,
$langs->trans('MahnungCronArchivOeffnen')
);
}
$this->output = $langs->trans('MahnungCronEintraege', $title, count($pending));
$this->lastresult = count($pending);
return 0;
}
/**
* Baut eine absolute URL aus einem relativen Pfad anhand der Dolibarr-URL-Konfig.
*
* @param string $relPath
* @return string
*/
private static function buildAbsoluteUrl($relPath)
{
$base = trim((string) getDolGlobalString('DOLIBARR_MAIN_URL_ROOT', ''));
if (empty($base) && defined('DOL_MAIN_URL_ROOT')) {
$base = DOL_MAIN_URL_ROOT;
}
if (empty($base)) {
return $relPath;
}
// Protokoll sicherstellen — ohne Protokoll wird die URL im Browser als relativ interpretiert
if (!preg_match('/^https?:\/\//', $base)) {
$base = 'http://'.$base;
}
return rtrim($base, '/').$relPath;
}
}