From 0244a5b07ebeb2c9474a3a93ef8f3fafa0922e8f Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Wed, 22 Jul 2026 21:31:25 +0200 Subject: [PATCH] feat(mahnung): Mailversand ueber FormMail, Versandprotokoll, HTML-Mails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 59 + CLAUDE.md | 27 +- README.md | 62 +- admin/setup.php | 1311 +++++++++++++++-- ajax/createmahnung.php | 191 ++- ajax/sammelbrief.php | 208 +-- ajax/sendmail.php | 1306 ++++++++++++++-- card.php | 1308 +++++++++++++--- class/mahnung.class.php | 82 +- class/mahnungcron.class.php | 89 +- class/mahnungstufe.class.php | 229 ++- class/mahnungvorschlag.class.php | 627 ++++++-- core/boxes/box_mahnung_offen.php | 98 +- .../doc/doc_generic_mahnung_odt.modules.php | 41 +- .../doc/pdf_standard_mahnung.modules.php | 64 +- core/modules/modMahnung.class.php | 430 +++++- ...ce_99_modMahnung_MahnungTriggers.class.php | 30 +- langs/de_DE/mahnung.lang | 156 +- langs/en_US/mahnung.lang | 163 +- lib/mahnung_anlage.lib.php | 223 +++ lib/mahnung_ui.lib.php | 111 ++ list.php | 273 +++- sql/llx_mahnung_mahnung.sql | 5 +- sql/llx_mahnung_mailprotokoll.key.sql | 4 + sql/llx_mahnung_mailprotokoll.sql | 33 + sql/llx_mahnung_stufe.sql | 27 +- 26 files changed, 6188 insertions(+), 969 deletions(-) create mode 100644 lib/mahnung_anlage.lib.php create mode 100644 lib/mahnung_ui.lib.php create mode 100644 sql/llx_mahnung_mailprotokoll.key.sql create mode 100644 sql/llx_mahnung_mailprotokoll.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d53b04..d0a12d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ ## [Unreleased] +### Zahlungserinnerung (Stufe 0) + frei konfigurierbare Mahnstufen +- **Vor der ersten echten Mahnung steht jetzt eine kostenlose Zahlungserinnerung.** Ob eine Stufe eine Erinnerung ist, entscheidet ausschließlich das neue Flag `llx_mahnung_stufe.ist_erinnerung` — **nicht** die Stufennummer. Eine Erinnerung kostet nichts: Mahngebühr, §288-Abs.-5-Pauschale und Verzugszinsen werden in `mahnungBaueVorgang()` hart auf 0 gesetzt, unabhängig davon, was in der Stufe konfiguriert ist. +- **Keine feste Obergrenze bei Stufe 3 mehr.** Stufen sind frei konfigurierbar (Nummer 0–127, Lücken erlaubt). Einstieg ist die kleinste aktive Stufe (`minStufe()`), die Folgestufe liefert `naechsteStufeNach()` — Lücken werden übersprungen. Auch der Button „Als uneinbringlich klassifizieren" hängt nicht mehr an `stufe === 3`, sondern daran, dass **keine weitere aktive Stufe** mehr folgt. +- **Wartefrist korrigiert:** maßgeblich ist `frist_tage` der **Ziel**stufe, nicht `neue_frist_tage` der Vorstufe. Vorher war `frist_tage` der Stufen 2/3 ein toter Konfigurationswert. Bezugspunkt ist `date_versand`, Fallback `date_mahnung` (Bestandsdaten haben kein Versanddatum). +- **Neue Spalte `llx_mahnung_mahnung.kosten_vorstufen`**: die Gebühren und Pauschalen bereits gemahnter Vorstufen fielen bisher aus der Forderung heraus, weil `rechneSumme()` nur die aktuelle Stufe addierte. +- **Zinssatz-Spalten von `DECIMAL(5,4)` auf `DECIMAL(6,3)`**: `5,4` konnte maximal 9,9999 abbilden — der B2B-Standardsatz (Basiszins + 9 %) passte damit gar nicht hinein. +- **`modMahnung::ensureSchema()`** zieht das Schema nach einem reinen Datei-Deploy nach (Aufruf am Kopf von `list.php`, `card.php` und den AJAX-Endpoints). Ohne das schlug nach jedem Deploy jedes `create()` fehl, bis jemand die Setup-Seite aufgerufen hatte. +- **Label-Vereindeutigung** läuft genau einmal (Marker `MAHNUNG_LABEL_MIGR_DONE`) und trifft nur unveränderte Seed-Texte: Stufe 2 „1. Mahnung" → „2. Mahnung", danach Stufe 1 „Zahlungserinnerung" → „1. Mahnung". Die Reihenfolge ist zwingend, sonst hieße „1. Mahnung" doppelt. Bestandsdaten (Mahnvorgänge, Beträge) bleiben unangetastet. + +### E-Mail-Versand über das Dolibarr-Mailformular (FormMail) +- **Der Versand der Zahlungserinnerung läuft jetzt über das Standard-Mailformular auf der Mahnungskarte** (`card.php?id=…&action=presend`). Empfänger, Betreff, Text und Anhang sind vor dem Absenden **sichtbar und änderbar** — vorher ging die Mail nach einem bloßen Ja/Nein-Dialog ungesehen raus. + - **Empfängerauswahl** enthält die Firmenadresse **und alle aktiven Ansprechpartner** des Kunden (`thirdparty_and_contact_email_array(1)`), dazu ein Freitextfeld und ein CC-Feld. Aufgelöst wird ein Empfänger nur, wenn sein Schlüssel wirklich aus der Liste **dieses** Kunden stammt: `Societe::contact_get_property()` fragt allein über die `rowid` ab und prüft die Firmenzugehörigkeit **nicht** — ein manipuliertes `receiver[]` hätte die Erinnerung sonst an einen fremden Kontakt schicken können. + - **Absender ist die Firmenadresse**, nicht der angemeldete Benutzer — eine Zahlungserinnerung geht im Namen des Betriebs raus. Sie wird nur angezeigt, nicht zur Auswahl gestellt; ein trotzdem geposteter Wert wird ignoriert. + - **Angehängt wird die unveränderte Original-Rechnungs-PDF**, vorbelegt beim Öffnen des Formulars (Parameter `mailinit=1`) und bei Bedarf über `Facture::generateDocument()` nacherzeugt. Weitere Dateien lassen sich anhängen und wieder entfernen. Bewusst **nicht** Dolibarrs `mode=init`: `FormMail::get_form()` leert bei diesem Parameter selbst die Anhangsliste und hätte die eingehängte Rechnung sofort wieder entfernt. + - Betreff und Text kommen aus der Stufen-Konfiguration mit **bereits ersetzten Platzhaltern** (`{rechnung}`, `{summe}`, `{frist}`, `{kunde}`, `{ref}`, `{stufe}`) — im Formular stehen keine `{…}`-Marken mehr. Nachträglich ergänzte Platzhalter werden beim Absenden erneut aufgelöst. +- **`ajax/sendmail.php` ist kein Endpoint mehr, sondern eine Funktionsbibliothek.** Es gibt genau **einen** Sendeweg (`mahnungSendeErinnerungsMail()`); zwei nebeneinander hätten auseinanderlaufen können. Ein Direktaufruf der Datei per HTTP verschickt nichts, sondern leitet auf das Formular um (und antwortet einem alten jQuery-Aufruf sauber mit JSON statt mit HTML). +- **Doppelversand ist blockiert**: der Vorgang wird **vor** dem Senden per bedingtem `UPDATE` atomar auf VERSENDET reserviert — senden darf nur der Request, dessen UPDATE tatsächlich eine Zeile trifft. Schlägt der Versand fehl, wird ausschließlich die **eigene** Reservierung wieder zurückgenommen. +- **Beträge und Frist werden zum Versandzeitpunkt frisch geprüft**: ist die Rechnung inzwischen bezahlt, storniert oder abgeschrieben, wird der Versand abgelehnt; bei einer Teilzahlung werden `betrag_offen`/`summe_mahnung` nachgezogen, damit Mail und Datensatz denselben Betrag nennen. Die neue Zahlungsfrist wird aus `neue_frist_tage` neu gerechnet — sonst nennt eine später abgeschickte Erinnerung eine bereits verstrichene Frist. +- **Fachliche Sperre bleibt**: versendbar ist ausschließlich eine Stufe mit `ist_erinnerung = 1`. Echte Mahnungen gehen per Post bzw. Einschreiben raus, weil eine E-Mail nicht beweisbar zugestellt ist. + +### Versandprotokoll: was ging wann an wen raus +- Jede versendete Zahlungserinnerung wird protokolliert — **Empfänger, Kopie, Blindkopie, Absender, Betreff, der komplette Text und die Namen der Anhänge**, jeweils im Stand des Sendezeitpunkts. Ändert jemand später die Vorlage in der Stufen-Konfiguration, bleibt der Nachweis davon unberührt. +- Einsehbar auf der Mahnungskarte unter **Versandstatus → „Versendete E-Mails"**: zugeklappt stehen Zeitpunkt und Empfänger, aufgeklappt der vollständige Mailinhalt. HTML-Mails werden formatiert dargestellt (durch `dol_string_onlythesehtmltags()`, da der Inhalt im Browser ausgegeben wird), Klartext mit erhaltenen Zeilenumbrüchen. +- **Eigene Tabelle `llx_mahnung_mailprotokoll` statt Spalten am Mahnvorgang**: seit dem Wiederholversand kann dieselbe Erinnerung mehrfach rausgehen — Spalten am Vorgang würden den vorherigen Versand überschreiben, also genau den Nachweis zerstören, um den es geht. Jeder Versand ist ein eigener Eintrag, neueste zuerst. +- Geschrieben wird ausschließlich nach **erfolgreichem** Versand, direkt in `mahnungSendeErinnerungsMail()` — damit kann kein Sendeweg das Protokollieren umgehen. Scheitert das Protokollieren selbst, wird das nur ins Syslog geschrieben und der Versand nicht nachträglich als gescheitert gemeldet. +- Die Tabelle entsteht auf Bestandsinstallationen über die Lazy-Migration (`ensureSchema()` → `ensureMailProtokollTabelle()`), `DB_VERSION` steht jetzt auf **0.4.0**. Ein reiner Datei-Deploy genügt, es braucht keine Modul-Neuaktivierung. + +### Setup: PDF-Einleitungstext nur noch, wo es ein PDF gibt +- Bei Versandart **E-Mail** und bei jeder **Zahlungserinnerung** stand im Block „Texte für das Schreiben" weiterhin der **PDF-Einleitungstext** — dabei entsteht in beiden Fällen gar kein Mahnschreiben, in das er könnte (eine Erinnerung hat nie ein eigenes PDF, angehängt wird die Original-Rechnung). Das Feld wird jetzt spiegelbildlich zu den E-Mail-Feldern ein- und ausgeblendet, auch beim Umschalten der Versandart ohne Neuladen (`syncEmailRows()`). +- Bewusst ausgeblendet statt entfernt: ein bereits gepflegter Einleitungstext bleibt im Formular und damit erhalten, falls die Stufe später wieder auf Postversand umgestellt wird. + +### Zahnrad in die Einstellungen +- Vorschlagsliste, Archiv und Mahnungskarte haben oben rechts neben dem Titel ein **Zahnrad**, das direkt in die Modul-Einstellungen führt (`mahnungSetupLink()` in `lib/mahnung_ui.lib.php`, gesetzt über den `morehtmlright`-Parameter von `load_fiche_titre()`). Sichtbar nur mit dem Recht `mahnung.setup` oder für Administratoren — wer die Seite ohnehin nicht öffnen darf, sieht kein Icon, das ihn in eine Fehlermeldung laufen lässt. + +### HTML-Mails, Wiederholversand, Rechnung in der Dokumentenliste +- **Mailtexte lassen sich als HTML pflegen — mit dem gewohnten Dolibarr-Editor.** Im Setup hing bisher ein nacktes `', + 'mahnung_pdfrow_'.$k, + $pdfStyle + ); + + // Welche Platzhalter es gibt, stand bisher nur als Kommentar in der Sprachdatei — + // im Formular war es Ratesache. Beide Schreibweisen werden unterstützt. + mahnungSetupFeldZeile( + '', + ''.$langs->trans('MahnungPlatzhalterHilfe').'', + 'mahnung_emailrow_'.$k, + $mailStyle + ); + + // E-Mail-Felder bei Versandart "E-Mail" oder bei einer Zahlungserinnerung + // (Umschalten ohne Neuladen per JS unten — siehe syncEmailRows()). + mahnungSetupFeldZeile( + $langs->trans('MahnungStufeEmailSubject'), + '', + 'mahnung_emailrow_'.$k, + $mailStyle + ); + + // Mailtext mit dem gewohnten Dolibarr-Editor pflegen (WYSIWYG, wenn + // FCKEDITOR_ENABLE_MAIL gesetzt ist — dieselbe Konstante und dieselbe + // Toolbar wie bei Dolibarrs eigenen Mailvorlagen, siehe + // admin/mails_templates.php). Wer lieber Klartext schreibt, bekommt bei + // abgeschalteter Konstante weiterhin ein einfaches Textfeld. + $editorEmailBody = new DolEditor( + $prefix.'email_body', + (string) $s->email_body, + '', + 220, + 'dolibarr_mailings', + 'In', + false, + true, + (bool) getDolGlobalString('FCKEDITOR_ENABLE_MAIL'), + ROWS_5, + '90%' + ); + mahnungSetupFeldZeile( + $langs->trans('MahnungStufeEmailBody'), + $editorEmailBody->Create(1), + 'mahnung_emailrow_'.$k, + $mailStyle + ); + + print ''; + print ''; +} // --------------------------------------------------------------- // POST: Allgemeine Konstanten speichern @@ -95,6 +816,18 @@ if ($action === 'save_consts' && $user->hasRight('mahnung', 'setup')) { $topic = GETPOST('MAHNUNG_NTFY_TOPIC', 'alphanohtml'); $boxmax = (int) GETPOST('MAHNUNG_BOX_MAXLINES', 'int'); + // Absender der Erinnerungs-Mails. Leer = Firmenadresse bzw. globale + // Dolibarr-Absenderadresse (siehe mahnungAbsender()). + $senderMail = trim((string) GETPOST('MAHNUNG_EMAIL_SENDER', 'alphanohtml')); + $senderName = trim((string) GETPOST('MAHNUNG_EMAIL_SENDER_NAME', 'alphanohtml')); + if ($senderMail !== '' && !isValidEmail($senderMail)) { + // Eine ungültige Absenderadresse würde jeden Versand scheitern lassen — + // lieber gar nicht erst speichern. + setEventMessages($langs->trans('MahnungSenderMailUngueltig', $senderMail), null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + dolibarr_set_const($db, 'MAHNUNG_BASISZINS', (string) (float) $basis, 'chaine', 0, '', 0); dolibarr_set_const($db, 'MAHNUNG_AUFSCHLAG_B2C', (string) (float) $b2c, 'chaine', 0, '', 0); dolibarr_set_const($db, 'MAHNUNG_AUFSCHLAG_B2B', (string) (float) $b2b, 'chaine', 0, '', 0); @@ -102,6 +835,8 @@ if ($action === 'save_consts' && $user->hasRight('mahnung', 'setup')) { dolibarr_set_const($db, 'MAHNUNG_NTFY_TOPIC', (string) $topic, 'chaine', 0, '', $conf->entity); // Widget-Anzeige: max. Zeilen offener Rechnungen (0 = alle) dolibarr_set_const($db, 'MAHNUNG_BOX_MAXLINES', (string) $boxmax, 'chaine', 0, '', 0); + dolibarr_set_const($db, 'MAHNUNG_EMAIL_SENDER', $senderMail, 'chaine', 0, '', $conf->entity); + dolibarr_set_const($db, 'MAHNUNG_EMAIL_SENDER_NAME', $senderName, 'chaine', 0, '', $conf->entity); setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs'); header('Location: '.$_SERVER['PHP_SELF']); @@ -109,93 +844,112 @@ if ($action === 'save_consts' && $user->hasRight('mahnung', 'setup')) { } // --------------------------------------------------------------- -// POST: Stufen-Tabelle speichern (Bulk-Update aller 3 Stufen) +// POST: Stufen speichern (Bulk-Update über alle konfigurierten Stufen) // --------------------------------------------------------------- if ($action === 'save_stufen' && $user->hasRight('mahnung', 'setup')) { - $stufeObj = new MahnungStufe($db); - $alle = $stufeObj->fetchAllActive(); - // Auch inaktive laden (active=0) — fetchAllActive filtert; hier inkl. inaktive: - $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe WHERE entity = ".((int) $conf->entity)." ORDER BY stufe"; - $resql = $db->query($sql); - $ids = array(); - if ($resql) { - while ($obj = $db->fetch_object($resql)) { - $ids[] = (int) $obj->rowid; - } - $db->free($resql); - } - $ok = true; - foreach ($ids as $id) { - $s = loadStufeById($db, $id, $conf->entity); - if (!$s) { + $errors = array(); + $toSave = array(); + + foreach (mahnungSetupAlleStufen($db, $conf->entity) as $s) { + $prefix = 'stufe'.((int) $s->id).'_'; + // Block war gar nicht im Formular (z. B. Stufe parallel angelegt) -> nicht anfassen + if (!GETPOSTISSET($prefix.'present')) { continue; } - $prefix = 'stufe_'.$s->stufe.'_'; - $s->label = GETPOST($prefix.'label', 'alphanohtml'); - $s->frist_tage = (int) GETPOST($prefix.'frist_tage', 'int'); - $s->neue_frist_tage = (int) GETPOST($prefix.'neue_frist_tage', 'int'); - $s->mahngebuehr_b2c = (float) str_replace(',', '.', GETPOST($prefix.'mahngebuehr_b2c', 'alphanohtml')); - $s->mahngebuehr_b2b = (float) str_replace(',', '.', GETPOST($prefix.'mahngebuehr_b2b', 'alphanohtml')); - $s->pauschale_b2b_einmalig = GETPOSTISSET($prefix.'pauschale_b2b_einmalig') ? 1 : 0; - $ovB2c = trim((string) GETPOST($prefix.'zinssatz_b2c', 'alphanohtml')); - $ovB2b = trim((string) GETPOST($prefix.'zinssatz_b2b', 'alphanohtml')); - $s->zinssatz_b2c_uebersteuern = $ovB2c === '' ? null : (float) str_replace(',', '.', $ovB2c); - $s->zinssatz_b2b_uebersteuern = $ovB2b === '' ? null : (float) str_replace(',', '.', $ovB2b); - $s->versandart_default = GETPOST($prefix.'versandart', 'alphanohtml') ?: 'pdf'; - $s->pdf_intro = GETPOST($prefix.'pdf_intro', 'restricthtml'); - $s->email_subject = GETPOST($prefix.'email_subject', 'alphanohtml'); - $s->email_body = GETPOST($prefix.'email_body', 'restricthtml'); - $s->active = GETPOSTISSET($prefix.'active') ? 1 : 0; - - if ($s->update($user) <= 0) { - $ok = false; - setEventMessages($s->error, null, 'errors'); - } + mahnungSetupApplyPost($s, $prefix, (string) $s->stufe, $errors); + $toSave[(int) $s->id] = $s; } - if ($ok) { - setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs'); - header('Location: '.$_SERVER['PHP_SELF']); - exit; + + if (!empty($errors)) { + // Nichts speichern — die eingegebenen Werte bleiben im Formular stehen + setEventMessages('', $errors, 'errors'); + $stufenPost = $toSave; + } else { + $ok = true; + foreach ($toSave as $s) { + if ($s->update($user) <= 0) { + $ok = false; + setEventMessages($s->error ?: $langs->trans('MahnungSpeichernFehlgeschlagen'), null, 'errors'); + } + } + if ($ok) { + setEventMessages($langs->trans('MahnungSettingsSaved'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + $stufenPost = $toSave; } } -/** - * Helfer: Stufe per rowid + entity laden (CRUD-Klasse hat nur fetchByStufe). - * - * @param DoliDB $db - * @param int $id - * @param int $entity - * @return MahnungStufe|null - */ -function loadStufeById($db, $id, $entity) -{ - $sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_stufe as t"; - $sql .= " WHERE t.rowid = ".((int) $id); - $sql .= " AND t.entity = ".((int) $entity); - $resql = $db->query($sql); - if (!$resql || !$db->num_rows($resql)) { - return null; +// --------------------------------------------------------------- +// POST: Neue Mahnstufe anlegen +// --------------------------------------------------------------- +if ($action === 'add_stufe' && $user->hasRight('mahnung', 'setup')) { + $errors = array(); + + $neu = new MahnungStufe($db); + $neu->entity = $conf->entity; + + // Stufennummer: frei wählbar, aber TINYINT -> 0..127 + $rawStufe = trim((string) GETPOST('new_stufe', 'alphanohtml')); + if (!preg_match('/^\d+$/', $rawStufe) || (int) $rawStufe > 127) { + $errors[] = $langs->transnoentities('MahnungStufeNummerUngueltig'); + $neu->stufe = 0; + } else { + $neu->stufe = (int) $rawStufe; } - $obj = $db->fetch_object($resql); - $s = new MahnungStufe($db); - $s->id = (int) $obj->rowid; - $s->entity = (int) $obj->entity; - $s->stufe = (int) $obj->stufe; - $s->label = $obj->label; - $s->frist_tage = (int) $obj->frist_tage; - $s->neue_frist_tage = (int) $obj->neue_frist_tage; - $s->mahngebuehr_b2c = $obj->mahngebuehr_b2c; - $s->mahngebuehr_b2b = $obj->mahngebuehr_b2b; - $s->pauschale_b2b_einmalig = (int) $obj->pauschale_b2b_einmalig; - $s->zinssatz_b2c_uebersteuern = $obj->zinssatz_b2c_uebersteuern; - $s->zinssatz_b2b_uebersteuern = $obj->zinssatz_b2b_uebersteuern; - $s->versandart_default = $obj->versandart_default; - $s->email_subject = $obj->email_subject; - $s->email_body = $obj->email_body; - $s->pdf_intro = $obj->pdf_intro; - $s->active = (int) $obj->active; - $db->free($resql); - return $s; + + mahnungSetupApplyPost($neu, 'new_', ($rawStufe !== '' ? $rawStufe : '?'), $errors); + + if (!empty($errors)) { + setEventMessages('', $errors, 'errors'); + $neuStufePost = $neu; + } else { + $resCreate = $neu->create($user); + if ($resCreate > 0) { + setEventMessages($langs->trans('MahnungStufeAngelegt'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + // -2 = Stufennummer bereits vergeben (MahnungStufeNummerVergeben) + setEventMessages($neu->error ?: $langs->trans('MahnungSpeichernFehlgeschlagen'), null, 'errors'); + $neuStufePost = $neu; + } +} + +// --------------------------------------------------------------- +// POST: Mahnstufe löschen (bestätigt über den formconfirm-Dialog weiter unten) +// --------------------------------------------------------------- +if ($action === 'confirm_delete_stufe' && $confirm === 'yes' && $rowid > 0 && $user->hasRight('mahnung', 'setup')) { + $del = new MahnungStufe($db); + if ($del->fetch($rowid) <= 0) { + setEventMessages($langs->trans('MahnungStufeNichtGefunden'), null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + + // Löschsperre serverseitig: verweist auch nur ein Mahnvorgang auf diese + // Stufennummer, darf die Konfiguration nicht verschwinden — sonst verlieren + // Bestandsmahnungen ihre Grundlage. Dann ist "deaktivieren" der richtige Weg. + $nbUse = mahnungSetupZaehleMahnungen($db, (int) $del->stufe); + if ($nbUse < 0) { + setEventMessages($langs->trans('MahnungStufePruefungFehlgeschlagen'), null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + if ($nbUse > 0) { + setEventMessages($langs->transnoentities('MahnungStufeNichtLoeschbarAnzahl', (int) $del->stufe, $nbUse), null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF']); + exit; + } + + if ($del->delete($user) > 0) { + setEventMessages($langs->trans('MahnungStufeGeloescht'), null, 'mesgs'); + } else { + setEventMessages($del->error ?: $langs->trans('MahnungSpeichernFehlgeschlagen'), null, 'errors'); + } + header('Location: '.$_SERVER['PHP_SELF']); + exit; } // --------------------------------------------------------------- @@ -279,94 +1033,353 @@ foreach ($boxMaxOpts as $v => $lbl) { print ''; print ' '.$langs->trans('MahnungBoxMaxLinesHelp').''; +// Absender der Erinnerungs-Mails. Leer lassen = Firmenadresse aus Start > Einstellungen +// > Firma/Stiftung, sonst die globale Dolibarr-Absenderadresse. +print ''.$langs->trans('MahnungSenderMail').''; +print ''; +print ' '.$langs->trans('MahnungSenderMailHelp').''; + +print ''.$langs->trans('MahnungSenderName').''; +print ''; +print ' '.$langs->trans('MahnungSenderNameHelp').''; + print ''; print '
'; print ''; // --- Block: Stufen ----------------------------------------------------------- -$stufeObj = new MahnungStufe($db); -$stufen = array(); -$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_stufe WHERE entity = ".((int) $conf->entity)." ORDER BY stufe ASC"; -$resql = $db->query($sql); -if ($resql) { - while ($obj = $db->fetch_object($resql)) { - $s = loadStufeById($db, (int) $obj->rowid, (int) $conf->entity); - if ($s) { - $stufen[] = $s; - } +// Lokales CSS (kein CDN) für das Mahnstufen-Akkordeon. Bewusst ohne feste +// Theme-Farben: Hintergründe sind neutrale Grau-Schleier mit Alpha, Rahmen und +// Pfeil nutzen currentColor, die Schrift erbt die Farbe des Themes. Damit bleibt +// alles sowohl im hellen Standard-Theme als auch im dunklen awl-dark lesbar. +// Die Dolibarr-Klasse "badge" liefert nur die Grundform — Farbe und Hintergrund +// setzt "mahnung_badge" bewusst neu, weil Dolibarrs Badge-Varianten weißen Text +// mitbringen, der auf einem hellen Untergrund verschwinden würde. +print ''; + +$stufen = mahnungSetupAlleStufen($db, $conf->entity); +// Nach fehlgeschlagener Validierung die eingegebenen Werte weiterverwenden +foreach ($stufen as $idx => $s) { + if (isset($stufenPost[(int) $s->id])) { + $stufen[$idx] = $stufenPost[(int) $s->id]; + } +} +$belegung = mahnungSetupStufenBelegung($db); + +// Löschen-Bestätigung (Dolibarr-Dialog). Der 7. Parameter ist $useajax=1, das "Ja" +// ruft deshalb per GET setup.php?rowid=X&action=confirm_delete_stufe&confirm=yes&token=… +// auf — das Token hängt der Dialog selbst an, geprüft wird es oben im CSRF-Wächter. +if ($action === 'ask_delete_stufe' && $rowid > 0 && $user->hasRight('mahnung', 'setup')) { + $askDel = new MahnungStufe($db); + if ($askDel->fetch($rowid) > 0) { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?rowid='.((int) $askDel->id), + $langs->trans('MahnungStufeLoeschenTitel'), + $langs->transnoentities('MahnungStufeLoeschenFrage', (int) $askDel->stufe, dol_escape_htmltag((string) $askDel->label)), + 'confirm_delete_stufe', + '', + 0, + 1 + ); } - $db->free($resql); } print '

'; +print load_fiche_titre($langs->trans('MahnungStufen'), '', ''); +print '
'.$langs->trans('MahnungStufenIntro').'
'; +print '
'.$langs->trans('MahnungStufenAkkordeonHilfe').'

'; + print '
'; print ''; print ''; -print ''; -print ''; - -foreach ($stufen as $s) { - $prefix = 'stufe_'.$s->stufe.'_'; - print ''; - - print ''; - print ''; - - print ''; - print ''; - - print ''; - print ''; - - print ''; - print ''; - - print ''; - print ''; - - print ''; - print ''; - - // Standard-Zinssatz berechnen (Basiszins + Aufschlag) - $basisVal = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); - $aufB2c = (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); - $aufB2b = (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0'); - $stdB2c = $basisVal + $aufB2c; - $stdB2b = $basisVal + $aufB2b; - - print ''; - print ''; - - print ''; - print ''; - - print ''; - $va = $s->versandart_default ?: 'pdf'; - print ''; - - print ''; - print ''; - - print ''; - print ''; - - print ''; - print ''; +if (empty($stufen)) { + print '
'.$langs->trans('MahnungStufeKeine').'
'; +} + +foreach ($stufen as $s) { + $key = (int) $s->id; + $prefix = 'stufe'.$key.'_'; + $nbUse = isset($belegung[(int) $s->stufe]) ? (int) $belegung[(int) $s->stufe] : 0; + // Alle Stufen starten zugeklappt, damit man sie auf einen Blick hat. Ausnahme: + // nach einer fehlgeschlagenen Validierung wird die beanstandete Stufe aufgeklappt + // gezeigt — sonst sucht man die zurückgewiesene Eingabe hinter einer Kopfzeile. + $offen = isset($stufenPost[$key]); + + print '
'; + print '
'; + mahnungSetupPrintKopf($s, (string) $key, $nbUse); + print '
'; + print ''; + mahnungSetupPrintFelder($s, $prefix, (string) $key); + print '
'; + print '
'; + + // Löschen nur, wenn kein Mahnvorgang auf die Stufennummer verweist (Löschsperre; + // serverseitig noch einmal geprüft in der Action confirm_delete_stufe). Der Link + // sitzt bewusst neben dem und nicht darin: ein Klick auf einen Link + // innerhalb eines würde zusätzlich den Abschnitt auf- oder zuklappen. + // Ist die Stufe gesperrt, steht der Hinweis dafür in der Kopfzeile. + if ($nbUse === 0) { + print ''; + print ''; + print img_picto($langs->trans('Delete'), 'delete'); + print ''; + print ''; + } + print '
'; } -print '
'.$langs->trans('MahnungStufe').'
'.dol_escape_htmltag($langs->trans('MahnungStufe').' '.$s->stufe).' '; - print 'active ? ' checked' : '').'> '.$langs->trans('Active'); - print '
'.$langs->trans('MahnungStufeLabel').'
'.$langs->trans('MahnungStufeFristTage').'
'.$langs->trans('MahnungStufeNeueFristTage').'
'.$langs->trans('MahnungStufeGebuehrB2C').' EUR
'.$langs->trans('MahnungStufeGebuehrB2B').' EUR
'.$langs->trans('MahnungPauschaleB2B').' (§288 Abs. 5)pauschale_b2b_einmalig ? ' checked' : '').'>
'.$langs->trans('MahnungStufeZinssatzB2C').' %'; - print ' '.$langs->trans('MahnungZinssatzHelpB2C', number_format($basisVal, 2, ',', '.'), number_format($aufB2c, 1, ',', '.'), number_format($stdB2c, 2, ',', '.')).'
'.$langs->trans('MahnungStufeZinssatzB2B').' %'; - print ' '.$langs->trans('MahnungZinssatzHelpB2B', number_format($basisVal, 2, ',', '.'), number_format($aufB2b, 1, ',', '.'), number_format($stdB2b, 2, ',', '.')).'
'.$langs->trans('MahnungStufeVersandartDefault').'
'.$langs->trans('MahnungStufePdfIntro').'
'.$langs->trans('MahnungStufeEmailSubject').'
'.$langs->trans('MahnungStufeEmailBody').'
'; print '
'; print '
'; +// --- Block: Neue Stufe anlegen ---------------------------------------------- +$neuVorlage = $neuStufePost; +if ($neuVorlage === null) { + $stufeHelper = new MahnungStufe($db); + $neuVorlage = new MahnungStufe($db); + $neuVorlage->stufe = $stufeHelper->naechsteFreieStufe(); + $neuVorlage->label = ''; + $neuVorlage->frist_tage = 0; + $neuVorlage->neue_frist_tage = 7; + $neuVorlage->versandart_default = 'pdf'; + $neuVorlage->active = 1; +} + +print '
'; +print load_fiche_titre($langs->trans('MahnungStufeNeu'), '', ''); + +print '
'; +print ''; +print ''; + +// Das Anlege-Formular bleibt bewusst offen (kein Akkordeon): es gibt nur eines und +// wer hier landet, will gerade eine Stufe anlegen. Das Aktiv-Häkchen steckt in +// mahnungSetupPrintFelder() — es darf pro Formular nur einmal vorkommen, sonst +// entscheidet die Reihenfolge im POST über den gespeicherten Wert. +print '
'; + +mahnungSetupFeldZeile( + $langs->trans('MahnungStufeNummer'), + ' ' + .''.$langs->trans('MahnungStufeNummerHelp').'' +); + +mahnungSetupPrintFelder($neuVorlage, 'new_', 'new'); + +print '
'; +print '
'; +print '
'; + +// Textbausteine für die Kopfzeilen-Aktualisierung im Browser. transnoentities() +// liefert die Rohfassung ohne HTML-Entities — für JSON ist das richtig, die +// JSON_HEX_*-Flags entschärfen alles, was den '; + // --- Block: Dokumentenmodelle ----------------------------------------------------------- print '

'; print load_fiche_titre($langs->trans('MahnungDokumentModelle'), ''.$langs->trans('MahnungSetupTemplateVars').'', ''); diff --git a/ajax/createmahnung.php b/ajax/createmahnung.php index 9c70951..2559fa2 100644 --- a/ajax/createmahnung.php +++ b/ajax/createmahnung.php @@ -13,9 +13,19 @@ * als auch AJAX-Calls. Antwortet je nach Accept-Header HTML-Redirect * oder JSON. * + * Ist die Zielstufe eine kostenlose Zahlungserinnerung (ist_erinnerung = 1), + * wird KEIN Mahn-PDF erzeugt: Anhang ist später die unveränderte + * Original-Rechnung, der Versand läuft per E-Mail über ajax/sendmail.php und + * ausschließlich nach ausdrücklicher Bestätigung. + * + * Hat der Kunde keine (gültige) E-Mail-Adresse, wird die Erinnerung trotzdem + * angelegt — die Rechnung darf nicht aus dem Mahnlauf fallen — und in der Antwort + * unter "ohne_email" namentlich gemeldet (Adresse nachtragen oder per Post senden). + * * POST: * - facture_ids[] Array Rechnungs-IDs (oder einzelne facture_id) - * - stufe Optional: Stufe erzwingen (sonst Vorschlag-Logik) + * - stufe Optional: Stufe erzwingen (sonst Vorschlag-Logik). + * '0' ist ein gültiger Wert, '' bedeutet "nicht gesetzt". * - token CSRF */ @@ -28,12 +38,18 @@ require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; 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'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_anlage.lib.php'; require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php'; global $db, $user, $langs, $conf; $langs->loadLangs(array('mahnung@mahnung')); +// Schema nach einem reinen Datei-Deploy nachziehen. Kritisch hier: ohne die +// Spalte kosten_vorstufen schlägt Mahnung::create() mit "Unknown column" fehl. +modMahnung::ensureSchema($db); + /** * @param bool $success * @param string $message @@ -59,11 +75,20 @@ function respond($success, $message, $extra = array()) exit; } - // Klassischer Submit -> Redirect zur Liste mit Flash-Message - global $user; + // Klassischer Submit -> Redirect mit Flash-Message. + // + // Wurde GENAU EIN Vorgang erzeugt, geht es direkt auf dessen Karte: der Vorgang + // muss ohnehin bearbeitet werden (Versand anstoßen, Texte prüfen), und in der + // Vorschlagsliste taucht die Rechnung jetzt gar nicht mehr auf — die Wartefrist + // zur Folgestufe läuft. Ohne diesen Sprung wäre die frisch angelegte Mahnung nur + // über den Umweg Archiv auffindbar. if (function_exists('setEventMessages')) { setEventMessages($message, null, $success ? 'mesgs' : 'errors'); } + if ($success && !empty($extra['single_id']) && (int) $extra['single_id'] > 0) { + header('Location: '.DOL_URL_ROOT.'/custom/mahnung/card.php?id='.((int) $extra['single_id'])); + exit; + } header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag'); exit; } @@ -95,74 +120,58 @@ if (empty($factureIds)) { respond(false, $langs->trans('MahnungKeineRechnungenAusgewaehlt'), array('code' => 'noinput')); } -$forceStufe = GETPOSTINT('stufe'); -$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0; - -// 4) Verarbeitung — pro Rechnung Vorschlag holen, Mahnung erzeugen, PDF generieren $service = new MahnungVorschlag($db); + +// 4) Verarbeitung — Vorschläge einmal holen, dann pro Rechnung Mahnung erzeugen. +// Bewusst VOR der Stufen-Validierung: liefert die Stufenkonfiguration einen +// Fehler, soll dieser gemeldet werden und nicht "Stufe ungültig". +$vorschlaege = mahnungVorschlagsIndex($service); +if ($vorschlaege === false) { + respond(false, mahnungVorschlagFehlertext($service), array('code' => 'vorschlagfehler')); +} + +// Zielstufe erzwingen? 0 ist gültig (Zahlungserinnerung), '' heißt "nicht gesetzt". +$forceStufe = mahnungGetForceStufe($service); +if ($forceStufe === MAHNUNG_STUFE_UNGUELTIG) { + respond(false, $langs->trans('MahnungStufeUngueltig'), array('code' => 'badstufe')); +} + $basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); $created = 0; +$createdErinnerung = 0; $skipped = 0; $failed = array(); +// IDs der tatsächlich angelegten Vorgänge — bei genau einem wird direkt auf dessen +// Karte weitergeleitet (siehe respond()). +$createdIds = array(); +// Angelegte Zahlungserinnerungen, die mangels (gültiger) E-Mail-Adresse nicht +// automatisch versendet werden können — siehe Kommentar in der Schleife. +$ohneEmail = array(); foreach ($factureIds as $fid) { - $rows = $service->getVorschlaege(array('soc_id' => 0)); // ohne Filter holen - $row = null; - foreach ($rows as $r) { - if ((int) $r['facture_id'] === (int) $fid) { - $row = $r; - break; - } + if (!isset($vorschlaege[$fid])) { + // Keine offene Mahnungs-Empfehlung — z.B. weil die Wartefrist noch läuft + $skipped++; + continue; } - if ($row === null) { - // Keine offene Mahnungs-Empfehlung — z.B. weil Wartefrist noch läuft + $row = $vorschlaege[$fid]; + + $stufeNr = ($forceStufe !== MAHNUNG_STUFE_NICHT_GESETZT) + ? $forceStufe + : (isset($row['vorgeschlagene_stufe']) ? (int) $row['vorgeschlagene_stufe'] : null); + if ($stufeNr === null) { $skipped++; continue; } - $stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe']; $stufe = $service->getStufe($stufeNr); if ($stufe === null) { $failed[] = $langs->trans('MahnungStufeNichtKonfiguriert', $fid, $stufeNr); continue; } - $mahnung = new Mahnung($db); - $mahnung->fk_facture = $fid; - $mahnung->fk_soc = (int) $row['soc_id']; - $mahnung->stufe = $stufeNr; - $mahnung->date_mahnung = dol_now(); - $mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement']; - $mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd'); - $mahnung->betrag_offen = (float) $row['betrag_offen']; - $mahnung->customertype = $row['kundentyp']; - $mahnung->basiszins_snapshot = $basiszins; - $mahnung->versandart = $stufe->versandart_default ?: Mahnung::VERSAND_PDF; - - // Gebühren + Pauschale - $mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype); - - // §288 Abs. 5 Pauschale: nur einmal pro Rechnung B2B (in Stufe mit pauschale_b2b_einmalig=1) - if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1) { - $alreadyApplied = pauschaleBereitsAngewendet($db, (int) $fid); - if (!$alreadyApplied) { - $mahnung->pauschale_b2b = (float) getDolGlobalString('MAHNUNG_PAUSCHALE_B2B', '40.00'); - } - } - - // Verzugszinsen - $override = $stufe->getZinssatzOverride($mahnung->customertype); - $mahnung->verzugszinsen = Mahnung::berechneVerzugszinsen( - $mahnung->betrag_offen, - (int) $row['tage_verzug'], - $mahnung->customertype, - $basiszins, - $override - ); - - $mahnung->rechneSumme(); - $mahnung->status = Mahnung::STATUS_ERSTELLT; + $mahnung = mahnungBaueVorgang($db, $row, $stufe, $basiszins); $newId = $mahnung->create($user); if ($newId <= 0) { @@ -170,44 +179,70 @@ foreach ($factureIds as $fid) { continue; } + if ($stufe->istErinnerung()) { + // Kostenlose Zahlungserinnerung: kein Mahn-PDF. Versendet wird die + // Original-Rechnung per E-Mail, erst nach Bestätigung auf der Karte. + $createdErinnerung++; + $created++; + + // Ohne (gültige) E-Mail-Adresse ist die Erinnerung per Mail nicht zustellbar — + // ajax/sendmail.php bricht dann mit MahnungKundeKeineEmail ab. Trotzdem wird sie + // angelegt und NICHT abgewiesen: die Erinnerung ist in der Regel die kleinste + // aktive Stufe, ein Abweisen würde die Rechnung dauerhaft aus dem Mahnlauf + // werfen (ohne Vorstufe schlägt der Vorschlag immer wieder dieselbe Erinnerung + // vor, die immer wieder scheitert — eine echte Mahnstufe würde nie erreicht). + // Angelegt läuft die Wartefrist zur Folgestufe dagegen ab date_mahnung weiter + // (MahnungVorschlag::ermittleZielstufe() nutzt date_versand nur als Vorzug), + // die Rechnung erreicht also Stufe 1 auch ohne Mailadresse. + // Damit die Erinnerung nicht still liegen bleibt, wird sie hier namentlich + // gemeldet: Adresse nachtragen oder das Schreiben per Post rausschicken. + $kundenMail = trim((string) (isset($row['soc_email']) ? $row['soc_email'] : '')); + if ($kundenMail === '' || !isValidEmail($kundenMail)) { + $refText = trim((string) (isset($row['facture_ref']) ? $row['facture_ref'] : '')); + if ($refText === '') { + $refText = '#'.((int) $fid); + } + $kundeText = trim((string) (isset($row['soc_nom']) ? $row['soc_nom'] : '')); + $ohneEmail[] = ($kundeText !== '') ? $refText.' ('.$kundeText.')' : $refText; + } + $createdIds[] = (int) $newId; + continue; + } + $docResult = $mahnung->generateDocument('', $langs); if ($docResult <= 0) { - $failed[] = 'Rechnung #'.$fid.' (Mahnung '.$mahnung->ref.'): Dokument-Fehler '.$mahnung->error; + $failed[] = 'Rechnung #'.$fid.' (Mahnung '.$mahnung->ref.'): '.$langs->trans('MahnungDokumentFehler').' '.$mahnung->error; continue; } $created++; + $createdIds[] = (int) $newId; } $msg = $langs->trans('MahnungMahnungErstellt', $created); +if ($createdErinnerung > 0) { + $msg .= $langs->trans('MahnungErinnerungenErstelltHinweis', $createdErinnerung); +} if ($skipped > 0) { $msg .= $langs->trans('MahnungUebersprungen2', $skipped); } +// Nicht versendbare Erinnerungen deutlich benennen — sonst wartet Eddy auf einen +// Mailversand, der nie kommt. trans() escapt die eingesetzten Kundennamen selbst, +// deshalb hier KEIN zusätzliches dol_escape_htmltag() (sonst doppeltes Encoding). +if (!empty($ohneEmail)) { + $msg .= $langs->trans('MahnungErinnerungOhneEmailHinweis', count($ohneEmail), implode(', ', $ohneEmail)); +} if (!empty($failed)) { $msg .= $langs->trans('MahnungFehlerLabel', implode(' | ', $failed)); - respond(false, $msg, array('created' => $created, 'failed' => $failed)); -} -respond(true, $msg, array('created' => $created, 'skipped' => $skipped)); - -/** - * Prüft, ob für eine Rechnung bereits in einer aktiven Mahnung die §288-B2B-Pauschale gesetzt wurde. - * - * @param DoliDB $db - * @param int $factureId - * @return bool - */ -function pauschaleBereitsAngewendet($db, $factureId) -{ - $sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; - $sql .= " WHERE fk_facture = ".((int) $factureId); - $sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")"; - $sql .= " AND pauschale_b2b > 0"; - $sql .= " LIMIT 1"; - $resql = $db->query($sql); - if (!$resql) { - return false; - } - $has = (bool) $db->num_rows($resql); - $db->free($resql); - return $has; + respond(false, $msg, array('created' => $created, 'erinnerungen' => $createdErinnerung, 'ohne_email' => $ohneEmail, 'failed' => $failed)); } +// single_id nur bei GENAU einem angelegten Vorgang — dann springt respond() direkt +// auf dessen Karte, weil er ohnehin bearbeitet werden muss. +respond(true, $msg, array( + 'created' => $created, + 'erinnerungen' => $createdErinnerung, + 'ohne_email' => $ohneEmail, + 'skipped' => $skipped, + 'created_ids' => $createdIds, + 'single_id' => (count($createdIds) === 1) ? $createdIds[0] : 0, +)); diff --git a/ajax/sammelbrief.php b/ajax/sammelbrief.php index edfc122..43e2fbf 100644 --- a/ajax/sammelbrief.php +++ b/ajax/sammelbrief.php @@ -10,12 +10,16 @@ * \brief AJAX/Form-Endpoint: Sammelbrief — für eine Auswahl von Rechnungen * Mahnungen erzeugen und alle Einzel-PDFs in EIN PDF zusammenfassen. * + * Kostenlose Zahlungserinnerungen (ist_erinnerung = 1) sind hier + * ausgeschlossen: sie erzeugen kein Mahn-PDF und werden ausschließlich + * per E-Mail mit der Original-Rechnung als Anhang versendet. + * * POST: * facture_ids[] Rechnungs-IDs * stufe (opt) Stufe erzwingen (sonst Vorschlag) * token CSRF * - * Response: PDF-Download "sammelbrief-YYYYMMDD-N.pdf". + * Response: PDF-Download "sammelbrief-YYYYMMDD.pdf". */ if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1'); @@ -28,26 +32,30 @@ require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php'; 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'; -require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungpdf.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_anlage.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php'; -global $db, $user, $langs; +global $db, $user, $langs, $conf; $langs->loadLangs(array('mahnung@mahnung')); +// Schema nach einem reinen Datei-Deploy nachziehen (der Sammelbrief legt +// Mahnvorgänge an und braucht dafür die Spalte kosten_vorstufen). +modMahnung::ensureSchema($db); + // CSRF $postedToken = GETPOST('token', 'alphanohtml'); if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) { - while (ob_get_level() > 0) { - ob_end_clean(); - } httpExitError(403, $langs->trans('MahnungSammelbriefCsrfFehler')); } -// Permission -if (!$user->hasRight('mahnung', 'send') && !$user->hasRight('mahnung', 'write')) { - while (ob_get_level() > 0) { - ob_end_clean(); - } - httpExitError(403, $langs->trans('MahnungSammelbriefNichtBerechtigt')); +// Permission: der Sammelbrief LEGT Mahnvorgänge an (mahnungBaueVorgang() + +// Mahnung::create()) und erzeugt die zugehörigen PDFs — das ist eine schreibende +// Aktion. 'send' allein reicht dafür nicht: dieses Recht deckt nur das Versenden +// bereits vorhandener Mahnungen ab. Die Liste blendet den Sammelbrief-Button +// ohnehin nur bei mahnung.write ein (list.php: $canWrite), der Endpoint muss +// dieselbe Grenze ziehen. +if (!$user->hasRight('mahnung', 'write')) { + httpExitError(403, $langs->trans('MahnungNichtBerechtigtWrite')); } $factureIds = GETPOST('facture_ids', 'array:int'); @@ -55,89 +63,95 @@ $factureIds = array_values(array_filter(array_unique(array_map('intval', $factur return $v > 0; })); if (empty($factureIds)) { - while (ob_get_level() > 0) { - ob_end_clean(); - } httpExitError(400, $langs->trans('MahnungKeineRechnungenAusgewaehlt')); } -$forceStufe = GETPOSTINT('stufe'); -$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0; - $service = new MahnungVorschlag($db); -$pdfGen = new MahnungPdf($db); + +// Bewusst VOR der Stufen-Validierung: liefert die Stufenkonfiguration einen +// Fehler, soll dieser gemeldet werden und nicht "Stufe ungültig". +$vorschlaege = mahnungVorschlagsIndex($service); +if ($vorschlaege === false) { + httpExitError(500, mahnungVorschlagFehlertext($service)); +} + +// Zielstufe erzwingen? 0 ist gültig (Zahlungserinnerung), '' heißt "nicht gesetzt". +$forceStufe = mahnungGetForceStufe($service); +if ($forceStufe === MAHNUNG_STUFE_UNGUELTIG) { + httpExitError(400, $langs->trans('MahnungStufeUngueltig')); +} + $basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); $paths = array(); +$skippedErinnerung = 0; + foreach ($factureIds as $fid) { - $rows = $service->getVorschlaege(); - $row = null; - foreach ($rows as $r) { - if ((int) $r['facture_id'] === (int) $fid) { - $row = $r; - break; - } - } - if ($row === null) { + if (!isset($vorschlaege[$fid])) { continue; } - $stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe']; + $row = $vorschlaege[$fid]; + + $stufeNr = ($forceStufe !== MAHNUNG_STUFE_NICHT_GESETZT) + ? $forceStufe + : (isset($row['vorgeschlagene_stufe']) ? (int) $row['vorgeschlagene_stufe'] : null); + if ($stufeNr === null) { + continue; + } + $stufe = $service->getStufe($stufeNr); if ($stufe === null) { continue; } - $mahnung = new Mahnung($db); - $mahnung->fk_facture = $fid; - $mahnung->fk_soc = (int) $row['soc_id']; - $mahnung->stufe = $stufeNr; - $mahnung->date_mahnung = dol_now(); - $mahnung->date_lim_reglement_alt = $row['facture_date_lim_reglement']; - $mahnung->date_lim_reglement_neu = dol_time_plus_duree(dol_now(), (int) $stufe->neue_frist_tage, 'd'); - $mahnung->betrag_offen = (float) $row['betrag_offen']; - $mahnung->customertype = $row['kundentyp']; - $mahnung->basiszins_snapshot = $basiszins; - $mahnung->versandart = Mahnung::VERSAND_DRUCK; - $mahnung->mahngebuehr = $stufe->getMahngebuehr($mahnung->customertype); - if ($mahnung->customertype === Mahnung::KUNDENTYP_B2B && (int) $stufe->pauschale_b2b_einmalig === 1 - && !pauschaleBereitsAngewendet($db, $fid)) { - $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) - ); - $mahnung->rechneSumme(); - $mahnung->status = Mahnung::STATUS_ERSTELLT; - - if ($mahnung->create($user) <= 0) { + // Zahlungserinnerungen gehören nicht in den Sammelbrief — sie erzeugen kein + // Mahn-PDF und werden per E-Mail mit der Original-Rechnung verschickt. + if ($stufe->istErinnerung()) { + $skippedErinnerung++; continue; } - $pdfPath = $pdfGen->generate($mahnung, $user); - if ($pdfPath !== false) { - $paths[] = $pdfPath; + + $mahnung = mahnungBaueVorgang($db, $row, $stufe, $basiszins, Mahnung::VERSAND_DRUCK); + + if ($mahnung->create($user) <= 0) { + dol_syslog('Mahnung Sammelbrief: Anlage für Rechnung #'.((int) $fid).' fehlgeschlagen: '.$mahnung->error, LOG_ERR); + continue; } + + // Dokumentenmodell-System statt eigener PDF-Klasse. Bewusst hart auf das + // PDF-Modell: ein ODT-Dokument ließe sich nicht in den Sammelbrief mischen. + $docResult = $mahnung->generateDocument('standard_mahnung', $langs); + if ($docResult <= 0) { + dol_syslog('Mahnung Sammelbrief: Dokument für '.$mahnung->ref.' fehlgeschlagen: '.$mahnung->error, LOG_ERR); + continue; + } + + // pdf_path setzt das Dokumentenmodell beim Schreiben, last_main_doc die + // Dolibarr-Basis. Beides prüfen, damit ein Modellwechsel nichts bricht. + $pdfPath = ''; + if (!empty($mahnung->pdf_path)) { + $pdfPath = (string) $mahnung->pdf_path; + } elseif (!empty($mahnung->last_main_doc)) { + $pdfPath = (string) $mahnung->last_main_doc; + } + if ($pdfPath === '' || !file_exists($pdfPath)) { + dol_syslog('Mahnung Sammelbrief: PDF zu '.$mahnung->ref.' nicht auffindbar ('.$pdfPath.')', LOG_ERR); + continue; + } + + $paths[] = $pdfPath; } if (empty($paths)) { - while (ob_get_level() > 0) { - ob_end_clean(); + if ($skippedErinnerung > 0) { + httpExitError(400, $langs->trans('MahnungSammelbriefNurErinnerungen')); } httpExitError(500, $langs->trans('MahnungSammelbriefKeinePdfs')); } -// Wenn TCPDI verfügbar, Seiten aller PDFs in EIN Dokument importieren. -// Andernfalls ZIP-Fallback würde sich anbieten — wir liefern stattdessen -// eine PDF-Konkatenation via TCPDI (Bestandteil von tecnickcom/tc-lib-pdf -// und Dolibarr-Tcpdi-Wrapper). -$absOut = this_buildSammelbriefPdf($paths); +// Alle Einzel-PDFs zu einem Dokument zusammenfassen (TCPDI). +$absOut = mahnungBaueSammelbriefPdf($paths); if ($absOut === null || !file_exists($absOut)) { - while (ob_get_level() > 0) { - ob_end_clean(); - } httpExitError(500, $langs->trans('MahnungSammelbriefFehler')); } @@ -154,14 +168,20 @@ exit; // ---------------------------------------------------------------- /** - * Konkateniert mehrere PDF-Dateien zu einer Datei. Gibt absoluten Pfad zurück - * oder null bei Fehler. + * Konkateniert mehrere PDF-Dateien zu einer temporären Datei. Gibt deren + * absoluten Pfad zurück oder null bei Fehler. * - * @param string[] $paths + * Der Rückgabewert ist IMMER eine Wegwerf-Kopie im Temp-Verzeichnis — der + * Aufrufer löscht sie nach dem Download, und dabei darf niemals das Original-PDF + * einer Mahnung erwischt werden. + * + * @param string[] $paths Absolute Pfade der Einzel-PDFs * @return string|null */ -function this_buildSammelbriefPdf(array $paths) +function mahnungBaueSammelbriefPdf(array $paths) { + $out = sys_get_temp_dir().'/mahnung-sammelbrief-'.uniqid('', true).'.pdf'; + if (!class_exists('TCPDI')) { // Dolibarr liefert TCPDI über tcpdf/tcpdi.php aus $tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php'; @@ -171,10 +191,13 @@ function this_buildSammelbriefPdf(array $paths) } if (!class_exists('TCPDI')) { dol_syslog('Mahnung Sammelbrief: TCPDI-Klasse nicht verfügbar — nur erstes PDF wird zurückgeliefert', LOG_WARNING); - return $paths[0] ?? null; + $first = reset($paths); + if (empty($first) || !file_exists($first)) { + return null; + } + return @copy($first, $out) ? $out : null; } - $out = sys_get_temp_dir().'/mahnung-sammelbrief-'.uniqid('', true).'.pdf'; $pdf = new TCPDI(); $pdf->setPrintHeader(false); $pdf->setPrintFooter(false); @@ -191,38 +214,25 @@ function this_buildSammelbriefPdf(array $paths) } } $pdf->Output($out, 'F'); + + if (!file_exists($out) || filesize($out) <= 0) { + return null; + } return $out; } /** - * Prüft, ob für eine Rechnung bereits §288-B2B-Pauschale gesetzt wurde. + * Bricht mit HTTP-Status und Klartext-Meldung ab. * - * @param DoliDB $db - * @param int $factureId - * @return bool - */ -function pauschaleBereitsAngewendet($db, $factureId) -{ - $sql = "SELECT 1 FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; - $sql .= " WHERE fk_facture = ".((int) $factureId); - $sql .= " AND status NOT IN (".Mahnung::STATUS_STORNIERT.")"; - $sql .= " AND pauschale_b2b > 0"; - $sql .= " LIMIT 1"; - $resql = $db->query($sql); - if (!$resql) { - return false; - } - $has = (bool) $db->num_rows($resql); - $db->free($resql); - return $has; -} - -/** - * @param int $code - * @param string $message + * @param int $code + * @param string $message + * @return void */ function httpExitError($code, $message) { + while (ob_get_level() > 0) { + ob_end_clean(); + } http_response_code($code); header('Content-Type: text/plain; charset=utf-8'); echo $message; diff --git a/ajax/sendmail.php b/ajax/sendmail.php index f821156..b18a0e2 100644 --- a/ajax/sendmail.php +++ b/ajax/sendmail.php @@ -7,143 +7,1209 @@ /** * \file htdocs/custom/mahnung/ajax/sendmail.php * \ingroup mahnung - * \brief AJAX-Endpoint: Mahnung per E-Mail an Kunde senden. + * \brief Funktionsbibliothek für den Versand der kostenlosen Zahlungserinnerung. * - * POST: - * mahnung_id ID des Mahnvorgangs (PDF muss existieren) - * token CSRF + * ACHTUNG — diese Datei ist KEIN eigenständiger Endpoint mehr. Sie + * stellt nur noch die gehärteten Bausteine bereit, die card.php + * benutzt: Empfängerermittlung, Rechnungsprüfung, Anhang-Suche, + * Platzhalter, Statuswächter, atomare Versand-Reservierung und den + * eigentlichen CMailFile-Aufruf. * - * Response: JSON {success, message} + * Warum kein eigener AJAX-Endpoint mehr? + * Der Versand läuft jetzt über das Dolibarr-Standard-Mailformular + * (FormMail) auf der Mahnungs-Karte: card.php?id=…&action=presend. + * Dort sind Empfänger (inkl. der Ansprechpartner des Kunden), Betreff, + * Text und Anhang sichtbar UND änderbar. Gäbe es daneben weiter einen + * Endpoint, der eigenständig sendet, existierten zwei Sendewege, die + * auseinanderlaufen können. Es gibt deshalb bewusst nur EINEN: + * mahnungSendeErinnerungsMail(). Ein Direktaufruf dieser Datei per HTTP + * verschickt nichts, sondern leitet auf das Formular um. + * + * Fachliche Sperre (unverändert): versendet werden darf ausschließlich + * eine Stufe mit llx_mahnung_stufe.ist_erinnerung = 1. Echte Mahnungen + * gehen per Post/Einschreiben raus — eine E-Mail ist nicht beweisbar + * zugestellt. + * + * Angehängt wird die UNVERÄNDERTE Original-Rechnungs-PDF. Es wird kein + * Mahn-PDF erzeugt, pdf_path des Mahnvorgangs bleibt unangetastet. + * Fehlt die Rechnungs-PDF auf der Platte, wird sie regulär über + * Facture::generateDocument() nacherzeugt — dafür ist zusätzlich das + * Recht facture.creer nötig. + * + * Doppelversand-Schutz: der Vorgang wird VOR dem Senden per bedingtem + * UPDATE auf VERSENDET reserviert (siehe mahnungReserviereVersand()). + * Nur wer die Zeile tatsächlich trifft, darf senden; schlägt der Versand + * fehl, wird die Reservierung wieder zurückgenommen. + * + * Die neue Zahlungsfrist im Mailtext ({frist}) wird zum Versandzeitpunkt + * aus neue_frist_tage der Stufe neu berechnet und mitgespeichert — sonst + * nennt eine später verschickte Erinnerung eine verstrichene Frist. + * + * Ebenso wird der offene Betrag ({summe}) zum Versandzeitpunkt frisch + * an der Rechnung geprüft: ist sie inzwischen bezahlt, storniert oder + * abgeschrieben, wird der Versand abgelehnt; bei einer Teilzahlung + * werden betrag_offen/summe_mahnung nachgezogen und mitgespeichert. */ -if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1'); -if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1'); +// --------------------------------------------------------------------------- +// Direktaufruf per HTTP (Altlast) +// --------------------------------------------------------------------------- +// Wird die Datei von card.php eingebunden, lief main.inc.php längst und +// DOL_DOCUMENT_ROOT ist definiert — dann steht unterhalb nur Funktionsdefinition +// und es passiert beim Einbinden nichts. +// Ruft dagegen jemand ajax/sendmail.php direkt auf (altes Lesezeichen, eine noch +// im Browser stehende Seite von vor dem Umbau), landet er hier: es wird nichts +// verschickt, sondern auf das Mailformular der Karte weitergeleitet. +if (!defined('DOL_DOCUMENT_ROOT')) { + if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); + } + if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); + } -ob_start(); + require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; + + $langs->loadLangs(array('mahnung@mahnung')); + + $mahnungIdAlt = GETPOSTINT('mahnung_id'); + $zielAlt = DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung'; + if ($mahnungIdAlt > 0) { + // mailinit=1, NICHT mode=init: FormMail::get_form() leert bei mode=init selbst + // die Anhangsliste und würde die von card.php eingehängte Rechnungs-PDF gleich + // wieder entfernen — das Formular käme ohne Anhang hoch. + // Token mitgeben: 'presend' ist token-pflichtig, weil dabei die Rechnungs-PDF + // bereitgestellt (und notfalls erzeugt) wird. + $zielAlt = DOL_URL_ROOT.'/custom/mahnung/card.php?id='.$mahnungIdAlt.'&action=presend&mailinit=1&token='.newToken().'#formmail'; + } + + // Der alte Aufruf war ein jQuery.post() mit dataType "json". Damit eine noch + // offene alte Seite keinen kryptischen Parse-Fehler zeigt, wird dort sauber + // mit JSON geantwortet statt mit einem Redirect auf HTML. + $willJson = false; + if (!empty($_SERVER['HTTP_ACCEPT']) && stripos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { + $willJson = true; + } + if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') { + $willJson = true; + } + + if ($willJson) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(array( + 'success' => false, + 'redirect' => $zielAlt, + 'message' => $langs->transnoentities('MahnungMailEndpointEntfallen'), + )); + exit; + } + + header('Location: '.$zielAlt); + exit; +} -require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/CMailFile.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; -global $db, $user, $langs, $conf, $mysoc; -$langs->loadLangs(array('mahnung@mahnung')); + +// --------------------------------------------------------------------------- +// Empfänger +// --------------------------------------------------------------------------- /** - * @param bool $success - * @param string $message - */ -function jsonExit($success, $message) -{ - while (ob_get_level() > 0) { - ob_end_clean(); - } - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(array('success' => (bool) $success, 'message' => $message)); - exit; -} - -// CSRF -$postedToken = GETPOST('token', 'alphanohtml'); -if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) { - jsonExit(false, $langs->trans('MahnungCsrfTokenUngueltig')); -} - -if (!$user->hasRight('mahnung', 'send')) { - jsonExit(false, $langs->trans('MahnungNichtBerechtigtSend')); -} - -$mahnungId = GETPOSTINT('mahnung_id'); -if ($mahnungId <= 0) { - jsonExit(false, $langs->trans('MahnungIdFehlt')); -} - -$mahnung = new Mahnung($db); -if ($mahnung->fetch($mahnungId) <= 0) { - jsonExit(false, $langs->trans('MahnungNichtGefunden', $mahnungId)); -} - -if (empty($mahnung->pdf_path) || !file_exists($mahnung->pdf_path)) { - jsonExit(false, $langs->trans('MahnungPdfFehlt', $mahnung->ref)); -} - -$societe = new Societe($db); -if ($societe->fetch((int) $mahnung->fk_soc) <= 0) { - jsonExit(false, $langs->trans('MahnungKundeNichtLadbar')); -} -$toEmail = trim((string) ($societe->email ?? '')); -if (empty($toEmail)) { - jsonExit(false, $langs->trans('MahnungKundeKeineEmail')); -} - -$facture = new Facture($db); -$facture->fetch((int) $mahnung->fk_facture); - -$stufeObj = new MahnungStufe($db); -$stufeObj->fetchByStufe((int) $mahnung->stufe); - -$replacements = array( - '{ref}' => $mahnung->ref, - '{stufe}' => (string) (int) $mahnung->stufe, - '{summe}' => price((float) $mahnung->summe_mahnung).' EUR', - '{rechnung}' => $facture->ref ?? '', - '{frist}' => dol_print_date($mahnung->date_lim_reglement_neu, 'day'), - '{kunde}' => $societe->name ?? '', -); - -$subject = strtr($stufeObj->email_subject ?: $langs->trans('MahnungEmailDefaultSubject'), $replacements); -$body = strtr($stufeObj->email_body ?: defaultMailBody((int) $mahnung->stufe), $replacements); - -$fromEmail = $mysoc->email ?? getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); -$fromName = $mysoc->name ?? ''; -$from = !empty($fromName) ? $fromName.' <'.$fromEmail.'>' : $fromEmail; - -$attachments = array($mahnung->pdf_path); -$mimes = array('application/pdf'); -$names = array(basename($mahnung->pdf_path)); - -$mailFile = new CMailFile( - $subject, - $toEmail, - $from, - $body, - $attachments, - $mimes, - $names, - '', - '', - 0, - 1 -); - -if (!$mailFile->error) { - if ($mailFile->sendfile()) { - $mahnung->status = Mahnung::STATUS_VERSENDET; - $mahnung->update($user); - jsonExit(true, $langs->trans('MahnungEmailGesendet', $toEmail)); - } -} -jsonExit(false, $langs->trans('MahnungEmailFehlgeschlagen', $mailFile->error)); - -/** - * Default-Body je Stufe. + * Auswahlliste der möglichen Empfänger eines Kunden. * - * @param int $stufe - * @return string + * Liefert exakt das, was Dolibarr im Standard-Mailformular anbietet: die + * Haupt-E-Mail der Firma UND die E-Mail-Adressen aller aktiven Ansprechpartner + * (llx_socpeople). Genau das braucht Eddy — viele Kunden haben eine + * Firmenadresse und zusätzlich einen Kontakt, an den die Rechnung gehen soll. + * + * Format: array(|'thirdparty' => "Name - Position "). + * Der SCHLÜSSEL transportiert die Identität, das Label ist nur Anzeige — so + * erwartet es FormMail::getHtmlForTo(). + * + * @param Societe $societe Geladener Kunde + * @return array */ -function defaultMailBody($stufe) +function mahnungEmpfaengerListe($societe) +{ + if (!is_object($societe) || empty($societe->id)) { + return array(); + } + + $liste = array(); + // thirdparty_and_contact_email_array(1) = aktive Kontakte + Firmenadresse. + foreach ($societe->thirdparty_and_contact_email_array(1) as $key => $label) { + $liste[$key] = (string) $label; + } + return $liste; +} + +/** + * Zerlegt einen Empfängereintrag "Name " (oder "mail@x") und prüft ihn. + * + * Der Name wird von Zeichen befreit, die eine Adressliste bzw. einen Mail-Header + * zerlegen könnten (Komma, Semikolon, spitze Klammern, Anführungszeichen, + * Steuerzeichen). Ohne gültige Mailadresse gibt es null — Kontakte ohne Mail + * tragen im Label den übersetzten Text "NoEMail" und fallen hier heraus. + * + * @param string $eintrag Roher Eintrag + * @return array{mail:string,voll:string}|null + */ +function mahnungAdresseNormieren($eintrag) +{ + $eintrag = mahnungHeaderEinzeilig($eintrag); + if ($eintrag === '') { + return null; + } + + $reg = array(); + if (preg_match('/^(.*)<([^<>]+)>[^<>]*$/', $eintrag, $reg)) { + $name = trim(dol_string_nospecial($reg[1], ' ', array(',', ';', '<', '>', '"'))); + $mail = trim($reg[2]); + } else { + $name = ''; + $mail = trim($eintrag); + } + + if ($mail === '' || !isValidEmail($mail)) { + return null; + } + + return array( + // Kleinschreibung dient nur als Dubletten-Schlüssel — verschickt wird 'voll'. + 'mail' => strtolower($mail), + 'voll' => ($name !== '' ? $name.' <'.$mail.'>' : $mail), + ); +} + +/** + * Zerlegt ein Freitext-Adressfeld ("a@x, Name ") in geprüfte Adressen. + * + * Genutzt für die frei eintragbaren Felder des Mailformulars (An, Kopie, + * Blindkopie). Getrennt wird an Komma UND Semikolon: Dolibarr nennt zwar nur das + * Komma, aus Outlook ist das Semikolon aber so geläufig, dass "a@x; b@y" sonst + * als eine einzige — ungültige — Adresse durchliefe. + * + * Unbrauchbare Teile werden NICHT still geschluckt, sondern in $verworfen + * zurückgegeben. Ein Tippfehler in einer Adresse darf nicht dazu führen, dass die + * Mail klaglos an weniger Empfänger geht, als im Formular standen. + * + * Der Rückgabe-Schlüssel ist die kleingeschriebene Adresse, wodurch Dubletten + * (auch über mehrere Aufrufe hinweg) von selbst zusammenfallen. + * + * @param string $eingabe Roher Feldinhalt + * @param array $verworfen Rückgabe: nicht verwertbare Teile + * @return array Mailadresse (klein) => "Name " + */ +function mahnungAdressenAusFreitext($eingabe, &$verworfen = array()) +{ + $treffer = array(); + $verworfen = array(); + + foreach (preg_split('/[,;]/', (string) $eingabe) as $teil) { + if (trim((string) $teil) === '') { + continue; + } + $norm = mahnungAdresseNormieren($teil); + if ($norm === null) { + $verworfen[] = trim((string) $teil); + continue; + } + $treffer[$norm['mail']] = $norm['voll']; + } + return $treffer; +} + +/** + * Sendefertige Adresse zu einem Schlüssel aus mahnungEmpfaengerListe(). + * + * Wichtig für die Sicherheit: der Aufrufer MUSS vorher prüfen, dass der + * Schlüssel wirklich aus der Liste dieses Kunden stammt. Societe:: + * contact_get_property() fragt llx_socpeople allein über die rowid ab und + * validiert die Firmenzugehörigkeit NICHT — ein manipuliertes receiver[] könnte + * sonst die Erinnerung an einen beliebigen fremden Kontakt schicken. + * + * @param Societe $societe Geladener Kunde + * @param int|string $key 'thirdparty' oder socpeople.rowid + * @return string "Name " oder '' wenn unbrauchbar + */ +function mahnungEmpfaengerAdresse($societe, $key) +{ + $key = (string) $key; + + if ($key === 'thirdparty') { + $mail = trim((string) $societe->email); + if ($mail === '' || !isValidEmail($mail)) { + return ''; + } + // mahnungHeaderEinzeilig() zuerst: dol_string_nospecial() entfernt zwar die + // aufgezählten Zeichen, lässt CR/LF/TAB aber stehen — ein Firmenname mit + // Zeilenumbruch (aus einem Import) könnte damit einen Mail-Header aufspalten. + $name = trim(dol_string_nospecial(mahnungHeaderEinzeilig((string) $societe->name), ' ', array(',', ';', '<', '>', '"'))); + return ($name !== '' ? $name.' <'.$mail.'>' : $mail); + } + + if ((int) $key <= 0) { + return ''; + } + + $norm = mahnungAdresseNormieren((string) $societe->contact_get_property((int) $key, 'email')); + return ($norm === null) ? '' : $norm['voll']; +} + + +// --------------------------------------------------------------------------- +// Rechnung: ist überhaupt noch etwas zu mahnen? +// --------------------------------------------------------------------------- + +/** + * Prüft die Rechnung zum Versandzeitpunkt und zieht den offenen Betrag nach. + * + * Zwischen der Anlage des Mahnvorgangs (Vorschlagsliste) und dem Versand liegt + * bauartbedingt eine manuelle Bestätigung — also beliebig viel Zeit. In dieser + * Zeit kann die Rechnung bezahlt, TEILbezahlt, storniert oder als uneinbringlich + * abgeschrieben worden sein. Der Statuswächter am Mahnvorgang sieht davon + * nichts: der Zahlungs-Trigger erledigt Mahnvorgänge nur bei VOLLzahlung + * (istRechnungVollBezahlt()), und auf BILL_CANCEL hört das Modul gar nicht. + * Deshalb wird der aktuelle Stand hier am Original nachgeprüft. + * + * Ändert sich der offene Betrag, werden betrag_offen und summe_mahnung am + * übergebenen Objekt nachgezogen (in-memory). Gespeichert wird das erst mit der + * Versand-Reservierung, damit Mail und Datensatz denselben Betrag nennen. + * + * @param Mahnung $mahnung Geladener Mahnvorgang (wird ggf. angepasst) + * @param Facture $facture Geladene Rechnung + * @param string $fehler Rückgabe: Grund der Ablehnung + * @param bool $betragAngepasst Rückgabe: true = Betrag wurde nachgezogen + * @return bool true = es ist noch etwas offen + */ +function mahnungRechnungNochOffen($mahnung, $facture, &$fehler, &$betragAngepasst) { global $langs; - $langs->load('mahnung@mahnung'); - switch ((int) $stufe) { - case 1: - return $langs->trans('MahnungEmailDefaultBody1'); - case 2: - return $langs->trans('MahnungEmailDefaultBody2'); - case 3: - default: - return $langs->trans('MahnungEmailDefaultBody3'); + + $fehler = ''; + $betragAngepasst = false; + + // $facture->status ist der aktuelle Feldname, $facture->statut der alte — + // beide werden von Facture::fetch() befüllt, der Fallback hält ältere + // Dolibarr-Versionen offen. + $statutRechnung = (int) (isset($facture->status) ? $facture->status : $facture->statut); + if (!empty($facture->paye) || $statutRechnung !== Facture::STATUS_VALIDATED) { + $fehler = $langs->trans('MahnungMailRechnungNichtMehrOffen', $facture->ref); + return false; } + + $betragOffenAktuell = mahnungBetragOffenAktuell($facture); + if ($betragOffenAktuell === null) { + // Konnte nicht sicher gerechnet werden — lieber nichts verschicken als einen + // falschen Betrag zu mahnen. + $fehler = $langs->trans('MahnungMailBetragNichtErmittelbar', $facture->ref); + return false; + } + // Schwelle 0,005 EUR: Rundungsreste unterhalb eines halben Cents sind keine + // offene Forderung mehr (gleiche Toleranz wie im Zahlungs-Trigger). + if ($betragOffenAktuell <= 0.005) { + $fehler = $langs->trans('MahnungMailRechnungBereitsBezahlt', $facture->ref); + return false; + } + + // Teilzahlung o.Ä.: der Vorgang trägt sonst den Stand vom Anlagezeitpunkt in die + // Mail ({summe}). Betrag nachziehen, bevor die Platzhalter gebaut werden. + // Bei einer Zahlungserinnerung sind Mahngebühr, §288-Pauschale, Verzugszinsen + // und Vorstufenkosten hart 0 (siehe mahnungBaueVorgang()) — rechneSumme() lässt + // die Erinnerung damit kostenfrei, summe_mahnung folgt exakt dem offenen Betrag. + $betragOffenVorher = (float) $mahnung->betrag_offen; + if (abs($betragOffenAktuell - $betragOffenVorher) >= 0.005) { + dol_syslog('mahnung/sendmail: offener Betrag zu '.$facture->ref.' hat sich von ' + .price2num($betragOffenVorher, 'MT').' auf '.price2num($betragOffenAktuell, 'MT') + .' geaendert — Mahnvorgang wird mitgezogen', LOG_INFO); + $mahnung->betrag_offen = $betragOffenAktuell; + $mahnung->rechneSumme(); + $betragAngepasst = true; + } + + return true; +} + +/** + * Aktuell offener Betrag einer Rechnung. + * + * Gerechnet wird wie im Rechnungsmodul selbst (compta/facture/card.php): + * total_ttc - Zahlungen - verrechnete Anzahlungen - verrechnete Gutschriften + * Die reine Differenz aus total_ttc und llx_paiement_facture würde eine per + * Gutschrift oder Anzahlung ausgeglichene Rechnung fälschlich als offen + * ausweisen — genau das würde hier eine Zahlungserinnerung auslösen. + * + * Alle drei Dolibarr-Methoden liefern bei einem SQL-Fehler -1 und setzen + * $facture->error. Ein stumm mitverrechnetes -1 würde den offenen Betrag + * verfälschen (Vorzeichen: Betrag würde um 1 EUR steigen), deshalb wird der + * Fehlerfall sauber als "nicht ermittelbar" gemeldet statt gerechnet. + * + * @param Facture $facture Geladene Rechnung + * @return float|null Offener Betrag in EUR, null = nicht ermittelbar + */ +function mahnungBetragOffenAktuell($facture) +{ + $summen = array(); + foreach (array('getSommePaiement', 'getSumDepositsUsed', 'getSumCreditNotesUsed') as $methode) { + $facture->error = ''; + $wert = (float) $facture->$methode(); + if ($wert < 0 && $facture->error !== '') { + dol_syslog('mahnung/sendmail: '.$methode.'() zu Rechnung '.((int) $facture->id).' fehlgeschlagen: '.$facture->error, LOG_ERR); + return null; + } + $summen[] = $wert; + } + + return round(((float) $facture->total_ttc) - array_sum($summen), 2); +} + + +// --------------------------------------------------------------------------- +// Anhang (Original-Rechnungs-PDF) +// --------------------------------------------------------------------------- + +/** + * Liefert die Original-Rechnungs-PDF und erzeugt sie bei Bedarf nach. + * + * Bewusst die unveränderte Rechnung: sie weist den vollen Rechnungsbetrag aus, + * der noch offene Rest steht im Mailtext ({summe}). Die Datei wird nicht + * verändert und nicht in pdf_path geschrieben — eine Zahlungserinnerung hat + * kein eigenes Mahn-PDF. + * + * @param Facture $facture Geladene Rechnung + * @param User $user Aufrufender Benutzer (Rechteprüfung) + * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) + * @return string Absoluter Pfad oder '' + */ +function mahnungHoleRechnungsPdf($facture, $user, &$fehler) +{ + global $langs; + + $fehler = ''; + + $pdfDatei = mahnungFindeRechnungsPdf($facture); + if ($pdfDatei !== '') { + return $pdfDatei; + } + + // Regulär über das Dokumentenmodell des Rechnungsmoduls nacherzeugen. + // + // generateDocument() schreibt in das Dokumentenverzeichnis des RECHNUNGS- + // moduls. Das Recht mahnung.send allein reicht dafür nicht — wer keine + // Rechnungen anlegen/ändern darf, darf hier auch keine Rechnungs-PDF + // erzeugen lassen. + if (!$user->hasRight('facture', 'creer')) { + $fehler = $langs->trans('MahnungMailRechnungsPdfKeinRecht', $facture->ref); + return ''; + } + + dol_syslog('mahnung/sendmail: Rechnungs-PDF zu '.$facture->ref.' fehlt — wird nacherzeugt', LOG_INFO); + if ($facture->generateDocument('', $langs) > 0) { + $pdfDatei = mahnungFindeRechnungsPdf($facture); + } + + if ($pdfDatei === '') { + $fehler = $langs->trans('MahnungMailRechnungsPdfFehlt', $facture->ref); + } + return $pdfDatei; +} + +/** + * Ermittelt den absoluten Pfad der ORIGINAL-Rechnungs-PDF. + * + * Reihenfolge: + * 1. $facture->last_main_doc (von Dolibarr gepflegter Zeiger, relativ zu + * DOL_DATA_ROOT) + * 2. Standardpfad //.pdf + * + * Die Referenz läuft durch dol_sanitizeFileName() (entfernt u.a. "/", "\" und + * ".."), zusätzlich wird über realpath() geprüft, dass die gefundene Datei + * wirklich unterhalb des Rechnungs-Ausgabeverzeichnisses liegt. Damit ist ein + * Ausbrechen aus dem Verzeichnis (Path Traversal) ausgeschlossen. + * + * @param Facture $facture Geladene Rechnung + * @return string Absoluter Pfad oder '' wenn keine PDF vorhanden + */ +function mahnungFindeRechnungsPdf($facture) +{ + $baseDir = mahnungRechnungsAusgabeVerzeichnis($facture); + if ($baseDir === '') { + return ''; + } + + $refSan = dol_sanitizeFileName((string) $facture->ref); + if ($refSan === '') { + return ''; + } + + $kandidaten = array(); + + // 1) Hauptdokument laut Dolibarr. ACHTUNG: last_main_doc ist relativ zu + // DOL_DATA_ROOT (z.B. "facture/FA2601-0001/FA2601-0001.pdf"), NICHT zum + // Ausgabeverzeichnis des Rechnungsmoduls — siehe commonGenerateDocument(). + if (!empty($facture->last_main_doc)) { + $rel = ltrim(str_replace('\\', '/', (string) $facture->last_main_doc), '/'); + if ($rel !== '' && strpos($rel, '..') === false && preg_match('/\.pdf$/i', $rel)) { + $kandidaten[] = rtrim(DOL_DATA_ROOT, '/').'/'.$rel; + } + } + + // 2) Standardpfad des Rechnungsmoduls + $kandidaten[] = $baseDir.'/'.$refSan.'/'.$refSan.'.pdf'; + + // Beide Kandidaten müssen unterhalb des Rechnungs-Ausgabeverzeichnisses + // liegen — damit kann auch ein manipulierter last_main_doc nichts anderes + // als eine Rechnungs-PDF anhängen. + foreach ($kandidaten as $pfad) { + if (mahnungIstNutzbarePdf($pfad, $baseDir)) { + return $pfad; + } + } + return ''; +} + +/** + * Ausgabeverzeichnis des Rechnungsmoduls für die Entity der Rechnung. + * $conf->facture ist der Altname, $conf->invoice zeigt auf dasselbe Objekt. + * + * @param Facture $facture Geladene Rechnung + * @return string Verzeichnis ohne Slash am Ende oder '' + */ +function mahnungRechnungsAusgabeVerzeichnis($facture) +{ + global $conf; + + $entity = !empty($facture->entity) ? (int) $facture->entity : (int) $conf->entity; + + if (!empty($conf->facture->multidir_output[$entity])) { + return rtrim($conf->facture->multidir_output[$entity], '/'); + } + if (!empty($conf->invoice->multidir_output[$entity])) { + return rtrim($conf->invoice->multidir_output[$entity], '/'); + } + if (!empty($conf->facture->dir_output)) { + return rtrim($conf->facture->dir_output, '/'); + } + if (!empty($conf->invoice->dir_output)) { + return rtrim($conf->invoice->dir_output, '/'); + } + return ''; +} + +/** + * Prüft, ob eine Datei als Mail-Anhang taugt: existiert, lesbar, nicht leer + * und nachweislich unterhalb des erlaubten Basisverzeichnisses. + * + * @param string $pfad Zu prüfender absoluter Pfad + * @param string $baseDir Erlaubtes Basisverzeichnis + * @return bool + */ +function mahnungIstNutzbarePdf($pfad, $baseDir) +{ + if (!is_file($pfad) || !is_readable($pfad)) { + return false; + } + if ((int) filesize($pfad) <= 0) { + return false; + } + + $real = realpath($pfad); + $realBase = realpath($baseDir); + if ($real === false || $realBase === false) { + return false; + } + return strpos($real, rtrim($realBase, '/').'/') === 0; +} + + +// --------------------------------------------------------------------------- +// Betreff / Text +// --------------------------------------------------------------------------- + +/** + * Zahlungsfrist zum Versandzeitpunkt. + * + * date_lim_reglement_neu wurde bei der ANLAGE gesetzt (Anlagedatum + + * neue_frist_tage). Zwischen Anlage und Versand können Tage liegen — dann stünde + * in der Mail eine bereits verstrichene Frist. Deshalb hier aus neue_frist_tage + * der Stufe neu rechnen; gespeichert wird der Wert zusammen mit der + * Versand-Reservierung, damit Mail und Datensatz dieselbe Frist nennen. + * + * @param MahnungStufe $stufeObj Geladene Stufenkonfiguration + * @param int $jetzt Versandzeitpunkt (Unix-Zeit) + * @return int Unix-Zeit der neuen Frist + */ +function mahnungBerechneNeueFrist($stufeObj, $jetzt) +{ + return dol_time_plus_duree($jetzt, (int) $stufeObj->neue_frist_tage, 'd'); +} + +/** + * Wertetabelle für die Platzhalter des Mailtextes. + * + * Unterstützt werden ZWEI Schreibweisen für dieselben Werte: + * - die modul-eigene mit geschweiften Klammern: {rechnung} + * - die Dolibarr-übliche mit Unterstrichen: __REF__ + * Wer aus Dolibarr kommt, tippt selbstverständlich __REF__ — steht das Modul nur + * auf {…}, bleibt die Marke unersetzt im Betreff stehen. Beide Formen kosten + * nichts und ersparen das Nachschlagen. + * + * Zu __REF__: gemeint ist die RECHNUNGSnummer, nicht die des Mahnvorgangs. In + * einer Zahlungserinnerung spricht der Text immer von der Rechnung; die interne + * Mahnungs-Referenz sagt dem Kunden nichts. Wer sie doch braucht, nimmt + * __MAHNUNGREF__. + * + * @param Mahnung $mahnung Mahnvorgang + * @param ?Facture $facture Zugehörige Rechnung + * @param ?Societe $societe Kunde + * @param int $neueFrist Neu berechnete Zahlungsfrist (Unix-Zeit) + * @param ?MahnungStufe $stufeObj Stufenkonfiguration (für die Anzahl Fristtage) + * @return array + */ +function mahnungPlatzhalterWerte($mahnung, $facture, $societe, $neueFrist, $stufeObj = null) +{ + global $mysoc; + + $refMahnung = (string) $mahnung->ref; + $refRechnung = (string) (is_object($facture) ? $facture->ref : ''); + $stufe = (string) ((int) $mahnung->stufe); + $summe = price((float) $mahnung->summe_mahnung).' EUR'; + $frist = $neueFrist ? dol_print_date($neueFrist, 'day') : ''; + $kunde = (string) (is_object($societe) ? $societe->name : ''); + $faellig = !empty($mahnung->date_lim_reglement_alt) ? dol_print_date($mahnung->date_lim_reglement_alt, 'day') : ''; + $firma = (string) (isset($mysoc->name) ? $mysoc->name : ''); + + // Rechnungsdatum und -fälligkeit direkt aus der Rechnung; fällt sie weg, greifen + // die im Mahnvorgang gespeicherten Werte. + $rechnungsDatum = ''; + if (is_object($facture) && !empty($facture->date)) { + $rechnungsDatum = dol_print_date($facture->date, 'day'); + } + $faelligRechnung = ''; + if (is_object($facture) && !empty($facture->date_lim_reglement)) { + $faelligRechnung = dol_print_date($facture->date_lim_reglement, 'day'); + } + if ($faelligRechnung === '') { + $faelligRechnung = $faellig; + } + + // Gesamtbetrag der Rechnung — bewusst NICHT der offene Betrag: __AMOUNT__ meint in + // Dolibarr den Rechnungsbetrag ("Rechnung über X"). Der noch offene Rest steht in + // {summe} / __SUMME__. + $rechnungsBetrag = is_object($facture) ? price((float) $facture->total_ttc).' EUR' : ''; + + // Anzahl Tage der neuen Zahlungsfrist — direkt aus der Stufen-Konfiguration + // (neue_frist_tage). Damit nennt der Text dieselbe Frist, die auch am Vorgang + // gespeichert wird; eine im Text fest eingetippte Zahl liefe sonst auseinander, + // sobald jemand die Stufe umkonfiguriert. + $fristTage = is_object($stufeObj) ? (string) ((int) $stufeObj->neue_frist_tage) : ''; + + return array( + // Modul-eigene Schreibweise + '{ref}' => $refMahnung, + '{stufe}' => $stufe, + '{summe}' => $summe, + '{rechnung}' => $refRechnung, + '{frist}' => $frist, + '{kunde}' => $kunde, + '{faellig}' => $faellig, + '{firma}' => $firma, + '{fristtage}' => $fristTage, + + '{rechnungsbetrag}' => $rechnungsBetrag, + '{rechnungsdatum}' => $rechnungsDatum, + + // Dolibarr-Schreibweise + '__REF__' => $refRechnung, + '__RECHNUNG__' => $refRechnung, + '__FACREF__' => $refRechnung, + '__MAHNUNGREF__' => $refMahnung, + '__STUFE__' => $stufe, + '__SUMME__' => $summe, + '__FRIST__' => $frist, + '__FRIST_TAGE__' => $fristTage, + '__NACHFRIST__' => $frist, + '__NACHFRIST_TAGE__' => $fristTage, + '__FAELLIG__' => $faellig, + '__KUNDE__' => $kunde, + '__THIRDPARTY_NAME__' => $kunde, + '__FIRMA__' => $firma, + '__MYCOMPANY_NAME__' => $firma, + + // Rechnungsdaten in Dolibarr-Benennung. __AMOUNT__ ist dort der Betrag DER + // RECHNUNG — deshalb hier total_ttc und nicht der offene Rest. + // "FORMATED" mit einem T ist Dolibarrs eigene Schreibweise; die naheliegende + // Variante mit zwei T wird zusätzlich akzeptiert, damit ein Vertipper nicht + // als roher Platzhalter beim Kunden landet. + '__DATE_YMD__' => $rechnungsDatum, + '__DATE_DUE_YMD__' => $faelligRechnung, + '__AMOUNT__' => $rechnungsBetrag, + '__AMOUNT_FORMATED__' => $rechnungsBetrag, + '__AMOUNT_FORMATTED__' => $rechnungsBetrag, + '__TOTAL_TTC__' => $rechnungsBetrag, + ); +} + +/** + * Betreff und Text aus der Stufen-Konfiguration, Platzhalter bereits ersetzt. + * + * Der Benutzer soll im Formular den FERTIGEN Text sehen, keine {…}-Marken. + * + * @param MahnungStufe $stufeObj Geladene Stufenkonfiguration + * @param array $werte Platzhalter aus mahnungPlatzhalterWerte() + * @return array{subject:string,body:string,ishtml:int} + */ +function mahnungMailVorlage($stufeObj, $werte) +{ + global $langs; + + // transnoentities() statt trans(): trans() encodiert Umlaute zu HTML-Entities + // ("Grüßen" -> "Grüßen"), was in einer Klartext-Mail sichtbar wäre. + $rawSubject = (string) ($stufeObj->email_subject !== null && $stufeObj->email_subject !== '' + ? $stufeObj->email_subject + : $langs->transnoentities('MahnungErinnerungMailBetreff')); + $rawBody = (string) ($stufeObj->email_body !== null && $stufeObj->email_body !== '' + ? $stufeObj->email_body + : $langs->transnoentities('MahnungErinnerungMailText')); + + // HTML-Modus aus dem ROH-Template ableiten — NICHT aus dem fertigen Text. + // Sonst würde ein Kundenname wie "Muster " den Modus umschalten. + $isHtml = dol_textishtml($rawBody) ? 1 : 0; + + $subject = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter($rawSubject, $werte, 0)); + if ($subject === '') { + // Leerer Betreff landet zuverlässig im Spam — auf die Standardvorlage zurückfallen. + $subject = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter($langs->transnoentities('MahnungErinnerungMailBetreff'), $werte, 0)); + } + + return array( + 'subject' => $subject, + 'body' => mahnungErsetzePlatzhalter($rawBody, $werte, $isHtml), + 'ishtml' => $isHtml, + ); +} + +/** + * Ersetzt die {…}-Platzhalter in Betreff oder Text. + * + * Im HTML-Modus werden die eingesetzten Werte escaped — {kunde} und {rechnung} + * kommen aus der Datenbank und dürfen im HTML-Body keine Tags aufmachen. + * + * Wird zweimal angewandt: beim Vorbelegen des Formulars und noch einmal auf den + * abgeschickten Text. Das ist unschädlich (bereits ersetzte Marken sind weg) und + * löst Platzhalter auf, die der Benutzer im Formular selbst ergänzt hat. + * + * @param string $text Roher Text mit Platzhaltern + * @param array $werte Platzhalter => Wert + * @param int $isHtml 1 = Werte HTML-escapen + * @return string + */ +function mahnungErsetzePlatzhalter($text, $werte, $isHtml) +{ + return strtr((string) $text, $isHtml ? array_map('dol_escape_htmltag', $werte) : $werte); +} + +/** + * Nimmt die Formular-Rundreise wieder aus einem KLARTEXT-Mailtext heraus. + * + * GETPOST(..., 'restricthtml') jagt jeden NICHT-HTML-Text durch dol_nl2br() + * (functions.lib.php, dol_htmlwithnojs) — allein durch das Abschicken des + * Formulars wird aus einem Klartext-Mailtext also HTML mit
-Tags. Das schlägt + * doppelt durch: im Textfeld stehen sichtbar die Tags, und dol_textishtml() stuft + * den Text danach als HTML ein, sodass eine als Klartext gepflegte Erinnerung als + * HTML-Mail rausginge. + * + * Zurückgebaut wird deshalb NUR dieses Artefakt: ein Text, der außer
keine + * Tags enthält, und auch nur dann, wenn die Stufen-Vorlage Klartext ist. Sobald + * echte Formatierung im Spiel ist — der Text wurde im WYSIWYG-Editor bearbeitet + * oder die Vorlage ist von vornherein HTML — bleibt alles unangetastet, sonst + * würde diese Funktion Fettschrift, Listen und Links wieder wegwerfen. + * + * @param string $text Text aus dem Formular + * @param int $vorlageIstHtml 1 = die Stufen-Vorlage ist HTML + * @return string + */ +function mahnungBodyEntkleiden($text, $vorlageIstHtml) +{ + $text = (string) $text; + if (!empty($vorlageIstHtml) || !dol_textishtml($text)) { + return $text; + } + + // Enthält der Text außer
noch andere Tags, ist es echte Formatierung. + $ohneBr = preg_replace('//i', '', $text); + if (strip_tags($ohneBr) !== $ohneBr) { + return $text; + } + + // nl2br() hängt das
VOR den vorhandenen Zeilenumbruch — der folgende + // Umbruch wird deshalb mitgeschluckt, sonst verdoppeln sich die Leerzeilen. + $text = preg_replace('/\r?\n?/i', "\n", $text); + return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); +} + +/** + * Erzwingt einen einzeiligen Mail-Header-Wert. + * + * Betreffzeilen werden aus DB-Werten (Kundenname, Rechnungsreferenz) zusammen- + * gebaut. Ein CR/LF darin könnte in einen zusätzlichen Header umschlagen + * (Header-Injection) — deshalb werden alle Steuerzeichen durch ein Leerzeichen + * ersetzt und Mehrfach-Leerzeichen zusammengefasst. Die Ersetzung arbeitet + * bewusst byteweise ohne /u-Modifier: UTF-8-Folgebytes liegen alle >= 0x80, + * Umlaute bleiben also unangetastet, und ungültiges UTF-8 kann die Regex nicht + * scheitern lassen. + * + * @param string $text Roher Headerwert + * @return string Einzeiliger, getrimmter Headerwert + */ +function mahnungHeaderEinzeilig($text) +{ + $text = preg_replace('/[\x00-\x1F\x7F]+/', ' ', (string) $text); + $text = preg_replace('/ {2,}/', ' ', (string) $text); + return trim((string) $text); +} + + +// --------------------------------------------------------------------------- +// Absender +// --------------------------------------------------------------------------- + +/** + * Absenderadresse der Firma. + * + * Bewusst NICHT die Adresse des angemeldeten Benutzers: eine Zahlungserinnerung + * geht im Namen des Betriebs raus, nicht im Namen dessen, der gerade klickt. + * Deshalb wird der Absender im Formular nur angezeigt und nicht zur Auswahl + * gestellt; ein trotzdem geposteter Wert wird ignoriert. + * + * ?: statt ??, damit ein LEERER Firmen-Mailwert auf die globale Absenderadresse + * zurückfällt. + * + * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) + * @return array{name:string,mail:string,voll:string} + */ +function mahnungAbsender(&$fehler) +{ + global $langs, $mysoc; + + $fehler = ''; + + // Reihenfolge: eigene Absenderadresse des Moduls (Setup) -> Firmenadresse -> + // globale Dolibarr-Absenderadresse. ?: statt ??, damit ein LEERER Wert jeweils + // auf die nächste Stufe durchfällt. + $fromEmail = trim(getDolGlobalString('MAHNUNG_EMAIL_SENDER')) + ?: trim((string) (isset($mysoc->email) ? $mysoc->email : '')) + ?: trim(getDolGlobalString('MAIN_MAIL_EMAIL_FROM')); + if ($fromEmail === '' || !isValidEmail($fromEmail)) { + $fehler = $langs->trans('MahnungMailKeinAbsender'); + return array('name' => '', 'mail' => '', 'voll' => ''); + } + + // Anzeigename: eigener Name aus dem Setup, sonst der Firmenname. + // mahnungHeaderEinzeilig() zuerst — siehe mahnungEmpfaengerAdresse(): CR/LF im + // Namen würde dol_string_nospecial() stehen lassen. + $fromNameRoh = trim(getDolGlobalString('MAHNUNG_EMAIL_SENDER_NAME')) + ?: (string) (isset($mysoc->name) ? $mysoc->name : ''); + $fromName = trim(dol_string_nospecial(mahnungHeaderEinzeilig($fromNameRoh), ' ', array(',', ';', '<', '>', '"'))); + + return array( + 'name' => $fromName, + 'mail' => $fromEmail, + 'voll' => ($fromName !== '' ? $fromName.' <'.$fromEmail.'>' : $fromEmail), + ); +} + + +// --------------------------------------------------------------------------- +// Versand +// --------------------------------------------------------------------------- + +/** + * Statuswächter + fachliche Sperre. + * + * @param Mahnung $mahnung Geladener Mahnvorgang + * @param ?MahnungStufe $stufeObj Geladene Stufenkonfiguration + * @param bool $force Erneuter Versand einer bereits versendeten Erinnerung + * @param string $fehler Rückgabe: Grund der Ablehnung + * @return bool true = Versand erlaubt + */ +function mahnungVersandErlaubt($mahnung, $stufeObj, $force, &$fehler) +{ + global $langs; + + $fehler = ''; + + // Hinweis: $langs->trans() jagt den fertigen Text inkl. der eingesetzten + // Parameter durch htmlentities(). Die aus der DB stammenden Werte deshalb + // NICHT zusätzlich mit dol_escape_htmltag() behandeln — doppeltes Encoding. + $status = (int) $mahnung->status; + if ($status === Mahnung::STATUS_STORNIERT) { + $fehler = $langs->trans('MahnungMailStatusStorniert', $mahnung->ref); + return false; + } + if ($status === Mahnung::STATUS_ERLEDIGT) { + $fehler = $langs->trans('MahnungMailStatusErledigt', $mahnung->ref); + return false; + } + if ($status >= Mahnung::STATUS_VERSENDET && !$force) { + $fehler = $langs->trans( + 'MahnungMailBereitsVersendet', + $mahnung->ref, + $mahnung->date_versand ? dol_print_date($mahnung->date_versand, 'dayhour') : '-' + ); + return false; + } + + // Bewusste fachliche Sperre: echte Mahnungen werden per Post/Einschreiben + // versendet, weil eine E-Mail nicht beweisbar zugestellt ist. + if (!is_object($stufeObj) || !$stufeObj->istErinnerung()) { + $fehler = $langs->trans('MahnungMailNurErinnerung'); + return false; + } + + return true; +} + +/** + * Verschickt die Zahlungserinnerung — der EINZIGE Weg, auf dem dieses Modul + * eine Erinnerungsmail versendet. + * + * Ablauf: Statuswächter -> atomare Versand-Reservierung -> CMailFile -> + * bei Sendefehler Reservierung zurücknehmen. + * + * @param Mahnung $mahnung Geladener Mahnvorgang (wird bei Erfolg mitgeführt) + * @param MahnungStufe $stufeObj Geladene Stufenkonfiguration + * @param array $mail to, cc, bcc, from, subject, body, ishtml, + * paths, names, mimes, deliveryreceipt, + * trackid, force, frist + * @param User $user Bearbeitender Benutzer + * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) + * @return bool true = Mail ist raus + */ +function mahnungSendeErinnerungsMail($mahnung, $stufeObj, $mail, $user, &$fehler) +{ + global $langs; + + $fehler = ''; + $force = !empty($mail['force']); + + if (!mahnungVersandErlaubt($mahnung, $stufeObj, $force, $fehler)) { + return false; + } + + if (trim((string) $mail['to']) === '') { + $fehler = $langs->trans('MahnungMailKeinEmpfaenger'); + return false; + } + if (trim((string) $mail['from']) === '') { + $fehler = $langs->trans('MahnungMailKeinAbsender'); + return false; + } + + // ----------------------------------------------------------------------- + // Versand ATOMAR reservieren — zwingend VOR dem Senden + // ----------------------------------------------------------------------- + // Zwischen dem Statuswächter und dem Versand liegen mehrere DB- und + // Dateizugriffe (Stufe, Kunde, Rechnung, ggf. PDF-Erzeugung). Ohne Sperre + // kämen zwei parallele Klicks beide durch und die Erinnerung ginge doppelt + // raus. Deshalb wird der Vorgang JETZT per bedingtem UPDATE auf VERSENDET + // gesetzt: senden darf nur der Request, dessen UPDATE tatsächlich eine Zeile + // getroffen hat. Weil die Statusfortschreibung damit VOR dem Versand liegt, + // kann ein erfolgreicher Versand auch nicht mehr durch ein fehlgeschlagenes + // UPDATE "unsichtbar" werden. Die umgekehrte Richtung (Status gesetzt, Mail + // nicht raus) wird unten sauber zurückgerollt. + // + // Mitgeschrieben wird derselbe offene Betrag, den die Mail nennt (siehe + // mahnungRechnungNochOffen()) — sonst stünde in der Karte weiter der Stand + // vom Anlagezeitpunkt. + $statusVorVersand = (int) $mahnung->status; + $dateVersandVorher = $mahnung->date_versand; + $versandwegVorher = $mahnung->versandweg; + $fristVorher = $mahnung->date_lim_reglement_neu; + $reserviertAm = dol_now(); + $neueFrist = !empty($mail['frist']) ? (int) $mail['frist'] : $fristVorher; + + $sperrFehler = ''; + if (!mahnungReserviereVersand($mahnung, $statusVorVersand, $force, $reserviertAm, $neueFrist, (int) $user->id, $sperrFehler)) { + if ($sperrFehler !== '') { + dol_syslog('mahnung/sendmail: Versand-Reservierung fehlgeschlagen: '.$sperrFehler, LOG_ERR); + $fehler = $langs->trans('MahnungMailVersandSperreFehler', $sperrFehler); + return false; + } + // Kein SQL-Fehler, aber keine Zeile getroffen: ein paralleler Request war + // schneller oder der Vorgang wurde zwischenzeitlich verändert (Storno, + // Zahlungseingang). In beiden Fällen wird bewusst NICHT gesendet. + dol_syslog('mahnung/sendmail: Versand von '.$mahnung->ref.' bereits reserviert — kein zweiter Versand', LOG_WARNING); + $fehler = $langs->trans('MahnungMailVersandLaeuft', $mahnung->ref); + return false; + } + + // Reservierung steht — das geladene Objekt auf denselben Stand bringen. + $mahnung->status = Mahnung::STATUS_VERSENDET; + $mahnung->date_versand = $reserviertAm; + $mahnung->versandweg = 'email'; + $mahnung->date_lim_reglement_neu = $neueFrist; + + // ----------------------------------------------------------------------- + // Versand + // ----------------------------------------------------------------------- + $mailFile = new CMailFile( + (string) $mail['subject'], + (string) $mail['to'], + (string) $mail['from'], + (string) $mail['body'], + isset($mail['paths']) && is_array($mail['paths']) ? $mail['paths'] : array(), + isset($mail['mimes']) && is_array($mail['mimes']) ? $mail['mimes'] : array(), + isset($mail['names']) && is_array($mail['names']) ? $mail['names'] : array(), + (string) (isset($mail['cc']) ? $mail['cc'] : ''), + (string) (isset($mail['bcc']) ? $mail['bcc'] : ''), + empty($mail['deliveryreceipt']) ? 0 : 1, + (int) $mail['ishtml'], + '', + '', + (string) (isset($mail['trackid']) ? $mail['trackid'] : '') + ); + + // Ab hier ist der Vorgang reserviert: jeder Fehlerausstieg muss die + // Reservierung wieder zurücknehmen, sonst gälte die Erinnerung als versendet, + // ohne dass sie raus ist. + $sendeFehler = ''; + if (!empty($mailFile->error) || !empty($mailFile->errors)) { + $sendeFehler = !empty($mailFile->error) ? $mailFile->error : implode(', ', (array) $mailFile->errors); + } elseif (!$mailFile->sendfile()) { + $sendeFehler = !empty($mailFile->error) ? $mailFile->error : implode(', ', (array) $mailFile->errors); + if (trim($sendeFehler) === '') { + // sendfile() meldet nicht immer einen Text — der häufigste stille Grund + // ist die global abgeschaltete Mailfunktion. + $sendeFehler = getDolGlobalString('MAIN_DISABLE_ALL_MAILS') + ? 'MAIN_DISABLE_ALL_MAILS' + : $langs->transnoentities('MahnungMailUnbekannterFehler'); + } + } + + if ($sendeFehler !== '') { + $fehler = $langs->trans('MahnungEmailFehlgeschlagen', $sendeFehler); + + $rueckFehler = ''; + if (!mahnungVersandZuruecknehmen($mahnung, $statusVorVersand, $dateVersandVorher, $versandwegVorher, $fristVorher, $reserviertAm, $rueckFehler)) { + // Status steht jetzt auf VERSENDET, obwohl nichts raus ist — das muss der + // Benutzer erfahren, sonst hält er die Erinnerung für zugestellt. + dol_syslog('mahnung/sendmail: Ruecknahme der Versand-Reservierung fehlgeschlagen: '.$rueckFehler, LOG_ERR); + $fehler .= ' '.$langs->trans('MahnungMailStatusNichtZurueckgesetzt', $rueckFehler); + } else { + // Objekt wieder auf den Stand vor der Reservierung bringen, damit die + // Karte nach dem Fehlschlag nicht "versendet" anzeigt. + $mahnung->status = $statusVorVersand; + $mahnung->date_versand = $dateVersandVorher; + $mahnung->versandweg = $versandwegVorher; + $mahnung->date_lim_reglement_neu = $fristVorher; + } + return false; + } + + // Festhalten, was tatsächlich rausgegangen ist. Bewusst NACH dem erfolgreichen + // Versand: protokolliert wird nur, was den Mailserver auch verlassen hat. + // Ein Fehler beim Protokollieren darf den Versand nicht nachträglich als + // gescheitert erscheinen lassen — er landet im Syslog, mehr nicht. + mahnungProtokolliereVersand($mahnung, $mail, $user, $reserviertAm); + + // Fertig — Status/Versanddaten stehen bereits aus der Reservierung in der DB, + // pdf_path bleibt bewusst leer (kein Mahn-PDF). + return true; +} + +/** + * Schreibt einen versendeten Mailtext ins Protokoll. + * + * Gespeichert wird der Stand zum Sendezeitpunkt — ändert jemand später die + * Vorlage in der Stufen-Konfiguration, bleibt der Nachweis davon unberührt. + * + * @param Mahnung $mahnung Geladener Mahnvorgang + * @param array $mail Dieselben Werte, die an CMailFile gingen + * @param User $user Absendender Benutzer + * @param int $jetzt Versandzeitpunkt (Unix-Zeit) + * @return bool true = protokolliert + */ +function mahnungProtokolliereVersand($mahnung, $mail, $user, $jetzt) +{ + global $db, $conf; + + $namen = (isset($mail['names']) && is_array($mail['names'])) ? $mail['names'] : array(); + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mailprotokoll"; + $sql .= " (entity, fk_mahnung, date_versand, mail_from, mail_to, mail_cc, mail_bcc,"; + $sql .= " subject, body, ishtml, anhaenge, fk_user, datec) VALUES ("; + $sql .= ((int) $conf->entity); + $sql .= ", ".((int) $mahnung->id); + $sql .= ", '".$db->idate($jetzt)."'"; + $sql .= ", '".$db->escape((string) (isset($mail['from']) ? $mail['from'] : ''))."'"; + $sql .= ", '".$db->escape((string) (isset($mail['to']) ? $mail['to'] : ''))."'"; + $sql .= ", '".$db->escape((string) (isset($mail['cc']) ? $mail['cc'] : ''))."'"; + $sql .= ", '".$db->escape((string) (isset($mail['bcc']) ? $mail['bcc'] : ''))."'"; + // Spalte ist VARCHAR(255) — ein überlanger Betreff darf den INSERT nicht kippen. + $sql .= ", '".$db->escape(dol_trunc((string) (isset($mail['subject']) ? $mail['subject'] : ''), 250, 'right', 'UTF-8', 1))."'"; + $sql .= ", '".$db->escape((string) (isset($mail['body']) ? $mail['body'] : ''))."'"; + $sql .= ", ".(empty($mail['ishtml']) ? 0 : 1); + $sql .= ", '".$db->escape(implode('; ', $namen))."'"; + $sql .= ", ".((int) $user->id); + $sql .= ", '".$db->idate(dol_now())."'"; + $sql .= ")"; + + if (!$db->query($sql)) { + dol_syslog('mahnung/sendmail: Mailprotokoll konnte nicht geschrieben werden: '.$db->lasterror(), LOG_ERR); + return false; + } + return true; +} + +/** + * Liest das Versandprotokoll eines Mahnvorgangs, neuester Versand zuerst. + * + * @param int $mahnungId Mahnvorgang + * @return array + */ +function mahnungHoleMailProtokoll($mahnungId) +{ + global $db; + + $eintraege = array(); + + $sql = "SELECT rowid, date_versand, mail_from, mail_to, mail_cc, mail_bcc,"; + $sql .= " subject, body, ishtml, anhaenge, fk_user"; + $sql .= " FROM ".MAIN_DB_PREFIX."mahnung_mailprotokoll"; + $sql .= " WHERE fk_mahnung = ".((int) $mahnungId); + $sql .= " AND entity IN (".getEntity('mahnung').")"; + $sql .= " ORDER BY date_versand DESC, rowid DESC"; + + $resql = $db->query($sql); + if (!$resql) { + // Fehlt die Tabelle (Deploy ohne Migration), ist das kein Grund, die Karte + // scheitern zu lassen — der Block bleibt dann einfach leer. + dol_syslog('mahnung: Mailprotokoll nicht lesbar: '.$db->lasterror(), LOG_WARNING); + return $eintraege; + } + + while ($obj = $db->fetch_object($resql)) { + $obj->date_versand = $db->jdate($obj->date_versand); + $eintraege[] = $obj; + } + $db->free($resql); + + return $eintraege; +} + +/** + * Reserviert den Versand atomar. + * + * Setzt Status, Versanddatum, Versandweg, die neu berechnete Zahlungsfrist sowie + * den frisch bestimmten offenen Betrag per bedingtem UPDATE und liefert nur dann + * true, wenn genau eine Zeile getroffen wurde. Damit kann von zwei parallelen + * Requests nur einer senden. + * + * betrag_offen/summe_mahnung kommen direkt aus dem übergebenen Objekt — sie + * wurden dort bereits nachgezogen (mahnungRechnungNochOffen()), damit Mail und + * Datensatz denselben Betrag nennen. + * + * @param Mahnung $mahnung Geladener Mahnvorgang (liefert die Ausgangswerte) + * @param int $status Status, der im Wächter oben gelesen wurde + * @param bool $force Erneuter Versand einer bereits versendeten Erinnerung + * @param int $jetzt Versandzeitpunkt (Unix-Zeit) + * @param int $neueFrist Neu berechnete Zahlungsfrist (Unix-Zeit) + * @param int $userId Bearbeitender Benutzer + * @param string $fehler Rückgabe: DB-Fehlertext ('' = kein SQL-Fehler) + * @return bool true = reserviert, false = nicht senden + */ +function mahnungReserviereVersand($mahnung, $status, $force, $jetzt, $neueFrist, $userId, &$fehler) +{ + global $db; + + $fehler = ''; + + $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET"; + $sql .= " status = ".((int) Mahnung::STATUS_VERSENDET); + $sql .= ", date_versand = '".$db->idate($jetzt)."'"; + $sql .= ", versandweg = 'email'"; + $sql .= ", date_lim_reglement_neu = ".($neueFrist ? "'".$db->idate($neueFrist)."'" : "NULL"); + $sql .= ", betrag_offen = ".((float) $mahnung->betrag_offen); + $sql .= ", summe_mahnung = ".((float) $mahnung->summe_mahnung); + $sql .= ", fk_user_modif = ".((int) $userId); + $sql .= " WHERE rowid = ".((int) $mahnung->id); + $sql .= " AND entity IN (".getEntity('mahnung').")"; + // Exakt der Status, der oben geprüft wurde. Hat ihn zwischenzeitlich jemand + // verändert (paralleler Versand, Storno, Zahlungseingang), greift die + // Bedingung nicht mehr — und es wird nicht gesendet. + $sql .= " AND status = ".((int) $status); + if ($force) { + // Beim erzwungenen erneuten Versand bleibt der Status gleich (VERSENDET), + // die Statusbedingung allein ließe also beide Parallelklicks durch. + // Deshalb zusätzlich das bisherige Versanddatum festnageln. + $sql .= " AND date_versand ".($mahnung->date_versand ? "= '".$db->idate($mahnung->date_versand)."'" : "IS NULL"); + } + + dol_syslog('mahnung/sendmail: Versand reservieren fuer Mahnung '.((int) $mahnung->id), LOG_DEBUG); + $resql = $db->query($sql); + if (!$resql) { + $fehler = $db->lasterror(); + return false; + } + + // Hinweis: MySQL meldet 0 betroffene Zeilen auch dann, wenn die WHERE-Bedingung + // passt, sich aber kein einziger Wert ändert. Praktisch nur beim erzwungenen + // erneuten Versand innerhalb derselben Sekunde möglich — dann wird der Versand + // abgelehnt, was die sichere Richtung ist. + return ((int) $db->affected_rows($resql) === 1); +} + +/** + * Nimmt eine Versand-Reservierung zurück, wenn die Mail doch nicht rausging. + * + * Zurückgesetzt wird ausschließlich die EIGENE Reservierung (Status VERSENDET + * mit genau unserem Versandzeitpunkt) — ein zwischenzeitlich erfolgreicher + * anderer Vorgang darf nicht überschrieben werden. + * + * betrag_offen/summe_mahnung bleiben bewusst auf dem nachgezogenen Stand: der + * Kunde hat tatsächlich (teil)gezahlt, das ist auch dann richtig, wenn die Mail + * nicht rausging. + * + * @param Mahnung $mahnung Geladener Mahnvorgang + * @param int $status Status vor der Reservierung + * @param int|null $dateVersandAlt Versanddatum vor der Reservierung + * @param string|null $versandwegAlt Versandweg vor der Reservierung + * @param int|null $fristAlt Zahlungsfrist vor der Reservierung + * @param int $reserviertAm Unser Versandzeitpunkt (Unix-Zeit) + * @param string $fehler Rückgabe: Fehlertext ('' = alles gut) + * @return bool true = zurückgesetzt + */ +function mahnungVersandZuruecknehmen($mahnung, $status, $dateVersandAlt, $versandwegAlt, $fristAlt, $reserviertAm, &$fehler) +{ + global $db; + + $fehler = ''; + + $sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET"; + $sql .= " status = ".((int) $status); + $sql .= ", date_versand = ".($dateVersandAlt ? "'".$db->idate($dateVersandAlt)."'" : "NULL"); + $sql .= ", versandweg = ".($versandwegAlt ? "'".$db->escape($versandwegAlt)."'" : "NULL"); + $sql .= ", date_lim_reglement_neu = ".($fristAlt ? "'".$db->idate($fristAlt)."'" : "NULL"); + $sql .= " WHERE rowid = ".((int) $mahnung->id); + $sql .= " AND entity IN (".getEntity('mahnung').")"; + $sql .= " AND status = ".((int) Mahnung::STATUS_VERSENDET); + $sql .= " AND date_versand = '".$db->idate($reserviertAm)."'"; + + dol_syslog('mahnung/sendmail: Versand-Reservierung zuruecknehmen fuer Mahnung '.((int) $mahnung->id), LOG_DEBUG); + $resql = $db->query($sql); + if (!$resql) { + $fehler = $db->lasterror(); + return false; + } + if ((int) $db->affected_rows($resql) !== 1) { + $fehler = 'Datensatz wurde zwischenzeitlich veraendert'; + return false; + } + return true; } diff --git a/card.php b/card.php index 91c3568..c658637 100644 --- a/card.php +++ b/card.php @@ -38,8 +38,15 @@ if (!$res) { require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php'; global $langs, $user, $db; + +// Schema nach einem reinen Datei-Deploy nachziehen — ohne die Spalte +// kosten_vorstufen scheitert sonst jedes update() auf dieser Karte. +modMahnung::ensureSchema($db); + $langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills')); if (!$user->hasRight('mahnung', 'read')) { @@ -48,6 +55,9 @@ if (!$user->hasRight('mahnung', 'read')) { $id = GETPOSTINT('id'); $action = GETPOST('action', 'aZ09'); +// Antwort aus einem formconfirm-Dialog ('yes' | 'no'). Ohne diese Auswertung würde +// auch ein "Nein" die bestätigungspflichtige Aktion ausführen. +$confirm = GETPOST('confirm', 'alpha'); $mahnung = new Mahnung($db); if ($mahnung->fetch($id) <= 0) { @@ -55,36 +65,176 @@ if ($mahnung->fetch($id) <= 0) { exit; } -// Verzugszinsen für noch nicht versandte Mahnungen mit aktueller Konfiguration neu berechnen -if ((int) $mahnung->status <= Mahnung::STATUS_ERSTELLT && !empty($mahnung->fk_facture)) { - require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; - $_stufe = new MahnungStufe($db); - if ($_stufe->fetchByStufe((int) $mahnung->stufe) > 0) { - $_basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); - $_override = $_stufe->getZinssatzOverride($mahnung->customertype); - // Verzugstage: Differenz Mahndatum - Original-Fälligkeit - $_tageVerzug = 0; - if (!empty($mahnung->datec) && !empty($mahnung->date_lim_reglement_alt)) { - $_tageVerzug = max(0, (int) round(($mahnung->datec - $mahnung->date_lim_reglement_alt) / 86400)); - } - $_neueZinsen = Mahnung::berechneVerzugszinsen( - $mahnung->betrag_offen, - $_tageVerzug, - $mahnung->customertype, - $_basiszins, - $_override - ); - if (abs((float) $mahnung->verzugszinsen - $_neueZinsen) > 0.001) { - $mahnung->verzugszinsen = $_neueZinsen; - $mahnung->basiszins_snapshot = $_basiszins; - $mahnung->rechneSumme(); +// Externe Benutzer (Kundenkontakte mit eigenem Dolibarr-Zugang) dürfen nur die +// Mahnvorgänge ihrer eigenen Firma sehen. Mahnung::fetch() filtert bloß nach Entity — +// ohne diese Prüfung genügt das Durchzählen der id, um fremde Forderungen samt +// Beträgen zu lesen. +if ((int) $user->socid > 0 && (int) $user->socid !== (int) $mahnung->fk_soc) { + accessforbidden(); +} + +$canWrite = $user->hasRight('mahnung', 'write'); + +// --- CSRF-Absicherung aller zustandsändernden Aktionen ---------------------- +// Der Core-Check aus main.inc.php greift auf dieser Installation NICHT: in der +// conf.php steht $dolibarr_nocsrfcheck=1, zusätzlich ist +// MAIN_SECURITY_CSRF_WITH_TOKEN=0. Der komplette Prüfblock ab main.inc.php:333 +// wird damit übersprungen. Ohne eigene Prüfung genügt deshalb ein Aufruf wie +// card.php?id=42&action=send&… von einer fremden Seite aus, um im eingeloggten +// Kontext eine Aktion auszulösen — beim Mailversand ginge die Zahlungserinnerung +// raus, ohne dass jemand das Formular je gesehen hat. +// +// Verglichen wird gegen BEIDE Session-Slots: main.inc.php rollt das Token bei +// jedem Seitenaufruf ($_SESSION['token'] = das bisherige $_SESSION['newtoken']). +// Ohne MAIN_SECURITY_CSRF_TOKEN_RENEWAL_ON_EACH_CALL sind beide identisch; mit +// der Option landet das mitgeschickte Token in 'token', während 'newtoken' schon +// neu gewürfelt ist. Eine Prüfung nur gegen 'newtoken' würde bei aktivierter +// Option sämtliche Aktionen der Seite blockieren. +// hash_equals() statt ===: konstante Laufzeit, kein Timing-Orakel. +$tokenGesendet = (string) GETPOST('token', 'alphanohtml'); +$tokenOk = ($tokenGesendet !== '' && $tokenGesendet !== 'notrequired'); +if ($tokenOk) { + $tokenOk = ((!empty($_SESSION['token']) && hash_equals((string) $_SESSION['token'], $tokenGesendet)) + || (!empty($_SESSION['newtoken']) && hash_equals((string) $_SESSION['newtoken'], $tokenGesendet))); +} + +// Aktionen mit Seiteneffekt. Reine Anzeige-Actions (edit_versand, confirm_storno, +// ask_uneinbringlich) stehen bewusst NICHT in der Liste: die rendern nur ein +// Formular bzw. einen Bestätigungsdialog und werden teils über Links ohne Token +// aufgerufen. +// +// Bewusst KEIN zusätzlicher Zwang auf REQUEST_METHOD=POST: $form->formconfirm() +// liefert das "Ja" bei aktivem JavaScript per GET aus — $postconfirmas='GET' ist +// in html.form.class.php fest verdrahtet, der Dialog navigiert per location.href. +// Eine POST-Pflicht würde Storno, Uneinbringlich-Klassifikation und den +// Erinnerungsversand komplett lahmlegen. Das Token wird auf beiden Wegen +// mitgeschickt (ajax-Dialog: options="token=…", HTML-Fallback: hidden field) und +// ist für sich genommen ausreichend — erraten kann es ein Angreifer nicht. +$actionsMitToken = array( + 'storno', + 'regenerate_pdf', + 'delete_doc', + 'set_versand', + 'confirm_uneinbringlich', + 'apply_tracking', + 'dismiss_tracking', + 'scan_belege', + 'clear_versand', + // Mailformular: 'send' deckt Absenden, Anhang-Upload und Anhang-Entfernen ab — + // alle drei posten dasselbe Formular und tragen dessen Token. + 'send', + // 'presend' rendert zwar nur das Formular, legt dabei aber die Original-Rechnungs- + // PDF als Anhang bereit — und erzeugt sie über Facture::generateDocument() neu, + // falls sie fehlt. Damit ist es keine reine Anzeige-Action mehr. + 'presend', + // Core-Dateiaktionen aus core/actions_linkedfiles.inc.php (Sendebelege): das + // Include verarbeitet sie selbst und prüft dabei nur $permissiontoadd, kein Token. + // Ohne diese Einträge könnte ein untergeschobener GET-Aufruf einen hochgeladenen + // Zustellbeleg löschen oder umbenennen. + 'confirm_deletefile', + 'confirm_updateline', + 'renamefile', +); + +// 'sendit' und 'linkit' (ebenfalls aus actions_linkedfiles.inc.php) hängen an eigenen +// Parametern statt an $action und lassen sich über die Action-Liste nicht erfassen. +if (GETPOST('sendit', 'alpha') || GETPOST('linkit', 'alpha') || GETPOST('renamefilesave', 'alpha')) { + $actionsMitToken[] = $action; +} +if (in_array($action, $actionsMitToken, true) && !$tokenOk) { + setEventMessages($langs->trans('MahnungCsrfTokenUngueltig'), null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id)); + exit; +} + +// Stufen-Konfiguration laden. Ob es sich um die kostenlose Zahlungserinnerung +// handelt, entscheidet ausschließlich das Flag ist_erinnerung — NICHT die +// Stufennummer. Eine Erinnerung kostet nichts (keine Mahngebühr, keine Pauschale +// nach §288 Abs. 5, keine Verzugszinsen), hat kein eigenes Mahn-PDF und geht +// ausschließlich per E-Mail mit der Original-Rechnung im Anhang raus. +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; +$stufeObj = new MahnungStufe($db); +$stufeGeladen = ($stufeObj->fetchByStufe((int) $mahnung->stufe) > 0); +// Rohes Flag der Stufe: gilt für die Stufen-KONFIGURATION, nicht für den Vorgang. +$stufeIstErinnerung = ($stufeGeladen && $stufeObj->istErinnerung()); + +// Tatsächlich gespeicherte Kostenbestandteile dieses Vorgangs. +// Das Erinnerungs-Flag hängt an der AKTUELLEN Stufenkonfiguration, der Mahnvorgang +// selbst hat dafür (noch) kein eigenes Snapshot-Feld. Setzt jemand ist_erinnerung +// nachträglich auf eine bereits benutzte Stufe, würden ALTE Vorgänge mit echten +// Gebühren rückwirkend als "kostenlos" dargestellt und wären plötzlich per Mail +// versendbar — obwohl im längst erzeugten PDF Beträge stehen. +$hatGespeicherteKosten = (abs((float) $mahnung->mahngebuehr) > 0.001 + || abs((float) $mahnung->pauschale_b2b) > 0.001 + || abs((float) $mahnung->verzugszinsen) > 0.001 + || abs((float) $mahnung->kosten_vorstufen) > 0.001); + +// Maßgeblich für die gesamte Karte: als kostenlose Erinnerung gilt ein Vorgang nur, +// wenn Stufen-Flag UND gespeicherte Beträge zusammenpassen. Die Zusatzprüfung verengt +// die Erkennung ausschließlich — eine echte Erinnerung hat über mahnungBaueVorgang() +// immer 0,00 in allen vier Feldern, eine echte Mahnstufe wird dadurch nie fälschlich +// zur Erinnerung. Den umgekehrten Fall (Flag wird von einer Stufe wieder entfernt) +// kann diese Heuristik nicht abdecken, dafür braucht es ein Snapshot-Feld am Vorgang. +$istErinnerung = ($stufeIstErinnerung && !$hatGespeicherteKosten); + +// Ende des Eskalationspfads datengetrieben bestimmen — es gibt keine feste Obergrenze +// bei Stufe 3 mehr. Maßgeblich ist, ob nach der Stufe dieses Vorgangs überhaupt noch +// eine aktive Stufe konfiguriert ist. +// naechsteStufeNach() liefert null auch dann, wenn die Stufenkonfiguration gar nicht +// ladbar ist (SQL-Fehler oder keine aktive Stufe). Deshalb wird zusätzlich geprüft, +// dass überhaupt Stufen geladen wurden: ein Konfigurationsfehler darf den praktisch +// unwiderruflichen "Uneinbringlich"-Button nicht freischalten. +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php'; +$stufenService = new MahnungVorschlag($db); +$stufenKonfiguriert = (count($stufenService->getAlleStufen()) > 0); +$istLetzteStufe = ($stufenKonfiguriert && $stufenService->naechsteStufeNach((int) $mahnung->stufe) === null); + +// Verzugszinsen mit der aktuellen Konfiguration neu berechnen — aber NUR solange noch +// kein Dokument erzeugt wurde. +// Begründung für die Einschränkung: sobald pdf_path gesetzt ist, existiert ein +// Mahnschreiben mit festen Beträgen, das beim Kunden liegen kann. Der Block schreibt +// bei JEDEM Kartenaufruf; jede Änderung an der Berechnung (z.B. round() -> floor() bei +// den Verzugstagen) oder ein nachträglich gepflegter Zinssatz-Override würde +// Bestandsdaten rückwirkend verbiegen, bis DB und versendetes PDF auseinanderlaufen. +// Status ENTWURF bleibt zusätzlich erlaubt: ein Entwurf ist ausdrücklich noch nicht +// final, ein dort erzeugtes PDF ist nur Vorschau und wird vor dem Versand ohnehin neu +// gebaut. Bei einer Zahlungserinnerung entfällt die Neuberechnung komplett — die +// kostet nichts. +$neuberechnungErlaubt = (empty($mahnung->pdf_path) || (int) $mahnung->status === Mahnung::STATUS_ENTWURF); +if (!$istErinnerung && $stufeGeladen && $neuberechnungErlaubt + && (int) $mahnung->status <= Mahnung::STATUS_ERSTELLT && !empty($mahnung->fk_facture)) { + $_basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27'); + $_override = $stufeObj->getZinssatzOverride($mahnung->customertype); + // Verzugstage: Differenz Mahndatum - Original-Fälligkeit. + // floor() statt round(): angefangene Tage zählen nicht mit — konsistent zu + // MahnungVorschlag und ajax/createmahnung.php. Mit round() wich die hier + // angezeigte Berechnung sonst um bis zu einen Tag von der beim Anlegen ab. + $_tageVerzug = 0; + if (!empty($mahnung->datec) && !empty($mahnung->date_lim_reglement_alt)) { + $_tageVerzug = max(0, (int) floor(($mahnung->datec - $mahnung->date_lim_reglement_alt) / 86400)); + } + $_neueZinsen = Mahnung::berechneVerzugszinsen( + $mahnung->betrag_offen, + $_tageVerzug, + $mahnung->customertype, + $_basiszins, + $_override + ); + if (abs((float) $mahnung->verzugszinsen - $_neueZinsen) > 0.001) { + $mahnung->verzugszinsen = $_neueZinsen; + $mahnung->basiszins_snapshot = $_basiszins; + $mahnung->rechneSumme(); + // Nur mit Schreibrecht tatsächlich speichern: ein reiner Leser ruft die Karte + // per GET auf und darf dabei keinen DB-Write auslösen. Ohne Recht bleibt die + // Neuberechnung rein in-memory und dient nur der Anzeige. + if ($canWrite) { $mahnung->update($user); } } } -// Stornieren -if ($action === 'storno' && $user->hasRight('mahnung', 'delete')) { +// Stornieren — nur nach ausdrücklicher Bestätigung im formconfirm-Dialog +if ($action === 'storno' && $confirm === 'yes' && $user->hasRight('mahnung', 'delete')) { $mahnung->status = Mahnung::STATUS_STORNIERT; if ($mahnung->update($user) > 0) { setEventMessages($langs->trans('MahnungStornieren').' OK', null, 'mesgs'); @@ -95,13 +245,20 @@ if ($action === 'storno' && $user->hasRight('mahnung', 'delete')) { } // Dokument neu generieren -if ($action === 'regenerate_pdf' && $user->hasRight('mahnung', 'write')) { - $selectedModel = GETPOST('model', 'alphanohtml'); - $result = $mahnung->generateDocument($selectedModel, $langs); - if ($result > 0) { - setEventMessages($langs->trans('MahnungDokumentErstellt'), null, 'mesgs'); +if ($action === 'regenerate_pdf' && $canWrite) { + if ($istErinnerung) { + // Zahlungserinnerungen haben kein eigenes Mahn-PDF — Anhang ist die + // unveränderte Original-Rechnung. Button ist ausgeblendet, der Aufruf per + // URL wird hier zusätzlich abgefangen. + setEventMessages($langs->trans('MahnungErinnerungKeinPdf'), null, 'errors'); } else { - setEventMessages($langs->trans('MahnungDokumentFehler').': '.$mahnung->error, null, 'errors'); + $selectedModel = GETPOST('model', 'alphanohtml'); + $result = $mahnung->generateDocument($selectedModel, $langs); + if ($result > 0) { + setEventMessages($langs->trans('MahnungDokumentErstellt'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('MahnungDokumentFehler').': '.$mahnung->error, null, 'errors'); + } } header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id)); exit; @@ -154,8 +311,21 @@ if ($action === 'set_versand' && $user->hasRight('mahnung', 'write')) { // Rechnung als uneinbringlich klassifizieren (close_code='badcustomer') // — endgültiger Schritt nach erfolglosem Mahnverfahren / Vollstreckung. -if ($action === 'confirm_uneinbringlich' && $user->hasRight('mahnung', 'delete')) { +// Zwingend nur mit ausdrücklichem "Ja" aus dem formconfirm-Dialog: der Schritt +// ist praktisch unwiderruflich. +if ($action === 'confirm_uneinbringlich' && $confirm === 'yes' && $user->hasRight('mahnung', 'delete')) { require_once DOL_DOCUMENT_ROOT.'/core/class/commoninvoice.class.php'; + + // Der Schritt schreibt auf der RECHNUNG (fk_statut=3, close_code='badcustomer') + // und ist buchhalterisch praktisch unwiderruflich. Ein Mahnungs-Recht allein + // reicht dafür nicht — wer keine Rechnungen ändern darf, darf auch keine + // abschreiben. + if (!$user->hasRight('facture', 'creer')) { + setEventMessages($langs->trans('MahnungUneinbringlichKeinRechnungsrecht'), null, 'errors'); + header('Location: '.$_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id)); + exit; + } + $note = trim((string) GETPOST('uneinbringlich_note', 'nohtml')); if ($note === '') { $note = $langs->trans('MahnungVerfahrenErfolglos').' '.dol_print_date(dol_now(), 'day'); @@ -311,6 +481,339 @@ if ($action === 'clear_versand' && $user->hasRight('mahnung', 'write')) { exit; } +// --- Kontext für die Zahlungserinnerung (Empfänger, Rechnung, Anhang) --- +// Rechnung und Kunde werden hier einmal geladen und weiter unten nur noch angezeigt. +$facture = new Facture($db); +$factureGeladen = ($facture->fetch((int) $mahnung->fk_facture) > 0); + +$societe = new Societe($db); +$societeGeladen = ($societe->fetch((int) $mahnung->fk_soc) > 0); + +$kundeEmail = $societeGeladen ? trim((string) $societe->email) : ''; + +// Dateiname der Original-Rechnungs-PDF, die als Anhang mitgeht. Ermittelt und bei +// Bedarf erzeugt wird sie in mahnungHoleRechnungsPdf(); hier dient der Name nur dem +// Hinweistext in der Dokumenten-Sektion, damit auf der Karte sichtbar ist, was bei +// einer Zahlungserinnerung statt eines Mahn-PDFs rausgeht. +$anhangDatei = ''; +if ($factureGeladen) { + $anhangDatei = !empty($facture->last_main_doc) + ? basename($facture->last_main_doc) + : dol_sanitizeFileName($facture->ref).'.pdf'; +} + +// Funktionsbibliothek des Mailversands — liefert unter anderem die Empfängerliste, +// die schon für die Frage gebraucht wird, ob überhaupt versendet werden kann. +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/ajax/sendmail.php'; + +// Versand der Zahlungserinnerung ist nur erlaubt, wenn es wirklich eine Erinnerung +// ist, ein Empfänger existiert und das Recht passt. +// $istErinnerung schließt Vorgänge mit gespeicherten Gebühren/Zinsen aus: für die liegt +// ein Mahnschreiben mit Beträgen vor, das darf nicht als "freundliche Erinnerung" mit +// der blanken Original-Rechnung rausgehen. +// +// Ein bereits versendeter Vorgang bleibt ausdrücklich versendbar: die Mail kann als +// unzustellbar zurückkommen, im Spam hängen bleiben oder schlicht an die falsche +// Adresse gegangen sein. Gesperrt sind nur erledigte (= bezahlte) und stornierte +// Vorgänge — die prüft zusätzlich mahnungVersandErlaubt(). +// +// Empfänger: nicht nur die Firmenadresse, auch ein Ansprechpartner mit Mailadresse +// genügt; im Formular lässt sich außerdem eine beliebige Adresse eintragen. +$erinnerungEmpfaengerMoeglich = ($kundeEmail !== ''); +if (!$erinnerungEmpfaengerMoeglich && $societeGeladen) { + foreach (mahnungEmpfaengerListe($societe) as $kandidat) { + if (mahnungAdresseNormieren($kandidat) !== null) { + $erinnerungEmpfaengerMoeglich = true; + break; + } + } +} + +$canSendErinnerung = ($istErinnerung + && (int) $mahnung->status !== Mahnung::STATUS_ERLEDIGT + && (int) $mahnung->status !== Mahnung::STATUS_STORNIERT + && $erinnerungEmpfaengerMoeglich + && $user->hasRight('mahnung', 'send')); + +// Erneuter Versand? Wird ausschließlich aus dem Status abgeleitet, nicht aus einem +// Request-Parameter — so kann niemand den Doppelversand-Schutz per URL aushebeln. +$istErneuterVersand = ($canSendErinnerung && (int) $mahnung->status >= Mahnung::STATUS_VERSENDET); + +// --------------------------------------------------------------------------- +// Mailformular für die Zahlungserinnerung (Dolibarr-Standard FormMail) +// --------------------------------------------------------------------------- +// Verschickt wird über das gewohnte Dolibarr-Mailformular: Empfänger (inklusive +// aller Ansprechpartner des Kunden), Betreff, Text und Anhang stehen sichtbar im +// Formular und sind vor dem Absenden änderbar. Der Versand selbst läuft +// ausschließlich über mahnungSendeErinnerungsMail() — dort hängen Statuswächter, +// die fachliche Sperre (nur Erinnerungsstufen) und die atomare Reservierung gegen +// Doppelversand. ajax/sendmail.php stellt diese Bausteine nur noch bereit, es ist +// kein eigener Endpoint mehr. +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formmail.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + +// Die Anhangsliste liegt in der Session; der trackid trennt sie pro Mahnvorgang, +// sonst sähe eine zweite offene Karte die Anhänge der ersten. +$mahnungTrackId = 'mah'.((int) $mahnung->id); +$formmail = new FormMail($db); +$formmail->trackid = $mahnungTrackId; + +// Merker "für diesen Vorgang wurde die Anhangsliste schon einmal vorbelegt". +// Er wird zusammen mit der Liste geleert, damit ein später neu geöffnetes Formular +// die Rechnung wieder eingehängt bekommt. +$mahnungInitMarker = 'mahnung_mailinit_'.((int) $mahnung->id); + +$urlKarte = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id); + +// "Abbrechen" im Mailformular: hochgeladene Anhänge verwerfen, zurück zur Karte. +if ($action === 'send' && GETPOST('cancel', 'alpha')) { + $formmail->clear_attached_files(); + unset($_SESSION[$mahnungInitMarker]); + header('Location: '.$urlKarte); + exit; +} + +// Anhang hinzufügen / entfernen — gleiches Muster wie core/actions_sendmails.inc.php. +// Beide Buttons posten dasselbe Formular (action=send); danach wird das Formular +// erneut angezeigt und NICHT gesendet. +// +// $canSendErinnerung ist Pflicht: ohne die Prüfung könnte jeder eingeloggte Benutzer +// (das Leserecht ist Default-Recht) mit einem gültigen Token Dateien in sein +// Temp-Verzeichnis hochladen, obwohl er hier gar nichts versenden darf. +if ($action === 'send' && (GETPOST('addfile', 'alpha') || GETPOST('removedfile'))) { + if (!$canSendErinnerung) { + setEventMessages($langs->trans('MahnungMailNichtErlaubt'), null, 'errors'); + header('Location: '.$urlKarte); + exit; + } + // Temp-Verzeichnis pro Mahnvorgang: dol_add_file_process() speichert mit + // allowoverwrite, ein gleichnamiger Anhang eines anderen Vorgangs würde sonst + // überschrieben — und beide Vorgänge zeigten anschließend auf dieselbe Datei. + $uploadTmpDir = $conf->user->dir_output.'/'.((int) $user->id).'/temp/'.$mahnungTrackId; + if (GETPOST('addfile', 'alpha')) { + dol_add_file_process($uploadTmpDir, 1, 0, 'addedfile', '', null, $mahnungTrackId, 0); + } else { + // Nur aus der Liste nehmen, Datei NICHT von der Platte löschen: der + // Standardanhang ist die echte Original-Rechnungs-PDF im Dokumentenordner. + dol_remove_file_process(GETPOSTINT('removedfile'), 0, 1, $mahnungTrackId); + } + $action = 'presend'; +} + +// --- Absenden ------------------------------------------------------------- +if ($action === 'send' && $tokenOk) { + $sendFehler = ''; + + // Recht, Erinnerungs-Flag, Status und Kunden-Mailadresse hängen alle an + // $canSendErinnerung. Die feinkörnige Begründung liefert zusätzlich + // mahnungVersandErlaubt() innerhalb von mahnungSendeErinnerungsMail(). + if (!$canSendErinnerung) { + $sendFehler = $langs->trans('MahnungMailNichtErlaubt'); + } elseif (!$factureGeladen || !$societeGeladen) { + $sendFehler = $langs->trans('MahnungRechnungNichtLadbar'); + } + + // --- Empfänger --------------------------------------------------------- + // Aus der Auswahlliste kommen nur Schlüssel; die zugehörige Adresse wird über + // mahnungEmpfaengerAdresse() aufgelöst. Vorher wird geprüft, dass der Schlüssel + // wirklich aus der Liste DIESES Kunden stammt — Societe::contact_get_property() + // validiert die Firmenzugehörigkeit nicht, ein manipuliertes receiver[] könnte + // die Erinnerung sonst an einen fremden Kontakt schicken. + $adressen = array(); + if ($sendFehler === '') { + $empfaengerListe = mahnungEmpfaengerListe($societe); + foreach (GETPOST('receiver', 'array') as $key) { + if (!array_key_exists((string) $key, $empfaengerListe) && !array_key_exists((int) $key, $empfaengerListe)) { + continue; + } + // Über dieselbe Normierung wie der Freitextpfad schlüsseln (kleingeschriebene + // Mailadresse). Der rohe String "Name " als Schlüssel würde denselben + // Empfänger zweimal in den To-Header lassen, sobald er einmal ausgewählt und + // einmal von Hand eingetippt wurde. + $norm = mahnungAdresseNormieren(mahnungEmpfaengerAdresse($societe, $key)); + if ($norm !== null) { + $adressen[$norm['mail']] = $norm['voll']; + } + } + + // Zusätzlich frei eingetragene Adressen (Komma oder Semikolon getrennt). + $verworfen = array(); + foreach (mahnungAdressenAusFreitext(GETPOST('sendto', 'alphawithlgt'), $verworfen) as $mail => $voll) { + $adressen[$mail] = $voll; + } + if (!empty($verworfen)) { + // Nicht kommentarlos weniger Empfänger beliefern als im Formular standen. + $sendFehler = $langs->trans('MahnungMailEmpfaengerUngueltig', implode(', ', $verworfen)); + } + + if ($sendFehler === '' && empty($adressen)) { + $sendFehler = $langs->trans('MahnungMailKeinEmpfaenger'); + } + } + + // Kopie / Blindkopie — gleiche Prüfung, und wer schon im An-Feld steht, bekommt + // die Mail nicht zusätzlich als Kopie. + $adressenCc = array(); + $adressenBcc = array(); + if ($sendFehler === '') { + foreach (array('sendtocc' => 'cc', 'sendtoccc' => 'bcc') as $feld => $ziel) { + $verworfenKopie = array(); + $liste = mahnungAdressenAusFreitext(GETPOST($feld, 'alphawithlgt'), $verworfenKopie); + if (!empty($verworfenKopie)) { + $sendFehler = $langs->trans('MahnungMailEmpfaengerUngueltig', implode(', ', $verworfenKopie)); + break; + } + foreach ($liste as $mail => $voll) { + if (isset($adressen[$mail])) { + continue; + } + if ($ziel === 'cc') { + $adressenCc[$mail] = $voll; + } else { + $adressenBcc[$mail] = $voll; + } + } + } + } + + // --- Rechnung zum Versandzeitpunkt prüfen ------------------------------ + // Zwischen Anlage und Versand kann bezahlt, teilbezahlt, storniert oder + // abgeschrieben worden sein. Ändert sich der offene Betrag, zieht die Prüfung + // betrag_offen/summe_mahnung am Objekt nach, damit Mail und Datensatz denselben + // Betrag nennen. + $betragAngepasst = false; + if ($sendFehler === '') { + $pruefFehler = ''; + if (!mahnungRechnungNochOffen($mahnung, $facture, $pruefFehler, $betragAngepasst)) { + $sendFehler = $pruefFehler; + } elseif ($betragAngepasst) { + // Der Kunde hat zwischen dem Öffnen des Formulars und dem Klick auf "Senden" + // eine Teilzahlung geleistet. Im Textfeld steht noch der alte Betrag — die + // Platzhalter wurden beim Aufbau des Formulars bereits ersetzt, ein erneutes + // Ersetzen findet keine Marke mehr. Statt eine Mail mit falschem Betrag zu + // verschicken, wird zurück ins Formular geleitet: es baut Betreff und Text + // mit dem jetzt gültigen Betrag neu auf, und Eddy schickt bewusst ab. + $sendFehler = $langs->trans('MahnungMailBetragAngepasst', price($mahnung->summe_mahnung)); + // Die geposteten Werte verwerfen: FormMail zeigt ein vorhandenes subject/message + // bevorzugt an und würde sonst genau den veralteten Text wieder einsetzen. + unset($_POST['subject'], $_POST['message'], $_GET['subject'], $_GET['message']); + } + } + + // --- Absender ---------------------------------------------------------- + // Bewusst die Firmenadresse, nicht die des angemeldeten Benutzers: eine + // Zahlungserinnerung geht im Namen des Betriebs raus. Ein trotzdem geposteter + // frommail-Wert wird ignoriert. + $absender = array('voll' => ''); + if ($sendFehler === '') { + $absenderFehler = ''; + $absender = mahnungAbsender($absenderFehler); + if ($absenderFehler !== '') { + $sendFehler = $absenderFehler; + } + } + + // --- Senden ------------------------------------------------------------ + if ($sendFehler === '') { + $anhaenge = $formmail->get_attached_files(); + $neueFrist = mahnungBerechneNeueFrist($stufeObj, dol_now()); + $werte = mahnungPlatzhalterWerte($mahnung, $facture, $societe, $neueFrist, $stufeObj); + + // Betreff und Text kommen aus dem Formular. Platzhalter werden noch einmal + // aufgelöst — so wirken auch {…}-Marken, die im Formular ergänzt wurden. + // + // mahnungBodyEntkleiden() macht die nl2br-Umwandlung rückgängig, die GETPOST + // mit 'restricthtml' auf jedem Klartext anrichtet. Ohne das ginge eine als + // Klartext gepflegte Erinnerung als HTML-Mail mit sichtbaren
-Tags raus. + $vorlageIshtml = (int) mahnungMailVorlage($stufeObj, $werte)['ishtml']; + $bodyRoh = mahnungBodyEntkleiden(GETPOST('message', 'restricthtml'), $vorlageIshtml); + $isHtml = dol_textishtml($bodyRoh) ? 1 : 0; + $betreff = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter((string) GETPOST('subject', 'restricthtml'), $werte, 0)); + if ($betreff === '') { + $sendFehler = $langs->trans('MahnungMailKeinBetreff'); + } + + if ($sendFehler === '') { + $mailFehler = ''; + $versandOk = mahnungSendeErinnerungsMail( + $mahnung, + $stufeObj, + array( + 'to' => implode(', ', $adressen), + 'cc' => implode(', ', $adressenCc), + 'bcc' => implode(', ', $adressenBcc), + 'from' => $absender['voll'], + 'subject' => $betreff, + 'body' => mahnungErsetzePlatzhalter($bodyRoh, $werte, $isHtml), + 'ishtml' => $isHtml, + 'paths' => $anhaenge['paths'], + 'names' => $anhaenge['names'], + 'mimes' => $anhaenge['mimes'], + 'deliveryreceipt' => GETPOSTINT('deliveryreceipt'), + 'trackid' => 'mah'.((int) $mahnung->id), + 'frist' => $neueFrist, + // Erneuter Versand eines bereits versendeten Vorgangs (Mail kam nicht + // an, ging an die falsche Adresse …). Der Wert kommt aus dem Status, + // nicht aus dem Request — der Doppelversand-Schutz greift weiterhin: + // die Reservierung nagelt beim erneuten Versand zusätzlich das + // bisherige Versanddatum fest, sodass zwei Parallelklicks nicht + // beide durchkommen. + 'force' => $istErneuterVersand ? 1 : 0, + ), + $user, + $mailFehler + ); + + if ($versandOk) { + $formmail->clear_attached_files(); + unset($_SESSION[$mahnungInitMarker]); + setEventMessages($langs->trans('MahnungErinnerungGesendet'), null, 'mesgs'); + header('Location: '.$urlKarte); + exit; + } + $sendFehler = $mailFehler; + } + } + + // Fehlgeschlagen: Meldung zeigen und zurück ins Formular, damit die Eingaben + // nicht verloren gehen. + setEventMessages($sendFehler ?: $langs->trans('MahnungErinnerungSendenFehler'), null, 'errors'); + $action = 'presend'; +} + +// --- Anhang vorbelegen ---------------------------------------------------- +// Beim Öffnen des Formulars (Button trägt mailinit=1) wird die Anhangsliste +// zurückgesetzt und die UNVERÄNDERTE Original-Rechnungs-PDF hineingelegt. +// +// Die Vorbelegung hängt ausschließlich an `mailinit`, NICHT daran, ob die Liste +// leer ist: wer die Rechnung bewusst abwählt, hat danach eine leere Liste — sie +// würde sonst im selben Request sofort wieder eingehängt und ließe sich überhaupt +// nicht entfernen. Dass schon einmal initialisiert wurde, merkt sich ein +// Session-Marker; damit bekommt auch ein Direktaufruf von `action=presend` +// (Lesezeichen, Legacy-Redirect) seine Vorbelegung genau einmal. +// +// Bewusst ein eigener Parameter statt des Dolibarr-üblichen mode=init: FormMail:: +// get_form() räumt bei mode=init selbst die Session-Liste leer und würde die hier +// eingehängte Rechnung gleich wieder entfernen. +// +// Der Block läuft vor llxHeader(), damit eine fehlende Rechnungs-PDF sofort als +// Warnung erscheint und nicht erst beim nächsten Seitenaufruf. +if ($action === 'presend' && $canSendErinnerung && $factureGeladen) { + if (GETPOSTINT('mailinit') === 1 || empty($_SESSION[$mahnungInitMarker])) { + $formmail->clear_attached_files(); + $_SESSION[$mahnungInitMarker] = 1; + $pdfFehler = ''; + $pdfDatei = mahnungHoleRechnungsPdf($facture, $user, $pdfFehler); + if ($pdfDatei !== '') { + $formmail->add_attached_files($pdfDatei, basename($pdfDatei), dol_mimetype($pdfDatei)); + } elseif ($pdfFehler !== '') { + // Ohne Anhang kann trotzdem verschickt werden — der offene Betrag steht im + // Mailtext. Deshalb nur eine Warnung, keine Sperre. + setEventMessages($pdfFehler, null, 'warnings'); + } + } +} + // Upload-Verzeichnis für Sendebelege (muss VOR llxHeader stehen für actions_linkedfiles) require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; $mahnungSafeRef = dol_sanitizeFileName($mahnung->ref); @@ -327,38 +830,73 @@ include DOL_DOCUMENT_ROOT.'/core/actions_linkedfiles.inc.php'; llxHeader('', $langs->trans('MahnungRef').' '.$mahnung->ref); -print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, '', 'fa-envelope-open-text'); +print load_fiche_titre($langs->trans('MahnungRef').' '.$mahnung->ref, mahnungSetupLink(), 'fa-envelope-open-text'); print '
'; print '
'; print ''; print ''; -print ''; +// Stufe als EIN Badge (Nummer + Bezeichnung). Vorher stand die Bezeichnung als +// Text da und daneben noch ein Badge mit demselben Wort. +print ''; print ''; print ''; print ''; // Rechnung -$facture = new Facture($db); -if ($facture->fetch((int) $mahnung->fk_facture) > 0) { +if ($factureGeladen) { print ''; print ''; } // Kunde -$societe = new Societe($db); -if ($societe->fetch((int) $mahnung->fk_soc) > 0) { +if ($societeGeladen) { print ''; print ''; } print ''; -print ''; -if ((float) $mahnung->pauschale_b2b > 0) { - print ''; +if ($istErinnerung) { + // Kostenlose Zahlungserinnerung: keine Gebühren-, Pauschalen- oder Zinszeilen + // anzeigen — es fällt bewusst nichts an. Ein Altvorgang mit gespeicherten Beträgen + // behält seine Kostenzeilen (siehe $hatGespeicherteKosten weiter oben), auch wenn + // die Stufe inzwischen als Erinnerung markiert wurde. + print ''; +} else { + print ''; + if ((float) $mahnung->pauschale_b2b > 0) { + print ''; + } + if ((float) $mahnung->kosten_vorstufen > 0) { + print ''; + } + + // Ausgewiesener Zinssatz exakt wie im Schreiben (siehe + // pdf_standard_mahnung::getAusgewiesenerZinssatz()): der Stufen-Override hat + // Vorrang, nur ohne Override gilt Basiszins-Snapshot + Aufschlag. Vorher stand + // hier bloß der Basiszins — bei gesetztem Override wich die Karte damit vom + // tatsächlich berechneten und im PDF ausgewiesenen Satz ab. + $zinsOverride = $stufeGeladen ? $stufeObj->getZinssatzOverride($mahnung->customertype) : null; + $basisSnapshot = ($mahnung->basiszins_snapshot !== null) ? (float) $mahnung->basiszins_snapshot : 0.0; + if ($zinsOverride !== null) { + $zinssatzAusgewiesen = (float) $zinsOverride; + $zinsHerkunft = $langs->trans('MahnungZinssatzOverrideStufe'); + } else { + $zinsAufschlag = ($mahnung->customertype === Mahnung::KUNDENTYP_B2B) + ? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0') + : (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); + $zinssatzAusgewiesen = $basisSnapshot + $zinsAufschlag; + $zinsHerkunft = $langs->trans('MahnungZinssatzAusBasiszins', number_format($basisSnapshot, 2, ',', '.')); + } + // $langs->trans() kodiert sein Ergebnis selbst per htmlentities — kein zweites + // Escaping, das würde Sonderzeichen doppelt kodieren. + print ''; + + print ''; } -print ''; -print ''; print ''; print '
'.$langs->trans('MahnungRef').''.dol_escape_htmltag($mahnung->ref).'
'.$langs->trans('MahnungStufe').''.((int) $mahnung->stufe).'
'.$langs->trans('MahnungStufe').''; +print mahnungStufeBadge((int) $mahnung->stufe, $stufeGeladen ? $stufeObj->label : '', $istErinnerung); +print '
'.$langs->trans('MahnungDatum').''.dol_print_date($mahnung->date_mahnung, 'day').'
'.$langs->trans('MahnungFaelligkeitAlt').''.dol_print_date($mahnung->date_lim_reglement_alt, 'day').'
'.$langs->trans('MahnungFaelligkeitNeu').''.dol_print_date($mahnung->date_lim_reglement_neu, 'day').'
'.$langs->trans('MahnungRechnung').''.dol_escape_htmltag($facture->ref).'
'.$langs->trans('MahnungKunde').''.dol_escape_htmltag($societe->name).' ('.dol_escape_htmltag((string) $mahnung->customertype).')
'.$langs->trans('MahnungBetragOffen').''.price($mahnung->betrag_offen).'
'.$langs->trans('MahnungGebuehr').''.price($mahnung->mahngebuehr).'
'.$langs->trans('MahnungPauschaleB2B').''.price($mahnung->pauschale_b2b).'
'.$langs->trans('MahnungKosten').''.dol_escape_htmltag($langs->trans('MahnungErinnerungKostenfrei')).'
'.$langs->trans('MahnungGebuehr').''.price($mahnung->mahngebuehr).'
'.$langs->trans('MahnungPauschaleB2B').''.price($mahnung->pauschale_b2b).'
'.$langs->trans('MahnungKostenVorstufen').''.price($mahnung->kosten_vorstufen).'
'.$langs->trans('MahnungVerzugszinsen').''.price($mahnung->verzugszinsen) + .' ('.number_format($zinssatzAusgewiesen, 2, ',', '.').' % p.a. — '.$zinsHerkunft.')
'.$langs->trans('MahnungSumme').''.price($mahnung->summe_mahnung).'
'.$langs->trans('MahnungVerzugszinsen').''.price($mahnung->verzugszinsen).' (Basiszins '.number_format((float) $mahnung->basiszins_snapshot, 2, ',', '.').' %)
'.$langs->trans('MahnungSumme').''.price($mahnung->summe_mahnung).'
'.$langs->trans('Status').''.dol_escape_htmltag($mahnung->getStatusLabel()).'
'; print '
'; // Ende fichecenter Stammdaten @@ -367,6 +905,17 @@ print ''; // Ende fichecenter Stammdaten print '
'; print load_fiche_titre($langs->trans('Documents'), '', 'fa-file'); +if ($istErinnerung) { + // Für eine Zahlungserinnerung wird bewusst KEIN Mahn-PDF erzeugt. Angehängt + // wird die unveränderte Original-Rechnung. + // Kein dol_escape_htmltag drumherum: $langs->trans() setzt den Parameter per + // sprintf ein und kodiert das Ergebnis danach selbst per htmlentities. + // Ein zweites Escaping würde Sonderzeichen im Dateinamen doppelt kodieren. + print '
'; + print $langs->trans('MahnungErinnerungAnhangHinweis', $anhangDatei !== '' ? $anhangDatei : '-'); + print '
'; +} + // Dokumente im Rechnungsordner suchen die zur Mahnung gehoeren $docDir = ''; if ($facture->id > 0) { @@ -384,14 +933,38 @@ if (!empty($docDir) && is_dir($docDir)) { } } -if (!empty($fileList)) { +// Bei einer Zahlungserinnerung gehört die Original-Rechnung mit in die Liste: sie ist +// das Dokument, das tatsächlich rausgeht. Ohne sie stünde hier "keine Dokumente", und +// zum Nachschauen müsste man erst auf die Rechnungskarte wechseln. +// +// Bewusst mahnungFindeRechnungsPdf() statt mahnungHoleRechnungsPdf(): das bloße +// Anschauen der Karte darf keine PDF-Erzeugung auslösen. Fehlt die Datei noch, wird +// sie beim Öffnen des Mailformulars erzeugt. +$rechnungsPdf = null; +if ($istErinnerung && $factureGeladen) { + $rechnungsPdfPfad = mahnungFindeRechnungsPdf($facture); + if ($rechnungsPdfPfad !== '') { + $rechnungsPdf = array( + 'name' => basename($rechnungsPdfPfad), + 'fullname' => $rechnungsPdfPfad, + 'relativ' => dol_sanitizeFileName($facture->ref).'/'.basename($rechnungsPdfPfad), + ); + } +} + +if (!empty($fileList) || $rechnungsPdf !== null) { $canDeleteDoc = $user->hasRight('mahnung', 'write'); + // Eigener Lang-Key statt trans('Document'): den Singular gibt es in Dolibarrs + // deutschem Sprachpaket nicht, dort stand deshalb "Document" auf Englisch. + // Vorschau bekommt eine eigene, schmale Spalte — direkt neben dem Dateinamen + // klebte die Lupe am Text. print ''; print ''; - print ''; - print ''; - print ''; - print ''; + print ''; + print ''; + print ''; + print ''; + print ''; print ''; foreach ($fileList as $f) { $fname = $f['name']; @@ -404,23 +977,26 @@ if (!empty($fileList)) { $filedate = !empty($f['date']) ? $f['date'] : filemtime($f['fullname']); print ''; - // Dateiname mit Icon + Lupe direkt daneben + // Dateiname mit Icon print ''; + // Vorschau (nur PDF) + print ''; - // Groesse - print ''; + // Größe — mit shortvalue/shortunit, sonst stünde dort die rohe Byte-Zahl + print ''; // Datum print ''; // Aktionen: Download + Löschen print ''; print ''; } + + // Original-Rechnung der Zahlungserinnerung — gleiche Darstellung wie oben, aber + // ohne Löschen-Knopf: die Datei gehört der Rechnung, nicht dem Mahnvorgang. + if ($rechnungsPdf !== null) { + $rDlUrl = DOL_URL_ROOT.'/document.php?modulepart=facture&file='.urlencode($rechnungsPdf['relativ']); + $rViewUrl = $rDlUrl.'&attachment=0'; + $rSize = (int) @filesize($rechnungsPdf['fullname']); + $rDate = (int) @filemtime($rechnungsPdf['fullname']); + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + print '
'.$langs->trans('Document').''.$langs->trans('Size').''.$langs->trans('Date').''.$langs->trans('MahnungDokument').''.$langs->trans('MahnungVorschau').''.$langs->trans('Size').''.$langs->trans('Date').'
'; print ''; print img_picto('', $icon, 'class="pictofixedwidth"'); print dol_escape_htmltag($fname); print ''; + print ''; if ($ext === 'pdf') { - print ' '.img_picto($langs->trans('Preview'), 'search').''; + print ''.img_picto($langs->trans('Preview'), 'search').''; } print ''.dol_print_size($filesize, 0, 0).''.dol_print_size($filesize, 1, 1).''.dol_print_date($filedate, 'dayhour').''; - print ''.img_picto($langs->trans('Download'), 'download').''; + print ''.img_picto($langs->trans('Download'), 'download').''; if ($canDeleteDoc) { $delUrl = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=delete_doc&file='.urlencode($fname).'&token='.newToken(); print ' '; @@ -430,7 +1006,43 @@ if (!empty($fileList)) { print '
'; + print ''; + print img_picto('', 'pdf', 'class="pictofixedwidth"'); + print dol_escape_htmltag($rechnungsPdf['name']); + print ''; + // Als Badge statt als grauer Fließtext — sonst verschwimmt der Hinweis + // mit dem Dateinamen. + print ' '; + print dol_escape_htmltag($langs->trans('MahnungErinnerungAnhangOriginal')); + print ''; + print ''; + print ''.img_picto($langs->trans('Preview'), 'search').''; + print ''.($rSize > 0 ? dol_print_size($rSize, 1, 1) : '').''.($rDate > 0 ? dol_print_date($rDate, 'dayhour') : '').''; + print ''.img_picto($langs->trans('Download'), 'download').''; + print '
'; +} elseif ($istErinnerung) { + // Erinnerung ohne auffindbare Rechnungs-PDF: sie wird beim Öffnen des + // Mailformulars erzeugt, hier gibt es deshalb noch nichts zu zeigen. + print '
'.$langs->trans('MahnungErinnerungRechnungsPdfNochNicht').'
'; } else { print '
'.$langs->trans('NoDocuments').'
'; } @@ -441,197 +1053,300 @@ require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; $form = new Form($db); $formfile = new FormFile($db); -// --- Versand & Belege --- -print '
'; -print load_fiche_titre($langs->trans('MahnungVersandBelege'), '', 'fa-truck'); -print '
'; -print '
'; +// Postalischer Versand (Versandweg, Sendungsverfolgung, Belege) ist nur sinnvoll, +// wenn die Mahnung tatsächlich auf dem Postweg rausgeht. Eine Zahlungserinnerung — +// und generell jeder Vorgang mit Versandart "E-Mail" — braucht weder Einschreiben- +// Nummer noch Belegscan. Statt der kompletten Sektion wird dann nur der +// Mail-Versandstatus angezeigt. +$istMailVersand = ($mahnung->versandart === Mahnung::VERSAND_MAIL) || $istErinnerung; -// Versandwege (Dropdown-Optionen, Label kommt aus Lang-File MahnungVersandweg*) -$versandwege = array( - 'post' => $langs->trans('MahnungVersandwegPost'), - 'einschreiben' => $langs->trans('MahnungVersandwegEinschreiben'), - 'dhl' => $langs->trans('MahnungVersandwegDhl'), - 'dpd' => $langs->trans('MahnungVersandwegDpd'), - 'hermes' => $langs->trans('MahnungVersandwegHermes'), - 'ups' => $langs->trans('MahnungVersandwegUps'), - 'fax' => $langs->trans('MahnungVersandwegFax'), - 'email' => $langs->trans('MahnungVersandwegEmail'), - 'persoenlich' => $langs->trans('MahnungVersandwegPersoenlich'), - 'eigen' => $langs->trans('MahnungVersandwegEigen'), -); - -$editVersand = ($action === 'edit_versand') || empty($mahnung->date_versand); -$canWrite = $user->hasRight('mahnung', 'write'); - -if (!empty($mahnung->date_versand) && $action !== 'edit_versand') { - // Anzeige der bereits erfassten Versanddaten +if ($istMailVersand) { + print '
'; + print load_fiche_titre($langs->trans('MahnungVersandStatus'), '', 'fa-envelope'); + print '
'; + print '
'; print ''; - print ''; - print ''; - if (!empty($mahnung->tracking_nr)) { - require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; - $trackUrl = (new MahnungTrackingPattern($db))->urlFor((string) $mahnung->tracking_provider, (string) $mahnung->tracking_nr); - print ''; + + print ''; } + if ($empfaenger !== '') { + print dol_escape_htmltag($empfaenger); + } else { + print ''.dol_escape_htmltag($langs->trans('MahnungKundeKeineEmail')).''; + } + print ''; + + print ''; + print '
'.$langs->trans('MahnungVersanddatum').''.dol_print_date($mahnung->date_versand, 'day').'
'.$langs->trans('MahnungVersandweg').'' - .($mahnung->versandweg && isset($versandwege[$mahnung->versandweg]) ? dol_escape_htmltag($versandwege[$mahnung->versandweg]) : dol_escape_htmltag((string) $mahnung->versandweg)) - .'
'.$langs->trans('MahnungTrackingNr').''; - print ''.dol_escape_htmltag($mahnung->tracking_nr).''; - if (!empty($trackUrl)) { - print ' '; - print img_picto('', 'fa-external-link-alt', 'class="pictofixedwidth"'); - print dol_escape_htmltag($langs->trans('MahnungSendungVerfolgen')).''; + + print '
'.$langs->trans('MahnungVersandart').''; + print dol_escape_htmltag($langs->trans('MahnungVersandwegEmail')); + print '
'.$langs->trans('MahnungEmpfaenger').''; + $empfaenger = ''; + if (!empty($mahnung->fk_soc)) { + $socMail = new Societe($db); + if ($socMail->fetch((int) $mahnung->fk_soc) > 0) { + $empfaenger = trim((string) $socMail->email); } - print '
'.$langs->trans('MahnungDateVersand').''; + if (!empty($mahnung->date_versand)) { + print dol_print_date($mahnung->date_versand, 'dayhour'); + } else { + print ''.dol_escape_htmltag($langs->trans('MahnungNochNichtVersendet')).''; + } + print '
'; - if ($canWrite) { + + // --- Versandprotokoll --------------------------------------------------- + // Was ist wann an wen rausgegangen? Festgehalten wird der Stand zum + // Sendezeitpunkt; eine später geänderte Stufen-Vorlage ändert das Protokoll + // nicht. Mehrere Einträge entstehen beim erneuten Versand. + $mailProtokoll = mahnungHoleMailProtokoll((int) $mahnung->id); + if (!empty($mailProtokoll)) { + print '
'; + print ''; + print ''; + print ''; + print ''; + + foreach ($mailProtokoll as $eintrag) { + print ''; + } + print '
'.$langs->trans('MahnungMailProtokoll').'
'; + + // Kopfzeile: zugeklappt sieht man Zeitpunkt und Empfänger, mehr braucht + // es im Regelfall nicht. + print '
'; + print ''; + print ''.dol_print_date($eintrag->date_versand, 'dayhour').''; + print '   '.dol_escape_htmltag($eintrag->mail_to); + print ''; + + print '
'; + print ''; + print ''; + print ''; + if (trim((string) $eintrag->mail_cc) !== '') { + print ''; + } + if (trim((string) $eintrag->mail_bcc) !== '') { + print ''; + } + print ''; + if (trim((string) $eintrag->anhaenge) !== '') { + print ''; + } + print '
'.$langs->trans('MahnungMailVon').''.dol_escape_htmltag((string) $eintrag->mail_from).'
'.$langs->trans('MahnungMailAn').''.dol_escape_htmltag((string) $eintrag->mail_to).'
'.$langs->trans('MahnungMailKopie').''.dol_escape_htmltag((string) $eintrag->mail_cc).'
'.$langs->trans('MahnungMailBlindkopie').''.dol_escape_htmltag((string) $eintrag->mail_bcc).'
'.$langs->trans('MahnungMailBetreff').''.dol_escape_htmltag((string) $eintrag->subject).'
'.$langs->trans('MahnungMailAnhaenge').''.dol_escape_htmltag((string) $eintrag->anhaenge).'
'; + + // Der Text so, wie er verschickt wurde. Bei HTML durch + // dol_string_onlythesehtmltags() — der Inhalt kam zwar schon gefiltert + // herein, aber ausgegeben wird er hier ungefragt im Browser. + print '
'; + if (!empty($eintrag->ishtml)) { + print dol_string_onlythesehtmltags((string) $eintrag->body); + } else { + print nl2br(dol_escape_htmltag((string) $eintrag->body)); + } + print '
'; + + print '
'; + print '
'; + print '
'; + } + + print '
'; +} else { + // --- Versand & Belege --- + print '
'; + print load_fiche_titre($langs->trans('MahnungVersandBelege'), '', 'fa-truck'); + print '
'; + print '
'; + + // Versandwege (Dropdown-Optionen, Label kommt aus Lang-File MahnungVersandweg*) + $versandwege = array( + 'post' => $langs->trans('MahnungVersandwegPost'), + 'einschreiben' => $langs->trans('MahnungVersandwegEinschreiben'), + 'dhl' => $langs->trans('MahnungVersandwegDhl'), + 'dpd' => $langs->trans('MahnungVersandwegDpd'), + 'hermes' => $langs->trans('MahnungVersandwegHermes'), + 'ups' => $langs->trans('MahnungVersandwegUps'), + 'fax' => $langs->trans('MahnungVersandwegFax'), + 'email' => $langs->trans('MahnungVersandwegEmail'), + 'persoenlich' => $langs->trans('MahnungVersandwegPersoenlich'), + 'eigen' => $langs->trans('MahnungVersandwegEigen'), + ); + + // $canWrite wird bereits oben (vor den Action-Handlern) gesetzt + $editVersand = ($action === 'edit_versand') || empty($mahnung->date_versand); + + if (!empty($mahnung->date_versand) && $action !== 'edit_versand') { + // Anzeige der bereits erfassten Versanddaten + print ''; + print ''; + print ''; + if (!empty($mahnung->tracking_nr)) { + require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungtrackingpattern.class.php'; + $trackUrl = (new MahnungTrackingPattern($db))->urlFor((string) $mahnung->tracking_provider, (string) $mahnung->tracking_nr); + print ''; + } + print '
'.$langs->trans('MahnungVersanddatum').''.dol_print_date($mahnung->date_versand, 'day').'
'.$langs->trans('MahnungVersandweg').'' + .($mahnung->versandweg && isset($versandwege[$mahnung->versandweg]) ? dol_escape_htmltag($versandwege[$mahnung->versandweg]) : dol_escape_htmltag((string) $mahnung->versandweg)) + .'
'.$langs->trans('MahnungTrackingNr').''; + print ''.dol_escape_htmltag($mahnung->tracking_nr).''; + if (!empty($trackUrl)) { + print ' '; + print img_picto('', 'fa-external-link-alt', 'class="pictofixedwidth"'); + print dol_escape_htmltag($langs->trans('MahnungSendungVerfolgen')).''; + } + print '
'; + if ($canWrite) { + print ''; + } + } elseif ($canWrite) { + // Versand-Formular (Erfassung oder Bearbeitung) + $dateInit = !empty($mahnung->date_versand) ? $mahnung->date_versand : dol_now(); + print '
'; + print ''; + print ''; + print ''; + + // Versanddatum + print ''; + + // Versandweg + print ''; + + // Tracking-Nr + print ''; + + // Optional: Tracking-Provider override + print ''; + + print '
'.$langs->trans('MahnungVersanddatum').''; + print $form->selectDate($dateInit, 'versand_', 0, 0, 0, '', 1, 0); + print '
'.$langs->trans('MahnungVersandweg').''; + print ''; + print '
'.$langs->trans('MahnungTrackingNr').''; + print ''; + print ' ('.dol_escape_htmltag($langs->trans('MahnungTrackingProviderAuto')).')'; + print '
'.$langs->trans('MahnungTrackingProvider').''; + print ''; + print '
'; print '
'; - print ''.img_picto('', 'edit').' '.dol_escape_htmltag($langs->trans('MahnungVersandBearbeiten')).' '; - print ''.dol_escape_htmltag($langs->trans('MahnungVersandLeeren')).''; + print ' '; + if (!empty($mahnung->date_versand)) { + print ''.dol_escape_htmltag($langs->trans('Cancel')).''; + } + print '
'; + print '
'; + } + + // --- Sendebelege (Beleg-Upload via Dolibarr-Standard) --- + print '
'; + print load_fiche_titre($langs->trans('MahnungSendebelege'), '', 'fa-paperclip'); + print '
'.$langs->trans('MahnungSendebelegeHint').'
'; + + // Tracking-Vorschläge aus Session-Flash (vom Scan) anzeigen + $suggKey = 'mahnung_tracking_suggestions_'.((int) $mahnung->id); + $suggestions = $_SESSION[$suggKey] ?? null; + if (is_array($suggestions) && !empty($suggestions)) { + print '
'; + print ''.$langs->trans('MahnungTrackingErkannt').' ('.count($suggestions).')'; + print ''; + foreach ($suggestions as $sg) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + print '
'.img_picto('', 'pdf', 'class="pictofixedwidth"').dol_escape_htmltag($sg['file']).''.dol_escape_htmltag($sg['label']).''.dol_escape_htmltag($sg['nr']).''.img_picto('', 'fa-external-link-alt').''; + if ($canWrite) { + $applyUrl = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=apply_tracking' + .'&nr='.urlencode((string) $sg['nr']) + .'&provider='.urlencode((string) $sg['provider']) + .'&token='.newToken(); + print ''.dol_escape_htmltag($langs->trans('MahnungTrackingUebernehmen')).''; + } + print '
'; + if ($canWrite) { + print ''; + } print '
'; } -} elseif ($canWrite) { - // Versand-Formular (Erfassung oder Bearbeitung) - $dateInit = !empty($mahnung->date_versand) ? $mahnung->date_versand : dol_now(); - print '
'; - print ''; - print ''; - print ''; - // Versanddatum - print ''; - - // Versandweg - print ''; - - // Tracking-Nr - print ''; - - // Optional: Tracking-Provider override - print ''; - - print '
'.$langs->trans('MahnungVersanddatum').''; - print $form->selectDate($dateInit, 'versand_', 0, 0, 0, '', 1, 0); - print '
'.$langs->trans('MahnungVersandweg').''; - print ''; - print '
'.$langs->trans('MahnungTrackingNr').''; - print ''; - print ' ('.dol_escape_htmltag($langs->trans('MahnungTrackingProviderAuto')).')'; - print '
'.$langs->trans('MahnungTrackingProvider').''; - print ''; - print '
'; - print '
'; - print ' '; - if (!empty($mahnung->date_versand)) { - print ''.dol_escape_htmltag($langs->trans('Cancel')).''; - } - print '
'; - print '
'; -} - -// --- Sendebelege (Beleg-Upload via Dolibarr-Standard) --- -print '
'; -print load_fiche_titre($langs->trans('MahnungSendebelege'), '', 'fa-paperclip'); -print '
'.$langs->trans('MahnungSendebelegeHint').'
'; - -// Tracking-Vorschläge aus Session-Flash (vom Scan) anzeigen -$suggKey = 'mahnung_tracking_suggestions_'.((int) $mahnung->id); -$suggestions = $_SESSION[$suggKey] ?? null; -if (is_array($suggestions) && !empty($suggestions)) { - print '
'; - print ''.$langs->trans('MahnungTrackingErkannt').' ('.count($suggestions).')'; - print ''; - foreach ($suggestions as $sg) { - print ''; - print ''; - print ''; - print ''; - print ''; - print ''; - print ''; - } - print '
'.img_picto('', 'pdf', 'class="pictofixedwidth"').dol_escape_htmltag($sg['file']).''.dol_escape_htmltag($sg['label']).''.dol_escape_htmltag($sg['nr']).''.img_picto('', 'fa-external-link-alt').''; - if ($canWrite) { - $applyUrl = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id).'&action=apply_tracking' - .'&nr='.urlencode((string) $sg['nr']) - .'&provider='.urlencode((string) $sg['provider']) - .'&token='.newToken(); - print ''.dol_escape_htmltag($langs->trans('MahnungTrackingUebernehmen')).''; - } - print '
'; + // Scan-Button (Belege durchsuchen) if ($canWrite) { - print ''; + print ''; } - print '
'; + + $urlSelf = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id); + + // Upload-Formular (Durchsuchen + Upload-Button) + $formfile->form_attach_new_file( + $urlSelf, + '', // title + 0, // addcancel + 0, // sectionid + (int) $canWrite, // perm + 50, // size + $mahnung, // object + '', // options + 1, // useajax + '', // savingdocmask + 0, // linkfiles + 'formuserfile', // htmlname + '', // accept + '', // sectiondir + 0, // usewithoutform + 0, // capture + 0 // disablemulti + ); + + // Dateiliste der bereits hochgeladenen Belege + print $formfile->showdocuments( + 'mahnung', // $modulepart + $mahnungSafeRef, // $modulesubdir + $upload_dir, // $filedir + $urlSelf, // $urlsource + 0, // $genallowed (kein PDF-Gen-Button hier) + (int) $canWrite, // $delallowed + '', // $modelselected + 0, // $allowgenifempty + 0, // $forcenomultilang + 0, // $iconPDF + 0, // $notused + 0, // $noform + '', // $param + '', // $title + '', // $buttonlabel + '', // $codelang + '', // $morepicto + $mahnung, // $object + 0 // $hideifempty + ); + + print '
'; // Ende fichecenter Versand & Belege } -// Scan-Button (Belege durchsuchen) -if ($canWrite) { - print ''; -} - -$urlSelf = $_SERVER['PHP_SELF'].'?id='.((int) $mahnung->id); - -// Upload-Formular (Durchsuchen + Upload-Button) -$formfile->form_attach_new_file( - $urlSelf, - '', // title - 0, // addcancel - 0, // sectionid - (int) $canWrite, // perm - 50, // size - $mahnung, // object - '', // options - 1, // useajax - '', // savingdocmask - 0, // linkfiles - 'formuserfile', // htmlname - '', // accept - '', // sectiondir - 0, // usewithoutform - 0, // capture - 0 // disablemulti -); - -// Dateiliste der bereits hochgeladenen Belege -print $formfile->showdocuments( - 'mahnung', // $modulepart - $mahnungSafeRef, // $modulesubdir - $upload_dir, // $filedir - $urlSelf, // $urlsource - 0, // $genallowed (kein PDF-Gen-Button hier) - (int) $canWrite, // $delallowed - '', // $modelselected - 0, // $allowgenifempty - 0, // $forcenomultilang - 0, // $iconPDF - 0, // $notused - 0, // $noform - '', // $param - '', // $title - '', // $buttonlabel - '', // $codelang - '', // $morepicto - $mahnung, // $object - 0 // $hideifempty -); - -print '
'; // Ende fichecenter Versand & Belege - if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', 'delete')) { if ($action === 'confirm_storno') { print $form->formconfirm( @@ -669,7 +1384,9 @@ if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', } print '
'; -if ($user->hasRight('mahnung', 'write')) { +// Mahn-PDF: für Zahlungserinnerungen gibt es keins — dort wird die unveränderte +// Original-Rechnung angehängt, kein eigenes Dokument erzeugt. +if ($canWrite && !$istErinnerung) { // Modellauswahl require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/mahnung/modules_mahnung.php'; $modellist = ModelePDFMahnung::liste_modeles($db); @@ -687,13 +1404,50 @@ if ($user->hasRight('mahnung', 'write')) { print $langs->trans('MahnungGenerate'); print ' '; } -// "Als uneinbringlich klassifizieren" — nur für Stufe-3-Mahnungen mit -// Status >= ERSTELLT (also nicht im Entwurf) und nicht bereits storniert. + +// "Zahlungserinnerung per E-Mail senden" — ausschließlich für Erinnerungs-Stufen. +// Echte Mahnungen (Stufe ohne ist_erinnerung) werden bewusst NICHT per Mail +// verschickt, weil der Zugang dort nicht beweisbar ist. +// mailinit=1 setzt die Anhangsliste zurück und legt die Original-Rechnung frisch +// hinein — sonst hingen bei jedem erneuten Öffnen die alten Dateien noch dran. +if ($canSendErinnerung) { + // Nach einem bereits erfolgten Versand heißt der Button "erneut senden": die Mail + // kann unzustellbar zurückgekommen sein, im Spam hängen oder an die falsche + // Adresse gegangen sein. Der Empfänger lässt sich im Formular ändern. + $sendeLabel = $istErneuterVersand ? 'MahnungErinnerungErneutSenden' : 'MahnungErinnerungSenden'; + $sendeHint = $istErneuterVersand ? 'MahnungErinnerungErneutSendenHint' : 'MahnungErinnerungSendenHint'; + print ''; + print img_picto('', 'email', 'class="pictofixedwidth"'); + print $langs->trans($sendeLabel); + print ' '; +} elseif ($istErinnerung + && (int) $mahnung->status !== Mahnung::STATUS_ERLEDIGT + && (int) $mahnung->status !== Mahnung::STATUS_STORNIERT + && $user->hasRight('mahnung', 'send') + && !$erinnerungEmpfaengerMoeglich +) { + // Grund für den deaktivierten Button sichtbar machen + print ''; + print $langs->trans('MahnungErinnerungSenden'); + print ' '; +} + +// "Als uneinbringlich klassifizieren" — der letzte Schritt im Eskalationspfad. +// Angeboten wird er, wenn nach der Stufe dieses Vorgangs keine weitere aktive Stufe +// mehr konfiguriert ist ($istLetzteStufe, siehe MahnungVorschlag::naechsteStufeNach()), +// der Vorgang keine kostenlose Zahlungserinnerung ist, Status >= ERSTELLT gilt (also +// kein Entwurf) und nichts storniert wurde. Früher hing das an der harten Grenze +// stufe === 3 — mit frei konfigurierbaren Stufen war der Button damit bei jeder +// abweichenden Stufennummer unerreichbar. // Setzt die Rechnung auf STATUS_ABANDONED mit close_code='badcustomer'. if ($mahnung->status !== Mahnung::STATUS_STORNIERT - && (int) $mahnung->stufe === 3 + && $istLetzteStufe + && !$istErinnerung && (int) $mahnung->status >= Mahnung::STATUS_ERSTELLT && $user->hasRight('mahnung', 'delete') + // Der Schritt schreibt auf der Rechnung — ohne Rechnungsrecht würde der Button + // nur in eine Fehlermeldung führen (siehe Handler oben). + && $user->hasRight('facture', 'creer') ) { // Prüfen ob Rechnung schon abandoned ist — dann Button verstecken $facStatus = 0; @@ -719,5 +1473,89 @@ if ($mahnung->status !== Mahnung::STATUS_STORNIERT && $user->hasRight('mahnung', } print '
'; +// --------------------------------------------------------------------------- +// Mailformular für die Zahlungserinnerung +// --------------------------------------------------------------------------- +// Standard-Dolibarr-Formular: Empfänger (Firmenadresse + alle Ansprechpartner), +// Betreff, Text und Anhang stehen sichtbar da und sind änderbar. Abgeschickt wird +// gegen action=send auf dieser Karte — der Versand selbst läuft ausschließlich über +// mahnungSendeErinnerungsMail() (siehe oben). +if ($action === 'presend' && $canSendErinnerung) { + print '
'; + print load_fiche_titre($langs->trans($istErneuterVersand ? 'MahnungErinnerungErneutSenden' : 'MahnungErinnerungSenden'), '', 'fa-envelope'); + if ($istErneuterVersand) { + // Deutlich sichtbar, dass diese Erinnerung schon einmal raus war — sonst + // verschickt man sie versehentlich ein zweites Mal an denselben Empfänger. + print '
'; + print $langs->trans( + 'MahnungErinnerungErneutSendenWarnung', + $mahnung->date_versand ? dol_print_date($mahnung->date_versand, 'dayhour') : '-' + ); + print '
'; + } + print '
'.$langs->trans('MahnungErinnerungMailHint').'
'; + + // Betreff und Text aus der Stufen-Konfiguration, Platzhalter bereits ersetzt. + // Die Frist wird zum jetzigen Zeitpunkt gerechnet; beim Absenden passiert das + // erneut, damit eine später abgeschickte Mail keine verstrichene Frist nennt. + $mailFrist = mahnungBerechneNeueFrist($stufeObj, dol_now()); + $mailWerte = mahnungPlatzhalterWerte($mahnung, $facture, $societe, $mailFrist, $stufeObj); + $mailVorlage = mahnungMailVorlage($stufeObj, $mailWerte); + + // Absender ist die Firmenadresse, nicht der angemeldete Benutzer — deshalb nur + // anzeigen (withfromreadonly), nicht zur Auswahl stellen. + $absenderAnzeigeFehler = ''; + $absenderAnzeige = mahnungAbsender($absenderAnzeigeFehler); + if ($absenderAnzeigeFehler !== '') { + print '
'.dol_escape_htmltag($absenderAnzeigeFehler).'
'; + } + + // param-Einträge werden von FormMail als hidden inputs ausgegeben und landen + // damit im POST des Formulars. + $formmail->param['action'] = 'send'; + $formmail->param['id'] = (int) $mahnung->id; + $formmail->param['returnurl'] = $urlKarte; + // 'none' = keine Vorlagen aus c_email_templates anbieten. Betreff und Text + // kommen aus der Stufen-Konfiguration des Moduls; eine zweite Textquelle + // daneben würde nur auseinanderlaufen. + $formmail->param['models'] = 'none'; + + $formmail->withform = 1; + $formmail->fromtype = 'special'; + $formmail->fromname = $absenderAnzeige['name']; + $formmail->frommail = $absenderAnzeige['mail']; + $formmail->withfrom = 1; + $formmail->withfromreadonly = 1; + // Auswahlliste: Firmenadresse + alle aktiven Ansprechpartner des Kunden. + $formmail->withto = mahnungEmpfaengerListe($societe); + // Nach einem fehlgeschlagenen Versand den eingetippten Freitext-Empfänger + // zurückschreiben. FormMail füllt das Feld sonst nur aus GETPOST('sendto'), wenn + // withtofree numerisch ist — bei einem nicht-numerischen Wert gilt dieser als + // Feldinhalt, genau das brauchen wir hier. + $formmail->withtofree = GETPOSTISSET('sendto') ? GETPOST('sendto', 'alphawithlgt') : 1; + $formmail->withtocc = 1; + $formmail->withtopic = $mailVorlage['subject']; + // Eine bereits getippte Nutzereingabe hat Vorrang vor der Vorlage — sie kommt + // aber
-verseucht aus GETPOST(…, 'restricthtml') zurück (siehe + // mahnungBodyEntkleiden). Deshalb: bereinigen, als Vorgabewert setzen und den + // Rohwert aus dem Request nehmen, sonst zeigt FormMail wieder die Tag-Fassung. + $formmail->withbody = $mailVorlage['body']; + if (GETPOSTISSET('message')) { + $formmail->withbody = mahnungBodyEntkleiden(GETPOST('message', 'restricthtml'), (int) $mailVorlage['ishtml']); + unset($_POST['message'], $_GET['message']); + } + // -1 = Dolibarr entscheidet anhand von FCKEDITOR_ENABLE_MAIL, genau wie beim + // Mailversand aus Rechnungen und Aufträgen. Damit lässt sich der Text hier mit + // dem gewohnten Editor formatieren (fett, Listen, Links); ist der WYSIWYG global + // abgeschaltet, bleibt es ein einfaches Textfeld. + $formmail->withfckeditor = -1; + // 2 = Anhänge anzeigen UND weitere hinzufügen/entfernen können. + $formmail->withfile = 2; + $formmail->withdeliveryreceipt = 1; + $formmail->withcancel = 1; + + print $formmail->get_form(); +} + llxFooter(); $db->close(); diff --git a/class/mahnung.class.php b/class/mahnung.class.php index b64b462..5c81bd5 100644 --- a/class/mahnung.class.php +++ b/class/mahnung.class.php @@ -50,7 +50,7 @@ class Mahnung extends CommonObject /** @var int */ public $fk_soc; - /** @var int 1, 2, 3 */ + /** @var int Stufennummer aus llx_mahnung_stufe (frei konfigurierbar, 0 möglich) */ public $stufe; /** @var int Unix-Zeit */ @@ -74,6 +74,13 @@ class Mahnung extends CommonObject /** @var float */ public $verzugszinsen = 0; + /** + * @var float Kumulierte Gebühren/Pauschalen der Vorstufen zu derselben Rechnung. + * Wird beim Anlegen einer echten Mahnstufe aus summeVorstufenKosten() + * befüllt und fließt in rechneSumme() ein. Bei Zahlungserinnerungen 0. + */ + public $kosten_vorstufen = 0; + /** @var float */ public $summe_mahnung = 0; @@ -177,7 +184,7 @@ class Mahnung extends CommonObject $sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mahnung ("; $sql .= "entity, ref, fk_facture, fk_soc, stufe, date_mahnung,"; $sql .= " date_lim_reglement_alt, date_lim_reglement_neu,"; - $sql .= " betrag_offen, mahngebuehr, pauschale_b2b, verzugszinsen, summe_mahnung,"; + $sql .= " betrag_offen, mahngebuehr, pauschale_b2b, verzugszinsen, kosten_vorstufen, summe_mahnung,"; $sql .= " versandart, customertype, basiszins_snapshot, pdf_path, note_private,"; $sql .= " status, datec, fk_user_creat"; $sql .= ") VALUES ("; @@ -193,6 +200,7 @@ class Mahnung extends CommonObject $sql .= ((float) $this->mahngebuehr).","; $sql .= ((float) $this->pauschale_b2b).","; $sql .= ((float) $this->verzugszinsen).","; + $sql .= ((float) $this->kosten_vorstufen).","; $sql .= ((float) $this->summe_mahnung).","; $sql .= "'".$this->db->escape($this->versandart ?: self::VERSAND_PDF)."',"; $sql .= ($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL").","; @@ -228,6 +236,10 @@ class Mahnung extends CommonObject { $sql = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_mahnung as t"; $sql .= " WHERE t.rowid = ".((int) $id); + // Entity-Filter: sonst ließe sich per URL-Manipulation ein Mahnvorgang + // einer fremden Entity laden. $this->entity wird unten aus der DB-Zeile + // überschrieben und bleibt damit der tatsächliche Wert des Datensatzes. + $sql .= " AND t.entity IN (".getEntity('mahnung').")"; $resql = $this->db->query($sql); if (!$resql) { @@ -253,6 +265,15 @@ class Mahnung extends CommonObject $this->mahngebuehr = $obj->mahngebuehr; $this->pauschale_b2b = $obj->pauschale_b2b; $this->verzugszinsen = $obj->verzugszinsen; + // isset-Guard bleibt bewusst stehen, obwohl modMahnung::ensureSchema() an den + // schreibenden Einstiegspunkten hängt: es gibt LESENDE Pfade ohne ensureSchema() + // (Zahlungs-Trigger, Cron, Home-Widget). Dort liefert SELECT t.* die Spalte + // nicht, solange die Migration nicht lief -> PHP-Warning "Undefined property". + // Der Guard schützt ausschließlich das Lesen. create()/update() schreiben die + // Spalte hart und laufen ohne Migration weiter in "Unknown column" — das ist + // gewollt: ein sichtbarer Fehler ist besser als stillschweigend verlorene + // Vorstufenkosten in einer bereits versendeten Mahnung. + $this->kosten_vorstufen = isset($obj->kosten_vorstufen) ? $obj->kosten_vorstufen : 0; $this->summe_mahnung = $obj->summe_mahnung; $this->versandart = $obj->versandart; $this->customertype = $obj->customertype; @@ -395,6 +416,7 @@ class Mahnung extends CommonObject $sql .= ", mahngebuehr = ".((float) $this->mahngebuehr); $sql .= ", pauschale_b2b = ".((float) $this->pauschale_b2b); $sql .= ", verzugszinsen = ".((float) $this->verzugszinsen); + $sql .= ", kosten_vorstufen = ".((float) $this->kosten_vorstufen); $sql .= ", summe_mahnung = ".((float) $this->summe_mahnung); $sql .= ", versandart = '".$this->db->escape($this->versandart)."'"; $sql .= ", customertype = ".($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL"); @@ -613,7 +635,8 @@ class Mahnung extends CommonObject } /** - * Setzt $summe_mahnung = betrag_offen + mahngebuehr + pauschale_b2b + verzugszinsen. + * Setzt $summe_mahnung = betrag_offen + mahngebuehr + pauschale_b2b + * + verzugszinsen + kosten_vorstufen. * * @return float Neue Summe */ @@ -623,12 +646,63 @@ class Mahnung extends CommonObject (float) $this->betrag_offen + (float) $this->mahngebuehr + (float) $this->pauschale_b2b - + (float) $this->verzugszinsen, + + (float) $this->verzugszinsen + + (float) $this->kosten_vorstufen, 2 ); return $this->summe_mahnung; } + /** + * Summe der bereits berechneten Gebühren und Pauschalen der VORSTUFEN einer + * Rechnung: alle nicht stornierten Mahnvorgänge mit einer kleineren + * Stufennummer als $this->stufe. Wird beim Anlegen einer Folgestufe als + * kosten_vorstufen übernommen, damit die neue Mahnung den kompletten offenen + * Betrag inklusive der Kosten der Vorstufen ausweist. + * + * WICHTIG: $this->stufe muss zum Aufrufzeitpunkt gesetzt sein — sie ist die + * Obergrenze. In mahnungBaueVorgang() (lib/mahnung_anlage.lib.php) passiert + * das direkt beim Aufbau des Objekts, also vor diesem Aufruf. Ohne gesetzte + * Stufe liefert die Methode 0, addiert also nichts Falsches dazu. + * + * Ohne die Stufen-Grenze zählte eine zweimal angelegte Stufe ihre Gebühr + * doppelt. Zusätzlich wird je Stufennummer nur der höchste Kostenbetrag + * gewertet (MAX + GROUP BY): existieren zu einer Vorstufe versehentlich zwei + * nicht stornierte Mahnvorgänge, fließt ihre Gebühr trotzdem nur einmal ein. + * + * Verzugszinsen bleiben bewusst außen vor — die werden bei jeder Stufe neu + * tagesgenau auf den offenen Rechnungsbetrag gerechnet. + * + * @param int $factureId Rechnungs-ID + * @param int $exceptId rowid, die ausgeklammert wird (die Mahnung selbst) + * @return float Summe in EUR (0 wenn keine Vorstufen existieren) + */ + public function summeVorstufenKosten($factureId, $exceptId = 0) + { + // COALESCE auf die Einzelfelder: mahngebuehr/pauschale_b2b sind NULL-fähig, + // ohne das würde ein einzelnes NULL die ganze Zeile aus der Summe kippen. + $sub = "SELECT stufe, MAX(COALESCE(mahngebuehr, 0) + COALESCE(pauschale_b2b, 0)) as kosten"; + $sub .= " FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; + $sub .= " WHERE fk_facture = ".((int) $factureId); + $sub .= " AND entity IN (".getEntity('mahnung').")"; + $sub .= " AND status <> ".((int) self::STATUS_STORNIERT); + $sub .= " AND rowid <> ".((int) $exceptId); + $sub .= " AND stufe < ".((int) $this->stufe); + $sub .= " GROUP BY stufe"; + + $sql = "SELECT COALESCE(SUM(v.kosten), 0) as kosten FROM (".$sub.") as v"; + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return 0.0; + } + $obj = $this->db->fetch_object($resql); + $this->db->free($resql); + + return round((float) $obj->kosten, 2); + } + /** * Lokalisiertes Status-Label. * diff --git a/class/mahnungcron.class.php b/class/mahnungcron.class.php index bc8f1e5..b16d890 100644 --- a/class/mahnungcron.class.php +++ b/class/mahnungcron.class.php @@ -13,6 +13,7 @@ */ require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungntfy.class.php'; class MahnungCron @@ -44,49 +45,88 @@ class MahnungCron * Sucht überfällige Rechnungen, ermittelt Vorschläge je Stufe, * sendet Ntfy-Push mit Anzahl je Stufe und Gesamtwert. * + * Fehler und Leerstand werden strikt getrennt: nur wenn die Vorschlagsliste + * erfolgreich ermittelt wurde UND leer ist, werden die Notifications geräumt. + * Bei einem Fehler bleibt alles stehen und der Cron-Job wird als fehlerhaft + * markiert (Rückgabe < 0). + * * @return int 0 bei Erfolg, < 0 bei Fehler */ public function buildVorschlagsliste() { - global $conf; + global $langs; + + $langs->load('mahnung@mahnung'); $service = new MahnungVorschlag($this->db); - $vorschlaege = $service->getVorschlaege(); + $vorschlaege = $service->getVorschlaege(array()); + + // false = Vorschläge konnten gar nicht ermittelt werden (SQL-Fehler oder + // keine Mahnstufe konfiguriert). Dann NICHTS aufräumen — sonst verschwindet + // die Notification, obwohl die offenen Vorgänge weiter existieren. + if ($vorschlaege === false || !is_array($vorschlaege)) { + $detail = (isset($service->error) && $service->error !== '') ? (string) $service->error : ''; + if ($detail === '') { + $detail = $langs->transnoentities('MahnungCronFehlerUnbekannt'); + } + $this->error = $langs->transnoentities('MahnungCronVorschlaegeFehler', $detail); + $this->errors = (isset($service->errors) && is_array($service->errors)) ? $service->errors : array(); + $this->output = $this->error; + $this->lastresult = -1; + dol_syslog('MahnungCron::buildVorschlagsliste '.$this->error, LOG_ERR); + return -1; + } $count = count($vorschlaege); - $counts = array(1 => 0, 2 => 0, 3 => 0); + + // Zähler dynamisch über die tatsächlich konfigurierten Stufen aufbauen. + // Stufennummern sind frei wählbar (0 = kostenlose Zahlungserinnerung), + // eine feste 1/2/3-Liste wäre falsch. + $stufeObj = new MahnungStufe($this->db); + $counts = array(); + $labels = array(); + foreach ($stufeObj->fetchAllActive() as $s) { + $counts[(int) $s->stufe] = 0; + $labels[(int) $s->stufe] = (string) $s->label; + } + $summe = 0.0; foreach ($vorschlaege as $v) { $stufe = (int) $v['vorgeschlagene_stufe']; - if (isset($counts[$stufe])) { - $counts[$stufe]++; + if (!isset($counts[$stufe])) { + // Stufe zwischenzeitlich deaktiviert — trotzdem mitzählen statt schlucken + $counts[$stufe] = 0; + $labels[$stufe] = (string) ($v['vorgeschlagene_stufe_label'] ?? ''); } + $counts[$stufe]++; $summe += (float) $v['betrag_offen']; } + ksort($counts); $summe = round($summe, 2); if ($count === 0) { - // Alte Notifications räumen — es gibt nichts mehr zu tun + // Echter Leerstand: alte Notifications räumen — es gibt nichts mehr zu tun self::clearGlobalNotify(); - global $langs; - $langs->load('mahnung@mahnung'); - $this->output = $langs->trans('MahnungCronKeineUeberfaellige'); + $this->output = $langs->transnoentities('MahnungCronKeineUeberfaellige'); $this->lastresult = 0; return 0; } - global $langs; - $langs->load('mahnung@mahnung'); - - $dolUrl = trim((string) getDolGlobalString('MAIN_INFO_SOCIETE_NOM', '')); $relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag'; $absUrl = self::buildAbsoluteUrl($relPath); - $title = $langs->trans('MahnungCronOffeneVorschlaege', $count); - $message = $langs->trans('MahnungCronStufe1Erinnerung', $counts[1])."\n"; - $message .= $langs->trans('MahnungCronStufe2Mahnung', $counts[2])."\n"; - $message .= $langs->trans('MahnungCronStufe3LetzteMahnung', $counts[3])."\n"; - $message .= $langs->trans('MahnungCronOffenerBetrag', number_format($summe, 2, ',', '.')); + // transnoentities statt trans: der Ntfy-Push ist Klartext, HTML-Entities + // (ü etc.) würden dort wörtlich auftauchen. + $title = $langs->transnoentities('MahnungCronOffeneVorschlaege', $count); + $zeilen = array(); + foreach ($counts as $stufe => $anzahl) { + $label = (isset($labels[$stufe]) && $labels[$stufe] !== '') + ? $labels[$stufe] + : $langs->transnoentities('MahnungCronStufeOhneLabel'); + $zeilen[] = $langs->transnoentities('MahnungCronStufeAnzahl', $stufe, $label, $anzahl); + } + $zeilen[] = $langs->transnoentities('MahnungCronOffenerBetrag', number_format($summe, 2, ',', '.')); + $message = implode("\n", $zeilen); MahnungNtfy::send($title, $message, $absUrl, array('envelope_with_arrow', 'warning')); @@ -166,8 +206,12 @@ class MahnungCron $resql = $this->db->query($sql); if (!$resql) { + // Fehler: nichts aufräumen, Job als fehlerhaft melden $this->error = $this->db->lasterror(); + $this->errors[] = $this->error; + $this->output = $this->error; $this->lastresult = -1; + dol_syslog('MahnungCron::versandReminder SQL-Fehler: '.$this->error, LOG_ERR); return -1; } @@ -181,24 +225,25 @@ class MahnungCron $langs->load('mahnung@mahnung'); if (empty($pending)) { - $this->output = $langs->trans('MahnungCronKeineUnversendet', $tageSchwelle); + $this->output = $langs->transnoentities('MahnungCronKeineUnversendet', $tageSchwelle); $this->lastresult = 0; return 0; } $relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv'; $absUrl = self::buildAbsoluteUrl($relPath); - $title = $langs->trans('MahnungCronUnversendetTitel', count($pending)); + // transnoentities: Ntfy-Push ist Klartext, keine HTML-Entities + $title = $langs->transnoentities('MahnungCronUnversendetTitel', count($pending)); $lines = array(); foreach ($pending as $p) { $tage = (int) floor((time() - strtotime((string) $p->datec)) / 86400); - $lines[] = $langs->trans('MahnungCronStufeAlter', $p->ref, $p->stufe, $tage, $p->soc_nom); + $lines[] = $langs->transnoentities('MahnungCronStufeAlter', $p->ref, $p->stufe, $tage, $p->soc_nom); } // Auf 8 Zeilen kürzen, Rest als "+N weitere" if (count($lines) > 8) { $rest = count($lines) - 8; $lines = array_slice($lines, 0, 8); - $lines[] = $langs->trans('MahnungCronWeitere', $rest); + $lines[] = $langs->transnoentities('MahnungCronWeitere', $rest); } $message = implode("\n", $lines); diff --git a/class/mahnungstufe.class.php b/class/mahnungstufe.class.php index d32c864..e019467 100644 --- a/class/mahnungstufe.class.php +++ b/class/mahnungstufe.class.php @@ -14,8 +14,12 @@ require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php'; /** - * Eine Mahnstufe (1..3): Frist-Konfiguration, Gebühren, optionaler Zinssatz-Override, - * Versandart-Default, E-Mail-/PDF-Templates. + * 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 { @@ -28,13 +32,13 @@ class MahnungStufe extends CommonObject /** @var int */ public $entity; - /** @var int 1..3 */ + /** @var int Frei wählbare Stufennummer (0 = Default der Zahlungserinnerung) */ public $stufe; /** @var string */ public $label; - /** @var int Tage nach Fälligkeit (Stufe 1) bzw. nach Vorgängerstufe (>1) */ + /** @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) */ @@ -67,6 +71,13 @@ class MahnungStufe extends CommonObject /** @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; @@ -87,7 +98,34 @@ class MahnungStufe extends CommonObject } /** - * @param int $stufe 1..3 + * 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) @@ -138,6 +176,164 @@ class MahnungStufe extends CommonObject 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 @@ -162,8 +358,12 @@ class MahnungStufe extends CommonObject $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); @@ -174,6 +374,17 @@ class MahnungStufe extends CommonObject 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. * @@ -224,6 +435,14 @@ class MahnungStufe extends CommonObject $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); diff --git a/class/mahnungvorschlag.class.php b/class/mahnungvorschlag.class.php index 65cec5a..5e796ef 100644 --- a/class/mahnungvorschlag.class.php +++ b/class/mahnungvorschlag.class.php @@ -12,6 +12,11 @@ * die nächste vorgeschlagene Mahnstufe ermitteln. * * Geteilte Logik zwischen Cron-Job (Ntfy-Push) und Vorschlagslisten-UI. + * + * Die Stufenlogik ist vollständig datengetrieben: es gibt keine hartkodierte + * Stufe 1 und keine Obergrenze bei Stufe 3 mehr. Maßgeblich ist ausschließlich + * das, was in llx_mahnung_stufe als aktiv konfiguriert ist — inklusive Stufe 0 + * (kostenlose Zahlungserinnerung) und beliebig vieler weiterer Stufen. */ require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; @@ -19,15 +24,50 @@ require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php'; class MahnungVorschlag { + /** + * Typ-Codes aus llx_c_typent, die für sich genommen belegen, dass der Kunde + * KEIN Verbraucher im Sinne des §13 BGB ist (Whitelist, per Konstante + * MAHNUNG_B2B_TYPENT_CODES überschreibbar). + * + * Bewusst NICHT enthalten: + * - TE_SMALL ('TPE'/Kleinstunternehmen): in den Stammdaten tragen mehrere + * reine Privatkunden diesen Typ. Wer dort wirklich Unternehmer ist, wird + * ohnehin über tva_intra/siret/siren als B2B erkannt. + * - TE_OTHER ('Autres'): Sammelposten ohne Aussage über die Verbrauchereigenschaft. + * + * Leerer Wert der Konstante = fk_typent wird gar nicht ausgewertet. + */ + const B2B_TYPENT_CODES_DEFAULT = 'TE_GROUP,TE_MEDIUM,TE_ADMIN,TE_WHOLE,TE_RETAIL'; + /** @var DoliDB */ public $db; /** @var int */ public $entity; - /** @var MahnungStufe[] indexed by stufe (1..3) */ + /** @var string Letzte Fehlermeldung (gesetzt wenn eine Methode false liefert) */ + public $error = ''; + + /** @var string[] Alle Fehlermeldungen des letzten Aufrufs */ + public $errors = array(); + + /** + * Aktive Stufen, indiziert nach Stufennummer und aufsteigend sortiert. + * Stufennummern sind frei konfigurierbar, 0 ist erlaubt. + * + * @var MahnungStufe[] + */ private $stufen = array(); + /** @var bool true = loadStufen() wurde bereits ausgeführt (auch bei leerem Ergebnis) */ + private $stufenGeladen = false; + + /** @var string SQL-Fehler beim Laden der Stufen (leer = kein Fehler) */ + private $stufenFehler = ''; + + /** @var string[]|null Aufgelöste B2B-Typ-Codes (null = noch nicht ausgewertet) */ + private $b2bTypentCodes = null; + /** * @param DoliDB $db */ @@ -44,31 +84,212 @@ class MahnungVorschlag * * Rückgabe-Schlüssel je Eintrag: * facture_id, facture_ref, facture_date_lim_reglement (Unix), facture_total_ttc, - * soc_id, soc_nom, soc_tva_intra, + * soc_id, soc_nom, soc_tva_intra, soc_siret, soc_siren, soc_typent_code, + * soc_phone, soc_email, * kundentyp ('B2C'|'B2B'), * tage_verzug, * betrag_offen, * letzte_mahnung_id (int|null), letzte_mahnung_stufe (int|null), letzte_mahnung_datum (Unix|null), - * vorgeschlagene_stufe (int 1..3), - * vorgeschlagene_stufe_label (string) + * vorgeschlagene_stufe (int|null — frei konfigurierbare Stufennummer, 0 möglich), + * vorgeschlagene_stufe_label (string|null), + * vorgeschlagene_stufe_ist_erinnerung (int 0|1), + * skip_reason (string|null) + * + * WICHTIG für Aufrufer: der Rückgabewert ist false, wenn etwas schiefgelaufen ist + * (SQL-Fehler oder keine aktive Stufe konfiguriert). Ein leeres array() bedeutet + * dagegen echten Leerstand — "es gibt nichts zu mahnen". Die beiden Fälle dürfen + * NICHT gleich behandelt werden (sonst räumt z.B. der Cron bei einem DB-Ausfall + * die Benachrichtigungen weg, als wäre alles bezahlt). * * @param array $filter Optional: 'soc_id', 'min_tage_verzug', 'max_tage_verzug', 'stufe', * 'min_betrag' (float), 'kundentyp' ('B2B'|'B2C') - * @return array + * @return array|false false bei Fehler ($this->error gesetzt) */ public function getVorschlaege(array $filter = array()) { - $this->loadStufen(); - if (empty($this->stufen)) { - return array(); + $this->resetError(); + + if (!$this->loadStufen()) { + return false; } $today = dol_now(); + $rows = $this->ladeUeberfaelligeRechnungen($filter, $today, 'getVorschlaege'); + if ($rows === false) { + return false; + } + + $result = array(); + foreach ($rows as $obj) { + $row = $this->buildVorschlag($obj, $today); + if ($row === null) { + continue; + } + if (!$this->passtZuBasisFilter($row, $filter)) { + continue; + } + if (isset($filter['max_tage_verzug']) && $row['tage_verzug'] > (int) $filter['max_tage_verzug']) { + continue; + } + // Achtung: Stufe 0 ist eine gültige Stufennummer — deshalb wird gegen '' + // geprüft (Filter nicht gesetzt) und nicht per empty(). + if (isset($filter['stufe']) && $filter['stufe'] !== '' && (int) $row['vorgeschlagene_stufe'] !== (int) $filter['stufe']) { + continue; + } + $result[] = $row; + } + return $result; + } + + /** + * Liefert alle überfälligen Rechnungen, für die aktuell KEIN Vorschlag passt, + * inkl. Begründung (skip_reason). Diagnose-Hilfe für das UI. + * + * @param array $filter (siehe getVorschlaege) + * @return array|false false bei Fehler ($this->error gesetzt) + */ + public function getUebersprungeneRechnungen(array $filter = array()) + { + $rows = $this->buildAlleVorschlaege($filter); + if ($rows === false) { + // $this->error wurde bereits von buildAlleVorschlaege() gesetzt + return false; + } + + $skipped = array(); + foreach ($rows as $r) { + if ($r['vorgeschlagene_stufe'] === null) { + $skipped[] = $r; + } + } + return $skipped; + } + + /** + * Liefert sowohl vorgeschlagene als auch übersprungene Rechnungen in einem Durchlauf. + * Result-Schlüssel je Eintrag wie bei getVorschlaege(). + * + * Der Stufen-Filter wird hier bewusst NICHT angewandt: übersprungene Rechnungen + * haben keine Zielstufe und würden sonst komplett herausfallen. + * + * @param array $filter + * @return array|false false bei Fehler ($this->error gesetzt) + */ + public function buildAlleVorschlaege(array $filter = array()) + { + $this->resetError(); + + if (!$this->loadStufen()) { + return false; + } + + $today = dol_now(); + $rows = $this->ladeUeberfaelligeRechnungen($filter, $today, 'buildAlleVorschlaege'); + if ($rows === false) { + return false; + } + + $result = array(); + foreach ($rows as $obj) { + $row = $this->buildVorschlag($obj, $today, true); + if ($row === null) { + continue; + } + if (!$this->passtZuBasisFilter($row, $filter)) { + continue; + } + $result[] = $row; + } + return $result; + } + + /** + * Kleinste aktive Stufennummer — der Einstiegspunkt für Rechnungen ohne Vormahnung. + * Ist Stufe 0 (Zahlungserinnerung) aktiv, wird 0 geliefert. + * + * @return int|null null wenn keine Stufe konfiguriert/ladbar ist ($this->error gesetzt) + */ + public function minStufe() + { + if (!$this->loadStufen()) { + return null; + } + $keys = array_keys($this->stufen); + return (int) $keys[0]; + } + + /** + * Nächste aktive Stufe nach einer bereits gemahnten Stufe. + * Lücken werden übersprungen (0 -> 1 -> 5 -> 9 ist zulässig), eine Obergrenze + * gibt es nicht — Ende ist erreicht, wenn keine höhere aktive Stufe existiert. + * + * @param int $lastStufe Zuletzt gemahnte Stufennummer + * @return int|null null = keine weitere Stufe vorhanden + */ + public function naechsteStufeNach($lastStufe) + { + if (!$this->loadStufen()) { + return null; + } + foreach (array_keys($this->stufen) as $nr) { + if ((int) $nr > (int) $lastStufe) { + return (int) $nr; + } + } + return null; + } + + /** + * Gibt die geladene MahnungStufe zurück oder null. + * + * @param int $stufe Stufennummer (frei konfigurierbar, 0 möglich) + * @return MahnungStufe|null + */ + public function getStufe($stufe) + { + if (!$this->loadStufen()) { + return null; + } + return isset($this->stufen[(int) $stufe]) ? $this->stufen[(int) $stufe] : null; + } + + /** + * Alle aktiven Stufen, aufsteigend nach Stufennummer indiziert. + * Praktisch für Aufrufer, die dynamisch über die konfigurierten Stufen laufen + * müssen (Cron-Zähler, Setup-/Filter-Dropdowns). + * + * @return MahnungStufe[] leeres Array wenn nichts konfiguriert ist + */ + public function getAlleStufen() + { + if (!$this->loadStufen()) { + return array(); + } + return $this->stufen; + } + + /** + * Überfällige, offene Rechnungen der Entity laden. + * + * Die Kundentyp-Erkennung braucht neben tva_intra auch siret/siren und den + * Typ-Code aus llx_c_typent — deshalb der LEFT JOIN. Ein Kunde ohne USt-IdNr. + * ist nicht automatisch eine Privatperson (Kleinunternehmer §19 UStG!). + * + * @param array $filter siehe getVorschlaege() + * @param int $today Unix-Zeit + * @param string $context Aufrufer-Name für das Syslog + * @return object[]|false DB-Zeilen oder false bei SQL-Fehler + */ + private function ladeUeberfaelligeRechnungen(array $filter, $today, $context) + { $sql = "SELECT f.rowid AS facture_id, f.ref AS facture_ref, f.date_lim_reglement,"; $sql .= " f.total_ttc, f.fk_soc, f.paye, f.fk_statut,"; - $sql .= " s.nom AS soc_nom, s.tva_intra, s.phone AS soc_phone, s.email AS soc_email"; + $sql .= " s.nom AS soc_nom, s.tva_intra, s.siret AS soc_siret, s.siren AS soc_siren,"; + $sql .= " s.phone AS soc_phone, s.email AS soc_email,"; + $sql .= " te.code AS typent_code"; $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; $sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_typent as te ON te.id = s.fk_typent"; $sql .= " WHERE f.entity = ".((int) $this->entity); $sql .= " AND f.fk_statut = 1"; $sql .= " AND f.paye = 0"; @@ -84,121 +305,47 @@ class MahnungVorschlag $resql = $this->db->query($sql); if (!$resql) { - dol_syslog('MahnungVorschlag::getVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR); - return array(); + $dbError = $this->db->lasterror(); + dol_syslog('MahnungVorschlag::'.$context.' SQL-Fehler: '.$dbError, LOG_ERR); + $this->ladeSprache(); + global $langs; + $this->setError($langs->trans('MahnungVorschlagSqlFehler', $dbError)); + return false; } - $result = array(); + $rows = array(); while ($obj = $this->db->fetch_object($resql)) { - $row = $this->buildVorschlag($obj, $today); - if ($row === null) { - continue; - } - if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) { - continue; - } - if (isset($filter['max_tage_verzug']) && $row['tage_verzug'] > (int) $filter['max_tage_verzug']) { - continue; - } - if (isset($filter['stufe']) && $filter['stufe'] !== '' && (int) $row['vorgeschlagene_stufe'] !== (int) $filter['stufe']) { - continue; - } - if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) { - continue; - } - if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) { - continue; - } - $result[] = $row; + $rows[] = $obj; } $this->db->free($resql); - return $result; + return $rows; } /** - * Liefert alle überfälligen Rechnungen, für die aktuell KEIN Vorschlag passt, - * inkl. Begründung (skip_reason). Diagnose-Hilfe für das UI. - * - * @param array $filter (siehe getVorschlaege) - * @return array - */ - public function getUebersprungeneRechnungen(array $filter = array()) - { - $rows = $this->buildAlleVorschlaege($filter); - $skipped = array(); - foreach ($rows as $r) { - if ($r['vorgeschlagene_stufe'] === null) { - $skipped[] = $r; - } - } - return $skipped; - } - - /** - * Liefert sowohl vorgeschlagene als auch übersprungene Rechnungen in einem Durchlauf. - * Result-Schlüssel je Eintrag wie bei getVorschlaege(), zusätzlich: - * skip_reason (string|null) + * Filter, die für Vorschlagsliste UND Übersprungen-Liste gleichermaßen gelten. * + * @param array $row * @param array $filter - * @return array + * @return bool true = Zeile behalten */ - public function buildAlleVorschlaege(array $filter = array()) + private function passtZuBasisFilter(array $row, array $filter) { - $this->loadStufen(); - if (empty($this->stufen)) { - return array(); + if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) { + return false; } - - $today = dol_now(); - $sql = "SELECT f.rowid AS facture_id, f.ref AS facture_ref, f.date_lim_reglement,"; - $sql .= " f.total_ttc, f.fk_soc, f.paye, f.fk_statut,"; - $sql .= " s.nom AS soc_nom, s.tva_intra, s.phone AS soc_phone, s.email AS soc_email"; - $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; - $sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc"; - $sql .= " WHERE f.entity = ".((int) $this->entity); - $sql .= " AND f.fk_statut = 1"; - $sql .= " AND f.paye = 0"; - $sql .= " AND f.type IN (0, 2, 3)"; - $sql .= " AND f.date_lim_reglement IS NOT NULL"; - $sql .= " AND f.date_lim_reglement < '".$this->db->idate($today)."'"; - - if (!empty($filter['soc_id'])) { - $sql .= " AND f.fk_soc = ".((int) $filter['soc_id']); + if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) { + return false; } - - $sql .= " ORDER BY f.date_lim_reglement ASC"; - - $resql = $this->db->query($sql); - if (!$resql) { - dol_syslog('MahnungVorschlag::buildAlleVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR); - return array(); + if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) { + return false; } - - $result = array(); - while ($obj = $this->db->fetch_object($resql)) { - $row = $this->buildVorschlag($obj, $today, true); - if ($row === null) { - continue; - } - if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) { - continue; - } - if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) { - continue; - } - if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) { - continue; - } - $result[] = $row; - } - $this->db->free($resql); - return $result; + return true; } /** * Berechnet für eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird. * - * @param object $factureObj DB-Reihe aus facture+societe + * @param object $factureObj DB-Reihe aus facture+societe+c_typent * @param int $today Unix-Zeit * @param bool $includeSkipped true = liefert auch übersprungene mit skip_reason * @return array|null @@ -214,41 +361,18 @@ class MahnungVorschlag $tageVerzug = 0; } - $kundentyp = !empty($factureObj->tva_intra) ? Mahnung::KUNDENTYP_B2B : Mahnung::KUNDENTYP_B2C; + $kundentyp = $this->ermittleKundentyp($factureObj); // Letzte aktive Mahnung zur Rechnung holen $lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id); - // Nächste Stufe ermitteln - $proposedStufe = null; - $skipReason = null; + $this->ladeSprache(); global $langs; - $langs->load('mahnung@mahnung'); - if ($lastMahnung === null) { - $frist1 = isset($this->stufen[1]) ? (int) $this->stufen[1]->frist_tage : 0; - if (!isset($this->stufen[1])) { - $skipReason = $langs->trans('MahnungVorschlagStufeNichtKonfiguriert'); - } elseif ($tageVerzug >= $frist1) { - $proposedStufe = 1; - } else { - $skipReason = $langs->trans('MahnungVorschlagFristNichtErreicht', $frist1, $tageVerzug); - } - } else { - $lastStufe = (int) $lastMahnung->stufe; - $nextStufe = $lastStufe + 1; - if ($lastStufe >= 3 || !isset($this->stufen[$nextStufe])) { - $skipReason = $langs->trans('MahnungVorschlagAlleStufenAusgeschoepft', $lastStufe); - } else { - $wartefrist = isset($this->stufen[$lastStufe]) ? (int) $this->stufen[$lastStufe]->neue_frist_tage : 7; - $tageSeitMahnung = (int) floor(($today - $lastMahnung->date_mahnung) / 86400); - if ($tageSeitMahnung >= $wartefrist) { - $proposedStufe = $nextStufe; - } else { - $skipReason = $langs->trans('MahnungVorschlagWartefristLaeuft', $lastStufe, $tageSeitMahnung, $wartefrist); - } - } - } + // Zielstufe rein datengetrieben ermitteln + $entscheidung = $this->ermittleZielstufe($lastMahnung, $tageVerzug, $today); + $proposedStufe = $entscheidung['stufe']; + $skipReason = $entscheidung['grund']; // Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen) $betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc); @@ -264,6 +388,8 @@ class MahnungVorschlag return null; } + $zielStufeObj = ($proposedStufe !== null && isset($this->stufen[$proposedStufe])) ? $this->stufen[$proposedStufe] : null; + return array( 'facture_id' => (int) $factureObj->facture_id, 'facture_ref' => $factureObj->facture_ref, @@ -272,8 +398,11 @@ class MahnungVorschlag 'soc_id' => (int) $factureObj->fk_soc, 'soc_nom' => $factureObj->soc_nom, 'soc_tva_intra' => $factureObj->tva_intra, - 'soc_phone' => $factureObj->soc_phone ?? '', - 'soc_email' => $factureObj->soc_email ?? '', + 'soc_siret' => isset($factureObj->soc_siret) ? $factureObj->soc_siret : '', + 'soc_siren' => isset($factureObj->soc_siren) ? $factureObj->soc_siren : '', + 'soc_typent_code' => isset($factureObj->typent_code) ? $factureObj->typent_code : '', + 'soc_phone' => isset($factureObj->soc_phone) ? $factureObj->soc_phone : '', + 'soc_email' => isset($factureObj->soc_email) ? $factureObj->soc_email : '', 'kundentyp' => $kundentyp, 'tage_verzug' => $tageVerzug, 'betrag_offen' => $betragOffen, @@ -281,11 +410,158 @@ class MahnungVorschlag 'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null, 'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null, 'vorgeschlagene_stufe' => $proposedStufe, - 'vorgeschlagene_stufe_label' => $proposedStufe !== null ? $this->stufen[$proposedStufe]->label : null, + 'vorgeschlagene_stufe_label' => $zielStufeObj !== null ? $zielStufeObj->label : null, + 'vorgeschlagene_stufe_ist_erinnerung' => ($zielStufeObj !== null && $zielStufeObj->istErinnerung()) ? 1 : 0, 'skip_reason' => $skipReason, ); } + /** + * Kern der Stufenlogik — komplett datengetrieben, ohne hartkodierte Stufennummern. + * + * 1. Ohne Vormahnung: kleinste aktive Stufe (minStufe()). Dadurch ist Stufe 0 + * (kostenlose Zahlungserinnerung) überhaupt erst erreichbar. + * 2. Mit Vormahnung: naechsteStufeNach() — Lücken in der Nummerierung werden + * übersprungen, eine Obergrenze gibt es nicht. + * 3. Wartefrist ist immer frist_tage der ZIELstufe (nicht neue_frist_tage der + * Vorstufe — dadurch war frist_tage der Folgestufen bisher wirkungslos). + * 4. Bezugspunkt der Wartefrist ist date_versand der letzten Mahnung, ersatzweise + * date_mahnung. Ohne diesen Fallback würden Bestandsmahnungen ohne + * date_versand dauerhaft blockieren. + * + * @param Mahnung|null $lastMahnung Letzte nicht stornierte Mahnung der Rechnung + * @param int $tageVerzug Tage seit Fälligkeit der Rechnung + * @param int $today Unix-Zeit + * @return array array('stufe' => int|null, 'grund' => string|null) + */ + private function ermittleZielstufe($lastMahnung, $tageVerzug, $today) + { + global $langs; + $this->ladeSprache(); + + // --- Einstieg: noch keine Mahnung zu dieser Rechnung --- + if ($lastMahnung === null) { + $zielStufe = $this->minStufe(); + if ($zielStufe === null) { + // loadStufen() hat $this->error bereits gesetzt + return array('stufe' => null, 'grund' => $this->error); + } + $frist = (int) $this->stufen[$zielStufe]->frist_tage; + if ($tageVerzug < $frist) { + return array( + 'stufe' => null, + 'grund' => $langs->trans('MahnungVorschlagFristNichtErreichtStufe', $zielStufe, $frist, $tageVerzug), + ); + } + return array('stufe' => $zielStufe, 'grund' => null); + } + + // --- Folgestufe --- + $lastStufe = (int) $lastMahnung->stufe; + $zielStufe = $this->naechsteStufeNach($lastStufe); + if ($zielStufe === null) { + return array( + 'stufe' => null, + 'grund' => $langs->trans('MahnungVorschlagAlleStufenAusgeschoepft', $lastStufe), + ); + } + + $wartefrist = (int) $this->stufen[$zielStufe]->frist_tage; + + // Bezugspunkt: tatsächlicher Versand, ersatzweise das Mahndatum. + $basis = !empty($lastMahnung->date_versand) ? $lastMahnung->date_versand : $lastMahnung->date_mahnung; + if (empty($basis)) { + // Weder Versand- noch Mahndatum vorhanden (defekter Datensatz): + // konservativ ab jetzt rechnen statt die Rechnung stumm durchzulassen. + $basis = $today; + } + + $tageSeit = (int) floor(($today - $basis) / 86400); + if ($tageSeit < 0) { + $tageSeit = 0; + } + if ($tageSeit < $wartefrist) { + return array( + 'stufe' => null, + 'grund' => $langs->trans('MahnungVorschlagWartefristZielstufe', $zielStufe, $tageSeit, $wartefrist, $lastStufe), + ); + } + + return array('stufe' => $zielStufe, 'grund' => null); + } + + /** + * Kundentyp B2B/B2C bestimmen. + * + * B2B, sobald EINES dieser Merkmale greift: + * - USt-IdNr. (tva_intra) gefüllt + * - siret oder siren gefüllt (Handelsregister-/Unternehmensnummer) + * - fk_typent zeigt auf einen der eindeutigen Unternehmens-/Behördentypen + * aus B2B_TYPENT_CODES_DEFAULT bzw. MAHNUNG_B2B_TYPENT_CODES + * Erst wenn nichts davon zutrifft: B2C. + * + * Warum überhaupt mehr als tva_intra: Kleinunternehmer nach §19 UStG haben oft + * gar keine USt-IdNr. — die Erkennung allein über tva_intra hat sie als + * Privatkunden eingestuft und damit den falschen Verzugszins (5 statt + * 9 Prozentpunkte) sowie keine 40-EUR-Pauschale nach §288 Abs. 5 angesetzt. + * + * Warum fk_typent als WHITELIST und nicht als "alles außer TE_PRIVATE": + * eine Blacklist stuft jeden Datensatz mit gepflegtem Typ automatisch zum + * Unternehmer hoch — auch natürliche Personen, die versehentlich TE_SMALL + * ('TPE') oder TE_OTHER ('Autres') tragen. Gegen die Stammdaten geprüft + * hätte das mehrere reine Privatkunden zu B2B gemacht, mit 40-EUR-Pauschale + * und 9 statt 5 Prozentpunkten Verzugszins — bei einem Verbraucher rechtlich + * nicht haltbar. Die Fehlerrichtung ist bewusst asymmetrisch gewählt: + * ein zu Unrecht als B2C geführter Unternehmer kostet Eddy etwas Zins, + * ein zu Unrecht als B2B gemahnter Verbraucher kostet ihn die Forderung. + * + * @param object $factureObj DB-Reihe + * @return string 'B2B'|'B2C' + */ + private function ermittleKundentyp($factureObj) + { + $tvaIntra = isset($factureObj->tva_intra) ? trim((string) $factureObj->tva_intra) : ''; + $siret = isset($factureObj->soc_siret) ? trim((string) $factureObj->soc_siret) : ''; + $siren = isset($factureObj->soc_siren) ? trim((string) $factureObj->soc_siren) : ''; + $typent = isset($factureObj->typent_code) ? trim((string) $factureObj->typent_code) : ''; + + if ($tvaIntra !== '' || $siret !== '' || $siren !== '') { + return Mahnung::KUNDENTYP_B2B; + } + if ($typent !== '' && in_array(strtoupper($typent), $this->getB2bTypentCodes(), true)) { + return Mahnung::KUNDENTYP_B2B; + } + return Mahnung::KUNDENTYP_B2C; + } + + /** + * Typ-Codes, die allein schon B2B begründen. Einmal je Instanz aufgelöst. + * + * Anpassbar über die Konstante MAHNUNG_B2B_TYPENT_CODES (kommaseparierte + * Codes aus llx_c_typent). Sind die Stammdaten sauber gepflegt, kann dort + * z.B. TE_SMALL ergänzt werden; ein leerer Wert schaltet die Auswertung + * von fk_typent komplett ab (dann zählen nur noch tva_intra/siret/siren). + * + * Der Wert wird von Hand gepflegt, deshalb tolerant einlesen: Leerzeichen + * werden getrimmt und die Codes auf Großschreibung normalisiert. + * + * @return string[] + */ + private function getB2bTypentCodes() + { + if ($this->b2bTypentCodes === null) { + $this->b2bTypentCodes = array(); + $raw = getDolGlobalString('MAHNUNG_B2B_TYPENT_CODES', self::B2B_TYPENT_CODES_DEFAULT); + foreach (explode(',', (string) $raw) as $code) { + $code = strtoupper(trim($code)); + if ($code !== '') { + $this->b2bTypentCodes[] = $code; + } + } + } + return $this->b2bTypentCodes; + } + /** * Offener Betrag = total_ttc - SUM(paiement.amount). * @@ -309,28 +585,83 @@ class MahnungVorschlag } /** - * Stufen einmal in $this->stufen[1..3] cachen. + * Aktive Stufen einmal laden und nach Stufennummer aufsteigend indizieren. + * Unterscheidet sauber zwischen SQL-Fehler und "nichts konfiguriert" — + * beides ist ein Fehlerzustand für die Vorschlagslogik, aber mit + * unterschiedlicher Meldung. + * + * @return bool false = keine nutzbare Stufenkonfiguration ($this->error gesetzt) */ private function loadStufen() { - if (!empty($this->stufen)) { - return; + if (!$this->stufenGeladen) { + $this->stufenGeladen = true; + + $so = new MahnungStufe($this->db); + $liste = $so->fetchAllActive(); + if (!empty($so->error)) { + // fetchAllActive() liefert bei einem SQL-Fehler ebenfalls array() — + // nur $so->error unterscheidet Fehler von Leerstand. + $this->stufenFehler = $so->error; + dol_syslog('MahnungVorschlag::loadStufen SQL-Fehler: '.$so->error, LOG_ERR); + } else { + foreach ($liste as $s) { + $this->stufen[(int) $s->stufe] = $s; + } + // fetchAllActive() sortiert bereits, ksort sichert die Reihenfolge + // unabhängig davon ab — minStufe()/naechsteStufeNach() verlassen sich darauf. + ksort($this->stufen, SORT_NUMERIC); + } } - $so = new MahnungStufe($this->db); - foreach ($so->fetchAllActive() as $s) { - $this->stufen[(int) $s->stufe] = $s; + + $this->ladeSprache(); + global $langs; + + if ($this->stufenFehler !== '') { + $this->setError($langs->trans('MahnungVorschlagSqlFehler', $this->stufenFehler)); + return false; } + if (empty($this->stufen)) { + $this->setError($langs->trans('MahnungVorschlagKeineStufenKonfiguriert')); + return false; + } + return true; } /** - * Gibt die geladene MahnungStufe (1..3) zurück oder null. + * Sprachdatei des Moduls sicherstellen (Mehrfachaufruf ist unkritisch, + * $langs->load() cached intern). * - * @param int $stufe - * @return MahnungStufe|null + * @return void */ - public function getStufe($stufe) + private function ladeSprache() { - $this->loadStufen(); - return $this->stufen[(int) $stufe] ?? null; + global $langs; + $langs->load('mahnung@mahnung'); + } + + /** + * Fehlerkanal zurücksetzen — an jedem öffentlichen Einstiegspunkt. + * + * @return void + */ + private function resetError() + { + $this->error = ''; + $this->errors = array(); + } + + /** + * Fehlermeldung setzen (error + errors[]). + * + * @param string $msg + * @return void + */ + private function setError($msg) + { + $this->error = (string) $msg; + if (!in_array($this->error, $this->errors, true)) { + $this->errors[] = $this->error; + } } } diff --git a/core/boxes/box_mahnung_offen.php b/core/boxes/box_mahnung_offen.php index 68ec38a..c90165f 100644 --- a/core/boxes/box_mahnung_offen.php +++ b/core/boxes/box_mahnung_offen.php @@ -16,6 +16,7 @@ require_once DOL_DOCUMENT_ROOT.'/core/boxes/modules_boxes.php'; require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php'; /** * Widget: Aelteste offene Kundenrechnungen mit Mahnstufe-Badge. @@ -30,6 +31,9 @@ class box_mahnung_offen extends ModeleBoxes /** @var array Zahlprognose je socid — 1 Query pro Kunde/Request (Cache) */ private static $prognoseCache = array(); + /** @var int[]|null Stufennummern mit ist_erinnerung = 1 — 1 Query pro Request (Cache) */ + private static $erinnerungsStufenCache = null; + /** * @param DoliDB $db * @param string $param @@ -80,16 +84,19 @@ class box_mahnung_offen extends ModeleBoxes $sql .= " f.total_ht, f.total_tva, f.total_ttc,"; $sql .= " f.paye, f.fk_statut as status,"; $sql .= " SUM(pf.amount) as am,"; - // Letzte aktive Mahnstufe + // Letzte aktive Mahnstufe. Der Zweit-Sortierschlüssel rowid DESC ist Pflicht: + // ohne ihn könnten die drei Subqueries bei zwei Mahnvorgängen derselben Stufe + // unterschiedliche Zeilen treffen (Badge-Datum und Kartenlink aus verschiedenen + // Mahnungen). Stufe 0 (Zahlungserinnerung) ist eine gültige Stufe. $sql .= " (SELECT m2.stufe FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m2"; $sql .= " WHERE m2.fk_facture = f.rowid AND m2.status != ".((int) Mahnung::STATUS_STORNIERT); - $sql .= " ORDER BY m2.stufe DESC LIMIT 1) as mahnstufe,"; + $sql .= " ORDER BY m2.stufe DESC, m2.rowid DESC LIMIT 1) as mahnstufe,"; $sql .= " (SELECT m3.date_mahnung FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m3"; $sql .= " WHERE m3.fk_facture = f.rowid AND m3.status != ".((int) Mahnung::STATUS_STORNIERT); - $sql .= " ORDER BY m3.stufe DESC LIMIT 1) as mahndatum,"; + $sql .= " ORDER BY m3.stufe DESC, m3.rowid DESC LIMIT 1) as mahndatum,"; $sql .= " (SELECT m4.rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung as m4"; $sql .= " WHERE m4.fk_facture = f.rowid AND m4.status != ".((int) Mahnung::STATUS_STORNIERT); - $sql .= " ORDER BY m4.stufe DESC LIMIT 1) as mahnid"; + $sql .= " ORDER BY m4.stufe DESC, m4.rowid DESC LIMIT 1) as mahnid"; $sql .= " FROM ".MAIN_DB_PREFIX."facture as f"; $sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."paiement_facture as pf ON f.rowid = pf.fk_facture"; @@ -143,6 +150,10 @@ class box_mahnung_offen extends ModeleBoxes $boxmax = getDolGlobalInt('MAHNUNG_BOX_MAXLINES', 0); $renderLimit = ($boxmax > 0) ? min($num, $boxmax) : $num; + // Stufen-Konfiguration einmal laden — daraus kommt die Kennzeichnung + // "kostenlose Zahlungserinnerung" (Flag ist_erinnerung) für das Badge. + $erinnerungsStufen = $this->getErinnerungsStufen(); + while ($line < $renderLimit) { $objp = $this->db->fetch_object($result); @@ -177,17 +188,36 @@ class box_mahnung_offen extends ModeleBoxes $late = img_warning(sprintf($l_due_date, dol_print_date($datelimit, 'day', 'tzuserrel'))); } - // Mahnstufe-Badge (mit Link zur Mahnung) oder Strich für keine Mahnung + // Mahnstufe-Badge (mit Link zur Mahnung) oder Strich für keine Mahnung. + // KEIN empty(): die Zahlungserinnerung hat per Default die Stufennummer 0, + // empty('0') wäre true — die Zeile würde fälschlich als "keine Mahnung" + // (Strich, kein Link zur Karte) erscheinen. Nur NULL/'' bedeutet + // "zu dieser Rechnung existiert kein Mahnvorgang". $mahnCell = ''; - if (!empty($objp->mahnstufe)) { + if ($objp->mahnstufe !== null && $objp->mahnstufe !== '') { $stufe = (int) $objp->mahnstufe; - $colors = array(1 => '#4a90d9', 2 => '#e68a00', 3 => '#cc3333'); - $color = $colors[$stufe] ?? '#666'; - $label = $langs->trans('MahnungBoxStufe', $stufe); + // Ob es eine kostenlose Zahlungserinnerung ist, entscheidet ausschließlich + // das Flag ist_erinnerung der Stufe — NICHT die Stufennummer. + $istErinnerung = in_array($stufe, $erinnerungsStufen, true); $mahnDatum = $objp->mahndatum ? dol_print_date($this->db->jdate($objp->mahndatum), 'day') : ''; - $tooltip = $mahnDatum ? $langs->trans('MahnungBoxStufeVom', $stufe, $mahnDatum) : $label; - $badge = ''.$label.''; - if (!empty($objp->mahnid)) { + + // Farbskala kommt aus lib/mahnung_ui.lib.php — dieselbe Quelle wie auf + // der Mahnungskarte und in der Vorschlagsliste. + $color = mahnungStufeFarbe($stufe, $istErinnerung); + if ($istErinnerung) { + $label = $langs->trans('MahnungBoxErinnerung'); + $tooltip = $mahnDatum + ? $langs->trans('MahnungBoxErinnerungVom', $mahnDatum) + : $langs->trans('MahnungIstErinnerung'); + } else { + // Im Widget bewusst nur "Stufe N" statt der ausgeschriebenen + // Bezeichnung: die Spalte ist schmal, der Klick führt zur Karte. + $label = $langs->trans('MahnungBoxStufe', $stufe); + $tooltip = $mahnDatum ? $langs->trans('MahnungBoxStufeVom', $stufe, $mahnDatum) : $label; + } + + $badge = ''.dol_escape_htmltag($label).''; + if ((int) $objp->mahnid > 0) { $mahnCell = ''.$badge.''; } else { $mahnCell = $badge; @@ -313,6 +343,50 @@ class box_mahnung_offen extends ModeleBoxes $this->db->free($result); } + /** + * Stufennummern der Entity, die als kostenlose Zahlungserinnerung konfiguriert + * sind (Flag ist_erinnerung = 1). Maßgeblich ist das Flag, NICHT die Nummer — + * Stufennummern sind frei wählbar. Wird pro Request genau einmal gelesen + * (kleine Tabelle, wenige Zeilen). + * + * Bewusst "SELECT *": auf Installationen, auf denen die Schema-Migration + * (Setup-Seite einmal aufrufen) noch nicht lief, fehlt die Spalte + * ist_erinnerung — ein explizites Feld-SELECT würde die Query und damit das + * ganze Widget in einen SQL-Fehler laufen lassen. Dort bleibt die Liste leer + * und das Widget zeigt wie bisher nur die Stufennummer. + * Auch INAKTIVE Stufen werden geladen, damit Bestandsmahnungen auf einer + * später deaktivierten Stufe weiterhin richtig beschriftet werden. + * + * @return int[] Stufennummern mit ist_erinnerung = 1 + */ + private function getErinnerungsStufen() + { + if (self::$erinnerungsStufenCache !== null) { + return self::$erinnerungsStufenCache; + } + + self::$erinnerungsStufenCache = array(); + + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."mahnung_stufe"; + $sql .= " WHERE entity IN (".getEntity('mahnung').")"; + + $resql = $this->db->query($sql); + if (!$resql) { + // Kein harter Fehler: das Widget fällt dann auf die reine Stufennummer zurück + dol_syslog("box_mahnung_offen::getErinnerungsStufen SQL-Fehler: ".$this->db->lasterror(), LOG_ERR); + return self::$erinnerungsStufenCache; + } + while ($obj = $this->db->fetch_object($resql)) { + // isset-Guard: Spalte fehlt, solange die Schema-Migration noch nicht lief + if (!empty($obj->ist_erinnerung)) { + self::$erinnerungsStufenCache[] = (int) $obj->stufe; + } + } + $this->db->free($resql); + + return self::$erinnerungsStufenCache; + } + /** * Zahlungsverhalten eines Kunden aus seinen bezahlten Rechnungen. * diff --git a/core/modules/mahnung/doc/doc_generic_mahnung_odt.modules.php b/core/modules/mahnung/doc/doc_generic_mahnung_odt.modules.php index d73741c..eabb5ef 100644 --- a/core/modules/mahnung/doc/doc_generic_mahnung_odt.modules.php +++ b/core/modules/mahnung/doc/doc_generic_mahnung_odt.modules.php @@ -118,6 +118,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung $texthelp .= '
Mahnung-spezifisch:
'; $texthelp .= '{mahnung_ref}, {mahnung_stufe}, {mahnung_stufe_label}, {mahnung_date},
'; $texthelp .= '{mahnung_betrag_offen}, {mahnung_mahngebuehr}, {mahnung_verzugszinsen},
'; + $texthelp .= '{mahnung_kosten_vorstufen}, {mahnung_pauschale_b2b},
'; $texthelp .= '{mahnung_summe}, {mahnung_basiszins}, {mahnung_zinssatz}, {mahnung_kundentyp},
'; $texthelp .= '{mahnung_pdf_intro}, {mahnung_date_lim_alt}, {mahnung_date_lim_neu}
'; $texthelp .= '
Rechnungsdaten:
'; @@ -125,7 +126,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung $texthelp .= '
Bankdaten:
'; $texthelp .= '{mahnung_bank_label}, {mahnung_bank_iban}, {mahnung_bank_bic}
'; $texthelp .= '
Stufen-spezifische Templates:
'; - $texthelp .= 'mahnung_stufe1.odt, mahnung_stufe2.odt, mahnung_stufe3.odt
'; + $texthelp .= 'mahnung_stufe{Nummer}.odt — z.B. mahnung_stufe1.odt, mahnung_stufe2.odt
'; $texte .= $form->textwithpicto($texttitle, $texthelp, 1, 'help', '', 1, 3, $this->name); $texte .= '
'; @@ -151,13 +152,13 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung $texte .= '
'; } - // Hinweis zur Benennung + // Hinweis zur Benennung — Stufennummern sind frei konfigurierbar $texte .= '
'; $texte .= 'Dateinamen-Konvention:
'; - $texte .= 'mahnung_stufe1.odt = Zahlungserinnerung (Stufe 1)
'; - $texte .= 'mahnung_stufe2.odt = 1. Mahnung (Stufe 2)
'; - $texte .= 'mahnung_stufe3.odt = Letzte Mahnung (Stufe 3)
'; - $texte .= 'mahnung.odt = Fallback für alle Stufen'; + $texte .= 'mahnung_stufe1.odt = Template für Stufe 1
'; + $texte .= 'mahnung_stufe2.odt = Template für Stufe 2 usw.
'; + $texte .= 'mahnung.odt = Fallback für alle Stufen
'; + $texte .= 'Für Zahlungserinnerungen wird kein Dokument erzeugt — dort ist die Original-Rechnung der Anhang.'; $texte .= '
'; // Upload-Feld @@ -231,7 +232,19 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung $mahnung->thirdparty = $societe; $stufeObj = new MahnungStufe($this->db); - $stufeObj->fetchByStufe((int) $mahnung->stufe); + if ($stufeObj->fetchByStufe((int) $mahnung->stufe) <= 0) { + // Nicht abbrechen: die Stufe kann zwischenzeitlich gelöscht worden sein, + // das Dokument soll trotzdem noch erzeugbar bleiben. + dol_syslog('doc_generic_mahnung_odt::write_file Stufe '.((int) $mahnung->stufe).' nicht konfiguriert', LOG_WARNING); + } + + // Zahlungserinnerungen bekommen KEIN eigenes Dokument: Anhang ist die + // unveränderte Original-Rechnung, Kosten entstehen keine. + if ($stufeObj->istErinnerung()) { + $this->error = $outputlangs->trans('MahnungPdfNichtFuerErinnerung', $stufeObj->label); + $outputlangs->charset_output = $sav_charset_output; + return -1; + } // Stufen-spezifisches Template suchen $srctemplatepath = $this->findStufeTemplate($srctemplatepath, (int) $mahnung->stufe); @@ -382,7 +395,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung * Fallback: das übergebene generische Template. * * @param string $srctemplatepath Generisches Template (von commonGenerateDocument) - * @param int $stufe Mahnstufe 1-3 + * @param int $stufe Stufennummer (frei konfigurierbar) * @return string Pfad zum besten Template */ private function findStufeTemplate($srctemplatepath, $stufe) @@ -421,6 +434,11 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung ? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0') : (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); + // {mahnung_zinssatz} muss dem Satz entsprechen, mit dem tatsächlich gerechnet + // wurde: Stufen-Override hat Vorrang, sonst Basiszins + Aufschlag. + $override = is_object($stufe) ? $stufe->getZinssatzOverride($mahnung->customertype) : null; + $zinssatz = ($override !== null) ? (float) $override : ($basiszins + $aufschlag); + $intro = (string) ($stufe->pdf_intro ?? ''); if (empty($intro)) { $intro = $this->defaultIntro((int) $mahnung->stufe); @@ -439,9 +457,10 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung 'mahnung_mahngebuehr' => price((float) $mahnung->mahngebuehr), 'mahnung_pauschale_b2b' => price((float) $mahnung->pauschale_b2b), 'mahnung_verzugszinsen' => price((float) $mahnung->verzugszinsen), + 'mahnung_kosten_vorstufen' => price((float) $mahnung->kosten_vorstufen), 'mahnung_summe' => price((float) $mahnung->summe_mahnung), 'mahnung_basiszins' => number_format($basiszins, 2, ',', '.'), - 'mahnung_zinssatz' => number_format($basiszins + $aufschlag, 2, ',', '.'), + 'mahnung_zinssatz' => number_format($zinssatz, 2, ',', '.'), 'mahnung_kundentyp' => $mahnung->customertype, 'mahnung_versandart' => $mahnung->versandart ?? '', 'mahnung_pdf_intro' => $intro, @@ -479,7 +498,8 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung } /** - * Default-Intro je Stufe. + * Default-Intro je Stufe. Stufennummern sind frei konfigurierbar — alles oberhalb + * von 3 bekommt den schärfsten Text, alles unterhalb von 2 den mildesten. * * @param int $stufe * @return string @@ -489,6 +509,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung global $langs; $langs->load('mahnung@mahnung'); switch ((int) $stufe) { + case 0: case 1: return $langs->trans('MahnungPdfDefaultIntro1'); case 2: diff --git a/core/modules/mahnung/doc/pdf_standard_mahnung.modules.php b/core/modules/mahnung/doc/pdf_standard_mahnung.modules.php index be011cb..f065fc3 100644 --- a/core/modules/mahnung/doc/pdf_standard_mahnung.modules.php +++ b/core/modules/mahnung/doc/pdf_standard_mahnung.modules.php @@ -82,6 +82,15 @@ class pdf_standard_mahnung extends ModelePDFMahnung return -1; } + // Zahlungserinnerungen bekommen KEIN Mahn-PDF: Anhang ist die unveränderte + // Original-Rechnung, Kosten entstehen keine. Der Anlage-Pfad ruft den Generator + // dafür gar nicht erst auf — dieser Wächter verhindert, dass über "Dokument neu + // erzeugen" oder ein konfiguriertes Standardmodell doch eines entsteht. + if ($stufeObj->istErinnerung()) { + $this->error = $outputlangs->trans('MahnungPdfNichtFuerErinnerung', $stufeObj->label); + return -1; + } + // Ziel-Verzeichnis $dirOutput = $this->getOutputDir($mahnung, $facture); if (dol_mkdir($dirOutput) < 0) { @@ -111,6 +120,17 @@ class pdf_standard_mahnung extends ModelePDFMahnung $pdf->Output($absPath, 'F'); + // Schreibvorgang verifizieren — TCPDF meldet einen fehlgeschlagenen Write + // nicht zurück. Ohne Prüfung stünde ein pdf_path in der DB, hinter dem keine + // (oder eine leere) Datei liegt. + clearstatcache(true, $absPath); + if (!file_exists($absPath) || filesize($absPath) <= 0) { + $this->error = $outputlangs->trans('MahnungPdfSchreibfehler', $absPath); + dol_syslog('pdf_standard_mahnung::write_file '.$this->error, LOG_ERR); + return -1; + } + dolChmod($absPath); + // Pfad in DB persistieren $mahnung->pdf_path = $absPath; $mahnung->update($user); @@ -212,6 +232,13 @@ class pdf_standard_mahnung extends ModelePDFMahnung $pdf->SetFont('helvetica', '', 10); $pdf->Cell(130, 6, $outputlangs->trans('MahnungBetragOffen'), 0, 0, 'L'); $pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R'); + // Kosten der Vorstufen ausweisen — sie stecken in summe_mahnung, ohne eigene + // Zeile würde die Gesamtsumme im Brief nicht mit den Positionen aufgehen. + if ((float) $mahnung->kosten_vorstufen > 0) { + $pdf->SetX(25); + $pdf->Cell(130, 6, $outputlangs->trans('MahnungKostenVorstufen'), 0, 0, 'L'); + $pdf->Cell(30, 6, price((float) $mahnung->kosten_vorstufen).' EUR', 0, 1, 'R'); + } if ((float) $mahnung->mahngebuehr > 0) { $pdf->SetX(25); $pdf->Cell(130, 6, $outputlangs->trans('MahnungGebuehr'), 0, 0, 'L'); @@ -224,11 +251,7 @@ class pdf_standard_mahnung extends ModelePDFMahnung } if ((float) $mahnung->verzugszinsen > 0) { $pdf->SetX(25); - $basis = $mahnung->basiszins_snapshot !== null ? (float) $mahnung->basiszins_snapshot : 0.0; - $auf = $mahnung->customertype === Mahnung::KUNDENTYP_B2B - ? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0') - : (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); - $satz = $basis + $auf; + $satz = $this->getAusgewiesenerZinssatz($mahnung, $stufe); $pdf->Cell(130, 6, $outputlangs->trans('MahnungVerzugszinsen').' ('.number_format($satz, 2, ',', '.').' %)', 0, 0, 'L'); $pdf->Cell(30, 6, price((float) $mahnung->verzugszinsen).' EUR', 0, 1, 'R'); } @@ -258,6 +281,32 @@ class pdf_standard_mahnung extends ModelePDFMahnung $pdf->Cell(0, 5, (string) $mysoc->name, 0, 1, 'L'); } + /** + * Zinssatz, der im Brief ausgewiesen wird. + * + * Muss exakt dem Satz entsprechen, mit dem Mahnung::berechneVerzugszinsen() + * gerechnet hat: der Stufen-Override hat Vorrang, nur ohne Override gilt + * Basiszins-Snapshot + Aufschlag. Die frühere Rekonstruktion aus + * basiszins + aufschlag hat bei gesetztem Override einen anderen Prozentsatz + * ausgewiesen als tatsächlich berechnet wurde. + * + * @param Mahnung $mahnung + * @param MahnungStufe $stufe + * @return float Prozentsatz p.a. + */ + private function getAusgewiesenerZinssatz($mahnung, $stufe) + { + $override = is_object($stufe) ? $stufe->getZinssatzOverride($mahnung->customertype) : null; + if ($override !== null) { + return (float) $override; + } + $basis = $mahnung->basiszins_snapshot !== null ? (float) $mahnung->basiszins_snapshot : 0.0; + $aufschlag = $mahnung->customertype === Mahnung::KUNDENTYP_B2B + ? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0') + : (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0'); + return $basis + $aufschlag; + } + /** * Fusszeile mit Bankverbindung + Firmen-Footer. */ @@ -306,7 +355,9 @@ class pdf_standard_mahnung extends ModelePDFMahnung } /** - * Default-Intro je Stufe. + * Default-Intro je Stufe. Greift nur, wenn die Stufe keinen eigenen pdf_intro-Text hat. + * Stufennummern sind frei konfigurierbar — alles oberhalb von 3 bekommt den + * schärfsten Text, alles unterhalb von 2 den mildesten. * * @param int $stufe * @return string @@ -316,6 +367,7 @@ class pdf_standard_mahnung extends ModelePDFMahnung global $langs; $langs->load('mahnung@mahnung'); switch ((int) $stufe) { + case 0: case 1: return $langs->trans('MahnungPdfDefaultIntro1'); case 2: diff --git a/core/modules/modMahnung.class.php b/core/modules/modMahnung.class.php index 13d3ba7..6f8134a 100644 --- a/core/modules/modMahnung.class.php +++ b/core/modules/modMahnung.class.php @@ -14,7 +14,8 @@ /** * \defgroup mahnung Modul Mahnwesen - * \brief Mahnwesen-Modul (3-stufig nach BGB §288) + * \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 */ @@ -26,6 +27,19 @@ include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; */ 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 */ @@ -54,7 +68,9 @@ class modMahnung extends DolibarrModules $this->editor_name = 'Alles Watt läuft'; $this->editor_url = ''; - $this->version = '0.2.0'; + // 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); @@ -325,13 +341,21 @@ class modMahnung extends DolibarrModules { 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; } - // Migration: Versand-Felder ergänzen, falls Tabelle aus alter Version stammt + // 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 @@ -368,35 +392,413 @@ class modMahnung extends DolibarrModules } /** - * Ergänzt Versand- und Tracking-Felder an llx_mahnung_mahnung, wenn sie - * in einer älteren Schema-Version noch fehlen. Idempotent — fehlende - * Spalten werden geprüft via SHOW COLUMNS und nur dann hinzugefügt. + * 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 $db; + global $conf; - $alter = array(); - $cols = array( + $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) { - $res = $db->query("SHOW COLUMNS FROM ".MAIN_DB_PREFIX."mahnung_mahnung LIKE '".$db->escape($col)."'"); - if ($res && $db->num_rows($res) == 0) { + $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; } - if (!empty($alter)) { - $db->query("ALTER TABLE ".MAIN_DB_PREFIX."mahnung_mahnung ".implode(', ', $alter)); + $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); } /** @@ -412,7 +814,7 @@ class modMahnung extends DolibarrModules */ public function migrateTimestampSpalten() { - global $db; + $db = $this->db; $tables = array('mahnung_mahnung', 'mahnung_stufe', 'mahnung_trackingpattern'); foreach ($tables as $table) { diff --git a/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php b/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php index accb045..08003ec 100644 --- a/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php +++ b/core/triggers/interface_99_modMahnung_MahnungTriggers.class.php @@ -41,7 +41,9 @@ class InterfaceMahnungTriggers extends DolibarrTriggers * @param User $user aktueller User * @param Translate $langs Sprache * @param Conf $conf Konfig - * @return int <0 Fehler, 0 nichts getan, >0 OK + * @return int 0 nichts getan, >0 OK — bewusst NIE <0, weil + * Dolibarr sonst die auslösende Transaktion + * (z.B. die Zahlungsbuchung) zurückrollt. */ public function runTrigger($action, $object, User $user, Translate $langs, Conf $conf) { @@ -70,9 +72,14 @@ class InterfaceMahnungTriggers extends DolibarrTriggers /** * Setzt alle offenen Mahnvorgänge zur Rechnung auf STATUS_ERLEDIGT. * + * WICHTIG: Dieser Trigger darf NIEMALS einen negativen Wert liefern. Dolibarr + * rollt bei <0 die auslösende Transaktion zurück — eine kaputte oder noch nicht + * migrierte Mahnungs-Tabelle würde damit die Zahlungsbuchung des Users + * verhindern. Fehler werden deshalb nur geloggt, Rückgabe bleibt 0. + * * @param int $factureId * @param User $user - * @return int >=0 Anzahl aktualisierter Mahnungen, <0 Fehler + * @return int >=0 Anzahl aktualisierter Mahnungen (0 auch im Fehlerfall) */ private function onRechnungBezahlt($factureId, User $user) { @@ -80,11 +87,14 @@ class InterfaceMahnungTriggers extends DolibarrTriggers $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; $sql .= " WHERE fk_facture = ".((int) $factureId); + $sql .= " AND entity IN (".getEntity('mahnung').")"; $sql .= " AND status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")"; $resql = $this->db->query($sql); if (!$resql) { - return -1; + // Kein -1: das würde die Zahlungsbuchung des Users zurückrollen + dol_syslog('InterfaceMahnungTriggers::onRechnungBezahlt SQL-Fehler: '.$this->db->lasterror(), LOG_ERR); + return 0; } $count = 0; @@ -92,6 +102,9 @@ class InterfaceMahnungTriggers extends DolibarrTriggers $m = new Mahnung($this->db); if ($m->fetch((int) $obj->rowid) > 0 && $m->setErledigt($user) > 0) { $count++; + } else { + // Nur protokollieren — ein einzelner Fehlschlag darf die Buchung nicht kippen + dol_syslog('InterfaceMahnungTriggers::onRechnungBezahlt Mahnung '.((int) $obj->rowid).' nicht erledigt: '.$m->error, LOG_WARNING); } } $this->db->free($resql); @@ -144,10 +157,12 @@ class InterfaceMahnungTriggers extends DolibarrTriggers private function hatOffeneMahnungen() { $sql = "SELECT COUNT(*) AS nb FROM ".MAIN_DB_PREFIX."mahnung_mahnung"; - $sql .= " WHERE status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")"; + $sql .= " WHERE entity IN (".getEntity('mahnung').")"; + $sql .= " AND status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")"; $resql = $this->db->query($sql); if (!$resql) { + dol_syslog('InterfaceMahnungTriggers::hatOffeneMahnungen SQL-Fehler: '.$this->db->lasterror(), LOG_ERR); return true; // Im Zweifel Badge stehen lassen } $obj = $this->db->fetch_object($resql); @@ -168,7 +183,12 @@ class InterfaceMahnungTriggers extends DolibarrTriggers $sql .= " GROUP BY f.rowid, f.total_ttc"; $resql = $this->db->query($sql); - if (!$resql || !$this->db->num_rows($resql)) { + if (!$resql) { + dol_syslog('InterfaceMahnungTriggers::istRechnungVollBezahlt SQL-Fehler: '.$this->db->lasterror(), LOG_ERR); + return false; + } + if (!$this->db->num_rows($resql)) { + $this->db->free($resql); return false; } $obj = $this->db->fetch_object($resql); diff --git a/langs/de_DE/mahnung.lang b/langs/de_DE/mahnung.lang index 3dfd365..9fcbc95 100644 --- a/langs/de_DE/mahnung.lang +++ b/langs/de_DE/mahnung.lang @@ -19,6 +19,7 @@ PermMahnungSetup = Mahnwesen konfigurieren # # Menüs # +MahnungSetupOeffnen = Mahnwesen-Einstellungen öffnen MahnungMenu = Mahnwesen MahnungVorschlagsliste = Vorschlagsliste MahnungArchiv = Mahnvorgänge @@ -40,10 +41,67 @@ MahnungStufeZinssatzB2B = Zinssatz B2B (Override) MahnungZinssatzHelpB2C = Leer = Standard (%s + %s %% = %s %%), 0 = keine Zinsen MahnungZinssatzHelpB2B = Leer = Standard (%s + %s %% = %s %%), 0 = keine Zinsen MahnungStufeVersandartDefault = Versandart-Default +MahnungSenderMail = Absender-Adresse für E-Mails +MahnungSenderMailHelp = Leer lassen = E-Mail-Adresse der Firma, sonst die globale Dolibarr-Absenderadresse. +MahnungSenderName = Absender-Name für E-Mails +MahnungSenderNameHelp = Leer lassen = Firmenname. +MahnungSenderMailUngueltig = Die Absender-Adresse %s ist keine gültige E-Mail-Adresse — es wurde nichts gespeichert. +MahnungPlatzhalterHilfe = Platzhalter (beide Schreibweisen möglich): __REF__ / {rechnung} = Rechnungsnummer · __SUMME__ / {summe} = offener Betrag · __FRIST__ / {frist} = neue Zahlungsfrist · __FAELLIG__ / {faellig} = ursprüngliche Fälligkeit · __KUNDE__ / {kunde} = Kundenname · __FIRMA__ / {firma} = eigener Firmenname · __STUFE__ / {stufe} = Stufennummer · __MAHNUNGREF__ / {ref} = Nummer des Mahnvorgangs MahnungStufeEmailSubject = E-Mail-Betreff MahnungStufeEmailBody = E-Mail-Text MahnungStufePdfIntro = PDF-Einleitungstext +# +# Stufen-Verwaltung (frei konfigurierbare Stufen + Zahlungserinnerung) +# +MahnungStufen = Mahnstufen +MahnungStufenIntro = Beliebig viele Stufen konfigurierbar. Die Mahnkette läuft aufsteigend nach Stufennummer, vorgeschlagen werden nur aktive Stufen. Die Stufennummer selbst ist nach dem Anlegen nicht mehr änderbar. +MahnungStufeKeine = Keine Mahnstufen konfiguriert — bitte unten eine anlegen. +MahnungStufeIstErinnerung = Zahlungserinnerung (kostenlos) +MahnungStufeIstErinnerungHelp = Keine Mahngebühr, keine 40-EUR-Pauschale nach §288 Abs. 5 und keine Verzugszinsen. Versand per E-Mail mit der unveränderten Original-Rechnung als Anhang. +MahnungStufeErinnerungKostenHinweis = Zahlungserinnerung: Gebühren, Pauschale und Zinsen werden nicht angewendet. Die gespeicherten Werte bleiben erhalten und gelten wieder, sobald das Häkchen entfernt wird. +MahnungStufeNichtAngewendet = wird bei einer Zahlungserinnerung nicht angewendet +MahnungStufeErinnerungVersandHinweis = Zahlungserinnerungen werden ausschließlich per E-Mail versendet — bitte Versandart „E-Mail" wählen. +MahnungStufeNeu = Neue Mahnstufe anlegen +MahnungStufeNummer = Stufennummer +MahnungStufeNummerHelp = Frei wählbar (0 bis 127). Die Reihenfolge der Mahnkette ergibt sich aus dieser Nummer, Lücken sind erlaubt. +MahnungStufeAnlegen = Stufe anlegen +MahnungStufeAngelegt = Mahnstufe angelegt. +MahnungStufeGeloescht = Mahnstufe gelöscht. +MahnungStufeLoeschenTitel = Mahnstufe löschen +MahnungStufeLoeschenFrage = Mahnstufe %s (%s) wirklich löschen? Die Konfiguration dieser Stufe geht dabei verloren. +MahnungStufeInVerwendung = wird von %s Mahnvorgang/-vorgängen verwendet — nicht löschbar +MahnungStufeNichtLoeschbar = Diese Mahnstufe kann nicht gelöscht werden, weil bereits Mahnvorgänge auf sie verweisen. Bitte deaktivieren statt löschen. +MahnungStufeNichtLoeschbarAnzahl = Stufe %s wird von %s Mahnvorgang/-vorgängen verwendet und kann nicht gelöscht werden. Bitte stattdessen deaktivieren (Haken bei „Aktiv" entfernen). +MahnungStufeNichtGefunden = Mahnstufe nicht gefunden. +MahnungStufePruefungFehlgeschlagen = Prüfung auf vorhandene Mahnvorgänge fehlgeschlagen — die Stufe wurde nicht gelöscht. +MahnungStufeNummerUngueltig = Stufennummer muss eine ganze Zahl zwischen 0 und 127 sein. +MahnungStufeNummerVergeben = Die Stufennummer %s ist bereits vergeben. +MahnungStufeLabelFehlt = Stufe %s: Bezeichnung fehlt. +MahnungStufeWertUngueltig = Stufe %s: %s ist ungültig — bitte eine Zahl ab 0 eingeben. +MahnungStufeZinssatzUngueltig = Stufe %s: %s muss zwischen 0 und 100 liegen (leer = Standardzinssatz, 0 = keine Zinsen). +MahnungStufeUngueltig = Ungültige Mahnstufe — die angeforderte Stufe ist nicht oder nicht aktiv konfiguriert. + +# +# Setup: Akkordeon-Kopfzeile einer Mahnstufe +# +MahnungStufenAkkordeonHilfe = Klick auf eine Kopfzeile öffnet die Einstellungen dieser Stufe. Alle Stufen starten zugeklappt. +MahnungStufeKopfKostenlos = kostenlos +MahnungStufeKopfSofort = sofort +MahnungStufeKopfNachTagen = nach %s Tagen +MahnungStufeKopfGebuehrTitel = Mahngebühr B2C / B2B +MahnungStufeInaktiv = inaktiv +MahnungStufeNichtLoeschbarKurz = nicht löschbar + +# +# Setup: Blöcke innerhalb einer Mahnstufe +# +MahnungBlockFristen = Fristen +MahnungBlockKosten = Kosten +MahnungBlockVersand = Versand +MahnungBlockZinssatzOverride = Zinssatz abweichend festlegen +MahnungBlockTexte = Texte für das Schreiben + # # Status # @@ -89,6 +147,11 @@ MahnungVersandGespeichert = Versanddaten gespeichert MahnungVersandGeleert = Versanddaten zurückgesetzt MahnungSendebelege = Sendebelege MahnungSendebelegeHint = Hier Beleg von Post/DHL/Fax/Mail hochladen (PDF, Foto). Bleibt am Mahnvorgang für spätere Nachweise. +MahnungVersandStatus = Versandstatus +MahnungVersandart = Versandart +MahnungEmpfaenger = Empfänger +MahnungDateVersand = Versendet am +MahnungNochNichtVersendet = noch nicht versendet # # Tracking-Patterns (Phase 3) @@ -168,6 +231,8 @@ MahnungPauschaleB2B = Pauschale (40 € §288) MahnungVerzugszinsen = Verzugszinsen MahnungSumme = Gesamtsumme MahnungBasiszinsSnapshot = Basiszins (Snapshot) +MahnungZinssatzOverrideStufe = Override der Mahnstufe +MahnungZinssatzAusBasiszins = Basiszins %s %% + Aufschlag MahnungLetzteMahnung = Letzte Mahnung MahnungVorgeschlageneStufe = Vorgeschlagene Stufe MahnungAktion = Aktion @@ -178,6 +243,65 @@ MahnungKeineUeberfaelligen = Keine überfälligen Rechnungen vorhanden. MahnungUebersprungen = Aktuell übersprungene Rechnungen MahnungUebersprungenHint = Diese Rechnungen sind überfällig, werden aber aktuell nicht vorgeschlagen (Wartefrist läuft noch oder alle Mahnstufen ausgeschöpft). MahnungSkipGrund = Grund +MahnungUebersprungenFehler = Die Liste der übersprungenen Rechnungen konnte nicht ermittelt werden: %s +MahnungArchivFehler = Die Mahnvorgänge konnten nicht geladen werden: %s + +# +# Zahlungserinnerung (kostenlose Vorstufe) +# +MahnungIstErinnerung = Zahlungserinnerung +MahnungKosten = Kosten +MahnungErinnerungKostenfrei = Kostenlos — keine Mahngebühr, keine Pauschale nach §288 Abs. 5 BGB, keine Verzugszinsen. +MahnungKostenVorstufen = Kosten vorheriger Mahnstufen +MahnungErinnerungAnhangHinweis = Für die Zahlungserinnerung wird kein eigenes Mahn-PDF erzeugt. Angehängt wird die unveränderte Original-Rechnung (%s). +MahnungErinnerungKeinPdf = Für eine Zahlungserinnerung wird kein Mahn-PDF erzeugt — angehängt wird die Original-Rechnung. +MahnungErinnerungSenden = Zahlungserinnerung per E-Mail senden +MahnungErinnerungSendenHint = Öffnet das E-Mail-Formular: Empfänger, Betreff, Text und Anhang lassen sich vor dem Versand prüfen und ändern. +MahnungErinnerungMailHint = Betreff und Text stammen aus der Stufen-Konfiguration und sind hier frei änderbar. Angehängt ist die unveränderte Original-Rechnung. Versendet wird erst mit "Senden". +MahnungErinnerungErneutSenden = Zahlungserinnerung erneut senden +MahnungErinnerungErneutSendenHint = Verschickt die Zahlungserinnerung noch einmal — z. B. wenn die Mail nicht angekommen ist oder an eine andere Adresse gehen soll. Empfänger, Betreff und Text lassen sich vorher ändern. +MahnungErinnerungErneutSendenWarnung = Diese Zahlungserinnerung wurde bereits am %s versendet. Beim Absenden geht sie ein weiteres Mal raus — bitte den Empfänger unten prüfen. +MahnungDokument = Dokument +MahnungVorschau = Vorschau +MahnungErinnerungAnhangOriginal = Anhang der E-Mail +MahnungErinnerungRechnungsPdfNochNicht = Die Original-Rechnung liegt noch nicht als PDF vor — sie wird beim Öffnen des E-Mail-Formulars erzeugt und dann hier angezeigt. +MahnungErinnerungGesendet = Zahlungserinnerung wurde per E-Mail versendet. +MahnungErinnerungSendenFehler = E-Mail-Versand fehlgeschlagen. +MahnungErinnerungenErstelltHinweis = , davon %s Zahlungserinnerung(en) — der E-Mail-Versand erfolgt erst nach Bestätigung auf der Mahnungs-Karte +MahnungErinnerungOhneEmailHinweis = — Achtung: %s Zahlungserinnerung(en) ohne gültige E-Mail-Adresse, also nicht per Mail versendbar: %s. Bitte E-Mail-Adresse beim Kunden nachtragen oder die Erinnerung ausdrucken und per Post schicken. +MahnungSammelbriefNurErinnerungen = Die Auswahl enthält ausschließlich Zahlungserinnerungen. Diese erzeugen kein Mahn-PDF und werden per E-Mail mit der Original-Rechnung versendet. + +# +# E-Mail-Versand der Zahlungserinnerung +# +MahnungUneinbringlichKeinRechnungsrecht = Die Rechnung kann nicht abgeschrieben werden: dafür wird zusätzlich das Recht "Rechnungen anlegen/ändern" benötigt. +MahnungMailProtokoll = Versendete E-Mails +MahnungMailVon = Von +MahnungMailAn = An +MahnungMailKopie = Kopie +MahnungMailBlindkopie = Blindkopie +MahnungMailBetreff = Betreff +MahnungMailAnhaenge = Anhänge +MahnungMailNurErinnerung = E-Mail-Versand ist nur für kostenlose Zahlungserinnerungen möglich. Echte Mahnungen werden per Post bzw. Einschreiben versendet. +MahnungMailNichtErlaubt = Für diesen Vorgang ist kein E-Mail-Versand möglich — geprüft werden Berechtigung, Erinnerungsstufe, Status und die E-Mail-Adresse des Kunden. +MahnungMailKeinEmpfaenger = Es wurde kein gültiger Empfänger ausgewählt. +MahnungMailKeinBetreff = Der Betreff darf nicht leer sein. +MahnungMailUnbekannterFehler = Unbekannter Fehler beim E-Mail-Versand. +MahnungMailEndpointEntfallen = Der Versand läuft jetzt über das E-Mail-Formular auf der Mahnungs-Karte. +MahnungMailStatusStorniert = Mahnung %s ist storniert — kein Versand möglich. +MahnungMailStatusErledigt = Mahnung %s ist bereits erledigt — kein Versand möglich. +MahnungMailBereitsVersendet = Mahnung %s wurde bereits am %s versendet. Erneuter Versand nur mit ausdrücklicher Bestätigung. +MahnungMailEmpfaengerUngueltig = Die beim Kunden hinterlegte E-Mail-Adresse ist ungültig: %s +MahnungMailRechnungsPdfFehlt = Die Original-Rechnungs-PDF zu %s wurde nicht gefunden und konnte nicht erzeugt werden. +MahnungMailRechnungsPdfKeinRecht = Die Original-Rechnungs-PDF zu %s fehlt und darf ohne das Recht "Rechnungen anlegen/ändern" nicht erzeugt werden. +MahnungMailVersandLaeuft = Der Versand von %s läuft bereits oder wurde zwischenzeitlich abgeschlossen — es wurde nichts erneut verschickt. +MahnungMailVersandSperreFehler = Der Versand konnte nicht reserviert werden (%s) — es wurde nichts verschickt. +MahnungMailStatusNichtZurueckgesetzt = Achtung: Der Versand ist fehlgeschlagen, der Status konnte aber nicht zurückgesetzt werden (%s). Bitte den Status der Mahnung prüfen. +MahnungMailKeinAbsender = Keine Absender-E-Mail konfiguriert — bitte im Firmenprofil oder unter MAIN_MAIL_EMAIL_FROM eine gültige Adresse hinterlegen. +MahnungMailRechnungNichtMehrOffen = Die Rechnung %s ist nicht mehr offen (bezahlt, storniert oder abgeschrieben) — es wurde nichts versendet. +MahnungMailBetragNichtErmittelbar = Der aktuell offene Betrag der Rechnung %s konnte nicht sicher ermittelt werden — es wurde nichts versendet. +MahnungMailRechnungBereitsBezahlt = Die Rechnung %s ist zwischenzeitlich vollständig bezahlt — es wurde nichts versendet. +MahnungMailBetragAngepasst = Der offene Betrag hat sich zwischenzeitlich auf %s geändert — es ist eine Zahlung eingegangen. Es wurde nichts verschickt; Betreff und Text sind unten mit dem aktuellen Betrag neu aufgebaut. Bitte prüfen und erneut senden. # # Setup-Seite @@ -185,10 +309,10 @@ MahnungSkipGrund = Grund MahnungSetup = Mahnwesen Einstellungen MahnungSetupPage = Mahnwesen Konfiguration MahnungSetupDescription = Mahnstufen, Basiszins, Versandwege und Ntfy-Topic konfigurieren. -MahnungBasiszins = BGB-Basiszins (%) +MahnungBasiszins = BGB-Basiszins (%%) MahnungBasiszinsHelp = Aktueller Basiszins der Bundesbank, halbjährlich pflegen (1.1. / 1.7.). -MahnungAufschlagB2C = Aufschlag B2C (%) -MahnungAufschlagB2B = Aufschlag B2B (%) +MahnungAufschlagB2C = Aufschlag B2C (%%) +MahnungAufschlagB2B = Aufschlag B2B (%%) MahnungPauschaleB2BLabel = Pauschale B2B (EUR) MahnungNtfyTopic = Ntfy-Topic MahnungNtfyTopicHelp = Topic für Push-Benachrichtigungen (Default: vk-builds). @@ -200,6 +324,11 @@ MahnungSettingsSaved = Einstellungen gespeichert. MahnungCronBuildVorschlag = Mahnwesen — Vorschlagsliste aufbauen MahnungCronVersandReminder = Mahnwesen — Versand-Reminder (unversendete Mahnungen) MahnungCronBuildVorschlagDesc = Sucht täglich überfällige Rechnungen und sendet einen Ntfy-Push mit der Anzahl neuer Vorschläge. +MahnungCronVersandReminderDesc = Tägliche Prüfung auf Mahnungen im Status ERSTELLT, die seit mehr als N Tagen nicht versendet wurden (MAHNUNG_VERSAND_REMINDER_DAYS, Default 2). +MahnungCronStufeAnzahl = Stufe %s (%s): %s +MahnungCronStufeOhneLabel = ohne Bezeichnung +MahnungCronVorschlaegeFehler = Vorschlagsliste konnte nicht ermittelt werden: %s +MahnungCronFehlerUnbekannt = Unbekannter Fehler # # Widget @@ -261,6 +390,7 @@ MahnungCronWeitere = + %s weitere MahnungCronArchivOeffnen = Archiv öffnen MahnungCronEintraege = %s — %s Einträge MahnungCsrfFehler = Token-Verifikation fehlgeschlagen (CSRF). +MahnungNurPostErlaubt = Diese Aktion ist nur über das Formular (POST) erlaubt. MahnungNichtBerechtigt = Nicht berechtigt. MahnungKeineRechnungenAusgewaehlt = Keine Rechnungen ausgewählt. MahnungStufeNichtKonfiguriert = Rechnung #%s: Stufe %s nicht konfiguriert @@ -268,13 +398,9 @@ MahnungMahnungErstellt = %s Mahnung(en) erstellt MahnungUebersprungen2 = , %s übersprungen (Wartefrist) MahnungFehlerLabel = — Fehler: %s MahnungCsrfTokenUngueltig = CSRF-Token ungültig. -MahnungNichtBerechtigtSend = Nicht berechtigt (mahnung.send). -MahnungIdFehlt = mahnung_id fehlt. +MahnungNichtBerechtigtWrite = Nicht berechtigt (mahnung.write). MahnungNichtGefunden = Mahnung %s nicht gefunden. -MahnungPdfFehlt = PDF zur Mahnung %s fehlt — bitte zuerst Mahnung erzeugen. -MahnungKundeNichtLadbar = Kunde nicht ladbar. MahnungKundeKeineEmail = Kunde hat keine E-Mail-Adresse hinterlegt. -MahnungEmailGesendet = E-Mail an %s gesendet. MahnungEmailFehlgeschlagen = E-Mail-Versand fehlgeschlagen: %s MahnungSammelbriefNichtBerechtigt = Nicht berechtigt. MahnungSammelbriefCsrfFehler = Token-Verifikation fehlgeschlagen. @@ -359,11 +485,19 @@ MahnungSetupTemplateVars = Verfügbare Template-Variablen MahnungTriggerBeschreibung = Mahnung-Trigger: erledigt offene Mahnvorgänge bei Zahlungseingang. MahnungBoxStufe = Stufe %s MahnungBoxStufeVom = Stufe %s vom %s +MahnungBoxErinnerung = Erinnerung +MahnungBoxErinnerungVom = Zahlungserinnerung vom %s MahnungVorschlagStufeNichtKonfiguriert = Stufe 1 nicht konfiguriert MahnungVorschlagFristNichtErreicht = Frist Stufe 1 (%s Tage) noch nicht erreicht (Verzug %s Tage) MahnungVorschlagAlleStufenAusgeschoepft = Alle Mahnstufen ausgeschöpft (zuletzt Stufe %s) MahnungVorschlagWartefristLaeuft = Wartefrist nach Stufe %s läuft noch (%s/%s Tage) MahnungVorschlagBetragNull = Offener Betrag <= 0 (vermutl. komplett bezahlt, paye-Flag noch nicht gesetzt) +MahnungVorschlagKeineStufenKonfiguriert = Keine aktive Mahnstufe konfiguriert — bitte im Modul-Setup mindestens eine Stufe anlegen und aktivieren. +MahnungVorschlagSqlFehler = Datenbankfehler beim Ermitteln der Mahnvorschläge: %s +MahnungVorschlagFehler = Die Mahnvorschläge konnten nicht ermittelt werden. Bitte Mahnstufen-Konfiguration und Log prüfen. +MahnungVorschlagFristNichtErreichtStufe = Frist für Stufe %s (%s Tage) noch nicht erreicht (Verzug %s Tage) +MahnungVorschlagWartefristZielstufe = Wartefrist für Stufe %s läuft noch (%s von %s Tagen seit Stufe %s) +MahnungVorschlagBadgeMahnungHelp = Kostenpflichtige Mahnung — Mahngebühr, ggf. Pauschale nach §288 Abs. 5 BGB und Verzugszinsen werden berechnet. MahnungPatternNichtGefunden = Pattern nicht gefunden MahnungSpeichernFehlgeschlagen = Speichern fehlgeschlagen MahnungCronCommentBuild = Sucht überfällige Rechnungen, ermittelt vorgeschlagene Mahnstufen, sendet Ntfy-Push @@ -385,6 +519,8 @@ MahnungPdfStufeNichtKonfiguriert = Mahnstufe %s nicht konfiguriert. MahnungPdfVerzeichnisFehler = Kann Verzeichnis nicht anlegen: %s MahnungPdfFooterEmail = E-Mail: %s MahnungPdfFooterTel = Tel: %s +MahnungPdfNichtFuerErinnerung = Für eine Zahlungserinnerung (%s) wird kein Mahnschreiben erzeugt — Anhang ist die unveränderte Original-Rechnung. +MahnungPdfSchreibfehler = PDF konnte nicht geschrieben werden: %s # # E-Mail-Defaults @@ -393,4 +529,8 @@ MahnungEmailDefaultSubject = Mahnung {stufe} zu Rechnung {rechnung} MahnungEmailDefaultBody1 = Sehr geehrter Kunde,\n\nanbei senden wir Ihnen eine freundliche Zahlungserinnerung zu Rechnung {rechnung}.\nOffener Betrag inkl. evtl. Zinsen: {summe}.\nWir bitten um Begleichung bis spätestens {frist}.\n\nMit freundlichen Grüßen MahnungEmailDefaultBody2 = Sehr geehrter Kunde,\n\nanbei die 1. Mahnung zur Rechnung {rechnung}.\nBitte überweisen Sie {summe} bis zum {frist}.\n\nMit freundlichen Grüßen MahnungEmailDefaultBody3 = Sehr geehrter Kunde,\n\nanbei die letzte Mahnung zur Rechnung {rechnung}.\nFalls der Betrag von {summe} nicht bis zum {frist} eingeht, leiten wir gerichtliche Schritte ein.\n\nMit freundlichen Grüßen +# Kostenlose Zahlungserinnerung — bewusst OHNE Gebühren, Zinsen und rechtliche Androhung. +# Das Wort "Mahnung" darf im Betreff nicht vorkommen. Platzhalter: {ref} {stufe} {summe} {rechnung} {frist} {kunde} +MahnungErinnerungMailBetreff = Zahlungserinnerung zu Rechnung {rechnung} +MahnungErinnerungMailText = Sehr geehrte Damen und Herren,\n\nvielen Dank, dass Sie uns mit Ihren Elektroarbeiten beauftragt haben.\n\nBeim Blick in unsere Buchhaltung ist uns aufgefallen, dass unsere Rechnung {rechnung} über {summe} noch als offen geführt wird. Sicher ist das im Alltag einfach untergegangen — zur Erinnerung finden Sie die Rechnung unverändert im Anhang dieser E-Mail.\n\nWir würden uns freuen, wenn Sie den Betrag bis zum {frist} überweisen. Sollte sich Ihre Zahlung mit dieser E-Mail überschnitten haben, betrachten Sie diese Erinnerung bitte als gegenstandslos.\n\nWenn zur Rechnung etwas unklar ist, melden Sie sich einfach kurz bei uns — dann klären wir das gemeinsam.\n\nMit freundlichen Grüßen MahnungKeineSendungsnummerErkannt = Keine Sendungsnummer in den Belegen erkannt. Bild-PDFs werden per OCR verarbeitet — falls trotzdem nichts gefunden wurde, enthält der Beleg evtl. keine Tracking-Nummer. diff --git a/langs/en_US/mahnung.lang b/langs/en_US/mahnung.lang index 21be5d0..2bfcd4b 100644 --- a/langs/en_US/mahnung.lang +++ b/langs/en_US/mahnung.lang @@ -19,6 +19,7 @@ PermMahnungSetup = Configure dunning module # # Menus # +MahnungSetupOeffnen = Open dunning settings MahnungMenu = Dunning MahnungVorschlagsliste = Proposal list MahnungArchiv = Dunning records @@ -40,10 +41,67 @@ MahnungStufeZinssatzB2B = Interest rate B2B (override) MahnungZinssatzHelpB2C = Empty = default (%s + %s %% = %s %%), 0 = no interest MahnungZinssatzHelpB2B = Empty = default (%s + %s %% = %s %%), 0 = no interest MahnungStufeVersandartDefault = Default dispatch method +MahnungSenderMail = Sender address for e-mails +MahnungSenderMailHelp = Leave empty = company e-mail address, otherwise the global Dolibarr sender address. +MahnungSenderName = Sender name for e-mails +MahnungSenderNameHelp = Leave empty = company name. +MahnungSenderMailUngueltig = The sender address %s is not a valid e-mail address — nothing was saved. +MahnungPlatzhalterHilfe = Placeholders (both notations work): __REF__ / {rechnung} = invoice number · __SUMME__ / {summe} = open amount · __FRIST__ / {frist} = new payment deadline · __FAELLIG__ / {faellig} = original due date · __KUNDE__ / {kunde} = customer name · __FIRMA__ / {firma} = own company name · __STUFE__ / {stufe} = level number · __MAHNUNGREF__ / {ref} = dunning record number MahnungStufeEmailSubject = E-mail subject MahnungStufeEmailBody = E-mail body MahnungStufePdfIntro = PDF introduction text +# +# Stage management (freely configurable stages + payment reminder) +# +MahnungStufen = Dunning levels +MahnungStufenIntro = Any number of levels can be configured. The dunning chain runs in ascending level order and only active levels are proposed. The level number itself cannot be changed after creation. +MahnungStufeKeine = No dunning levels configured — please create one below. +MahnungStufeIstErinnerung = Payment reminder (free of charge) +MahnungStufeIstErinnerungHelp = No dunning fee, no EUR 40 flat rate (§288 (5) BGB) and no default interest. Sent by e-mail with the unchanged original invoice attached. +MahnungStufeErinnerungKostenHinweis = Payment reminder: fees, lump sum and interest are not applied. The stored values are kept and take effect again as soon as the checkbox is cleared. +MahnungStufeNichtAngewendet = not applied for a payment reminder +MahnungStufeErinnerungVersandHinweis = Payment reminders are sent by e-mail only — please select the "E-mail" sending method. +MahnungStufeNeu = Create new dunning level +MahnungStufeNummer = Level number +MahnungStufeNummerHelp = Freely selectable (0 to 127). The order of the dunning chain follows this number, gaps are allowed. +MahnungStufeAnlegen = Create level +MahnungStufeAngelegt = Dunning level created. +MahnungStufeGeloescht = Dunning level deleted. +MahnungStufeLoeschenTitel = Delete dunning level +MahnungStufeLoeschenFrage = Really delete dunning level %s (%s)? The configuration of this level will be lost. +MahnungStufeInVerwendung = used by %s dunning record(s) — cannot be deleted +MahnungStufeNichtLoeschbar = This dunning level cannot be deleted because dunning records already refer to it. Please deactivate it instead of deleting it. +MahnungStufeNichtLoeschbarAnzahl = Level %s is used by %s dunning record(s) and cannot be deleted. Please deactivate it instead (uncheck "Active"). +MahnungStufeNichtGefunden = Dunning level not found. +MahnungStufePruefungFehlgeschlagen = Check for existing dunning records failed — the level was not deleted. +MahnungStufeNummerUngueltig = The level number must be an integer between 0 and 127. +MahnungStufeNummerVergeben = Level number %s is already in use. +MahnungStufeLabelFehlt = Level %s: label is missing. +MahnungStufeWertUngueltig = Level %s: %s is invalid — please enter a number of 0 or greater. +MahnungStufeZinssatzUngueltig = Level %s: %s must be between 0 and 100 (empty = standard rate, 0 = no interest). +MahnungStufeUngueltig = Invalid dunning level — the requested level is not configured or not active. + +# +# Setup: accordion header row of a dunning level +# +MahnungStufenAkkordeonHilfe = Click a header row to open that level's settings. All levels start collapsed. +MahnungStufeKopfKostenlos = free of charge +MahnungStufeKopfSofort = immediately +MahnungStufeKopfNachTagen = after %s days +MahnungStufeKopfGebuehrTitel = Dunning fee B2C / B2B +MahnungStufeInaktiv = inactive +MahnungStufeNichtLoeschbarKurz = cannot be deleted + +# +# Setup: blocks inside a dunning level +# +MahnungBlockFristen = Deadlines +MahnungBlockKosten = Costs +MahnungBlockVersand = Sending +MahnungBlockZinssatzOverride = Set a different interest rate +MahnungBlockTexte = Texts for the letter + # # Status # @@ -89,6 +147,11 @@ MahnungVersandGespeichert = Shipment data saved MahnungVersandGeleert = Shipment data reset MahnungSendebelege = Shipment receipts MahnungSendebelegeHint = Upload receipt from postal carrier/DHL/fax/mail (PDF or photo). Stays attached to the dunning case for later verification. +MahnungVersandStatus = Dispatch status +MahnungVersandart = Dispatch method +MahnungEmpfaenger = Recipient +MahnungDateVersand = Sent on +MahnungNochNichtVersendet = not sent yet # # Tracking patterns (Phase 3) @@ -168,6 +231,8 @@ MahnungPauschaleB2B = Flat fee (40 € §288) MahnungVerzugszinsen = Late-payment interest MahnungSumme = Total MahnungBasiszinsSnapshot = Base rate (snapshot) +MahnungZinssatzOverrideStufe = Overridden by dunning stage +MahnungZinssatzAusBasiszins = Base rate %s %% + surcharge MahnungLetzteMahnung = Last dunning MahnungVorgeschlageneStufe = Proposed stage MahnungAktion = Action @@ -178,6 +243,65 @@ MahnungKeineUeberfaelligen = No overdue invoices found. MahnungUebersprungen = Currently skipped invoices MahnungUebersprungenHint = These invoices are overdue but currently not proposed (waiting period running or all dunning stages exhausted). MahnungSkipGrund = Reason +MahnungUebersprungenFehler = The list of skipped invoices could not be determined: %s +MahnungArchivFehler = The dunning records could not be loaded: %s + +# +# Payment reminder (free pre-stage) +# +MahnungIstErinnerung = Payment reminder +MahnungKosten = Costs +MahnungErinnerungKostenfrei = Free of charge — no dunning fee, no lump sum under sec. 288 (5) BGB, no default interest. +MahnungKostenVorstufen = Costs of previous dunning levels +MahnungErinnerungAnhangHinweis = No separate dunning PDF is created for the payment reminder. The unchanged original invoice (%s) is attached instead. +MahnungErinnerungKeinPdf = No dunning PDF is generated for a payment reminder — the original invoice is attached. +MahnungErinnerungSenden = Send payment reminder by e-mail +MahnungErinnerungSendenHint = Opens the e-mail form: recipient, subject, text and attachment can be reviewed and changed before sending. +MahnungErinnerungMailHint = Subject and text come from the dunning level configuration and can be edited here. The unchanged original invoice is attached. Nothing is sent until you press "Send". +MahnungErinnerungErneutSenden = Send payment reminder again +MahnungErinnerungErneutSendenHint = Sends the payment reminder once more — e.g. if the mail never arrived or has to go to a different address. Recipient, subject and text can be changed beforehand. +MahnungErinnerungErneutSendenWarnung = This payment reminder was already sent on %s. Submitting will send it once more — please check the recipient below. +MahnungDokument = Document +MahnungVorschau = Preview +MahnungErinnerungAnhangOriginal = e-mail attachment +MahnungErinnerungRechnungsPdfNochNicht = The original invoice does not exist as a PDF yet — it is generated when the e-mail form is opened and will then be listed here. +MahnungErinnerungGesendet = Payment reminder has been sent by e-mail. +MahnungErinnerungSendenFehler = Sending the e-mail failed. +MahnungErinnerungenErstelltHinweis = , including %s payment reminder(s) — the email is only sent after confirmation on the dunning card +MahnungErinnerungOhneEmailHinweis = — Warning: %s payment reminder(s) without a valid e-mail address and therefore not sendable by mail: %s. Please add the e-mail address to the customer record or print the reminder and send it by post. +MahnungSammelbriefNurErinnerungen = The selection contains only payment reminders. These do not produce a dunning PDF and are sent by email with the original invoice attached. + +# +# E-mail dispatch of the payment reminder +# +MahnungUneinbringlichKeinRechnungsrecht = The invoice cannot be written off: this additionally requires the "create/modify invoices" permission. +MahnungMailProtokoll = Sent e-mails +MahnungMailVon = From +MahnungMailAn = To +MahnungMailKopie = Cc +MahnungMailBlindkopie = Bcc +MahnungMailBetreff = Subject +MahnungMailAnhaenge = Attachments +MahnungMailNurErinnerung = E-mail dispatch is only allowed for free payment reminders. Real dunning notices are sent by postal mail or registered letter. +MahnungMailNichtErlaubt = E-mail dispatch is not possible for this record — permission, reminder level, status and the customer e-mail address are all checked. +MahnungMailKeinEmpfaenger = No valid recipient was selected. +MahnungMailKeinBetreff = The subject must not be empty. +MahnungMailUnbekannterFehler = Unknown error while sending the e-mail. +MahnungMailEndpointEntfallen = Sending now happens through the e-mail form on the dunning card. +MahnungMailStatusStorniert = Dunning %s has been cancelled — dispatch is not possible. +MahnungMailStatusErledigt = Dunning %s is already settled — dispatch is not possible. +MahnungMailBereitsVersendet = Dunning %s was already sent on %s. Re-sending requires explicit confirmation. +MahnungMailEmpfaengerUngueltig = The e-mail address stored for the customer is invalid: %s +MahnungMailRechnungsPdfFehlt = The original invoice PDF for %s was not found and could not be generated. +MahnungMailRechnungsPdfKeinRecht = The original invoice PDF for %s is missing and cannot be generated without the "create/modify invoices" permission. +MahnungMailVersandLaeuft = Dispatch of %s is already running or has been completed in the meantime - nothing was sent again. +MahnungMailVersandSperreFehler = The dispatch could not be reserved (%s) - nothing was sent. +MahnungMailStatusNichtZurueckgesetzt = Warning: dispatch failed, but the status could not be reset (%s). Please check the status of the dunning notice. +MahnungMailKeinAbsender = No sender e-mail configured — please set a valid address in the company profile or in MAIN_MAIL_EMAIL_FROM. +MahnungMailRechnungNichtMehrOffen = Invoice %s is no longer open (paid, cancelled or written off) — nothing was sent. +MahnungMailBetragNichtErmittelbar = The currently open amount of invoice %s could not be determined reliably — nothing was sent. +MahnungMailRechnungBereitsBezahlt = Invoice %s has been paid in full in the meantime — nothing was sent. +MahnungMailBetragAngepasst = The open amount has changed to %s in the meantime — a payment came in. Nothing was sent; subject and body below have been rebuilt with the current amount. Please review and send again. # # Setup page @@ -185,10 +309,10 @@ MahnungSkipGrund = Reason MahnungSetup = Dunning settings MahnungSetupPage = Dunning configuration MahnungSetupDescription = Configure dunning stages, base rate, dispatch methods, and Ntfy topic. -MahnungBasiszins = BGB base rate (%) +MahnungBasiszins = BGB base rate (%%) MahnungBasiszinsHelp = Current Bundesbank base rate; update twice a year (Jan 1 / Jul 1). -MahnungAufschlagB2C = Surcharge B2C (%) -MahnungAufschlagB2B = Surcharge B2B (%) +MahnungAufschlagB2C = Surcharge B2C (%%) +MahnungAufschlagB2B = Surcharge B2B (%%) MahnungPauschaleB2BLabel = Flat fee B2B (EUR) MahnungNtfyTopic = Ntfy topic MahnungNtfyTopicHelp = Topic for push notifications (default: vk-builds). @@ -201,6 +325,18 @@ MahnungCronBuildVorschlag = Dunning — build proposal list MahnungCronBuildVorschlagDesc = Daily scan for overdue invoices, sends a Ntfy push with the count of new proposals. MahnungCronVersandReminder = Dunning — shipment reminder (unsent dunnings) MahnungCronVersandReminderDesc = Daily check for dunnings in status ERSTELLT that have not been sent for more than N days (MAHNUNG_VERSAND_REMINDER_DAYS, default 2). +MahnungCronStufeAnzahl = Level %s (%s): %s +MahnungCronStufeOhneLabel = unnamed +MahnungCronVorschlaegeFehler = Could not build the proposal list: %s +MahnungCronFehlerUnbekannt = Unknown error + +# +# Document models +# +MahnungDokumentModelle = Document models +MahnungPdfStandard = Standard PDF (DIN 5008) +MahnungGenerate = Generate document +NoDocuments = No documents available. MahnungDokumentLoeschenConfirm = Really delete document '%s'? # @@ -231,6 +367,7 @@ MahnungCronWeitere = + %s more MahnungCronArchivOeffnen = Open archive MahnungCronEintraege = %s — %s entries MahnungCsrfFehler = Token verification failed (CSRF). +MahnungNurPostErlaubt = This action is only allowed via form submission (POST). MahnungNichtBerechtigt = Not authorised. MahnungKeineRechnungenAusgewaehlt = No invoices selected. MahnungStufeNichtKonfiguriert = Invoice #%s: stage %s not configured @@ -238,13 +375,9 @@ MahnungMahnungErstellt = %s dunning(s) created MahnungUebersprungen2 = , %s skipped (waiting period) MahnungFehlerLabel = — Errors: %s MahnungCsrfTokenUngueltig = CSRF token invalid. -MahnungNichtBerechtigtSend = Not authorised (mahnung.send). -MahnungIdFehlt = mahnung_id missing. +MahnungNichtBerechtigtWrite = Not authorised (mahnung.write). MahnungNichtGefunden = Dunning %s not found. -MahnungPdfFehlt = PDF for dunning %s missing — please generate dunning first. -MahnungKundeNichtLadbar = Customer could not be loaded. MahnungKundeKeineEmail = Customer has no e-mail address configured. -MahnungEmailGesendet = E-mail sent to %s. MahnungEmailFehlgeschlagen = E-mail dispatch failed: %s MahnungSammelbriefNichtBerechtigt = Not authorised. MahnungSammelbriefCsrfFehler = Token verification failed. @@ -329,6 +462,8 @@ MahnungSetupTemplateVars = Available template variables MahnungTriggerBeschreibung = Dunning trigger: marks open dunnings as closed on payment receipt. MahnungBoxStufe = Stage %s MahnungBoxStufeVom = Stage %s from %s +MahnungBoxErinnerung = Reminder +MahnungBoxErinnerungVom = Payment reminder from %s MahnungBoxOffeneRechnungen = Overdue customer invoices with dunning stage MahnungBoxKeineOffenenRechnungen = No open customer invoices MahnungBoxMaxLines = Widget: displayed open invoices @@ -353,6 +488,12 @@ MahnungVorschlagFristNichtErreicht = Stage 1 deadline (%s days) not yet reached MahnungVorschlagAlleStufenAusgeschoepft = All dunning stages exhausted (last stage %s) MahnungVorschlagWartefristLaeuft = Waiting period after stage %s still running (%s/%s days) MahnungVorschlagBetragNull = Open amount <= 0 (probably fully paid, paye flag not yet set) +MahnungVorschlagKeineStufenKonfiguriert = No active dunning stage configured — please create and activate at least one stage in the module setup. +MahnungVorschlagSqlFehler = Database error while determining dunning proposals: %s +MahnungVorschlagFehler = Could not determine the dunning proposals. Please check the dunning level configuration and the log. +MahnungVorschlagFristNichtErreichtStufe = Deadline for stage %s (%s days) not yet reached (overdue %s days) +MahnungVorschlagWartefristZielstufe = Waiting period for stage %s still running (%s of %s days since stage %s) +MahnungVorschlagBadgeMahnungHelp = Chargeable dunning notice — dunning fee, lump sum under sec. 288 (5) BGB where applicable and default interest are charged. MahnungPatternNichtGefunden = Pattern not found MahnungSpeichernFehlgeschlagen = Save failed MahnungCronCommentBuild = Scans for overdue invoices, determines proposed dunning stages, sends Ntfy push @@ -374,6 +515,8 @@ MahnungPdfStufeNichtKonfiguriert = Dunning stage %s not configured. MahnungPdfVerzeichnisFehler = Cannot create directory: %s MahnungPdfFooterEmail = E-mail: %s MahnungPdfFooterTel = Phone: %s +MahnungPdfNichtFuerErinnerung = No dunning letter is generated for a payment reminder (%s) — the unmodified original invoice is attached instead. +MahnungPdfSchreibfehler = Could not write PDF file: %s # # E-mail defaults @@ -382,4 +525,8 @@ MahnungEmailDefaultSubject = Dunning notice {stufe} for invoice {rechnung} MahnungEmailDefaultBody1 = Dear Customer,\n\nplease find attached a friendly payment reminder for invoice {rechnung}.\nOutstanding amount incl. interest: {summe}.\nWe kindly ask for settlement by {frist} at the latest.\n\nKind regards MahnungEmailDefaultBody2 = Dear Customer,\n\nplease find attached the 1st dunning notice for invoice {rechnung}.\nPlease transfer {summe} by {frist}.\n\nKind regards MahnungEmailDefaultBody3 = Dear Customer,\n\nplease find attached the final dunning notice for invoice {rechnung}.\nIf the amount of {summe} is not received by {frist}, we will initiate legal proceedings.\n\nKind regards +# Free payment reminder — deliberately WITHOUT fees, interest or any legal threat. +# The word "dunning" must not appear in the subject. Placeholders: {ref} {stufe} {summe} {rechnung} {frist} {kunde} +MahnungErinnerungMailBetreff = Payment reminder for invoice {rechnung} +MahnungErinnerungMailText = Dear Sir or Madam,\n\nthank you very much for entrusting us with your electrical work.\n\nWhile going through our accounts we noticed that our invoice {rechnung} amounting to {summe} is still recorded as open. It has probably just slipped through in day-to-day business — for your convenience the invoice is attached to this e-mail, unchanged.\n\nWe would be glad if you could transfer the amount by {frist}. If your payment has crossed with this e-mail, please disregard this reminder.\n\nIf anything about the invoice is unclear, just drop us a line and we will sort it out together.\n\nKind regards MahnungKeineSendungsnummerErkannt = No tracking number found in receipts. Image PDFs are processed via OCR — if still nothing was found, the receipt may not contain a tracking number. diff --git a/lib/mahnung_anlage.lib.php b/lib/mahnung_anlage.lib.php new file mode 100644 index 0000000..6ee198e --- /dev/null +++ b/lib/mahnung_anlage.lib.php @@ -0,0 +1,223 @@ + + * + * 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; +} diff --git a/lib/mahnung_ui.lib.php b/lib/mahnung_ui.lib.php new file mode 100644 index 0000000..6fab5c6 --- /dev/null +++ b/lib/mahnung_ui.lib.php @@ -0,0 +1,111 @@ + + * + * GPL v3 (siehe COPYING). + */ + +/** + * \file htdocs/custom/mahnung/lib/mahnung_ui.lib.php + * \ingroup mahnung + * \brief Gemeinsame Anzeige-Bausteine des Moduls. + * + * Hier liegt die EINE Darstellung einer Mahnstufe. Vorher gab es sie + * dreimal — auf der Mahnungskarte, in der Vorschlagsliste und im Widget + * — jeweils mit eigener Farbskala. Dadurch stand die Stufe an zwei + * Stellen doppelt da (farbiger Badge UND derselbe Text daneben), und die + * Farben wären beim nächsten Eingriff auseinandergelaufen. + */ + + +/** + * Zahnrad-Link in die Modul-Einstellungen — für den Kopf einer Modulseite. + * + * Gedacht für den Parameter $morehtmlright von load_fiche_titre(), damit das Icon + * rechts neben dem Seitentitel steht (Dolibarr-Konvention). + * + * Sichtbar nur mit dem Recht mahnung.setup oder für Administratoren. Wer die + * Einstellungen ohnehin nicht öffnen darf, bekommt kein Icon zu sehen, das ihn in + * eine "Zugriff verweigert"-Meldung laufen lässt. + * + * @return string HTML ('' = kein Recht) + */ +function mahnungSetupLink() +{ + global $langs, $user; + + if (!$user->hasRight('mahnung', 'setup') && empty($user->admin)) { + return ''; + } + + return '' + .img_picto($langs->trans('MahnungSetupOeffnen'), 'setup', 'class="pictofixedwidth"') + .''; +} + +/** + * Farbe einer Mahnstufe. + * + * Maßgeblich ist das Flag ist_erinnerung der Stufe, NICHT die Stufennummer: eine + * kostenlose Zahlungserinnerung ist keine Eskalationsstufe und bleibt deshalb + * neutral grau. Stufennummern oberhalb der Standardskala sind frei konfigurierte + * Eskalationen und werden dunkelrot — sonst sähen sie harmloser aus als Stufe 3. + * + * @param int $stufe Stufennummer + * @param bool $istErinnerung true = kostenlose Zahlungserinnerung + * @return string CSS-Farbwert + */ +function mahnungStufeFarbe($stufe, $istErinnerung) +{ + if ($istErinnerung) { + return '#7a7a7a'; + } + + // Eintrag für 0 ist nötig, weil die Stufennummer 0 auch eine echte + // (kostenpflichtige) Mahnstufe sein kann — die kostenlose Erinnerung hängt am + // Flag, nicht an der Nummer, und ist oben schon abgehandelt. Gedecktes + // Blaugrau = mildeste Eskalation; bewusst nicht heller als Stufe 1, sonst + // leidet der Kontrast zur weißen Badge-Schrift. + $colors = array(0 => '#5b7fa6', 1 => '#4a90d9', 2 => '#e68a00', 3 => '#cc3333'); + if (isset($colors[(int) $stufe])) { + return $colors[(int) $stufe]; + } + return ((int) $stufe > 3) ? '#a32020' : '#666'; +} + +/** + * Badge einer Mahnstufe — Nummer und Bezeichnung in EINEM Element. + * + * Bewusst keine zweite Textausgabe daneben: die Bezeichnung der Stufe ist frei + * konfigurierbar und heißt in der Praxis "Zahlungserinnerung", "1. Mahnung" usw. + * Ein zusätzliches Typ-Etikett würde damit nur dasselbe Wort wiederholen. Welcher + * Art die Stufe ist, transportieren Farbe und Tooltip. + * + * @param int $stufe Stufennummer + * @param string $label Bezeichnung der Stufe ('' = nur Nummer) + * @param bool $istErinnerung true = kostenlose Zahlungserinnerung + * @param string $tooltip Zusatztext für das title-Attribut ('' = Standard) + * @return string HTML + */ +function mahnungStufeBadge($stufe, $label, $istErinnerung, $tooltip = '') +{ + global $langs; + + $stufe = (int) $stufe; + $label = trim((string) $label); + + if ($tooltip === '') { + $tooltip = $istErinnerung + ? $langs->transnoentities('MahnungErinnerungKostenfrei') + : $langs->transnoentities('MahnungVorschlagBadgeMahnungHelp'); + } + + $text = (string) $stufe; + if ($label !== '') { + $text .= ' — '.$label; + } + + $out = ''.dol_escape_htmltag($text).''; + return $out; +} diff --git a/list.php b/list.php index 1dfcafa..60bd28a 100644 --- a/list.php +++ b/list.php @@ -41,9 +41,20 @@ if (!$res) { 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'; +// Nur wegen mahnungVorschlagFehlertext() — damit der Fehlertext des Vorschlag-Service +// überall identisch aufbereitet wird (eine Quelle statt kopierter Fallback-Logik). +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_anlage.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; +require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php'; global $langs, $user, $db; + +// Schema nach einem reinen Datei-Deploy nachziehen (Pipeline ruft weder init() +// noch zwangsläufig die Setup-Seite auf). Im Regelfall nur ein Konstanten- +// Vergleich ohne Query — siehe modMahnung::ensureSchema(). +modMahnung::ensureSchema($db); + $langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills')); if (!$user->hasRight('mahnung', 'read')) { @@ -58,9 +69,19 @@ if ($mode !== 'archiv') { } $filter = array(); -$filter_stufe = GETPOST('filter_stufe', 'int'); -if ($filter_stufe !== '' && $filter_stufe !== null) { - $filter['stufe'] = (int) $filter_stufe; + +// Stufen-Filter: 0 ist eine gültige Stufennummer (kostenlose Zahlungserinnerung), +// "nicht gesetzt" darf deshalb NIE über empty()/falsy entschieden werden. +// Auch GETPOST(..., 'int') taugt hier nicht als alleinige Weiche: je nach +// Dolibarr-Version liefert es für einen leeren Parameter '' oder 0 — im zweiten +// Fall würde die Option "— Alle —" stumm zu "nur Stufe 0" werden und Eddy sähe +// keine einzige echte Mahnung mehr. Deshalb den Rohwert prüfen: nur eine echte +// Ganzzahl im Request setzt den Filter. $filter_stufe ist danach '' oder int. +$filter_stufe = ''; +$filterStufeRaw = trim((string) GETPOST('filter_stufe', 'alphanohtml')); +if ($filterStufeRaw !== '' && preg_match('/^-?\d+$/', $filterStufeRaw)) { + $filter_stufe = (int) $filterStufeRaw; + $filter['stufe'] = $filter_stufe; } $filter_minverzug = GETPOST('filter_minverzug', 'int'); if ($filter_minverzug !== '' && $filter_minverzug !== null) { @@ -87,7 +108,7 @@ llxHeader('', $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorsc print load_fiche_titre( $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'), - '', + mahnungSetupLink(), 'fa-envelope-open-text' ); @@ -105,11 +126,27 @@ print ''.$langs->trans('MahnungKunde').''; print ''; print ''; -// Mahnstufe +// Mahnstufe — Optionen kommen aus der Konfiguration, NICHT hartkodiert 1/2/3. +// Stufennummern sind frei wählbar und Stufe 0 (Zahlungserinnerung) muss filterbar sein. +$stufenOptionen = array(); +$stufenFilterObj = new MahnungStufe($db); +foreach ($stufenFilterObj->fetchAllActive() as $s) { + $stufenOptionen[(int) $s->stufe] = (string) $s->label; +} +// Eine bereits gewählte Stufe muss wählbar bleiben, auch wenn sie inzwischen +// deaktiviert wurde — sonst kippt der Filter beim nächsten Absenden stumm auf "Alle". +// Im Archiv-Modus gibt es zudem Mahnvorgänge zu nicht mehr aktiven Stufen. +if ((string) $filter_stufe !== '' && !isset($stufenOptionen[(int) $filter_stufe])) { + $stufenOptionen[(int) $filter_stufe] = ''; +} +ksort($stufenOptionen, SORT_NUMERIC); + print ''; @@ -133,22 +170,14 @@ print ''; // Kunden-Auswahl: Dolibarr-Standard select_company (Ajax wenn COMPANY_USE_SEARCH_TO_SELECT, // sonst klassisches Dropdown). htmlname='search_socid' bleibt = Backward-Kompatibilität // zu Direkt-Links (?search_socid=74) von der Kundenkarte. Wenn Kundentyp-Filter -// gesetzt ist, schränken wir die Dropdown-Liste passend ein. -// -// WICHTIG: select_thirdparty_list erwartet $filter im Universal-Search-Criteria-Format -// (siehe forgeSQLFromUniversalSearchCriteria), NICHT plain SQL. -// Syntax: (feld:operator:wert) mit AND/OR; Operatoren: =, !=, <, >, like, is, isnot, in, notin. -$socFilter = ''; -if ($filter_kundentyp === 'B2B') { - $socFilter = "(s.tva_intra:isnot:NULL) AND (s.tva_intra:!=:'')"; -} elseif ($filter_kundentyp === 'B2C') { - $socFilter = "(s.tva_intra:is:NULL) OR (s.tva_intra:=:'')"; -} +// gesetzt ist, schränken wir die Dropdown-Liste passend ein — deckungsgleich mit +// MahnungVorschlag::ermittleKundentyp(), siehe mahnungBaueKundentypSocFilter(). +$socFilter = mahnungBaueKundentypSocFilter($db, $filter_kundentyp); print ''; print $form->select_company( $filter_socid, // selected 'search_socid', // htmlname (Hidden-Input-Name) - $socFilter, // filter (SQL-Condition, von Dolibarr in WHERE eingebunden) + $socFilter, // filter im USC-Format, von Dolibarr in die WHERE eingebunden 'SelectThirdParty', // showempty (Translation-Key) 0, 0, array(), 0, 'minwidth250' ); @@ -179,8 +208,30 @@ function renderVorschlagsliste($db, $filter) global $langs, $user; $service = new MahnungVorschlag($db); + + // Fehler und Leerstand strikt trennen: getVorschlaege() liefert false, wenn die + // Vorschlagslogik gar nicht laufen konnte (SQL-Fehler oder keine aktive Mahnstufe + // konfiguriert) — array() dagegen bedeutet "es gibt wirklich nichts zu mahnen". + // Ohne diese Prüfung würde empty(false) === true den Fehler als "keine + // überfälligen Rechnungen" tarnen, also als "alles bezahlt". + // + // Reihenfolge ist wichtig: getUebersprungeneRechnungen() setzt $service->error + // zurück, deshalb wird das Ergebnis von getVorschlaege() ZUERST ausgewertet. $rows = $service->getVorschlaege($filter); + if ($rows === false) { + print '
'.dol_escape_htmltag(mahnungVorschlagFehlertext($service)).'
'; + return; + } + + // Die Übersprungen-Tabelle ist reine Diagnose. Scheitert nur sie, bleibt die + // Vorschlagsliste nutzbar — Hinweis ausgeben und ohne die Diagnose weitermachen. $skipped = $service->getUebersprungeneRechnungen($filter); + if ($skipped === false) { + print '
'.dol_escape_htmltag( + $langs->transnoentities('MahnungUebersprungenFehler', mahnungVorschlagFehlertext($service)) + ).'
'; + $skipped = array(); + } if (empty($rows) && empty($skipped)) { print '
'.$langs->trans('MahnungKeineUeberfaelligen').'
'; @@ -226,8 +277,8 @@ function renderVorschlagsliste($db, $filter) print ''.dol_print_date($r['facture_date_lim_reglement'], 'day').''; print ''.((int) $r['tage_verzug']).''; print ''.price($r['betrag_offen']).''; - print ''.($r['letzte_mahnung_stufe'] ? $langs->trans('MahnungStufeAmDatum', ((int) $r['letzte_mahnung_stufe']), dol_print_date($r['letzte_mahnung_datum'], 'day')) : '—').''; - print ''.((int) $r['vorgeschlagene_stufe']).' — '.dol_escape_htmltag($r['vorgeschlagene_stufe_label']).''; + print ''.renderLetzteMahnungZelle($r).''; + print ''.renderVorschlagStufeZelle($r).''; print ''; $summeOffen += (float) $r['betrag_offen']; } @@ -285,7 +336,7 @@ function renderUebersprungeneTabelle($skipped) print ''.dol_print_date($r['facture_date_lim_reglement'], 'day').''; print ''.((int) $r['tage_verzug']).''; print ''.price($r['betrag_offen']).''; - print ''.($r['letzte_mahnung_stufe'] ? $langs->trans('MahnungStufeAmDatum', ((int) $r['letzte_mahnung_stufe']), dol_print_date($r['letzte_mahnung_datum'], 'day')) : '—').''; + print ''.renderLetzteMahnungZelle($r).''; print ''.dol_escape_htmltag((string) $r['skip_reason']).''; print ''; } @@ -319,6 +370,175 @@ function renderKontaktIcons($phone, $email) return $out; } +/** + * Zelle "Letzte Mahnung" für Vorschlags- und Übersprungen-Tabelle. + * + * WICHTIG — KEIN Truthy-Check auf die Stufennummer: Stufennummern sind frei + * konfigurierbar und 0 (kostenlose Zahlungserinnerung) ist eine gültige Stufe. + * Mit `$r['letzte_mahnung_stufe'] ? ... : '—'` stand nach einer versendeten + * Erinnerung ein Strich in der Zelle, also "noch nie gemahnt" — genau der + * Zustand, an dem Eddy die Wartefrist bis zur Folgestufe abliest. + * MahnungVorschlag liefert null, wenn es wirklich keine Vormahnung gibt. + * + * @param array $r Vorschlags-/Skip-Zeile aus MahnungVorschlag + * @return string HTML + */ +function renderLetzteMahnungZelle(array $r) +{ + global $langs; + + if (!isset($r['letzte_mahnung_stufe'])) { + return ''; + } + + $datum = !empty($r['letzte_mahnung_datum']) ? dol_print_date($r['letzte_mahnung_datum'], 'day') : ''; + + // transnoentities() statt trans(): trans() liefert HTML-Entities, die zusammen + // mit einem zusätzlichen dol_escape_htmltag() doppelt kodiert würden. + // Beide Platzhalter sind hier unkritisch (int bzw. formatiertes Datum). + return $langs->transnoentities('MahnungStufeAmDatum', ((int) $r['letzte_mahnung_stufe']), $datum); +} + +/** + * Zelle "Vorgeschlagene Stufe" inkl. Badge kostenlos/kostenpflichtig. + * + * Ob eine Stufe die kostenlose Zahlungserinnerung ist, entscheidet ausschließlich + * das Flag ist_erinnerung der Stufe (von MahnungVorschlag als + * 'vorgeschlagene_stufe_ist_erinnerung' mitgeliefert) — NICHT die Stufennummer. + * Ohne das Badge war der Liste nicht anzusehen, ob ein Häkchen eine kostenlose + * E-Mail-Erinnerung oder eine kostenpflichtige Mahnung mit Gebühr, §288-Pauschale + * und Verzugszinsen auslöst. + * + * Farbskala bewusst identisch zu core/boxes/box_mahnung_offen.php. + * + * @param array $r Vorschlags-Zeile aus MahnungVorschlag + * @return string HTML + */ +function renderVorschlagStufeZelle(array $r) +{ + global $langs; + + // Nummer und Bezeichnung stehen zusammen im Badge — kein zweites Etikett + // daneben, das nur dasselbe Wort wiederholt (siehe lib/mahnung_ui.lib.php). + return mahnungStufeBadge( + (int) $r['vorgeschlagene_stufe'], + isset($r['vorgeschlagene_stufe_label']) ? (string) $r['vorgeschlagene_stufe_label'] : '', + !empty($r['vorgeschlagene_stufe_ist_erinnerung']) + ); +} + +/** + * Baut den USC-Filter für das Kunden-Dropdown passend zum gewählten Kundentyp. + * + * Muss deckungsgleich zu MahnungVorschlag::ermittleKundentyp() bleiben: dort ist + * ein Kunde B2B, sobald tva_intra ODER siret ODER siren gefüllt ist oder fk_typent + * auf einen Code der B2B-Whitelist zeigt. Der Dropdown-Filter schränkte bis hierher + * nur über tva_intra ein — dadurch tauchten bei Kundentyp "B2C" Kleinunternehmer + * ohne USt-IdNr. im Dropdown auf, die die Vorschlagslogik anschließend als B2B + * wieder herausfilterte: der Kunde war wählbar, die Liste danach leer. + * + * WICHTIG: select_thirdparty_list() erwartet das Universal-Search-Criteria-Format + * (siehe forgeSQLFromUniversalSearchCriteria), NICHT plain SQL. Syntax + * (feld:operator:wert), verknüpft mit AND/OR, Klammerung ist erlaubt. Der + * Tabellen-Alias der Kundentabelle ist dort immer 's'. + * + * Bewusst nur die Operatoren =, !=, is und isnot: IN/NOTIN wären kürzer, sind aber + * je nach Dolibarr-Version unterschiedlich implementiert, und ein nicht erkannter + * Operator endet in einem SQL-Syntaxfehler mit HTTP 500 statt in einer leeren Liste. + * + * @param DoliDB $db + * @param string $kundentyp '' | 'B2B' | 'B2C' + * @return string USC-Filterstring, '' = kein Filter + */ +function mahnungBaueKundentypSocFilter($db, $kundentyp) +{ + if ($kundentyp !== 'B2B' && $kundentyp !== 'B2C') { + return ''; + } + + $typentIds = mahnungB2bTypentIds($db); + + if ($kundentyp === 'B2B') { + // Eines der Merkmale genügt -> ODER-Verknüpfung + $teile = array( + "((s.tva_intra:isnot:NULL) AND (s.tva_intra:!=:''))", + "((s.siret:isnot:NULL) AND (s.siret:!=:''))", + "((s.siren:isnot:NULL) AND (s.siren:!=:''))", + ); + foreach ($typentIds as $id) { + $teile[] = "(s.fk_typent:=:".((int) $id).")"; + } + return '('.implode(' OR ', $teile).')'; + } + + // B2C ist die Negation: KEIN einziges B2B-Merkmal darf zutreffen. + $teile = array( + "((s.tva_intra:is:NULL) OR (s.tva_intra:=:''))", + "((s.siret:is:NULL) OR (s.siret:=:''))", + "((s.siren:is:NULL) OR (s.siren:=:''))", + ); + foreach ($typentIds as $id) { + // fk_typent IS NULL muss explizit mit rein: "fk_typent != 5" ist bei NULL + // nicht wahr, sondern NULL — der Kunde fiele sonst aus der B2C-Liste heraus. + $teile[] = "((s.fk_typent:is:NULL) OR (s.fk_typent:!=:".((int) $id)."))"; + } + return '('.implode(' AND ', $teile).')'; +} + +/** + * Löst die B2B-Typ-Codes der Whitelist auf ihre IDs in llx_c_typent auf. + * + * Quelle ist dieselbe wie in MahnungVorschlag::getB2bTypentCodes(): die Konstante + * MAHNUNG_B2B_TYPENT_CODES, ersatzweise MahnungVorschlag::B2B_TYPENT_CODES_DEFAULT. + * Ein leerer Wert schaltet die fk_typent-Auswertung ab — dann bleibt auch der + * Dropdown-Filter bei tva_intra/siret/siren. Der Wert wird von Hand gepflegt, + * deshalb tolerant einlesen (trimmen, auf Großschreibung normalisieren). + * + * llx_c_typent wird bewusst OHNE "active = 1" abgefragt: ermittleKundentyp() joint + * die Tabelle ebenfalls ohne Active-Filter. Ein deaktivierter Typ würde sonst in der + * Auswertung B2B ergeben, im Dropdown aber fehlen — genau die Divergenz, die hier + * beseitigt werden soll. + * + * @param DoliDB $db + * @return int[] leeres Array = fk_typent nicht auswerten + */ +function mahnungB2bTypentIds($db) +{ + static $cache = null; + if ($cache !== null) { + return $cache; + } + $cache = array(); + + $codesQuoted = array(); + $raw = getDolGlobalString('MAHNUNG_B2B_TYPENT_CODES', MahnungVorschlag::B2B_TYPENT_CODES_DEFAULT); + foreach (explode(',', (string) $raw) as $code) { + $code = strtoupper(trim($code)); + if ($code !== '') { + $codesQuoted[] = "'".$db->escape($code)."'"; + } + } + if (empty($codesQuoted)) { + return $cache; + } + + $sql = "SELECT id FROM ".MAIN_DB_PREFIX."c_typent"; + $sql .= " WHERE code IN (".implode(',', $codesQuoted).")"; + $resql = $db->query($sql); + if (!$resql) { + // Kein harter Abbruch: ohne die IDs bleibt der Dropdown-Filter bei + // tva_intra/siret/siren, die Liste selbst funktioniert weiter. + dol_syslog('mahnung/list.php: Auflösung der B2B-Typ-Codes fehlgeschlagen: '.$db->lasterror(), LOG_ERR); + return $cache; + } + while ($obj = $db->fetch_object($resql)) { + $cache[] = (int) $obj->id; + } + $db->free($resql); + + return $cache; +} + /** * Rendert das Archiv aller bestehenden Mahnvorgänge. * @@ -337,6 +557,15 @@ function renderArchiv($db, $filter) } $mahnungen = $mahnungObj->fetchAll('date_mahnung', 'DESC', 200, 0, $archivFilter); + // fetchAll() liefert bei einem SQL-Fehler -1 statt array(). empty(-1) ist false, + // der Wert liefe also ungeprüft in die foreach-Schleifen (PHP-Warning + leere + // Tabelle). Fehler und Leerstand deshalb auch hier trennen. + if (!is_array($mahnungen)) { + $detail = !empty($mahnungObj->error) ? (string) $mahnungObj->error : ''; + print '
'.dol_escape_htmltag($langs->transnoentities('MahnungArchivFehler', $detail)).'
'; + return; + } + if (empty($mahnungen)) { print '
'.$langs->trans('MahnungKeineVorgaenge').'
'; return; diff --git a/sql/llx_mahnung_mahnung.sql b/sql/llx_mahnung_mahnung.sql index 8a6cc45..7ab9e08 100644 --- a/sql/llx_mahnung_mahnung.sql +++ b/sql/llx_mahnung_mahnung.sql @@ -21,10 +21,13 @@ CREATE TABLE llx_mahnung_mahnung ( mahngebuehr DOUBLE(10,2) DEFAULT 0, pauschale_b2b DOUBLE(10,2) DEFAULT 0, verzugszinsen DOUBLE(10,2) DEFAULT 0, + -- Kumulierte Gebuehren/Pauschalen der Vorstufen zu derselben Rechnung + kosten_vorstufen DOUBLE(10,2) DEFAULT 0, summe_mahnung DOUBLE(24,8) DEFAULT 0, versandart VARCHAR(20) DEFAULT 'pdf', customertype VARCHAR(3), - basiszins_snapshot DECIMAL(5,4), + -- DECIMAL(6,3): das alte DECIMAL(5,4) konnte max. 9,9999 abbilden + basiszins_snapshot DECIMAL(6,3), pdf_path VARCHAR(255), note_private TEXT, status TINYINT DEFAULT 0 NOT NULL, diff --git a/sql/llx_mahnung_mailprotokoll.key.sql b/sql/llx_mahnung_mailprotokoll.key.sql new file mode 100644 index 0000000..1937056 --- /dev/null +++ b/sql/llx_mahnung_mailprotokoll.key.sql @@ -0,0 +1,4 @@ +-- Indizes fuer llx_mahnung_mailprotokoll +-- Zugriff erfolgt praktisch immer "alle Versaende eines Mahnvorgangs, neueste zuerst". +ALTER TABLE llx_mahnung_mailprotokoll ADD INDEX idx_mahnung_mailprotokoll_mahnung (fk_mahnung, date_versand); +ALTER TABLE llx_mahnung_mailprotokoll ADD INDEX idx_mahnung_mailprotokoll_entity (entity); diff --git a/sql/llx_mahnung_mailprotokoll.sql b/sql/llx_mahnung_mailprotokoll.sql new file mode 100644 index 0000000..92c5d94 --- /dev/null +++ b/sql/llx_mahnung_mailprotokoll.sql @@ -0,0 +1,33 @@ +-- Copyright (C) 2026 Eduard Wisch +-- GPL v3 (siehe COPYING). +-- +-- Protokoll der tatsaechlich versendeten Erinnerungs-Mails. +-- +-- Bewusst eine eigene Tabelle statt Spalten am Mahnvorgang: eine Erinnerung kann +-- mehrfach rausgehen (Mail kam nicht an, ging an die falsche Adresse). Spalten am +-- Vorgang wuerden den vorherigen Versand ueberschreiben — genau den Nachweis, um +-- den es hier geht. +-- +-- Festgehalten wird, was WIRKLICH verschickt wurde: Empfaenger, Betreff und Text +-- zum Sendezeitpunkt. Aendert jemand spaeter die Vorlage in der Stufe, bleibt das +-- Protokoll davon unberuehrt. + +CREATE TABLE llx_mahnung_mailprotokoll +( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + entity INTEGER DEFAULT 1 NOT NULL, + fk_mahnung INTEGER NOT NULL, + date_versand DATETIME NOT NULL, + mail_from VARCHAR(255), + mail_to TEXT NOT NULL, + mail_cc TEXT, + mail_bcc TEXT, + subject VARCHAR(255) NOT NULL, + body MEDIUMTEXT, + -- 1 = body ist HTML, 0 = Klartext. Entscheidet ueber die Darstellung auf der Karte. + ishtml TINYINT DEFAULT 0 NOT NULL, + -- Dateinamen der Anhaenge, mit "; " getrennt (nicht die Dateien selbst). + anhaenge TEXT, + fk_user INTEGER, + datec DATETIME NOT NULL +) ENGINE=innodb; diff --git a/sql/llx_mahnung_stufe.sql b/sql/llx_mahnung_stufe.sql index afada36..32f4f7b 100644 --- a/sql/llx_mahnung_stufe.sql +++ b/sql/llx_mahnung_stufe.sql @@ -5,7 +5,8 @@ -- the Free Software Foundation, either version 3 of the License, or -- (at your option) any later version. --- Mahnstufen-Konfiguration (3-stufig nach BGB §288) +-- Mahnstufen-Konfiguration (frei konfigurierbar, Basis BGB §288) +-- Stufe 0 = kostenlose Zahlungserinnerung (ist_erinnerung = 1) CREATE TABLE llx_mahnung_stufe ( rowid INTEGER AUTO_INCREMENT PRIMARY KEY, @@ -17,19 +18,33 @@ CREATE TABLE llx_mahnung_stufe ( mahngebuehr_b2c DOUBLE(10,2) DEFAULT 0, mahngebuehr_b2b DOUBLE(10,2) DEFAULT 0, pauschale_b2b_einmalig TINYINT DEFAULT 0, - zinssatz_b2c_uebersteuern DECIMAL(5,4), - zinssatz_b2b_uebersteuern DECIMAL(5,4), + -- DECIMAL(6,3): der B2B-Satz (Basiszins + 9 %) liegt ueber 9,9999 und passte + -- nicht mehr in das alte DECIMAL(5,4). + zinssatz_b2c_uebersteuern DECIMAL(6,3), + zinssatz_b2b_uebersteuern DECIMAL(6,3), versandart_default VARCHAR(20) DEFAULT 'pdf', email_subject VARCHAR(255), email_body TEXT, pdf_intro TEXT, + -- 1 = kostenlose Zahlungserinnerung: keine Mahngebuehr, keine Pauschale, + -- keine Verzugszinsen, Anhang ist die Original-Rechnungs-PDF. + -- Massgeblich ist dieses Flag, NICHT die Stufennummer. + ist_erinnerung TINYINT DEFAULT 0 NOT NULL, active TINYINT DEFAULT 1 NOT NULL, datec DATETIME, tms TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- Default-Stufen (idempotent: INSERT IGNORE wegen UNIQUE entity+stufe) +-- Die Labels muessen eindeutig sein: 'Zahlungserinnerung' ist ausschliesslich der +-- kostenlosen Stufe 0 vorbehalten, damit im Vorschlag/Sammelbrief/PDF sofort klar +-- ist, ob eine kostenlose Erinnerung oder eine kostenpflichtige Mahnung gemeint ist. INSERT IGNORE INTO llx_mahnung_stufe (entity, stufe, label, frist_tage, neue_frist_tage, mahngebuehr_b2c, mahngebuehr_b2b, pauschale_b2b_einmalig, versandart_default, datec) VALUES - (1, 1, 'Zahlungserinnerung', 7, 14, 0.00, 0.00, 1, 'pdf', NOW()), - (1, 2, '1. Mahnung', 14, 10, 5.00, 5.00, 0, 'pdf', NOW()), - (1, 3, 'Letzte Mahnung', 10, 7, 10.00, 10.00, 0, 'pdf', NOW()); + (1, 1, '1. Mahnung', 7, 14, 0.00, 0.00, 1, 'pdf', NOW()), + (1, 2, '2. Mahnung', 14, 10, 5.00, 5.00, 0, 'pdf', NOW()), + (1, 3, 'Letzte Mahnung', 10, 7, 10.00, 10.00, 0, 'pdf', NOW()); + +-- Stufe 0: kostenlose Zahlungserinnerung vor der ersten echten Mahnstufe. +-- frist_tage = 0 -> sofort ab Faelligkeit, Versand per E-Mail mit Original-Rechnung. +INSERT IGNORE INTO llx_mahnung_stufe (entity, stufe, label, frist_tage, neue_frist_tage, mahngebuehr_b2c, mahngebuehr_b2b, pauschale_b2b_einmalig, zinssatz_b2c_uebersteuern, zinssatz_b2b_uebersteuern, versandart_default, ist_erinnerung, active, datec) VALUES + (1, 0, 'Zahlungserinnerung', 0, 7, 0.00, 0.00, 0, 0.000, 0.000, 'mail', 1, 1, NOW());