mahnung/lib/mahnung_anlage.lib.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

223 lines
7.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/lib/mahnung_anlage.lib.php
* \ingroup mahnung
* \brief Gemeinsame Anlage-Logik für Mahnvorgänge.
*
* Eingebunden von ajax/createmahnung.php (Einzel-/Massenanlage) und
* ajax/sammelbrief.php (Anlage + gebündeltes Druck-PDF). Beide Endpunkte bauen
* ihre Mahnvorgänge aus denselben Vorschlagszeilen. Gebühren, §288-Pauschale,
* Verzugszinsen, Vorstufenkosten und der Sonderfall "kostenlose
* Zahlungserinnerung" stehen deshalb genau einmal — hier.
*/
// Nicht direkt per URL aufrufbar — die Datei definiert ausschließlich Funktionen.
if (!defined('DOL_VERSION')) {
print 'Diese Datei kann nicht direkt aufgerufen werden.';
exit;
}
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';
/**
* Sentinel: der POST-Parameter "stufe" wurde nicht gesetzt.
* Notwendig, weil 0 (Zahlungserinnerung) eine gültige Stufennummer ist und
* eine Falsy-Prüfung (`$forceStufe ?: ...`) sie verschlucken würde.
*/
if (!defined('MAHNUNG_STUFE_NICHT_GESETZT')) {
define('MAHNUNG_STUFE_NICHT_GESETZT', -1);
}
/** Sentinel: "stufe" wurde gesetzt, ist aber keine konfigurierte aktive Stufe. */
if (!defined('MAHNUNG_STUFE_UNGUELTIG')) {
define('MAHNUNG_STUFE_UNGUELTIG', -2);
}
/**
* Liest den optionalen POST-Parameter "stufe" (Zielstufe erzwingen).
*
* Der Wert wird bewusst als String geholt, damit '' (nicht gesetzt) von '0'
* (Zahlungserinnerung) unterschieden werden kann. Validiert wird gegen die
* tatsächlich konfigurierten aktiven Stufen — NICHT gegen einen festen
* Bereich 1..3, denn Stufennummern sind frei wählbar.
*
* @param MahnungVorschlag $service Service, aus dem die aktiven Stufen kommen
* @return int Stufennummer, MAHNUNG_STUFE_NICHT_GESETZT oder MAHNUNG_STUFE_UNGUELTIG
*/
function mahnungGetForceStufe($service)
{
$raw = GETPOST('stufe', 'alphanohtml');
if (is_array($raw)) {
return MAHNUNG_STUFE_UNGUELTIG;
}
$raw = trim((string) $raw);
if ($raw === '') {
return MAHNUNG_STUFE_NICHT_GESETZT;
}
if (!preg_match('/^-?\d+$/', $raw)) {
return MAHNUNG_STUFE_UNGUELTIG;
}
$nr = (int) $raw;
if ($service->getStufe($nr) === null) {
return MAHNUNG_STUFE_UNGUELTIG;
}
return $nr;
}
/**
* Holt die Vorschlagszeilen EINMALIG und indiziert sie nach Rechnungs-ID.
*
* getVorschlaege() liest immer die komplette Liste überfälliger Rechnungen —
* der Aufruf gehört deshalb vor die Schleife, nicht hinein.
*
* @param MahnungVorschlag $service
* @param array $filter Filter für getVorschlaege()
* @return array|false facture_id => Vorschlagszeile, false bei Fehler
*/
function mahnungVorschlagsIndex($service, array $filter = array())
{
$rows = $service->getVorschlaege($filter);
if ($rows === false) {
return false;
}
if (!is_array($rows)) {
return array();
}
$index = array();
foreach ($rows as $r) {
if (!isset($r['facture_id'])) {
continue;
}
$index[(int) $r['facture_id']] = $r;
}
return $index;
}
/**
* Fehlertext des Vorschlag-Service. isset()-Guard, damit die Funktion auch
* ohne die Property $error keine PHP-Notice auslöst.
*
* @param MahnungVorschlag $service
* @return string
*/
function mahnungVorschlagFehlertext($service)
{
global $langs;
if (isset($service->error) && $service->error !== '') {
return (string) $service->error;
}
return $langs->trans('MahnungVorschlagFehler');
}
/**
* Baut einen anlagefertigen (noch nicht gespeicherten) Mahnvorgang aus einer
* Vorschlagszeile und der Zielstufe.
*
* Ist die Zielstufe eine Zahlungserinnerung (ist_erinnerung = 1), werden
* Mahngebühr, §288-Pauschale, Verzugszinsen und Vorstufenkosten ZWINGEND auf 0
* gesetzt — unabhängig davon, was in der Stufen-Konfiguration steht. Die
* Erinnerung kostet nichts, Versandart ist immer E-Mail und es entsteht kein
* Mahn-PDF (pdf_path bleibt leer, Anhang ist die Original-Rechnung).
*
* Bei echten Mahnstufen werden die Gebühren/Pauschalen der Vorstufen derselben
* Rechnung als kosten_vorstufen übernommen und fließen in rechneSumme() ein.
*
* @param DoliDB $db
* @param array $row Zeile aus MahnungVorschlag::getVorschlaege()
* @param MahnungStufe $stufe Zielstufe
* @param float $basiszins Basiszinssatz in Prozent
* @param string $versandart Versandart erzwingen ('' = Default der Stufe)
* @return Mahnung
*/
function mahnungBaueVorgang($db, array $row, $stufe, $basiszins, $versandart = '')
{
$factureId = (int) $row['facture_id'];
$now = dol_now();
$mahnung = new Mahnung($db);
$mahnung->fk_facture = $factureId;
$mahnung->fk_soc = (int) $row['soc_id'];
$mahnung->stufe = (int) $stufe->stufe;
$mahnung->date_mahnung = $now;
$mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement'];
$mahnung->date_lim_reglement_neu = dol_time_plus_duree($now, (int) $stufe->neue_frist_tage, 'd');
$mahnung->betrag_offen = (float) $row['betrag_offen'];
$mahnung->customertype = $row['kundentyp'];
$mahnung->basiszins_snapshot = $basiszins;
if ($stufe->istErinnerung()) {
// Kostenlose Zahlungserinnerung: keine Mahngebühr, keine Pauschale nach
// §288 Abs. 5, keine Verzugszinsen, keine Vorstufenkosten. Bewusst hart
// genullt und nicht aus der Stufen-Konfiguration übernommen.
$mahnung->mahngebuehr = 0;
$mahnung->pauschale_b2b = 0;
$mahnung->verzugszinsen = 0;
$mahnung->kosten_vorstufen = 0;
$mahnung->versandart = Mahnung::VERSAND_MAIL;
$mahnung->pdf_path = null;
} else {
$mahnung->versandart = $versandart ?: ($stufe->versandart_default ?: Mahnung::VERSAND_PDF);
$mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype);
// §288 Abs. 5 Pauschale: nur einmal pro Rechnung und nur B2B
if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B
&& (int) $stufe->pauschale_b2b_einmalig === 1
&& !mahnungPauschaleBereitsAngewendet($db, $factureId)) {
$mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00');
}
$mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen(
$mahnung->betrag_offen,
(int) $row['tage_verzug'],
$mahnung->customertype,
$basiszins,
$stufe->getZinssatzOverride($mahnung->customertype)
);
// Gebühren/Pauschalen der Vorstufen kumulieren (Zinsen bleiben außen vor,
// die werden je Stufe tagesgenau neu gerechnet).
$mahnung->kosten_vorstufen = $mahnung->summeVorstufenKosten($factureId);
}
$mahnung->rechneSumme();
$mahnung->status = Mahnung::STATUS_ERSTELLT;
return $mahnung;
}
/**
* Prüft, ob für eine Rechnung bereits in einer nicht stornierten Mahnung die
* §288-Abs.-5-Pauschale berechnet wurde.
*
* @param DoliDB $db
* @param int $factureId
* @return bool
*/
function mahnungPauschaleBereitsAngewendet($db, $factureId)
{
$sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
$sql .= " WHERE fk_facture = ".((int) $factureId);
$sql .= " AND entity IN (".getEntity('mahnung').")";
$sql .= " AND status <> ".((int) Mahnung::STATUS_STORNIERT);
$sql .= " AND pauschale_b2b > 0";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if (!$resql) {
dol_syslog('mahnungPauschaleBereitsAngewendet SQL-Fehler: '.$db->lasterror(), LOG_ERR);
return false;
}
$has = (bool) $db->num_rows($resql);
$db->free($resql);
return $has;
}