mahnung/core/modules/modMahnung.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

849 lines
29 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 as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
/**
* \defgroup mahnung Modul Mahnwesen
* \brief Mahnwesen-Modul (frei konfigurierbare Stufen nach BGB §288,
* inkl. kostenloser Zahlungserinnerung vor der ersten Mahnstufe)
* \file htdocs/custom/mahnung/core/modules/modMahnung.class.php
* \ingroup mahnung
*/
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
/**
* Beschreibungs- und Aktivierungsklasse für Modul Mahnung
*/
class modMahnung extends DolibarrModules
{
/**
* Schema-Stand, den dieser Code-Stand erwartet.
*
* Nach einer nachweislich erfolgreichen Migration wird der Wert in der
* Dolibarr-Konstanten MAHNUNG_DB_VERSION festgehalten. Die Lazy-Migration
* (siehe ensureSchema()) vergleicht im Normalfall nur noch gegen diesen Wert
* und setzt dabei keine einzige Query ab.
*
* WICHTIG: bei jeder weiteren Schema-Änderung mit hochziehen — sonst läuft
* die Migration auf Bestandsinstallationen nicht an.
*/
const DB_VERSION = '0.4.0';
/**
* @param DoliDB $db Datenbank-Handler
*/
public function __construct($db)
{
global $conf, $langs;
$this->db = $db;
// Eindeutige Modul-ID. 500034..500036 sind durch das Bericht-Modul belegt
// (numero=500033, dessen rights id-Range 500033..500036 abdeckt).
// 500037 ist durch Eplan belegt, daher 500038.
$this->numero = 500038;
// Schlüssel für Rechte und Menüs
$this->rights_class = 'mahnung';
$this->family = 'financial';
$this->module_position = '50';
$this->name = preg_replace('/^mod/i', '', get_class($this));
$this->description = 'MahnungDescription';
$this->descriptionlong = 'MahnungDescription';
$this->editor_name = 'Alles Watt läuft';
$this->editor_url = '';
// Bei einer Schema-Änderung zusätzlich self::DB_VERSION hochziehen,
// damit die Lazy-Migration auf Bestandsinstallationen anspringt.
$this->version = '0.3.0';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
// FontAwesome 5 Free (Dolibarr-Bundle, KB #435). 'fa-envelope-open-o' ist FA4-Notation
// und rendert in Dolibarr lautlos kein Glyph; 'fa-envelope-open-text' ist FA5-Free.
$this->picto = 'fa-envelope-open-text';
$this->module_parts = array(
'triggers' => 1,
'login' => 0,
'substitutions' => 0,
'menus' => 0,
'tpl' => 0,
'barcode' => 0,
'models' => 1,
'printing' => 0,
'theme' => 0,
'css' => array(),
'js' => array(),
// Hook-Klasse: class/actions_mahnung.class.php (Standard-Lookup-Pfad)
'hooks' => array(
'data' => array(
'invoicecard',
'thirdpartycard',
'ordercard',
),
'entity' => '0',
),
'moduleforexternal' => 0,
'websitetemplates' => 0,
'captcha' => 0,
);
// Datenverzeichnisse bei Modul-Aktivierung.
// $conf->mahnung->dir_output und multidir_output werden von Dolibarrs
// Conf-Klasse beim Bootstrap automatisch auf DOL_DATA_ROOT/mahnung gesetzt
// (siehe core/class/conf.class.php:744). Damit funktioniert FormFile->showdocuments('mahnung', ...)
// und document.php?modulepart=mahnung out-of-the-box.
$this->dirs = array('/mahnung', '/mahnung/temp');
// Konfigurationsseite
$this->config_page_url = array('setup.php@mahnung');
$this->hidden = getDolGlobalInt('MODULE_MAHNUNG_DISABLED');
$this->depends = array();
$this->requiredby = array();
$this->conflictwith = array();
$this->langfiles = array('mahnung@mahnung');
$this->phpmin = array(7, 4);
$this->need_dolibarr_version = array(19, -3);
$this->need_javascript_ajax = 1;
$this->warnings_activation = array();
$this->warnings_activation_ext = array();
// Modul-Konstanten
$this->const = array(
0 => array(
'MAHNUNG_BASISZINS',
'chaine',
'1.27',
'BGB-Basiszins in Prozent (manuell halbjährlich pflegen)',
0,
'allentities',
1,
),
1 => array(
'MAHNUNG_NTFY_TOPIC',
'chaine',
'vk-builds',
'Ntfy-Topic für Mahnungs-Benachrichtigungen',
0,
'current',
1,
),
2 => array(
'MAHNUNG_AUFSCHLAG_B2C',
'chaine',
'5.0',
'Verzugszins-Aufschlag B2C in Prozent (BGB §288 Abs. 1)',
0,
'allentities',
1,
),
3 => array(
'MAHNUNG_AUFSCHLAG_B2B',
'chaine',
'9.0',
'Verzugszins-Aufschlag B2B in Prozent (BGB §288 Abs. 2)',
0,
'allentities',
1,
),
4 => array(
'MAHNUNG_PAUSCHALE_B2B',
'chaine',
'40.00',
'Pauschale B2B nach BGB §288 Abs. 5 in EUR',
0,
'allentities',
1,
),
5 => array(
'MAHNUNG_ADDON_PDF',
'chaine',
'standard_mahnung',
'Standard-Dokumentenmodell für Mahnungen',
0,
'current',
1,
),
6 => array(
'MAHNUNG_ADDON_PDF_ODT_PATH',
'chaine',
'DOL_DATA_ROOT/doctemplates/mahnung',
'Verzeichnis für ODT-Templates',
0,
'current',
1,
),
);
if (!isModEnabled('mahnung')) {
$conf->mahnung = new stdClass();
$conf->mahnung->enabled = 0;
}
// Tabs auf bestehenden Karten (Phase 5: aktivieren)
$this->tabs = array();
$this->dictionaries = array();
$this->boxes = array(
0 => array(
'file' => 'box_mahnung_offen@mahnung',
'enabledbydefaulton' => 'Home',
),
);
// Cron-Job: Vorschlagsliste täglich 06:00
$this->cronjobs = array(
0 => array(
'label' => 'MahnungCronBuildVorschlag',
'jobtype' => 'method',
'class' => '/mahnung/class/mahnungcron.class.php',
'objectname' => 'MahnungCron',
'method' => 'buildVorschlagsliste',
'parameters' => '',
'comment' => 'MahnungCronCommentBuild',
'frequency' => 1,
'unitfrequency' => 86400,
'status' => 1,
'test' => 'isModEnabled("mahnung")',
'priority' => 50,
),
1 => array(
'label' => 'MahnungCronVersandReminder',
'jobtype' => 'method',
'class' => '/mahnung/class/mahnungcron.class.php',
'objectname' => 'MahnungCron',
'method' => 'versandReminder',
'parameters' => '',
'comment' => 'MahnungCronCommentReminder',
'frequency' => 1,
'unitfrequency' => 86400,
'status' => 1,
'test' => 'isModEnabled("mahnung")',
'priority' => 55,
),
);
// Berechtigungen
$this->rights = array();
$r = 0;
$this->rights[$r][0] = $this->numero.'01';
$this->rights[$r][1] = 'PermMahnungRead';
$this->rights[$r][2] = 'r';
$this->rights[$r][3] = 1;
$this->rights[$r][4] = 'read';
$r++;
$this->rights[$r][0] = $this->numero.'02';
$this->rights[$r][1] = 'PermMahnungWrite';
$this->rights[$r][2] = 'w';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'write';
$r++;
$this->rights[$r][0] = $this->numero.'03';
$this->rights[$r][1] = 'PermMahnungSend';
$this->rights[$r][2] = 'w';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'send';
$r++;
$this->rights[$r][0] = $this->numero.'04';
$this->rights[$r][1] = 'PermMahnungDelete';
$this->rights[$r][2] = 'd';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'delete';
$r++;
$this->rights[$r][0] = $this->numero.'05';
$this->rights[$r][1] = 'PermMahnungSetup';
$this->rights[$r][2] = 'w';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'setup';
$r++;
// Linkes Menue unter "Rechnungen" (mainmenu=billing)
$this->menu = array();
$r = 0;
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=billing',
'type' => 'left',
'titre' => 'MahnungMenu',
'prefix' => img_picto('', 'fa-envelope-open-text', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'billing',
'leftmenu' => 'mahnung',
'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung',
'langs' => 'mahnung@mahnung',
'position' => 300,
'enabled' => 'isModEnabled("mahnung")',
'perms' => '$user->hasRight("mahnung", "read")',
'target' => '',
'user' => 2,
);
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=mahnung',
'type' => 'left',
'titre' => 'MahnungVorschlagsliste',
'mainmenu' => 'billing',
'leftmenu' => 'mahnung_vorschlag',
'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag',
'langs' => 'mahnung@mahnung',
'position' => 301,
'enabled' => 'isModEnabled("mahnung")',
'perms' => '$user->hasRight("mahnung", "read")',
'target' => '',
'user' => 2,
);
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=billing,fk_leftmenu=mahnung',
'type' => 'left',
'titre' => 'MahnungArchiv',
'mainmenu' => 'billing',
'leftmenu' => 'mahnung_archiv',
'url' => '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv',
'langs' => 'mahnung@mahnung',
'position' => 302,
'enabled' => 'isModEnabled("mahnung")',
'perms' => '$user->hasRight("mahnung", "read")',
'target' => '',
'user' => 2,
);
}
/**
* Aufruf bei Modul-Aktivierung: Tabellen anlegen, Konstanten/Rechte/Menüs schreiben.
*
* @param string $options Optionen ('', 'noboxes')
* @return int<-1,1> 1 = OK, <=0 = Fehler
*/
public function init($options = '')
{
global $conf;
// Schema-Migration VOR _load_tables: die Seed-Zeile der Stufe 0 in
// sql/llx_mahnung_stufe.sql schreibt in die Spalte ist_erinnerung. Auf einer
// Bestandsinstallation gibt es die Spalte noch nicht — dann liefe das
// INSERT IGNORE auf einen "Unknown column"-Fehler. Auf einer frischen
// Installation ist der Aufruf hier ein No-Op (Tabellen existieren noch nicht).
$this->migrateVersandFelder();
// Tabellen anlegen aus sql/-Verzeichnis
$result = $this->_load_tables('/mahnung/sql/');
if ($result < 0) {
return -1;
}
// Zweiter Lauf für die frische Installation: ergänzt/seedet, was erst nach
// dem Anlegen der Tabellen möglich ist. Idempotent, daher unschädlich.
$this->migrateVersandFelder();
// Migration: tms-Spalten auf "ON UPDATE CURRENT_TIMESTAMP" umstellen
$this->migrateTimestampSpalten();
// Default-Tracking-Patterns seeden (idempotent — nur beim ersten Mal)
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php';
MahnungTrackingPattern::seedDefaults($this->db);
// Dokumentenmodelle registrieren
$sql = array();
$sql[] = "DELETE FROM ".MAIN_DB_PREFIX."document_model WHERE nom = 'standard_mahnung' AND type = 'mahnung' AND entity = ".((int) $conf->entity);
$sql[] = "INSERT INTO ".MAIN_DB_PREFIX."document_model (nom, type, entity, libelle, description) VALUES ('standard_mahnung', 'mahnung', ".((int) $conf->entity).", 'Standard PDF (DIN 5008)', NULL)";
$sql[] = "DELETE FROM ".MAIN_DB_PREFIX."document_model WHERE nom = 'generic_mahnung_odt' AND type = 'mahnung' AND entity = ".((int) $conf->entity);
$sql[] = "INSERT INTO ".MAIN_DB_PREFIX."document_model (nom, type, entity, libelle, description) VALUES ('generic_mahnung_odt', 'mahnung', ".((int) $conf->entity).", 'ODT templates', 'MAHNUNG_ADDON_PDF_ODT_PATH')";
// ODT-Template-Verzeichnis anlegen
$doctemplatedir = DOL_DATA_ROOT.'/doctemplates/mahnung';
dol_mkdir($doctemplatedir);
return $this->_init($sql, $options);
}
/**
* Aufruf bei Modul-Deaktivierung. Tabellen bleiben erhalten (Datensicherheit).
*
* @param string $options Optionen
* @return int<-1,1> 1 = OK, <=0 = Fehler
*/
public function remove($options = '')
{
$sql = array();
return $this->_remove($sql, $options);
}
/**
* Lazy-Migration: bringt das DB-Schema auf self::DB_VERSION, ohne dass das
* Modul deaktiviert/aktiviert oder die Setup-Seite aufgerufen werden muss.
*
* Hintergrund: deployt wird über die Forgejo-Pipeline, also ein reiner
* Dateiabgleich. Dabei läuft weder init() noch zwangsläufig admin/setup.php.
* Fehlen die neuen Spalten, scheitern Mahnung::create()/update() (Spalte
* kosten_vorstufen) und MahnungStufe::update() (Spalte ist_erinnerung) mit
* "Unknown column" — im Zahlungs-Trigger sogar unsichtbar, weil der bei
* DB-Fehlern nur loggt und 0 liefert (er darf Eddys Zahlungsbuchung nicht
* zurückrollen). Diese Methode holt die Migration deshalb beim ersten
* Seitenaufruf nach dem Deploy nach.
*
* Aufruf gehört an den Kopf der Einstiegspunkte, die auf die Modul-Tabellen
* schreiben. Der Normalfall (Schema aktuell) kostet nur einen Vergleich gegen
* die Konstante MAHNUNG_DB_VERSION — es wird KEINE Query abgesetzt.
*
* @param DoliDB $db Datenbank-Handler
* @return int<0,1> 1 = Migration wurde ausgeführt, 0 = nichts zu tun
*/
public static function ensureSchema($db)
{
// Modul aus -> nichts anfassen. Der Konstruktor würde sonst zusätzlich
// $conf->mahnung (inkl. dir_output/multidir_output) überschreiben.
if (!isModEnabled('mahnung')) {
return 0;
}
// Schnellausstieg im Regelfall
if (getDolGlobalString('MAHNUNG_DB_VERSION') === self::DB_VERSION) {
return 0;
}
$mod = new self($db);
$mod->migrateVersandFelder();
$mod->migrateTimestampSpalten();
return 1;
}
/**
* Schema-Migration für Bestandsinstallationen. Ergänzt fehlende Spalten an
* llx_mahnung_mahnung und llx_mahnung_stufe, weitet die zu engen DECIMAL-Spalten,
* zieht die eindeutigen Stufen-Labels nach und seedet die Stufe 0 (kostenlose
* Zahlungserinnerung).
*
* Idempotent — jeder Schritt prüft vorher per SHOW COLUMNS bzw. SELECT, ob er
* überhaupt nötig ist. Mahnvorgänge werden dabei nie angefasst. Der einzige
* Eingriff in Bestandsdaten ist die einmalige Label-Umbenennung der Stufe 1
* (siehe migrateStufenLabels()); sie ist über einen Marker gegen jede
* Wiederholung abgesichert.
*
* Läuft bei der Modul-Aktivierung (init), bei jedem Aufruf der Setup-Seite und
* über ensureSchema() auch nach einem reinen Datei-Deploy. Am Ende wird der
* erreichte Schema-Stand in MAHNUNG_DB_VERSION festgehalten — aber nur, wenn
* die neuen Spalten danach wirklich existieren.
*
* @return void
*/
public function migrateVersandFelder()
{
global $conf;
$db = $this->db;
// Versand-/Tracking-Felder + kumulierte Vorstufen-Kosten
$this->addMissingColumns($db, 'mahnung_mahnung', array(
'date_versand' => "ADD COLUMN date_versand DATETIME NULL",
'versandweg' => "ADD COLUMN versandweg VARCHAR(30) NULL",
'tracking_nr' => "ADD COLUMN tracking_nr VARCHAR(50) NULL",
'tracking_provider' => "ADD COLUMN tracking_provider VARCHAR(20) NULL",
'kosten_vorstufen' => "ADD COLUMN kosten_vorstufen DOUBLE(10,2) DEFAULT 0",
));
// Kennzeichnung "kostenlose Zahlungserinnerung" an der Stufen-Konfiguration
$this->addMissingColumns($db, 'mahnung_stufe', array(
'ist_erinnerung' => "ADD COLUMN ist_erinnerung TINYINT DEFAULT 0 NOT NULL",
));
// DECIMAL(5,4) konnte nur bis 9,9999 abbilden — der B2B-Verzugszinssatz
// (Basiszins + 9 %) liegt mit z.B. 10,27 % darüber und wurde abgeschnitten.
$this->widenDecimal($db, 'mahnung_mahnung', 'basiszins_snapshot');
$this->widenDecimal($db, 'mahnung_stufe', 'zinssatz_b2c_uebersteuern');
$this->widenDecimal($db, 'mahnung_stufe', 'zinssatz_b2b_uebersteuern');
// Stufen-Labels eindeutig machen. Läuft VOR dem Seed der Stufe 0, damit nie
// zwei Zeilen gleichzeitig 'Zahlungserinnerung' heißen.
$this->migrateStufenLabels($db, (int) $conf->entity);
// Stufe 0 nachziehen, damit Bestandsinstallationen die Zahlungserinnerung
// ohne Modul-Neuaktivierung bekommen.
$this->seedStufeErinnerung($db, (int) $conf->entity);
// Protokolltabelle der versendeten Mails nachziehen.
$this->ensureMailProtokollTabelle($db);
// Erreichten Stand festhalten. Erst danach hört ensureSchema() auf zu prüfen.
$this->markSchemaVersion($db, (int) $conf->entity);
}
/**
* Hält den erreichten Schema-Stand in der Konstanten MAHNUNG_DB_VERSION fest.
*
* Wird bewusst erst gesetzt, nachdem die neuen Spalten nachweislich existieren:
* init() ruft migrateVersandFelder() einmal VOR _load_tables() auf, dort gibt es
* die Tabellen noch gar nicht. Ohne diese Prüfung würde der Stand als "aktuell"
* markiert, obwohl die Migration gar nichts tun konnte.
*
* @param DoliDB $db Datenbank-Handler
* @param int $entity Entity (der Stufen-Seed ist entity-bezogen)
* @return void
*/
private function markSchemaVersion($db, $entity)
{
// Bereits markiert (Setup-Seite migriert bei jedem Aufruf) -> kein Schreibzugriff
if (getDolGlobalString('MAHNUNG_DB_VERSION') === self::DB_VERSION) {
return;
}
if ($this->columnExists($db, 'mahnung_mahnung', 'kosten_vorstufen') !== true) {
return;
}
if ($this->columnExists($db, 'mahnung_stufe', 'ist_erinnerung') !== true) {
return;
}
$this->setModulKonstante($db, 'MAHNUNG_DB_VERSION', self::DB_VERSION, 'Schema-Stand des Mahnung-Moduls', $entity);
}
/**
* Schreibt eine Modul-Konstante und zieht sie im laufenden Request nach.
*
* Das Nachziehen in $conf->global ist wichtig: ohne das würde ein zweiter
* Migrationslauf im selben Request (z.B. card.php -> ajax/*) den bereits
* gesetzten Marker nicht sehen und erneut migrieren.
*
* @param DoliDB $db Datenbank-Handler
* @param string $name Konstantenname (modulintern, keine Nutzereingabe)
* @param string $value Wert
* @param string $note Beschreibung für die Konstanten-Übersicht
* @param int $entity Entity
* @return void
*/
private function setModulKonstante($db, $name, $value, $note, $entity)
{
global $conf;
// dolibarr_set_const() steckt in admin.lib.php — auf normalen Modulseiten
// (card.php, ajax/*) ist die nicht geladen.
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
dolibarr_set_const($db, $name, $value, 'chaine', 0, $note, $entity);
if (isset($conf->global)) {
$conf->global->$name = $value;
}
}
/**
* Macht die Stufen-Labels einer Bestandsinstallation eindeutig.
*
* Vorgeschichte: bis Version 0.2.x hieß die kostenpflichtige Stufe 1
* 'Zahlungserinnerung'. Seit der kostenlosen Stufe 0 (ist_erinnerung = 1) trugen
* damit ZWEI Stufen denselben Namen — in Vorschlagsliste, Sammelbrief und PDF war
* nicht mehr erkennbar, ob eine kostenlose Erinnerung oder eine kostenpflichtige
* Mahnung gemeint ist. Neue Installationen bekommen die Namen über den Seed in
* sql/llx_mahnung_stufe.sql, Bestandsinstallationen über dieses UPDATE.
*
* Bewusst extrem eng gefasst — es werden ausschließlich unveränderte Seed-Zeilen
* getroffen (exakte Stufennummer, exakter alter Label-Text, ist_erinnerung = 0).
* Hat der Nutzer einen Text selbst angepasst, greift die WHERE-Bedingung nicht mehr.
*
* Es sind ZWEI Zeilen betroffen, und die Reihenfolge ist zwingend:
* Der alte Seed hieß Stufe 1 = "Zahlungserinnerung", Stufe 2 = "1. Mahnung".
* Würde man nur Stufe 1 auf "1. Mahnung" ziehen, hätte der Nutzer diesen Namen
* doppelt. Deshalb wird zuerst Stufe 2 nach "2. Mahnung" verschoben und erst
* danach Stufe 1 nachgezogen. Stufe 3 ("Letzte Mahnung") bleibt unberührt.
*
* Läuft genau einmal. Der Marker MAHNUNG_LABEL_MIGR_DONE wird auch dann gesetzt,
* wenn das UPDATE keine Zeile getroffen hat — sonst würde eine spätere
* Rück-Umbenennung durch den Nutzer beim nächsten Migrationslauf wieder
* überschrieben.
*
* @param DoliDB $db Datenbank-Handler
* @param int $entity Entity
* @return void
*/
private function migrateStufenLabels($db, $entity)
{
if (getDolGlobalInt('MAHNUNG_LABEL_MIGR_DONE') > 0) {
return;
}
// Ohne ist_erinnerung lässt sich die kostenpflichtige Stufe 1 nicht sicher
// von einer kostenlosen Erinnerung unterscheiden -> nächster Lauf holt es nach.
if ($this->columnExists($db, 'mahnung_stufe', 'ist_erinnerung') !== true) {
return;
}
// Reihenfolge beachten: erst die Kollision auflösen (Stufe 2), dann Stufe 1
// nachziehen. Andernfalls hieße "1. Mahnung" zwischenzeitlich zweimal.
$umbenennungen = array(
// stufe => array(alter Text, neuer Text)
2 => array('1. Mahnung', '2. Mahnung'),
1 => array('Zahlungserinnerung', '1. Mahnung'),
);
foreach ($umbenennungen as $stufeNr => $texte) {
$sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_stufe";
$sql .= " SET label = '".$db->escape($texte[1])."'";
$sql .= " WHERE entity = ".((int) $entity);
$sql .= " AND stufe = ".((int) $stufeNr);
$sql .= " AND label = '".$db->escape($texte[0])."'";
$sql .= " AND ist_erinnerung = 0";
if (!$db->query($sql)) {
// Marker NICHT setzen — der nächste Lauf holt die Umbenennung nach.
return;
}
}
$this->setModulKonstante($db, 'MAHNUNG_LABEL_MIGR_DONE', '1', 'Stufen-Labels einmalig vereindeutigt', $entity);
}
/**
* Fügt einer Modul-Tabelle die Spalten hinzu, die noch fehlen. Existiert die
* Tabelle nicht, passiert nichts.
*
* @param DoliDB $db Datenbank-Handler
* @param string $table Tabellenname ohne Präfix (interne Konstante, keine Nutzereingabe)
* @param array $cols Spaltenname => vollständige ADD-COLUMN-Klausel
* @return void
*/
/**
* Legt die Protokolltabelle der versendeten Erinnerungs-Mails an, falls sie fehlt.
*
* Nötig für Bestandsinstallationen: die sql/-Dateien laufen nur bei der
* Modul-Aktivierung, ein reiner Datei-Deploy sieht sie nie. Ohne die Tabelle
* würde nach dem Versand das Protokollieren fehlschlagen.
*
* CREATE TABLE IF NOT EXISTS statt einer Existenzprüfung: idempotent und in
* einem Rutsch, das Statement wird ohnehin nur einmal pro Schema-Version
* erreicht (siehe ensureSchema/markSchemaVersion).
*
* @param DoliDB $db Datenbank-Handler
* @return void
*/
private function ensureMailProtokollTabelle($db)
{
$tabelle = MAIN_DB_PREFIX.'mahnung_mailprotokoll';
$sql = "CREATE TABLE IF NOT EXISTS ".$tabelle." (";
$sql .= " rowid INTEGER AUTO_INCREMENT PRIMARY KEY,";
$sql .= " entity INTEGER DEFAULT 1 NOT NULL,";
$sql .= " fk_mahnung INTEGER NOT NULL,";
$sql .= " date_versand DATETIME NOT NULL,";
$sql .= " mail_from VARCHAR(255),";
$sql .= " mail_to TEXT NOT NULL,";
$sql .= " mail_cc TEXT,";
$sql .= " mail_bcc TEXT,";
$sql .= " subject VARCHAR(255) NOT NULL,";
$sql .= " body MEDIUMTEXT,";
$sql .= " ishtml TINYINT DEFAULT 0 NOT NULL,";
$sql .= " anhaenge TEXT,";
$sql .= " fk_user INTEGER,";
$sql .= " datec DATETIME NOT NULL,";
$sql .= " INDEX idx_mahnung_mailprotokoll_mahnung (fk_mahnung, date_versand),";
$sql .= " INDEX idx_mahnung_mailprotokoll_entity (entity)";
$sql .= ") ENGINE=innodb";
if (!$db->query($sql)) {
dol_syslog('modMahnung::ensureMailProtokollTabelle fehlgeschlagen: '.$db->lasterror(), LOG_ERR);
}
}
private function addMissingColumns($db, $table, $cols)
{
$full = MAIN_DB_PREFIX.$table;
$alter = array();
foreach ($cols as $col => $clause) {
$exists = $this->columnExists($db, $table, $col);
if ($exists === null) {
// Tabelle fehlt (frische Installation legt sie über die sql/-Dateien an)
return;
}
if ($exists === false) {
$alter[] = $clause;
}
}
if (!empty($alter)) {
$db->query("ALTER TABLE ".$full." ".implode(', ', $alter));
}
}
/**
* Prüft, ob eine Spalte existiert.
*
* @param DoliDB $db Datenbank-Handler
* @param string $table Tabellenname ohne Präfix (interne Konstante, keine Nutzereingabe)
* @param string $column Spaltenname (interne Konstante, keine Nutzereingabe)
* @return bool|null true/false = Spalte da/nicht da, null = Tabelle fehlt
*/
private function columnExists($db, $table, $column)
{
$res = $db->query("SHOW COLUMNS FROM ".MAIN_DB_PREFIX.$table." LIKE '".$db->escape($column)."'");
if (!$res) {
return null;
}
$found = ($db->num_rows($res) > 0);
$db->free($res);
return $found;
}
/**
* Weitet eine DECIMAL(5,4)-Spalte auf DECIMAL(6,3). Idempotent: liegt der Typ
* bereits als decimal(6,3) vor, passiert nichts. Die NULL-Fähigkeit bleibt
* erhalten, Bestandswerte bleiben inhaltlich unverändert (2 Nachkommastellen).
*
* @param DoliDB $db Datenbank-Handler
* @param string $table Tabellenname ohne Präfix (interne Konstante, keine Nutzereingabe)
* @param string $column Spaltenname (interne Konstante, keine Nutzereingabe)
* @return void
*/
private function widenDecimal($db, $table, $column)
{
$full = MAIN_DB_PREFIX.$table;
$res = $db->query("SHOW COLUMNS FROM ".$full." LIKE '".$db->escape($column)."'");
if (!$res || $db->num_rows($res) == 0) {
if ($res) {
$db->free($res);
}
return;
}
$col = $db->fetch_object($res);
$db->free($res);
if (strtolower(str_replace(' ', '', (string) $col->Type)) === 'decimal(6,3)') {
return;
}
$db->query("ALTER TABLE ".$full." MODIFY COLUMN ".$column." DECIMAL(6,3) NULL");
}
/**
* Legt die Stufe 0 "Zahlungserinnerung" an, falls die Entity noch keine
* Erinnerungs-Stufe besitzt. Kostet nichts: keine Mahngebühr, keine Pauschale
* nach §288 Abs. 5, keine Verzugszinsen. Versand per E-Mail mit der
* Original-Rechnungs-PDF, frist_tage = 0 (sofort ab Fälligkeit).
*
* Idempotent über Existenzprüfung + INSERT IGNORE (UNIQUE entity+stufe).
* Vorhandene Stufen werden nicht angefasst.
*
* Läuft genau EINMAL, abgesichert über den Marker MAHNUNG_SEED_STUFE0_DONE.
* Ohne den Marker legte jeder Migrationslauf (u.a. jeder Aufruf der Setup-Seite)
* die Stufe 0 nach dem Löschen sofort wieder an — der Nutzer wurde sie also nie
* dauerhaft los. Der Marker wird auch gesetzt, wenn die Stufe bereits existierte.
*
* @param DoliDB $db Datenbank-Handler
* @param int $entity Entity
* @return void
*/
private function seedStufeErinnerung($db, $entity)
{
if (getDolGlobalInt('MAHNUNG_SEED_STUFE0_DONE') > 0) {
return;
}
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe";
$sql .= " WHERE entity = ".((int) $entity);
$sql .= " AND (stufe = 0 OR ist_erinnerung = 1)";
$res = $db->query($sql);
if (!$res) {
// Tabelle oder Spalte noch nicht vorhanden — nächster Aufruf holt es nach
return;
}
$exists = ($db->num_rows($res) > 0);
$db->free($res);
if ($exists) {
// Stufe 0 ist da (frische Installation über den SQL-Seed oder ein
// früherer Lauf) -> Marker setzen, damit ein späteres Löschen durch
// den Nutzer Bestand hat.
$this->setModulKonstante($db, 'MAHNUNG_SEED_STUFE0_DONE', '1', 'Zahlungserinnerung (Stufe 0) einmalig angelegt', $entity);
return;
}
$sql = "INSERT IGNORE 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, ist_erinnerung, active, datec";
$sql .= ") VALUES (";
$sql .= ((int) $entity).", 0, 'Zahlungserinnerung', 0, 7,";
$sql .= " 0, 0, 0,";
$sql .= " 0, 0,";
$sql .= " 'mail', 1, 1, '".$db->idate(dol_now())."'";
$sql .= ")";
if (!$db->query($sql)) {
// Fehlgeschlagen -> Marker NICHT setzen, nächster Lauf versucht es erneut
return;
}
$this->setModulKonstante($db, 'MAHNUNG_SEED_STUFE0_DONE', '1', 'Zahlungserinnerung (Stufe 0) einmalig angelegt', $entity);
}
/**
* Stellt die tms-Spalten der Modul-Tabellen auf das Dolibarr-Standardverhalten
* "DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" um. Ältere Installs
* hatten tms als reines "TIMESTAMP", was unter explicit_defaults_for_timestamp
* als "NULL DEFAULT NULL" angelegt wurde — tms blieb dadurch bei jedem UPDATE leer.
*
* Idempotent: Tabellen, deren tms bereits ON UPDATE trägt, werden übersprungen.
* Bestehende NULL-Werte werden vor der NOT-NULL-Umstellung aus datec befüllt.
*
* @return void
*/
public function migrateTimestampSpalten()
{
$db = $this->db;
$tables = array('mahnung_mahnung', 'mahnung_stufe', 'mahnung_trackingpattern');
foreach ($tables as $table) {
$full = MAIN_DB_PREFIX.$table;
// Aktuelle tms-Definition prüfen — Tabelle/Spalte fehlt -> überspringen
$res = $db->query("SHOW COLUMNS FROM ".$full." LIKE 'tms'");
if (!$res || $db->num_rows($res) == 0) {
if ($res) {
$db->free($res);
}
continue;
}
$col = $db->fetch_object($res);
$db->free($res);
// Bereits migriert (Extra enthält "on update ...") -> nichts zu tun
if (stripos((string) $col->Extra, 'on update') !== false) {
continue;
}
// Alt-Zeilen ohne tms aus dem Erstelldatum befüllen, damit die
// anschließende NOT-NULL-Umstellung keine 0000-Werte erzeugt.
$db->query("UPDATE ".$full." SET tms = COALESCE(datec, NOW()) WHERE tms IS NULL");
$db->query(
"ALTER TABLE ".$full." MODIFY COLUMN tms TIMESTAMP NOT NULL"
." DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
);
}
}
}