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

450 lines
14 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/mahnungstufe.class.php
* \ingroup mahnung
* \brief Konfigurations-Klasse für Mahnstufen (llx_mahnung_stufe).
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
/**
* Eine frei konfigurierbare Mahnstufe: Frist-Konfiguration, Gebühren, optionaler
* Zinssatz-Override, Versandart-Default, E-Mail-/PDF-Templates.
*
* Stufennummern sind frei wählbar (auch 0). Ob eine Stufe die kostenlose
* Zahlungserinnerung ist, entscheidet ausschließlich das Flag ist_erinnerung —
* NICHT die Stufennummer.
*/
class MahnungStufe extends CommonObject
{
/** @var string */
public $element = 'mahnungstufe';
/** @var string */
public $table_element = 'mahnung_stufe';
/** @var int */
public $entity;
/** @var int Frei wählbare Stufennummer (0 = Default der Zahlungserinnerung) */
public $stufe;
/** @var string */
public $label;
/** @var int Wartefrist in Tagen bis diese Stufe fällig wird (ab Fälligkeit bzw. ab Versand der Vorstufe) */
public $frist_tage = 0;
/** @var int Neue Zahlungsfrist im Mahnschreiben (Tage) */
public $neue_frist_tage = 7;
/** @var float */
public $mahngebuehr_b2c = 0;
/** @var float */
public $mahngebuehr_b2b = 0;
/** @var int 1 = nur in dieser Stufe Pauschale §288 Abs. 5 berechnen */
public $pauschale_b2b_einmalig = 0;
/** @var float|null Override Basiszins+5 % B2C-Default */
public $zinssatz_b2c_uebersteuern;
/** @var float|null Override Basiszins+9 % B2B-Default */
public $zinssatz_b2b_uebersteuern;
/** @var string pdf|mail|druck|none */
public $versandart_default = 'pdf';
/** @var string */
public $email_subject;
/** @var string */
public $email_body;
/** @var string */
public $pdf_intro;
/**
* @var int 0|1 — 1 = kostenlose Zahlungserinnerung: keine Mahngebühr, keine
* Pauschale nach §288 Abs. 5, keine Verzugszinsen. Anhang ist die
* unveränderte Original-Rechnungs-PDF, Versand nur per E-Mail.
*/
public $ist_erinnerung = 0;
/** @var int 0|1 */
public $active = 1;
/** @var int Unix-Zeit */
public $datec;
/** @var int Unix-Zeit */
public $tms;
/**
* @param DoliDB $db Datenbank-Handler
*/
public function __construct($db)
{
global $conf;
$this->db = $db;
$this->entity = $conf->entity;
}
/**
* Stufe anhand ihrer rowid laden (Entity-gefiltert).
*
* @param int $id rowid
* @return int -1 Fehler, 0 nicht gefunden, >0 OK
*/
public function fetch($id)
{
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t";
$sql .= " WHERE t.rowid = ".((int) $id);
$sql .= " AND t.entity = ".((int) $this->entity);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
if (!$this->db->num_rows($resql)) {
$this->db->free($resql);
return 0;
}
$obj = $this->db->fetch_object($resql);
$this->loadFromObj($obj);
$this->db->free($resql);
return 1;
}
/**
* @param int $stufe Stufennummer (frei konfigurierbar, 0 möglich)
* @return int -1 Fehler, 0 nicht gefunden, >0 OK
*/
public function fetchByStufe($stufe)
{
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t";
$sql .= " WHERE t.entity = ".((int) $this->entity);
$sql .= " AND t.stufe = ".((int) $stufe);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
if (!$this->db->num_rows($resql)) {
$this->db->free($resql);
return 0;
}
$obj = $this->db->fetch_object($resql);
$this->loadFromObj($obj);
$this->db->free($resql);
return 1;
}
/**
* Alle aktiven Stufen geordnet nach stufe ASC.
*
* @return MahnungStufe[]
*/
public function fetchAllActive()
{
$sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t";
$sql .= " WHERE t.entity = ".((int) $this->entity);
$sql .= " AND t.active = 1";
$sql .= " ORDER BY t.stufe ASC";
$resql = $this->db->query($sql);
$result = array();
if (!$resql) {
$this->error = $this->db->lasterror();
return $result;
}
while ($obj = $this->db->fetch_object($resql)) {
$s = new self($this->db);
$s->loadFromObj($obj);
$result[] = $s;
}
$this->db->free($resql);
return $result;
}
/**
* Neue Mahnstufe anlegen.
*
* @param User $user Anlegender User
* @return int <0 bei Fehler, sonst neue rowid
*/
public function create($user)
{
global $conf, $langs;
if (!isset($this->stufe) || $this->stufe === '') {
$this->error = 'MahnungStufe::create — stufe fehlt';
return -1;
}
if (empty($this->label)) {
$this->error = 'MahnungStufe::create — label fehlt';
return -1;
}
if (empty($this->entity)) {
$this->entity = $conf->entity;
}
// Stufennummer bereits vergeben? (UNIQUE entity+stufe wuerde sonst mit einer
// rohen MySQL-Meldung abbrechen)
$check = new self($this->db);
$check->entity = $this->entity;
$res = $check->fetchByStufe($this->stufe);
if ($res < 0) {
$this->error = $check->error;
return -1;
}
if ($res > 0) {
$this->error = $langs->trans('MahnungStufeNummerVergeben', (int) $this->stufe);
return -2;
}
$now = dol_now();
$this->db->begin();
$sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_stufe (";
$sql .= "entity, stufe, label, frist_tage, neue_frist_tage,";
$sql .= " mahngebuehr_b2c, mahngebuehr_b2b, pauschale_b2b_einmalig,";
$sql .= " zinssatz_b2c_uebersteuern, zinssatz_b2b_uebersteuern,";
$sql .= " versandart_default, email_subject, email_body, pdf_intro,";
$sql .= " ist_erinnerung, active, datec";
$sql .= ") VALUES (";
$sql .= ((int) $this->entity).",";
$sql .= ((int) $this->stufe).",";
$sql .= "'".$this->db->escape($this->label)."',";
$sql .= ((int) $this->frist_tage).",";
$sql .= ((int) $this->neue_frist_tage).",";
$sql .= ((float) $this->mahngebuehr_b2c).",";
$sql .= ((float) $this->mahngebuehr_b2b).",";
$sql .= ((int) (!empty($this->pauschale_b2b_einmalig) ? 1 : 0)).",";
$sql .= ($this->zinssatz_b2c_uebersteuern !== null && $this->zinssatz_b2c_uebersteuern !== '' ? ((float) $this->zinssatz_b2c_uebersteuern) : "NULL").",";
$sql .= ($this->zinssatz_b2b_uebersteuern !== null && $this->zinssatz_b2b_uebersteuern !== '' ? ((float) $this->zinssatz_b2b_uebersteuern) : "NULL").",";
$sql .= "'".$this->db->escape($this->versandart_default ?: 'pdf')."',";
$sql .= ($this->email_subject ? "'".$this->db->escape($this->email_subject)."'" : "NULL").",";
$sql .= ($this->email_body ? "'".$this->db->escape($this->email_body)."'" : "NULL").",";
$sql .= ($this->pdf_intro ? "'".$this->db->escape($this->pdf_intro)."'" : "NULL").",";
$sql .= ((int) (!empty($this->ist_erinnerung) ? 1 : 0)).",";
$sql .= ((int) (!empty($this->active) ? 1 : 0)).",";
$sql .= "'".$this->db->idate($now)."'";
$sql .= ")";
dol_syslog(get_class($this).'::create', LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
$this->db->rollback();
return -1;
}
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.'mahnung_stufe');
$this->datec = $now;
$this->db->commit();
return $this->id;
}
/**
* Mahnstufe löschen. Verweigert das Löschen, solange Mahnvorgänge auf diese
* Stufennummer verweisen — sonst würden Bestandsmahnungen ihre Konfiguration
* verlieren. In dem Fall ist "deaktivieren" (active = 0) der richtige Weg.
*
* @param User $user Löschender User
* @return int -2 = durch Mahnvorgänge blockiert, <0 = Fehler, sonst 1
*/
public function delete($user)
{
global $langs;
if (empty($this->id)) {
$this->error = 'MahnungStufe::delete — id missing';
return -1;
}
// Verweisen bereits Mahnvorgänge auf diese Stufe?
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
$sql .= " WHERE stufe = ".((int) $this->stufe);
$sql .= " AND entity = ".((int) $this->entity);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
$obj = $this->db->fetch_object($resql);
$this->db->free($resql);
if ($obj && (int) $obj->nb > 0) {
$this->error = $langs->trans('MahnungStufeNichtLoeschbar');
return -2;
}
$this->db->begin();
$sql = "DELETE FROM ".MAIN_DB_PREFIX."mahnung_stufe";
$sql .= " WHERE rowid = ".((int) $this->id);
$sql .= " AND entity = ".((int) $this->entity);
dol_syslog(get_class($this).'::delete', LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
$this->db->rollback();
return -1;
}
$this->db->commit();
return 1;
}
/**
* Nächste freie Stufennummer der Entity (max(stufe) + 1).
* Existiert noch keine Stufe, wird 0 geliefert (Zahlungserinnerung als Einstieg).
*
* @return int
*/
public function naechsteFreieStufe()
{
$sql = "SELECT MAX(stufe) as maxstufe FROM ".MAIN_DB_PREFIX."mahnung_stufe";
$sql .= " WHERE entity = ".((int) $this->entity);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return 0;
}
$obj = $this->db->fetch_object($resql);
$this->db->free($resql);
if (empty($obj) || $obj->maxstufe === null) {
return 0;
}
return ((int) $obj->maxstufe) + 1;
}
/**
* @param User $user
* @return int <0 Fehler, sonst rowid
*/
public function update($user)
{
if (empty($this->id)) {
$this->error = 'MahnungStufe::update — id missing';
return -1;
}
$sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_stufe SET";
$sql .= " label = '".$this->db->escape($this->label)."'";
$sql .= ", frist_tage = ".((int) $this->frist_tage);
$sql .= ", neue_frist_tage = ".((int) $this->neue_frist_tage);
$sql .= ", mahngebuehr_b2c = ".((float) $this->mahngebuehr_b2c);
$sql .= ", mahngebuehr_b2b = ".((float) $this->mahngebuehr_b2b);
$sql .= ", pauschale_b2b_einmalig = ".((int) (!empty($this->pauschale_b2b_einmalig) ? 1 : 0));
$sql .= ", zinssatz_b2c_uebersteuern = ".($this->zinssatz_b2c_uebersteuern !== null && $this->zinssatz_b2c_uebersteuern !== '' ? ((float) $this->zinssatz_b2c_uebersteuern) : "NULL");
$sql .= ", zinssatz_b2b_uebersteuern = ".($this->zinssatz_b2b_uebersteuern !== null && $this->zinssatz_b2b_uebersteuern !== '' ? ((float) $this->zinssatz_b2b_uebersteuern) : "NULL");
$sql .= ", versandart_default = '".$this->db->escape($this->versandart_default ?: 'pdf')."'";
$sql .= ", email_subject = ".($this->email_subject ? "'".$this->db->escape($this->email_subject)."'" : "NULL");
$sql .= ", email_body = ".($this->email_body ? "'".$this->db->escape($this->email_body)."'" : "NULL");
$sql .= ", pdf_intro = ".($this->pdf_intro ? "'".$this->db->escape($this->pdf_intro)."'" : "NULL");
$sql .= ", ist_erinnerung = ".((int) (!empty($this->ist_erinnerung) ? 1 : 0));
$sql .= ", active = ".((int) (!empty($this->active) ? 1 : 0));
$sql .= " WHERE rowid = ".((int) $this->id);
// Entity-Filter wie in fetch() und delete(): eine per URL untergeschobene
// rowid darf keine Stufen-Konfiguration einer fremden Entity überschreiben.
$sql .= " AND entity = ".((int) $this->entity);
dol_syslog(get_class($this).'::update', LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
return $this->id;
}
/**
* Ist diese Stufe die kostenlose Zahlungserinnerung?
* Maßgeblich ist das Flag, nicht die Stufennummer.
*
* @return bool
*/
public function istErinnerung()
{
return !empty($this->ist_erinnerung);
}
/**
* Mahngebühr für einen Kundentyp aus dieser Stufe lesen.
*
* @param string $kundentyp 'B2C'|'B2B'
* @return float
*/
public function getMahngebuehr($kundentyp)
{
return $kundentyp === Mahnung::KUNDENTYP_B2B
? (float) $this->mahngebuehr_b2b
: (float) $this->mahngebuehr_b2c;
}
/**
* Override-Zinssatz für einen Kundentyp (oder null falls Default gewünscht).
*
* @param string $kundentyp
* @return float|null
*/
public function getZinssatzOverride($kundentyp)
{
$value = $kundentyp === Mahnung::KUNDENTYP_B2B
? $this->zinssatz_b2b_uebersteuern
: $this->zinssatz_b2c_uebersteuern;
return ($value === null || $value === '') ? null : (float) $value;
}
/**
* Properties aus DB-Object-Reihe laden.
*
* @param object $obj
* @return void
*/
private function loadFromObj($obj)
{
$this->id = $obj->rowid;
$this->entity = $obj->entity;
$this->stufe = (int) $obj->stufe;
$this->label = $obj->label;
$this->frist_tage = (int) $obj->frist_tage;
$this->neue_frist_tage = (int) $obj->neue_frist_tage;
$this->mahngebuehr_b2c = $obj->mahngebuehr_b2c;
$this->mahngebuehr_b2b = $obj->mahngebuehr_b2b;
$this->pauschale_b2b_einmalig = (int) $obj->pauschale_b2b_einmalig;
$this->zinssatz_b2c_uebersteuern = $obj->zinssatz_b2c_uebersteuern;
$this->zinssatz_b2b_uebersteuern = $obj->zinssatz_b2b_uebersteuern;
$this->versandart_default = $obj->versandart_default;
$this->email_subject = $obj->email_subject;
$this->email_body = $obj->email_body;
$this->pdf_intro = $obj->pdf_intro;
// isset-Guard bleibt bewusst stehen: geladen wird per SELECT t.*, und es gibt
// lesende Einstiegspunkte ohne modMahnung::ensureSchema() (Cron, Home-Widget).
// Fehlt die Spalte dort, gäbe es sonst eine PHP-Warning "Undefined property".
// Fallback 0 = "keine Erinnerung" — die Stufe verhält sich dann wie vor der
// Migration, also als normale Mahnstufe. Der Guard betrifft NUR das Lesen;
// create()/update() schreiben die Spalte hart und scheitern ohne Migration
// sichtbar mit "Unknown column".
$this->ist_erinnerung = isset($obj->ist_erinnerung) ? (int) $obj->ist_erinnerung : 0;
$this->active = (int) $obj->active;
$this->datec = $this->db->jdate($obj->datec);
$this->tms = $this->db->jdate($obj->tms);
}
}