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>
450 lines
14 KiB
PHP
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);
|
|
}
|
|
}
|