* * 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" ); } } }