feat(mahnung): Mailversand ueber FormMail, Versandprotokoll, HTML-Mails
Phase 13/14 abgeschlossen. Der Mailversand war zuvor KAPUTT: ajax/sendmail.php
war bereits zur Funktionsbibliothek umgebaut, card.php postete aber weiterhin
per JS dagegen.
Mailformular (FormMail)
- card.php nutzt jetzt Dolibarrs Standard-Mailformular (action=presend/send):
Empfaenger (Firma + alle Ansprechpartner), Betreff, Text und Anhang sichtbar
und aenderbar. Gesendet wird ausschliesslich ueber mahnungSendeErinnerungsMail().
- Anhang = unveraenderte Original-Rechnungs-PDF, wird bei Bedarf nacherzeugt.
Eigener Parameter mailinit statt mode=init, weil get_form() bei mode=init die
Anhangsliste selbst leert.
- HTML-Mails: DolEditor im Setup + withfckeditor=-1 im Formular (folgt
FCKEDITOR_ENABLE_MAIL wie Dolibarrs eigene Mailvorlagen).
- Klartext bleibt Klartext: GETPOST('restricthtml') jagt jeden Nicht-HTML-Text
durch dol_nl2br() — mahnungBodyEntkleiden() nimmt nur dieses Artefakt zurueck
und laesst echte Formatierung unangetastet.
- Platzhalter jetzt auch in Dolibarr-Schreibweise (__REF__, __DATE_YMD__,
__AMOUNT_FORMATED__, __DATE_DUE_YMD__, __FRIST_TAGE__ ...), Liste sichtbar im Setup.
- Absender-Adresse und -Name konfigurierbar (MAHNUNG_EMAIL_SENDER[_NAME]).
- Erneuter Versand moeglich (force aus dem Status abgeleitet, nicht aus dem
Request — Doppelversand-Schutz bleibt wirksam).
Versandprotokoll (neue Tabelle llx_mahnung_mailprotokoll)
- Jede versendete Erinnerung wird mit Empfaenger, Kopie, Betreff, Text und
Anhangsnamen festgehalten, einsehbar unter Versandstatus. Historie statt
Spalten am Vorgang, weil erneut gesendet werden kann.
- Lazy-Migration legt die Tabelle an (DB_VERSION 0.4.0), kein Reaktivieren noetig.
Haertung nach Code-Review (21 bestaetigte Funde)
- Anhang liess sich nicht abwaehlen (wurde sofort wieder eingehaengt)
- Upload/Entfernen ohne Rechtepruefung; Temp-Verzeichnis pro Vorgang getrennt
- Teilzahlung zwischen Oeffnen und Senden fuehrt zurueck ins Formular
- Empfaenger: Semikolon-Trenner, keine stillen Verwerfungen, Dubletten, CR/LF
- CSRF: presend + Core-Dateiaktionen (confirm_deletefile, renamefile, sendit,
linkit) token-pflichtig
- Externe Benutzer sehen nur eigene Vorgaenge; Abschreiben verlangt facture.creer
UI
- Mahnstufe nur noch EINE Darstellung (Badge), Farbskala zentral in
lib/mahnung_ui.lib.php statt doppelt gepflegt
- Zahnrad oben rechts in die Einstellungen (nur mit Recht mahnung.setup)
- Original-Rechnung unter Verknuepfte Dokumente mit Vorschau, Groesse in KB
- PDF-Einleitungstext nur noch, wo ueberhaupt ein PDF entsteht
Sprachdateien de_DE/en_US deckungsgleich, 9 tote Keys entfernt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0e7d515f0b
commit
0244a5b07e
26 changed files with 6188 additions and 969 deletions
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -2,6 +2,65 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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 `<textarea>` am Stufen-Mailtext; jetzt steht dort `DolEditor` mit derselben Toolbar und derselben Konstante (`FCKEDITOR_ENABLE_MAIL`) wie bei Dolibarrs eigenen Mailvorlagen. Das Mailformular auf der Karte nutzt `withfckeditor = -1`, entscheidet also ebenfalls nach der globalen Einstellung. Ist der WYSIWYG abgeschaltet, bleibt alles beim einfachen Textfeld.
|
||||||
|
- `mahnungBodyEntkleiden()` greift deshalb nur noch, wenn der Text **außer `<br>` keine Tags** enthält: das nl2br-Artefakt wird zurückgebaut, echte Formatierung (fett, Listen, Links) bleibt unangetastet.
|
||||||
|
- **Eine Zahlungserinnerung lässt sich erneut versenden.** Vorher verschwand der Button nach dem ersten Versand — kam die Mail nicht an, landete im Spam oder ging an die falsche Adresse, gab es keinen Weg zurück. Der `force`-Pfad der Bibliothek ist jetzt verdrahtet: der Button heißt dann „Zahlungserinnerung erneut senden", das Formular warnt mit dem Datum des letzten Versands, und der Empfänger lässt sich vorher ändern. `force` wird **aus dem Status abgeleitet**, nicht aus einem Request-Parameter — der Doppelversand-Schutz greift weiter (die Reservierung nagelt zusätzlich das bisherige Versanddatum fest). Erledigte (bezahlte) und stornierte Vorgänge bleiben gesperrt.
|
||||||
|
- Versendbar ist ein Vorgang außerdem schon dann, wenn **irgendein** Empfänger existiert — auch nur ein Ansprechpartner mit Mailadresse; im Formular lässt sich ohnehin eine beliebige Adresse eintragen.
|
||||||
|
- **Die Original-Rechnung steht jetzt unter „Verknüpfte Dokumente"** — mit Größe, Datum, Download und Vorschau-Lupe, gekennzeichnet mit „geht als Anhang mit". Vorher meldete die Sektion bei einer Erinnerung „Keine Dokumente vorhanden", und zum Nachschauen musste man auf die Rechnungskarte wechseln. Gelistet wird nur, was schon existiert (`mahnungFindeRechnungsPdf()`) — das bloße Ansehen der Karte erzeugt bewusst kein PDF; fehlt es noch, sagt ein Hinweis, dass es beim Öffnen des Mailformulars entsteht.
|
||||||
|
|
||||||
|
### Härtung nach Code-Review des Mailformulars
|
||||||
|
- **Klartext bleibt Klartext.** `GETPOST(…, 'restricthtml')` schickt jeden Nicht-HTML-Text durch `dol_nl2br()` — allein durch das Abschicken des Formulars wurde aus dem Mailtext HTML mit `<br>`-Tags. Sichtbar wurde das doppelt: die Tags standen im Textfeld, und `dol_textishtml()` stufte den Text danach als HTML ein, sodass eine als Klartext gepflegte Erinnerung als HTML-Mail rausgegangen wäre. Neue Funktion `mahnungBodyEntkleiden()` macht das rückgängig, solange die Stufen-Vorlage Klartext ist.
|
||||||
|
- **Der Anhang ließ sich nicht abwählen**: die Vorbelegung hing an „Liste ist leer" und hängte die Rechnungs-PDF nach dem Entfernen im selben Request sofort wieder ein. Sie hängt jetzt ausschließlich am Parameter `mailinit` plus einem Session-Marker.
|
||||||
|
- **Upload/Entfernen von Anhängen prüft jetzt die Berechtigung** (`$canSendErinnerung`). Vorher konnte jeder eingeloggte Benutzer mit gültigem Token Dateien in sein Temp-Verzeichnis laden, obwohl er nichts versenden darf. Das Temp-Verzeichnis ist zusätzlich pro Mahnvorgang getrennt — `dol_add_file_process()` speichert mit `allowoverwrite`, gleichnamige Anhänge zweier Vorgänge hätten sich sonst überschrieben.
|
||||||
|
- **Teilzahlung zwischen Öffnen und Absenden**: Betreff und Text stehen mit bereits ersetzten Platzhaltern im Formular, ein nachgezogener Betrag wäre also nicht mehr in den Text gekommen. Statt eine Mail mit falschem Betrag zu verschicken, führt der Fall zurück ins Formular, das mit dem aktuellen Betrag neu aufgebaut wird (`MahnungMailBetragAngepasst`).
|
||||||
|
- **Empfänger**: Freitextfelder trennen jetzt auch am Semikolon (Outlook-Gewohnheit), unbrauchbare Einträge werden nicht mehr still verworfen sondern melden sich, Dubletten zwischen Auswahlliste und Freitext fallen zusammen (Schlüssel ist die kleingeschriebene Adresse), und wer im An-Feld steht, bekommt keine zusätzliche Kopie. Firmen- und Absendername laufen durch `mahnungHeaderEinzeilig()` — `dol_string_nospecial()` allein lässt CR/LF stehen.
|
||||||
|
- **CSRF**: `presend` ist token-pflichtig (die Action stellt die Rechnungs-PDF bereit und erzeugt sie notfalls neu), ebenso die Core-Dateiaktionen aus `core/actions_linkedfiles.inc.php` (`confirm_deletefile`, `confirm_updateline`, `renamefile` sowie die parameterbasierten `sendit`/`linkit`/`renamefilesave`). Der Core-CSRF-Check ist auf dieser Installation abgeschaltet; ohne die Ergänzung hätte ein untergeschobener GET-Aufruf einen hochgeladenen Zustellbeleg löschen können.
|
||||||
|
- **Externe Benutzer** (Kundenkontakte mit eigenem Zugang) sehen nur noch Mahnvorgänge ihrer eigenen Firma — `Mahnung::fetch()` filtert bloß nach Entity.
|
||||||
|
- **Abschreiben als uneinbringlich** verlangt zusätzlich das Recht „Rechnungen anlegen/ändern"; der Schritt schreibt auf der Rechnung und ist praktisch unwiderruflich. Ohne das Recht erscheint der Button nicht mehr.
|
||||||
|
- Neun tote Lang-Keys des alten Versandwegs entfernt; die Sprachdateien sind deckungsgleich und vollständig.
|
||||||
|
|
||||||
|
### Setup-Seite als Akkordeon
|
||||||
|
- Die Stufen-Konfiguration ist in aufklappbare Blöcke gegliedert (Fristen / Kosten / Versand / Zinssatz-Override / Texte). Alle Stufen starten zugeklappt; die Kopfzeile fasst das Wesentliche zusammen (kostenlos, Auslösefrist, Gebühr B2C/B2B, inaktiv, nicht löschbar).
|
||||||
|
|
||||||
|
### Darstellung der Mahnstufe vereinheitlicht
|
||||||
|
- Die Mahnstufe stand auf der Karte und in der Vorschlagsliste **doppelt** da: als farbiger Badge („Erinnerung" / „Zahlungserinnerung") **und** direkt daneben nochmal als Text („0 — Zahlungserinnerung"). Jetzt gibt es genau **eine** Darstellung — Nummer und Bezeichnung stehen zusammen im Badge, die Art der Stufe transportieren Farbe und Tooltip.
|
||||||
|
- Neue `lib/mahnung_ui.lib.php` mit `mahnungStufeFarbe()` und `mahnungStufeBadge()`. Die Farbskala war vorher in `list.php` und `core/boxes/box_mahnung_offen.php` doppelt gepflegt (mit dem Kommentar „bewusst identisch") und wäre beim nächsten Eingriff auseinandergelaufen; jetzt hat sie eine Quelle. Das Widget behält seine kompakte Beschriftung („Stufe N"), weil die Spalte dort schmal ist.
|
||||||
|
|
||||||
### Widget (box_mahnung_offen)
|
### Widget (box_mahnung_offen)
|
||||||
- **Neue Spalte „Vsl. Zahlung" (Zahlungsprognose)**: Je offener Rechnung wird prognostiziert, wann der Kunde sie voraussichtlich zahlt — als **konkretes Datum** plus der direkt lesbaren Kennzahl **„Ø X T nach Rechnung"** (Days-to-Pay). Zweizeilige Zelle: Ampel-Icon + Prognosedatum / darunter `Ø <Tage> nach Rechnung · n=<Stichprobe>`.
|
- **Neue Spalte „Vsl. Zahlung" (Zahlungsprognose)**: Je offener Rechnung wird prognostiziert, wann der Kunde sie voraussichtlich zahlt — als **konkretes Datum** plus der direkt lesbaren Kennzahl **„Ø X T nach Rechnung"** (Days-to-Pay). Zweizeilige Zelle: Ampel-Icon + Prognosedatum / darunter `Ø <Tage> nach Rechnung · n=<Stichprobe>`.
|
||||||
- **Prognose am Rechnungsdatum verankert**: `Prognosedatum = datef + Ø(Zahlung − Rechnungsdatum)`. Das ist die intuitive „zahlt X Tage nach Rechnungseingang"-Zahl (positiv, kein Kopfrechnen wie bei einer Differenz zur Fälligkeit) und verhindert Prognosen **vor** dem Rechnungsdatum. Fallback auf `Fälligkeit + diff`, falls `datef` fehlt.
|
- **Prognose am Rechnungsdatum verankert**: `Prognosedatum = datef + Ø(Zahlung − Rechnungsdatum)`. Das ist die intuitive „zahlt X Tage nach Rechnungseingang"-Zahl (positiv, kein Kopfrechnen wie bei einer Differenz zur Fälligkeit) und verhindert Prognosen **vor** dem Rechnungsdatum. Fallback auf `Fälligkeit + diff`, falls `datef` fehlt.
|
||||||
|
|
|
||||||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -7,8 +7,11 @@ Dolibarr Custom-Modul: 3-stufiges Mahnwesen nach BGB §288 + Versand-Tracking +
|
||||||
- **numero**: 500038 (NICHT ändern — 500037 ist Eplan)
|
- **numero**: 500038 (NICHT ändern — 500037 ist Eplan)
|
||||||
- **Deploy**: nur via Pipeline (`[deploy]` in Commit-Message), NIEMALS manuell kopieren
|
- **Deploy**: nur via Pipeline (`[deploy]` in Commit-Message), NIEMALS manuell kopieren
|
||||||
- **Prod-Pfad**: /mnt/appdata/firma/dolibarr-202509/modules/mahnung/
|
- **Prod-Pfad**: /mnt/appdata/firma/dolibarr-202509/modules/mahnung/
|
||||||
- **Lokal**: Symlink /var/www/dolibarr/custom/mahnung → repo/
|
- **Lokal**: Symlink /var/www/dolibarr/custom/mahnung → repo/, erreichbar unter http://localhost:8080
|
||||||
- **Test-DB**: dolibarr_test auf 192.168.155.11
|
- **Test-DB**: dolibarr_test auf 192.168.155.11 (User `dolibarr_test`)
|
||||||
|
- Seit 22.07.2026 zeigt auch `/var/www/dolibarr/conf/conf.php` dorthin; vorher lief localhost:8080 gegen eine lokale DB `dolibarr@localhost` mit Prod-Abzug. Backup der alten Datei: `conf.php.bak-lokaledb-20260722`.
|
||||||
|
- **Die Test-DB wird mit einer zweiten Instanz in einer VM geteilt.** Vorhandene Mahnvorgänge sind fremde Testdaten — nicht per SQL umbiegen, für eigene Tests einen neuen Vorgang anlegen.
|
||||||
|
- Sicherheitsnetz beim Testen: `MAIN_MAIL_FORCE_SENDTO = info@alleswattlaeuft.eu` — Testmails können nicht bei echten Kunden landen.
|
||||||
- **Forgejo-Repo**: data/mahnung (NICHT data-it/ — historisch, soll bleiben)
|
- **Forgejo-Repo**: data/mahnung (NICHT data-it/ — historisch, soll bleiben)
|
||||||
|
|
||||||
## Schema-Migration
|
## Schema-Migration
|
||||||
|
|
@ -33,6 +36,26 @@ Dolibarr Custom-Modul: 3-stufiges Mahnwesen nach BGB §288 + Versand-Tracking +
|
||||||
- **Summenzeile Netto+Brutto**: Betragszelle der `liste_total`-Zeile zeigt zweizeilig Netto (`SUM(f.total_ht)`) und Brutto (`SUM(f.total_ttc)`). Brutto kommt direkt aus `f.total_ttc` der Rechnung, NICHT aus Netto × Steuersatz hochgerechnet — sonst wären Reverse-Charge §13b, Steuerbefreiung und Kleinunternehmer §19 UStG falsch. Lang-Keys `MahnungBoxNetto`/`MahnungBoxBrutto`.
|
- **Summenzeile Netto+Brutto**: Betragszelle der `liste_total`-Zeile zeigt zweizeilig Netto (`SUM(f.total_ht)`) und Brutto (`SUM(f.total_ttc)`). Brutto kommt direkt aus `f.total_ttc` der Rechnung, NICHT aus Netto × Steuersatz hochgerechnet — sonst wären Reverse-Charge §13b, Steuerbefreiung und Kleinunternehmer §19 UStG falsch. Lang-Keys `MahnungBoxNetto`/`MahnungBoxBrutto`.
|
||||||
- **Spalte „Vsl. Zahlung" (Zahlungsprognose)**: `getZahlprognose()`/`buildPrognoseCell()`/`prognoseRating()`. Skala + Berechnung sind eine **self-contained Kopie aus BuchhaltungsWidget** (`getPaymentStatistics()`), Referenz **KB #886** — bei Skala-Änderungen BEIDE Module synchron halten (Schwellen ≤−5/≤0/≤7/≤14, Farben `#28a745/#ffc107/#fd7e14/#dc3545`, Filter `type IN (0,1,5)` + `fk_statut=2`+`paye=1`+`date_lim_reglement IS NOT NULL`). Prognosedatum = `datef + avg_pay` (am **Rechnungsdatum** verankert, avg_pay = Ø Tage nach Rechnungseingang), Fallback `Fälligkeit + diff`. Sichtbare Zahl = „Ø X T nach Rechnung" (intuitive Days-to-Pay, NICHT die Differenz zur Fälligkeit — die war zu unintuitiv, Eddy-Feedback). Ampel-Icon aber weiter über `diff = Ø Tage nach Fälligkeit` (Parität zur Kundenkarte). Mindest-`n` via `MAHNUNG_PROGNOSE_MIN_N` (**Default 1**). Prognose verstrichen + Rechnung offen → **Datum wird rot** (kein Zusatztext — sprengt sonst die Spalte; „später als üblich" nur im Tooltip). Die **Kundenkarten-Statistik selbst liefert BuchhaltungsWidget** (Hook `tabContentViewThirdparty`) — Mahnung baut dort KEINEN zweiten Block. **`$langs->transnoentities(...)` verwenden** (nicht `trans()`+`sprintf` → leere `%s`; nicht `trans()`+`dol_escape_htmltag` → doppeltes `&`-Encoding).
|
- **Spalte „Vsl. Zahlung" (Zahlungsprognose)**: `getZahlprognose()`/`buildPrognoseCell()`/`prognoseRating()`. Skala + Berechnung sind eine **self-contained Kopie aus BuchhaltungsWidget** (`getPaymentStatistics()`), Referenz **KB #886** — bei Skala-Änderungen BEIDE Module synchron halten (Schwellen ≤−5/≤0/≤7/≤14, Farben `#28a745/#ffc107/#fd7e14/#dc3545`, Filter `type IN (0,1,5)` + `fk_statut=2`+`paye=1`+`date_lim_reglement IS NOT NULL`). Prognosedatum = `datef + avg_pay` (am **Rechnungsdatum** verankert, avg_pay = Ø Tage nach Rechnungseingang), Fallback `Fälligkeit + diff`. Sichtbare Zahl = „Ø X T nach Rechnung" (intuitive Days-to-Pay, NICHT die Differenz zur Fälligkeit — die war zu unintuitiv, Eddy-Feedback). Ampel-Icon aber weiter über `diff = Ø Tage nach Fälligkeit` (Parität zur Kundenkarte). Mindest-`n` via `MAHNUNG_PROGNOSE_MIN_N` (**Default 1**). Prognose verstrichen + Rechnung offen → **Datum wird rot** (kein Zusatztext — sprengt sonst die Spalte; „später als üblich" nur im Tooltip). Die **Kundenkarten-Statistik selbst liefert BuchhaltungsWidget** (Hook `tabContentViewThirdparty`) — Mahnung baut dort KEINEN zweiten Block. **`$langs->transnoentities(...)` verwenden** (nicht `trans()`+`sprintf` → leere `%s`; nicht `trans()`+`dol_escape_htmltag` → doppeltes `&`-Encoding).
|
||||||
|
|
||||||
|
## Mailversand der Zahlungserinnerung (FormMail)
|
||||||
|
|
||||||
|
- **Genau EIN Sendeweg**: `mahnungSendeErinnerungsMail()` in `ajax/sendmail.php`. Die Datei ist trotz ihres Pfads **kein AJAX-Endpoint mehr**, sondern eine Funktionsbibliothek; ein HTTP-Direktaufruf leitet auf das Formular um. Keinen zweiten Sendepfad einbauen — er würde am Statuswächter, an der fachlichen Sperre und an der Doppelversand-Reservierung vorbeilaufen.
|
||||||
|
- **Formular**: `card.php?id=…&action=presend&mailinit=1#formmail`, Absenden gegen `action=send` auf derselben Karte. `send` steht in `$actionsMitToken` (der Core-CSRF-Check ist auf dieser Installation abgeschaltet).
|
||||||
|
- **`FormMail::get_form()` leert bei `GETPOST('mode')=='init'` selbst die Anhangsliste.** Deshalb benutzt card.php bewusst den eigenen Parameter `mailinit` statt des Dolibarr-üblichen `mode=init` — sonst würde die frisch eingehängte Rechnungs-PDF sofort wieder entfernt.
|
||||||
|
- Die Anhangsliste liegt in `$_SESSION['listofpaths'.'-'.$trackid]` (dazu `listofnames`, `listofmimes`). `trackid` ist hier `mah<id>`, damit zwei offene Karten sich nicht in die Quere kommen. Auslesen ausschließlich über `get_attached_files()`.
|
||||||
|
- Alles in `$formmail->param[...]` wird von `get_form()` als **hidden input** ausgegeben und landet im POST (`action`, `id`, `returnurl`, `models`). `models = 'none'` schaltet die `c_email_templates`-Vorlagen ab — Betreff/Text kommen aus der Stufen-Konfiguration.
|
||||||
|
- POST-Feldnamen des Standardformulars: `receiver[]` (Schlüssel aus der Empfängerliste), `sendto` / `sendtocc` / `sendtoccc` (Freitext), `subject`, `message`, `deliveryreceipt`, `addfile`, `removedfile`, `cancel`.
|
||||||
|
- **`Societe::contact_get_property()` prüft die Firmenzugehörigkeit NICHT** — vor dem Auflösen eines `receiver`-Schlüssels immer gegen `mahnungEmpfaengerListe($societe)` prüfen, sonst kann ein manipuliertes `receiver[]` die Mail an einen fremden Kontakt schicken.
|
||||||
|
- In den Lang-Dateien geschriebenes `\n` wandelt Dolibarr **beim Laden** in einen echten Zeilenumbruch (`translate.class.php`) — im Mailtext also unbedenklich. Für Klartext-Mails `transnoentities()` nutzen, `trans()` würde Umlaute zu HTML-Entities kodieren.
|
||||||
|
- **HTML-Mails**: Der Stufen-Mailtext wird im Setup mit `DolEditor` gepflegt (Toolbar `dolibarr_mailings`, Schalter `FCKEDITOR_ENABLE_MAIL` — dieselbe Konstante wie bei Dolibarrs Mailvorlagen). Das Formular auf der Karte setzt `withfckeditor = -1` und folgt damit derselben Einstellung. Ob die Mail als HTML rausgeht, entscheidet am Ende `dol_textishtml()` auf dem tatsächlichen Text.
|
||||||
|
- **`mahnungBodyEntkleiden()` niemals auf formatierten Text loslassen**: sie nimmt ausschließlich das nl2br-Artefakt zurück und prüft dafür, dass der Text außer `<br>` keine Tags enthält. Ohne diese Prüfung würde sie Fettschrift, Listen und Links wegwerfen.
|
||||||
|
- **Erneuter Versand**: `$istErneuterVersand` wird aus dem Status abgeleitet (`>= VERSENDET`), NICHT aus einem Request-Parameter, und als `force` an `mahnungSendeErinnerungsMail()` gereicht. Nur so bleibt der Doppelversand-Schutz wirksam — beim erzwungenen Versand nagelt `mahnungReserviereVersand()` zusätzlich das bisherige `date_versand` fest, sonst kämen zwei Parallelklicks beide durch. Erledigte und stornierte Vorgänge bleiben gesperrt (`mahnungVersandErlaubt()`).
|
||||||
|
- **Bekannte, harmlose Log-Warnungen:** bei `models = 'none'` bleibt `$arraydefaultmessage` im Core der Integer `-1`; `get_form()` greift trotzdem mit `->topic` / `->content` / `->content_lines` darauf zu. Das erzeugt unter PHP 8 pro Formularaufruf drei `Attempt to read property … on int`-Warnungen (`html.formmail.class.php:1472/994/1041`). Funktional folgenlos — der jeweilige `elseif`-Zweig setzt korrekt `withtopic`/`withbody` ein. Dolibarrs eigenes Mailing-Modul (`comm/mailing/card.php:1284`) nutzt `'none'` genauso. **Nicht** durch einen echten Vorlagentyp „wegkonfigurieren": das öffnete eine zweite Textquelle neben der Stufen-Konfiguration.
|
||||||
|
|
||||||
|
## Anzeige der Mahnstufe
|
||||||
|
|
||||||
|
- Es gibt **eine** Darstellung: `mahnungStufeBadge()` aus `lib/mahnung_ui.lib.php` (Nummer + Bezeichnung in einem Badge, Typ über Farbe und Tooltip). Kein zusätzliches Text-Etikett daneben — das wiederholte nur die Bezeichnung ("0 — Zahlungserinnerung" + Badge "Zahlungserinnerung").
|
||||||
|
- Farbskala ausschließlich über `mahnungStufeFarbe()`. Sie war vorher in `list.php` und `box_mahnung_offen.php` doppelt gepflegt. Das Widget behält sein kurzes Label ("Stufe N"), weil die Spalte schmal ist — aber dieselbe Farbquelle.
|
||||||
|
|
||||||
## Hooks-Stolperfallen
|
## Hooks-Stolperfallen
|
||||||
- **`completeTabsHead`** wird bei jedem Aufruf von `complete_head_from_modules()` getriggert — pro Karte mehrfach (core + external + remove). Filter auf `mode=add` + `filterorigmodule=external`, sonst doppelter Tab. (KB #601)
|
- **`completeTabsHead`** wird bei jedem Aufruf von `complete_head_from_modules()` getriggert — pro Karte mehrfach (core + external + remove). Filter auf `mode=add` + `filterorigmodule=external`, sonst doppelter Tab. (KB #601)
|
||||||
- Hook-Kontexte: `invoicecard`, `thirdpartycard`, `ordercard` — letztere für Bonitäts-Warnings.
|
- Hook-Kontexte: `invoicecard`, `thirdpartycard`, `ordercard` — letztere für Bonitäts-Warnings.
|
||||||
|
|
|
||||||
62
README.md
62
README.md
|
|
@ -1,10 +1,25 @@
|
||||||
# Modul Mahnung — 3-stufiges Mahnwesen für Dolibarr
|
# Modul Mahnung — Mahnwesen für Dolibarr
|
||||||
|
|
||||||
Mahnwesen-Modul für Dolibarr ERP: tägliche Vorschlagsliste überfälliger Rechnungen, 3-stufiger Workflow (Erinnerung → Mahnung → Letzte Mahnung), Mahngebühren + tagesgenaue Verzugszinsen nach **BGB §288**, PDF- und ODT-Mahnschreiben, E-Mail-Versand, Sammelbrief, Trigger auf Zahlungseingang.
|
Mahnwesen-Modul für Dolibarr ERP: tägliche Vorschlagsliste überfälliger Rechnungen, frei konfigurierbare Mahnkette mit einer kostenlosen Zahlungserinnerung davor, Mahngebühren + tagesgenaue Verzugszinsen nach **BGB §288**, PDF- und ODT-Mahnschreiben, E-Mail-Versand über das Dolibarr-Mailformular, Sammelbrief, Trigger auf Zahlungseingang.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version **0.2.0 + Phasen 1-6 (Unreleased)** — UX-Vorschlagsliste, Versand-Erfassung, Sendebeleg-Upload, konfigurierbare Tracking-Patterns mit Live-Vorschau, Beleg-Scan via pdftotext, Versand-Reminder, Bonitäts-Workflow + Uneinbringlich-Klassifikation.
|
Version **0.3.0 (Unreleased)** — Zahlungserinnerung als kostenlose Stufe 0, frei konfigurierbare Stufen, Mailversand über Dolibarrs `FormMail`, Setup-Seite als Akkordeon. Davor: UX-Vorschlagsliste, Versand-Erfassung, Sendebeleg-Upload, konfigurierbare Tracking-Patterns mit Live-Vorschau, Beleg-Scan via pdftotext, Versand-Reminder, Bonitäts-Workflow + Uneinbringlich-Klassifikation.
|
||||||
|
|
||||||
|
## Grundprinzip der Mahnkette
|
||||||
|
|
||||||
|
```
|
||||||
|
Fälligkeit überschritten
|
||||||
|
└─ Stufe 0 Zahlungserinnerung sofort, kostenlos, nur E-Mail
|
||||||
|
└─ Wartefrist (frist_tage der Zielstufe, gerechnet ab date_versand)
|
||||||
|
└─ Stufe 1 1. Mahnung Gebühr + Zinsen, Post/Einschreiben
|
||||||
|
└─ Stufe 2 … weitere Stufen frei konfigurierbar
|
||||||
|
└─ uneinbringlich (§288-Abschreibung)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Ob eine Stufe eine kostenlose Erinnerung ist, entscheidet das Flag `ist_erinnerung` — nicht die Stufennummer.** Eine Erinnerung kostet nichts (keine Mahngebühr, keine §288-Abs.-5-Pauschale, keine Verzugszinsen) und hat kein eigenes Mahn-PDF.
|
||||||
|
- **Erinnerungen gehen ausschließlich per E-Mail raus, echte Mahnungen ausschließlich per Post/Einschreiben.** Der Zugang einer E-Mail ist nicht beweisbar — das ist als Sperre im Code verdrahtet, nicht bloß Konvention.
|
||||||
|
- **Kein Mindestmaß an Stufen:** §286 BGB verlangt keine drei Mahnungen, eine genügt für den Verzug. Stufennummern sind frei wählbar (0–127), Lücken erlaubt; die Kette läuft aufsteigend über die aktiven Stufen.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -12,7 +27,8 @@ Version **0.2.0 + Phasen 1-6 (Unreleased)** — UX-Vorschlagsliste, Versand-Erfa
|
||||||
|---|---|
|
|---|---|
|
||||||
| Tägliche Vorschlagsliste überfälliger Rechnungen | Cron `MahnungCronBuildVorschlag` |
|
| Tägliche Vorschlagsliste überfälliger Rechnungen | Cron `MahnungCronBuildVorschlag` |
|
||||||
| Push-Benachrichtigung mit Anzahl je Stufe | Ntfy + GlobalNotify |
|
| Push-Benachrichtigung mit Anzahl je Stufe | Ntfy + GlobalNotify |
|
||||||
| 3 Stufen pflegbar (Frist, neue Frist, Gebühr B2C/B2B, Versandart, E-Mail-Template, PDF-Intro) | `admin/setup.php` |
|
| **Beliebig viele Stufen** pflegbar (Nummer, Frist, neue Frist, Gebühr B2C/B2B, Versandart, E-Mail-Template, PDF-Intro), Setup als Akkordeon | `admin/setup.php` |
|
||||||
|
| **Kostenlose Zahlungserinnerung** als eigene Stufe (Flag `ist_erinnerung`) — Gebühr, Pauschale und Zinsen hart auf 0 | `mahnungBaueVorgang()` in `lib/mahnung_anlage.lib.php` |
|
||||||
| B2C / B2B-Erkennung | über `llx_societe.tva_intra` |
|
| B2C / B2B-Erkennung | über `llx_societe.tva_intra` |
|
||||||
| Verzugszinsen | tagesgenau, B2C: Basiszins +5 %, B2B: +9 %; Override pro Stufe möglich; **auto-Neuberechnung** bei noch nicht versandten Mahnungen |
|
| Verzugszinsen | tagesgenau, B2C: Basiszins +5 %, B2B: +9 %; Override pro Stufe möglich; **auto-Neuberechnung** bei noch nicht versandten Mahnungen |
|
||||||
| §288 Abs. 5 Pauschale 40 € | nur bei B2B, einmalig pro Rechnung |
|
| §288 Abs. 5 Pauschale 40 € | nur bei B2B, einmalig pro Rechnung |
|
||||||
|
|
@ -22,7 +38,9 @@ Version **0.2.0 + Phasen 1-6 (Unreleased)** — UX-Vorschlagsliste, Versand-Erfa
|
||||||
| Template-Variablen-Referenz | `admin/templatevars.php` — alle verfügbaren ODT-Variablen |
|
| Template-Variablen-Referenz | `admin/templatevars.php` — alle verfügbaren ODT-Variablen |
|
||||||
| Dokumentenliste auf Mahnungs-Karte | Vorschau (Lupe), Download, Modellauswahl |
|
| Dokumentenliste auf Mahnungs-Karte | Vorschau (Lupe), Download, Modellauswahl |
|
||||||
| Bulk-Erstellung + Sammelbrief | TCPDI-basiert |
|
| Bulk-Erstellung + Sammelbrief | TCPDI-basiert |
|
||||||
| E-Mail-Versand mit PDF-Anhang | `CMailFile`, Subject/Body mit Platzhaltern |
|
| **E-Mail-Versand der Zahlungserinnerung über Dolibarrs Mailformular** — Empfänger (Firma + alle Ansprechpartner), Betreff, Text und Anhang vor dem Absenden sichtbar und änderbar | `card.php?action=presend` mit `FormMail`; gesendet wird ausschließlich über `mahnungSendeErinnerungsMail()` |
|
||||||
|
| Anhang = **unveränderte Original-Rechnungs-PDF** (wird bei Bedarf nacherzeugt), weitere Dateien anhängbar | `mahnungHoleRechnungsPdf()` |
|
||||||
|
| Schutz gegen Doppelversand | atomare Reservierung per bedingtem `UPDATE` **vor** dem Senden |
|
||||||
| Auto-Erledigung bei Zahlungseingang | Trigger `BILL_PAYED` + `PAYMENT_CUSTOMER_CREATE` |
|
| Auto-Erledigung bei Zahlungseingang | Trigger `BILL_PAYED` + `PAYMENT_CUSTOMER_CREATE` |
|
||||||
| Tab "Mahnungen (n)" auf Rechnungs- + Kundenkarte | Hook `completeTabsHead` (filtert auf add/external) |
|
| Tab "Mahnungen (n)" auf Rechnungs- + Kundenkarte | Hook `completeTabsHead` (filtert auf add/external) |
|
||||||
| Widget: Offene Rechnungen mit Mahnstufe (Zähler im Kopf, Zeilenanzahl einstellbar, Summenzeile Netto+Brutto) | `box_mahnung_offen`, Konstante `MAHNUNG_BOX_MAXLINES` |
|
| Widget: Offene Rechnungen mit Mahnstufe (Zähler im Kopf, Zeilenanzahl einstellbar, Summenzeile Netto+Brutto) | `box_mahnung_offen`, Konstante `MAHNUNG_BOX_MAXLINES` |
|
||||||
|
|
@ -132,7 +150,7 @@ Vollständige Liste: **Setup → Mahnwesen → Verfügbare Template-Variablen**
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `llx_mahnung_mahnung` | Mahnvorgänge — inkl. Versand-Felder `date_versand`, `versandweg`, `tracking_nr`, `tracking_provider` (idempotente Migration via `migrateVersandFelder()` beim Setup-Aufruf) |
|
| `llx_mahnung_mahnung` | Mahnvorgänge — inkl. Versand-Felder `date_versand`, `versandweg`, `tracking_nr`, `tracking_provider` (idempotente Migration via `migrateVersandFelder()` beim Setup-Aufruf) |
|
||||||
| `llx_mahnung_stufe` | Stufen-Konfiguration (Stufe 1/2/3 mit Fristen, Gebühren, Templates) |
|
| `llx_mahnung_stufe` | Stufen-Konfiguration (beliebig viele Stufen mit Fristen, Gebühren, Templates; `ist_erinnerung` markiert die kostenlose Zahlungserinnerung) |
|
||||||
| `llx_mahnung_trackingpattern` | Konfigurierbare Tracking-Regex + URL-Template pro Provider, Default-Seed mit DHL/DPAG/DPD/Hermes/UPS |
|
| `llx_mahnung_trackingpattern` | Konfigurierbare Tracking-Regex + URL-Template pro Provider, Default-Seed mit DHL/DPAG/DPD/Hermes/UPS |
|
||||||
|
|
||||||
## AJAX-Endpoints
|
## AJAX-Endpoints
|
||||||
|
|
@ -141,9 +159,10 @@ Vollständige Liste: **Setup → Mahnwesen → Verfügbare Template-Variablen**
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ajax/createmahnung.php` | Bulk-Mahnung-Erzeugung | `facture_ids[]`, `stufe`, `token` | `mahnung.write` |
|
| `ajax/createmahnung.php` | Bulk-Mahnung-Erzeugung | `facture_ids[]`, `stufe`, `token` | `mahnung.write` |
|
||||||
| `ajax/sammelbrief.php` | Sammelbrief-PDF | `facture_ids[]`, `token` | `mahnung.send` |
|
| `ajax/sammelbrief.php` | Sammelbrief-PDF | `facture_ids[]`, `token` | `mahnung.send` |
|
||||||
| `ajax/sendmail.php` | E-Mail-Versand | `mahnung_id`, `token` | `mahnung.send` |
|
|
||||||
| `ajax/regex_preview.php` | Live-Vorschau für Tracking-Patterns | `regex`, `sample`, `url_template` (POST) | `mahnung.setup` oder Admin |
|
| `ajax/regex_preview.php` | Live-Vorschau für Tracking-Patterns | `regex`, `sample`, `url_template` (POST) | `mahnung.setup` oder Admin |
|
||||||
|
|
||||||
|
> `ajax/sendmail.php` ist **kein Endpoint mehr**, sondern die Funktionsbibliothek des Mailversands (Empfängerermittlung, Rechnungsprüfung, Anhang, Platzhalter, Statuswächter, Versand-Reservierung, `CMailFile`-Aufruf). Verschickt wird ausschließlich über das Mailformular auf `card.php`. Ein Direktaufruf per HTTP sendet nichts, sondern leitet dorthin um.
|
||||||
|
|
||||||
## Hooks & Trigger
|
## Hooks & Trigger
|
||||||
|
|
||||||
**Hook-Klasse:** `class/actions_mahnung.class.php` — registriert für `invoicecard`, `thirdpartycard`, `ordercard`.
|
**Hook-Klasse:** `class/actions_mahnung.class.php` — registriert für `invoicecard`, `thirdpartycard`, `ordercard`.
|
||||||
|
|
@ -168,8 +187,9 @@ Vollständige Liste: **Setup → Mahnwesen → Verfügbare Template-Variablen**
|
||||||
Cron 06:00 (MahnungCronBuildVorschlag)
|
Cron 06:00 (MahnungCronBuildVorschlag)
|
||||||
|
|
|
|
||||||
|--> ueberfaellige Rechnungen einsammeln
|
|--> ueberfaellige Rechnungen einsammeln
|
||||||
|--> Stufe ermitteln (Stufe 1 ab frist_tage Verzug,
|
|--> Stufe ermitteln (Einstieg = kleinste aktive Stufe; Folgestufe, sobald
|
||||||
| Folgestufen nach neue_frist_tage seit Vor-Mahnung)
|
| frist_tage der ZIELstufe seit date_versand der
|
||||||
|
| Vorstufe verstrichen sind, Luecken werden uebersprungen)
|
||||||
|--> Ntfy-Push mit Anzahl je Stufe + Gesamt-EUR
|
|--> Ntfy-Push mit Anzahl je Stufe + Gesamt-EUR
|
||||||
|--> GlobalNotify "actionRequired" (wenn Modul aktiv)
|
|--> GlobalNotify "actionRequired" (wenn Modul aktiv)
|
||||||
|
|
|
|
||||||
|
|
@ -189,9 +209,17 @@ list.php (Vorschlagsliste)
|
||||||
v
|
v
|
||||||
card.php (Mahnung-Detailansicht)
|
card.php (Mahnung-Detailansicht)
|
||||||
| Documents: generierte PDFs + Modellauswahl + Regenerate
|
| Documents: generierte PDFs + Modellauswahl + Regenerate
|
||||||
| Versand: Datum, Versandweg, Sendungsnummer, Tracking-Provider -> Deep-Link
|
| (bei einer Zahlungserinnerung entfaellt das - kein eigenes Mahn-PDF)
|
||||||
| Sendebelege: Upload (formfile->showdocuments), Scan-Button
|
| Bei Versandart E-Mail: nur Versandstatus (Empfaenger, versendet am)
|
||||||
| Aktionen: Stornieren | Als uneinbringlich klassifizieren (nur Stufe 3)
|
| Bei Postversand: Datum, Versandweg, Sendungsnummer, Tracking-Deep-Link,
|
||||||
|
| Sendebeleg-Upload + Scan-Button
|
||||||
|
| Aktionen: Stornieren
|
||||||
|
| | Zahlungserinnerung per E-Mail senden (nur ist_erinnerung-Stufen)
|
||||||
|
| -> action=presend: Dolibarr-Mailformular mit Empfaengerauswahl
|
||||||
|
| (Firma + Ansprechpartner), Betreff, Text, Original-Rechnung
|
||||||
|
| als Anhang; action=send verschickt ueber
|
||||||
|
| mahnungSendeErinnerungsMail() (atomare Reservierung)
|
||||||
|
| | Als uneinbringlich klassifizieren (wenn keine weitere Stufe folgt)
|
||||||
|
|
|
|
||||||
Cron 06:00 (MahnungCronVersandReminder)
|
Cron 06:00 (MahnungCronVersandReminder)
|
||||||
|--> Mahnungen mit Status=ERSTELLT > N Tage unversendet -> Ntfy-Push
|
|--> Mahnungen mit Status=ERSTELLT > N Tage unversendet -> Ntfy-Push
|
||||||
|
|
@ -203,7 +231,7 @@ Zahlungseingang (BankImport / Manual)
|
||||||
|
|
|
|
||||||
Endgueltig uneinbringlich (Mahnbescheid + Vollstreckung erfolglos)
|
Endgueltig uneinbringlich (Mahnbescheid + Vollstreckung erfolglos)
|
||||||
|
|
|
|
||||||
v Stufe-3-Mahnung -> Button "Als uneinbringlich klassifizieren"
|
v letzte Mahnstufe -> Button "Als uneinbringlich klassifizieren"
|
||||||
|--> Facture::setCanceled($user, CLOSECODE_BADDEBT, $note)
|
|--> Facture::setCanceled($user, CLOSECODE_BADDEBT, $note)
|
||||||
| -> fk_statut=3 (ABANDONED), close_code='badcustomer'
|
| -> fk_statut=3 (ABANDONED), close_code='badcustomer'
|
||||||
| -> Mahnung wird storniert, Begruendung in note_private
|
| -> Mahnung wird storniert, Begruendung in note_private
|
||||||
|
|
@ -246,11 +274,15 @@ mahnung/
|
||||||
├── admin/tracking_patterns.php CRUD + Live-Vorschau fuer Tracking-Patterns
|
├── admin/tracking_patterns.php CRUD + Live-Vorschau fuer Tracking-Patterns
|
||||||
├── ajax/createmahnung.php Bulk-Mahnung-Erzeugung
|
├── ajax/createmahnung.php Bulk-Mahnung-Erzeugung
|
||||||
├── ajax/sammelbrief.php Sammelbrief-PDF-Konkatenation
|
├── ajax/sammelbrief.php Sammelbrief-PDF-Konkatenation
|
||||||
├── ajax/sendmail.php E-Mail-Versand
|
├── ajax/sendmail.php Funktionsbibliothek Mailversand
|
||||||
|
│ (KEIN Endpoint - siehe AJAX-Abschnitt)
|
||||||
├── ajax/regex_preview.php Live-Vorschau-Endpoint fuer Pattern-Setup
|
├── ajax/regex_preview.php Live-Vorschau-Endpoint fuer Pattern-Setup
|
||||||
|
├── lib/mahnung_anlage.lib.php Anlage eines Mahnvorgangs (mahnungBaueVorgang)
|
||||||
|
├── lib/mahnung_ui.lib.php Stufen-Badge + Farbskala (eine Quelle fuer
|
||||||
|
│ card.php, list.php und das Widget)
|
||||||
├── list.php Vorschlagsliste / Archiv (Select2-Filter)
|
├── list.php Vorschlagsliste / Archiv (Select2-Filter)
|
||||||
├── card.php Detailansicht: Versand, Belege, Tracking,
|
├── card.php Detailansicht: Versand, Belege, Tracking,
|
||||||
│ Uneinbringlich-Workflow
|
│ Mailformular (FormMail), Uneinbringlich-Workflow
|
||||||
├── sql/llx_mahnung_*.sql Schema + Seed
|
├── sql/llx_mahnung_*.sql Schema + Seed
|
||||||
└── langs/{de_DE,en_US}/mahnung.lang
|
└── langs/{de_DE,en_US}/mahnung.lang
|
||||||
```
|
```
|
||||||
|
|
|
||||||
1283
admin/setup.php
1283
admin/setup.php
File diff suppressed because it is too large
Load diff
|
|
@ -13,9 +13,19 @@
|
||||||
* als auch AJAX-Calls. Antwortet je nach Accept-Header HTML-Redirect
|
* als auch AJAX-Calls. Antwortet je nach Accept-Header HTML-Redirect
|
||||||
* oder JSON.
|
* 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:
|
* POST:
|
||||||
* - facture_ids[] Array Rechnungs-IDs (oder einzelne facture_id)
|
* - 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
|
* - 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/mahnung.class.php';
|
||||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.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/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.'/compta/facture/class/facture.class.php';
|
||||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.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;
|
global $db, $user, $langs, $conf;
|
||||||
$langs->loadLangs(array('mahnung@mahnung'));
|
$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 bool $success
|
||||||
* @param string $message
|
* @param string $message
|
||||||
|
|
@ -59,11 +75,20 @@ function respond($success, $message, $extra = array())
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Klassischer Submit -> Redirect zur Liste mit Flash-Message
|
// Klassischer Submit -> Redirect mit Flash-Message.
|
||||||
global $user;
|
//
|
||||||
|
// 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')) {
|
if (function_exists('setEventMessages')) {
|
||||||
setEventMessages($message, null, $success ? 'mesgs' : 'errors');
|
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');
|
header('Location: '.DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
@ -95,74 +120,58 @@ if (empty($factureIds)) {
|
||||||
respond(false, $langs->trans('MahnungKeineRechnungenAusgewaehlt'), array('code' => 'noinput'));
|
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);
|
$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');
|
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
|
$createdErinnerung = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$failed = array();
|
$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) {
|
foreach ($factureIds as $fid) {
|
||||||
$rows = $service->getVorschlaege(array('soc_id' => 0)); // ohne Filter holen
|
if (!isset($vorschlaege[$fid])) {
|
||||||
$row = null;
|
// Keine offene Mahnungs-Empfehlung — z.B. weil die Wartefrist noch läuft
|
||||||
foreach ($rows as $r) {
|
$skipped++;
|
||||||
if ((int) $r['facture_id'] === (int) $fid) {
|
continue;
|
||||||
$row = $r;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
$row = $vorschlaege[$fid];
|
||||||
if ($row === null) {
|
|
||||||
// Keine offene Mahnungs-Empfehlung — z.B. weil Wartefrist noch läuft
|
$stufeNr = ($forceStufe !== MAHNUNG_STUFE_NICHT_GESETZT)
|
||||||
|
? $forceStufe
|
||||||
|
: (isset($row['vorgeschlagene_stufe']) ? (int) $row['vorgeschlagene_stufe'] : null);
|
||||||
|
if ($stufeNr === null) {
|
||||||
$skipped++;
|
$skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stufeNr = $forceStufe ?: (int) $row['vorgeschlagene_stufe'];
|
|
||||||
$stufe = $service->getStufe($stufeNr);
|
$stufe = $service->getStufe($stufeNr);
|
||||||
if ($stufe === null) {
|
if ($stufe === null) {
|
||||||
$failed[] = $langs->trans('MahnungStufeNichtKonfiguriert', $fid, $stufeNr);
|
$failed[] = $langs->trans('MahnungStufeNichtKonfiguriert', $fid, $stufeNr);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$mahnung = new Mahnung($db);
|
$mahnung = mahnungBaueVorgang($db, $row, $stufe, $basiszins);
|
||||||
$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;
|
|
||||||
|
|
||||||
$newId = $mahnung->create($user);
|
$newId = $mahnung->create($user);
|
||||||
if ($newId <= 0) {
|
if ($newId <= 0) {
|
||||||
|
|
@ -170,44 +179,70 @@ foreach ($factureIds as $fid) {
|
||||||
continue;
|
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);
|
$docResult = $mahnung->generateDocument('', $langs);
|
||||||
if ($docResult <= 0) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$created++;
|
$created++;
|
||||||
|
$createdIds[] = (int) $newId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$msg = $langs->trans('MahnungMahnungErstellt', $created);
|
$msg = $langs->trans('MahnungMahnungErstellt', $created);
|
||||||
|
if ($createdErinnerung > 0) {
|
||||||
|
$msg .= $langs->trans('MahnungErinnerungenErstelltHinweis', $createdErinnerung);
|
||||||
|
}
|
||||||
if ($skipped > 0) {
|
if ($skipped > 0) {
|
||||||
$msg .= $langs->trans('MahnungUebersprungen2', $skipped);
|
$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)) {
|
if (!empty($failed)) {
|
||||||
$msg .= $langs->trans('MahnungFehlerLabel', implode(' | ', $failed));
|
$msg .= $langs->trans('MahnungFehlerLabel', implode(' | ', $failed));
|
||||||
respond(false, $msg, array('created' => $created, 'failed' => $failed));
|
respond(false, $msg, array('created' => $created, 'erinnerungen' => $createdErinnerung, 'ohne_email' => $ohneEmail, '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;
|
|
||||||
}
|
}
|
||||||
|
// 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,
|
||||||
|
));
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,16 @@
|
||||||
* \brief AJAX/Form-Endpoint: Sammelbrief — für eine Auswahl von Rechnungen
|
* \brief AJAX/Form-Endpoint: Sammelbrief — für eine Auswahl von Rechnungen
|
||||||
* Mahnungen erzeugen und alle Einzel-PDFs in EIN PDF zusammenfassen.
|
* 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:
|
* POST:
|
||||||
* facture_ids[] Rechnungs-IDs
|
* facture_ids[] Rechnungs-IDs
|
||||||
* stufe (opt) Stufe erzwingen (sonst Vorschlag)
|
* stufe (opt) Stufe erzwingen (sonst Vorschlag)
|
||||||
* token CSRF
|
* token CSRF
|
||||||
*
|
*
|
||||||
* Response: PDF-Download "sammelbrief-YYYYMMDD-N.pdf".
|
* Response: PDF-Download "sammelbrief-YYYYMMDD.pdf".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
|
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/mahnung.class.php';
|
||||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.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/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'));
|
$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
|
// CSRF
|
||||||
$postedToken = GETPOST('token', 'alphanohtml');
|
$postedToken = GETPOST('token', 'alphanohtml');
|
||||||
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
|
if (empty($postedToken) || empty($_SESSION['newtoken']) || $postedToken !== $_SESSION['newtoken']) {
|
||||||
while (ob_get_level() > 0) {
|
|
||||||
ob_end_clean();
|
|
||||||
}
|
|
||||||
httpExitError(403, $langs->trans('MahnungSammelbriefCsrfFehler'));
|
httpExitError(403, $langs->trans('MahnungSammelbriefCsrfFehler'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission
|
// Permission: der Sammelbrief LEGT Mahnvorgänge an (mahnungBaueVorgang() +
|
||||||
if (!$user->hasRight('mahnung', 'send') && !$user->hasRight('mahnung', 'write')) {
|
// Mahnung::create()) und erzeugt die zugehörigen PDFs — das ist eine schreibende
|
||||||
while (ob_get_level() > 0) {
|
// Aktion. 'send' allein reicht dafür nicht: dieses Recht deckt nur das Versenden
|
||||||
ob_end_clean();
|
// bereits vorhandener Mahnungen ab. Die Liste blendet den Sammelbrief-Button
|
||||||
}
|
// ohnehin nur bei mahnung.write ein (list.php: $canWrite), der Endpoint muss
|
||||||
httpExitError(403, $langs->trans('MahnungSammelbriefNichtBerechtigt'));
|
// dieselbe Grenze ziehen.
|
||||||
|
if (!$user->hasRight('mahnung', 'write')) {
|
||||||
|
httpExitError(403, $langs->trans('MahnungNichtBerechtigtWrite'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$factureIds = GETPOST('facture_ids', 'array:int');
|
$factureIds = GETPOST('facture_ids', 'array:int');
|
||||||
|
|
@ -55,89 +63,95 @@ $factureIds = array_values(array_filter(array_unique(array_map('intval', $factur
|
||||||
return $v > 0;
|
return $v > 0;
|
||||||
}));
|
}));
|
||||||
if (empty($factureIds)) {
|
if (empty($factureIds)) {
|
||||||
while (ob_get_level() > 0) {
|
|
||||||
ob_end_clean();
|
|
||||||
}
|
|
||||||
httpExitError(400, $langs->trans('MahnungKeineRechnungenAusgewaehlt'));
|
httpExitError(400, $langs->trans('MahnungKeineRechnungenAusgewaehlt'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$forceStufe = GETPOSTINT('stufe');
|
|
||||||
$forceStufe = ($forceStufe >= 1 && $forceStufe <= 3) ? $forceStufe : 0;
|
|
||||||
|
|
||||||
$service = new MahnungVorschlag($db);
|
$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');
|
$basiszins = (float) getDolGlobalString('MAHNUNG_BASISZINS', '1.27');
|
||||||
|
|
||||||
$paths = array();
|
$paths = array();
|
||||||
|
$skippedErinnerung = 0;
|
||||||
|
|
||||||
foreach ($factureIds as $fid) {
|
foreach ($factureIds as $fid) {
|
||||||
$rows = $service->getVorschlaege();
|
if (!isset($vorschlaege[$fid])) {
|
||||||
$row = null;
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
if ((int) $r['facture_id'] === (int) $fid) {
|
|
||||||
$row = $r;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($row === null) {
|
|
||||||
continue;
|
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);
|
$stufe = $service->getStufe($stufeNr);
|
||||||
if ($stufe === null) {
|
if ($stufe === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$mahnung = new Mahnung($db);
|
// Zahlungserinnerungen gehören nicht in den Sammelbrief — sie erzeugen kein
|
||||||
$mahnung->fk_facture = $fid;
|
// Mahn-PDF und werden per E-Mail mit der Original-Rechnung verschickt.
|
||||||
$mahnung->fk_soc = (int) $row['soc_id'];
|
if ($stufe->istErinnerung()) {
|
||||||
$mahnung->stufe = $stufeNr;
|
$skippedErinnerung++;
|
||||||
$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) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$pdfPath = $pdfGen->generate($mahnung, $user);
|
|
||||||
if ($pdfPath !== false) {
|
$mahnung = mahnungBaueVorgang($db, $row, $stufe, $basiszins, Mahnung::VERSAND_DRUCK);
|
||||||
$paths[] = $pdfPath;
|
|
||||||
|
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)) {
|
if (empty($paths)) {
|
||||||
while (ob_get_level() > 0) {
|
if ($skippedErinnerung > 0) {
|
||||||
ob_end_clean();
|
httpExitError(400, $langs->trans('MahnungSammelbriefNurErinnerungen'));
|
||||||
}
|
}
|
||||||
httpExitError(500, $langs->trans('MahnungSammelbriefKeinePdfs'));
|
httpExitError(500, $langs->trans('MahnungSammelbriefKeinePdfs'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn TCPDI verfügbar, Seiten aller PDFs in EIN Dokument importieren.
|
// Alle Einzel-PDFs zu einem Dokument zusammenfassen (TCPDI).
|
||||||
// Andernfalls ZIP-Fallback würde sich anbieten — wir liefern stattdessen
|
$absOut = mahnungBaueSammelbriefPdf($paths);
|
||||||
// eine PDF-Konkatenation via TCPDI (Bestandteil von tecnickcom/tc-lib-pdf
|
|
||||||
// und Dolibarr-Tcpdi-Wrapper).
|
|
||||||
$absOut = this_buildSammelbriefPdf($paths);
|
|
||||||
if ($absOut === null || !file_exists($absOut)) {
|
if ($absOut === null || !file_exists($absOut)) {
|
||||||
while (ob_get_level() > 0) {
|
|
||||||
ob_end_clean();
|
|
||||||
}
|
|
||||||
httpExitError(500, $langs->trans('MahnungSammelbriefFehler'));
|
httpExitError(500, $langs->trans('MahnungSammelbriefFehler'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,14 +168,20 @@ exit;
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Konkateniert mehrere PDF-Dateien zu einer Datei. Gibt absoluten Pfad zurück
|
* Konkateniert mehrere PDF-Dateien zu einer temporären Datei. Gibt deren
|
||||||
* oder null bei Fehler.
|
* 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
|
* @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')) {
|
if (!class_exists('TCPDI')) {
|
||||||
// Dolibarr liefert TCPDI über tcpdf/tcpdi.php aus
|
// Dolibarr liefert TCPDI über tcpdf/tcpdi.php aus
|
||||||
$tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php';
|
$tcpdiPath = DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdi.php';
|
||||||
|
|
@ -171,10 +191,13 @@ function this_buildSammelbriefPdf(array $paths)
|
||||||
}
|
}
|
||||||
if (!class_exists('TCPDI')) {
|
if (!class_exists('TCPDI')) {
|
||||||
dol_syslog('Mahnung Sammelbrief: TCPDI-Klasse nicht verfügbar — nur erstes PDF wird zurückgeliefert', LOG_WARNING);
|
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 = new TCPDI();
|
||||||
$pdf->setPrintHeader(false);
|
$pdf->setPrintHeader(false);
|
||||||
$pdf->setPrintFooter(false);
|
$pdf->setPrintFooter(false);
|
||||||
|
|
@ -191,38 +214,25 @@ function this_buildSammelbriefPdf(array $paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$pdf->Output($out, 'F');
|
$pdf->Output($out, 'F');
|
||||||
|
|
||||||
|
if (!file_exists($out) || filesize($out) <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return $out;
|
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 int $code
|
||||||
* @param string $message
|
* @param string $message
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
function httpExitError($code, $message)
|
function httpExitError($code, $message)
|
||||||
{
|
{
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
http_response_code($code);
|
http_response_code($code);
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
echo $message;
|
echo $message;
|
||||||
|
|
|
||||||
1306
ajax/sendmail.php
1306
ajax/sendmail.php
File diff suppressed because it is too large
Load diff
|
|
@ -50,7 +50,7 @@ class Mahnung extends CommonObject
|
||||||
/** @var int */
|
/** @var int */
|
||||||
public $fk_soc;
|
public $fk_soc;
|
||||||
|
|
||||||
/** @var int 1, 2, 3 */
|
/** @var int Stufennummer aus llx_mahnung_stufe (frei konfigurierbar, 0 möglich) */
|
||||||
public $stufe;
|
public $stufe;
|
||||||
|
|
||||||
/** @var int Unix-Zeit */
|
/** @var int Unix-Zeit */
|
||||||
|
|
@ -74,6 +74,13 @@ class Mahnung extends CommonObject
|
||||||
/** @var float */
|
/** @var float */
|
||||||
public $verzugszinsen = 0;
|
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 */
|
/** @var float */
|
||||||
public $summe_mahnung = 0;
|
public $summe_mahnung = 0;
|
||||||
|
|
||||||
|
|
@ -177,7 +184,7 @@ class Mahnung extends CommonObject
|
||||||
$sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mahnung (";
|
$sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mahnung (";
|
||||||
$sql .= "entity, ref, fk_facture, fk_soc, stufe, date_mahnung,";
|
$sql .= "entity, ref, fk_facture, fk_soc, stufe, date_mahnung,";
|
||||||
$sql .= " date_lim_reglement_alt, date_lim_reglement_neu,";
|
$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 .= " versandart, customertype, basiszins_snapshot, pdf_path, note_private,";
|
||||||
$sql .= " status, datec, fk_user_creat";
|
$sql .= " status, datec, fk_user_creat";
|
||||||
$sql .= ") VALUES (";
|
$sql .= ") VALUES (";
|
||||||
|
|
@ -193,6 +200,7 @@ class Mahnung extends CommonObject
|
||||||
$sql .= ((float) $this->mahngebuehr).",";
|
$sql .= ((float) $this->mahngebuehr).",";
|
||||||
$sql .= ((float) $this->pauschale_b2b).",";
|
$sql .= ((float) $this->pauschale_b2b).",";
|
||||||
$sql .= ((float) $this->verzugszinsen).",";
|
$sql .= ((float) $this->verzugszinsen).",";
|
||||||
|
$sql .= ((float) $this->kosten_vorstufen).",";
|
||||||
$sql .= ((float) $this->summe_mahnung).",";
|
$sql .= ((float) $this->summe_mahnung).",";
|
||||||
$sql .= "'".$this->db->escape($this->versandart ?: self::VERSAND_PDF)."',";
|
$sql .= "'".$this->db->escape($this->versandart ?: self::VERSAND_PDF)."',";
|
||||||
$sql .= ($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL").",";
|
$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 = "SELECT t.* FROM ".MAIN_DB_PREFIX."mahnung_mahnung as t";
|
||||||
$sql .= " WHERE t.rowid = ".((int) $id);
|
$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);
|
$resql = $this->db->query($sql);
|
||||||
if (!$resql) {
|
if (!$resql) {
|
||||||
|
|
@ -253,6 +265,15 @@ class Mahnung extends CommonObject
|
||||||
$this->mahngebuehr = $obj->mahngebuehr;
|
$this->mahngebuehr = $obj->mahngebuehr;
|
||||||
$this->pauschale_b2b = $obj->pauschale_b2b;
|
$this->pauschale_b2b = $obj->pauschale_b2b;
|
||||||
$this->verzugszinsen = $obj->verzugszinsen;
|
$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->summe_mahnung = $obj->summe_mahnung;
|
||||||
$this->versandart = $obj->versandart;
|
$this->versandart = $obj->versandart;
|
||||||
$this->customertype = $obj->customertype;
|
$this->customertype = $obj->customertype;
|
||||||
|
|
@ -395,6 +416,7 @@ class Mahnung extends CommonObject
|
||||||
$sql .= ", mahngebuehr = ".((float) $this->mahngebuehr);
|
$sql .= ", mahngebuehr = ".((float) $this->mahngebuehr);
|
||||||
$sql .= ", pauschale_b2b = ".((float) $this->pauschale_b2b);
|
$sql .= ", pauschale_b2b = ".((float) $this->pauschale_b2b);
|
||||||
$sql .= ", verzugszinsen = ".((float) $this->verzugszinsen);
|
$sql .= ", verzugszinsen = ".((float) $this->verzugszinsen);
|
||||||
|
$sql .= ", kosten_vorstufen = ".((float) $this->kosten_vorstufen);
|
||||||
$sql .= ", summe_mahnung = ".((float) $this->summe_mahnung);
|
$sql .= ", summe_mahnung = ".((float) $this->summe_mahnung);
|
||||||
$sql .= ", versandart = '".$this->db->escape($this->versandart)."'";
|
$sql .= ", versandart = '".$this->db->escape($this->versandart)."'";
|
||||||
$sql .= ", customertype = ".($this->customertype ? "'".$this->db->escape($this->customertype)."'" : "NULL");
|
$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
|
* @return float Neue Summe
|
||||||
*/
|
*/
|
||||||
|
|
@ -623,12 +646,63 @@ class Mahnung extends CommonObject
|
||||||
(float) $this->betrag_offen
|
(float) $this->betrag_offen
|
||||||
+ (float) $this->mahngebuehr
|
+ (float) $this->mahngebuehr
|
||||||
+ (float) $this->pauschale_b2b
|
+ (float) $this->pauschale_b2b
|
||||||
+ (float) $this->verzugszinsen,
|
+ (float) $this->verzugszinsen
|
||||||
|
+ (float) $this->kosten_vorstufen,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
return $this->summe_mahnung;
|
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.
|
* Lokalisiertes Status-Label.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungvorschlag.class.php';
|
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';
|
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungntfy.class.php';
|
||||||
|
|
||||||
class MahnungCron
|
class MahnungCron
|
||||||
|
|
@ -44,49 +45,88 @@ class MahnungCron
|
||||||
* Sucht überfällige Rechnungen, ermittelt Vorschläge je Stufe,
|
* Sucht überfällige Rechnungen, ermittelt Vorschläge je Stufe,
|
||||||
* sendet Ntfy-Push mit Anzahl je Stufe und Gesamtwert.
|
* 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
|
* @return int 0 bei Erfolg, < 0 bei Fehler
|
||||||
*/
|
*/
|
||||||
public function buildVorschlagsliste()
|
public function buildVorschlagsliste()
|
||||||
{
|
{
|
||||||
global $conf;
|
global $langs;
|
||||||
|
|
||||||
|
$langs->load('mahnung@mahnung');
|
||||||
|
|
||||||
$service = new MahnungVorschlag($this->db);
|
$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);
|
$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;
|
$summe = 0.0;
|
||||||
foreach ($vorschlaege as $v) {
|
foreach ($vorschlaege as $v) {
|
||||||
$stufe = (int) $v['vorgeschlagene_stufe'];
|
$stufe = (int) $v['vorgeschlagene_stufe'];
|
||||||
if (isset($counts[$stufe])) {
|
if (!isset($counts[$stufe])) {
|
||||||
$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'];
|
$summe += (float) $v['betrag_offen'];
|
||||||
}
|
}
|
||||||
|
ksort($counts);
|
||||||
$summe = round($summe, 2);
|
$summe = round($summe, 2);
|
||||||
|
|
||||||
if ($count === 0) {
|
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();
|
self::clearGlobalNotify();
|
||||||
global $langs;
|
$this->output = $langs->transnoentities('MahnungCronKeineUeberfaellige');
|
||||||
$langs->load('mahnung@mahnung');
|
|
||||||
$this->output = $langs->trans('MahnungCronKeineUeberfaellige');
|
|
||||||
$this->lastresult = 0;
|
$this->lastresult = 0;
|
||||||
return 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';
|
$relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=vorschlag';
|
||||||
$absUrl = self::buildAbsoluteUrl($relPath);
|
$absUrl = self::buildAbsoluteUrl($relPath);
|
||||||
|
|
||||||
$title = $langs->trans('MahnungCronOffeneVorschlaege', $count);
|
// transnoentities statt trans: der Ntfy-Push ist Klartext, HTML-Entities
|
||||||
$message = $langs->trans('MahnungCronStufe1Erinnerung', $counts[1])."\n";
|
// (ü etc.) würden dort wörtlich auftauchen.
|
||||||
$message .= $langs->trans('MahnungCronStufe2Mahnung', $counts[2])."\n";
|
$title = $langs->transnoentities('MahnungCronOffeneVorschlaege', $count);
|
||||||
$message .= $langs->trans('MahnungCronStufe3LetzteMahnung', $counts[3])."\n";
|
$zeilen = array();
|
||||||
$message .= $langs->trans('MahnungCronOffenerBetrag', number_format($summe, 2, ',', '.'));
|
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'));
|
MahnungNtfy::send($title, $message, $absUrl, array('envelope_with_arrow', 'warning'));
|
||||||
|
|
||||||
|
|
@ -166,8 +206,12 @@ class MahnungCron
|
||||||
|
|
||||||
$resql = $this->db->query($sql);
|
$resql = $this->db->query($sql);
|
||||||
if (!$resql) {
|
if (!$resql) {
|
||||||
|
// Fehler: nichts aufräumen, Job als fehlerhaft melden
|
||||||
$this->error = $this->db->lasterror();
|
$this->error = $this->db->lasterror();
|
||||||
|
$this->errors[] = $this->error;
|
||||||
|
$this->output = $this->error;
|
||||||
$this->lastresult = -1;
|
$this->lastresult = -1;
|
||||||
|
dol_syslog('MahnungCron::versandReminder SQL-Fehler: '.$this->error, LOG_ERR);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,24 +225,25 @@ class MahnungCron
|
||||||
$langs->load('mahnung@mahnung');
|
$langs->load('mahnung@mahnung');
|
||||||
|
|
||||||
if (empty($pending)) {
|
if (empty($pending)) {
|
||||||
$this->output = $langs->trans('MahnungCronKeineUnversendet', $tageSchwelle);
|
$this->output = $langs->transnoentities('MahnungCronKeineUnversendet', $tageSchwelle);
|
||||||
$this->lastresult = 0;
|
$this->lastresult = 0;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv';
|
$relPath = '/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung&mode=archiv';
|
||||||
$absUrl = self::buildAbsoluteUrl($relPath);
|
$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();
|
$lines = array();
|
||||||
foreach ($pending as $p) {
|
foreach ($pending as $p) {
|
||||||
$tage = (int) floor((time() - strtotime((string) $p->datec)) / 86400);
|
$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"
|
// Auf 8 Zeilen kürzen, Rest als "+N weitere"
|
||||||
if (count($lines) > 8) {
|
if (count($lines) > 8) {
|
||||||
$rest = count($lines) - 8;
|
$rest = count($lines) - 8;
|
||||||
$lines = array_slice($lines, 0, 8);
|
$lines = array_slice($lines, 0, 8);
|
||||||
$lines[] = $langs->trans('MahnungCronWeitere', $rest);
|
$lines[] = $langs->transnoentities('MahnungCronWeitere', $rest);
|
||||||
}
|
}
|
||||||
$message = implode("\n", $lines);
|
$message = implode("\n", $lines);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,12 @@
|
||||||
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine Mahnstufe (1..3): Frist-Konfiguration, Gebühren, optionaler Zinssatz-Override,
|
* Eine frei konfigurierbare Mahnstufe: Frist-Konfiguration, Gebühren, optionaler
|
||||||
* Versandart-Default, E-Mail-/PDF-Templates.
|
* 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
|
class MahnungStufe extends CommonObject
|
||||||
{
|
{
|
||||||
|
|
@ -28,13 +32,13 @@ class MahnungStufe extends CommonObject
|
||||||
/** @var int */
|
/** @var int */
|
||||||
public $entity;
|
public $entity;
|
||||||
|
|
||||||
/** @var int 1..3 */
|
/** @var int Frei wählbare Stufennummer (0 = Default der Zahlungserinnerung) */
|
||||||
public $stufe;
|
public $stufe;
|
||||||
|
|
||||||
/** @var string */
|
/** @var string */
|
||||||
public $label;
|
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;
|
public $frist_tage = 0;
|
||||||
|
|
||||||
/** @var int Neue Zahlungsfrist im Mahnschreiben (Tage) */
|
/** @var int Neue Zahlungsfrist im Mahnschreiben (Tage) */
|
||||||
|
|
@ -67,6 +71,13 @@ class MahnungStufe extends CommonObject
|
||||||
/** @var string */
|
/** @var string */
|
||||||
public $pdf_intro;
|
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 */
|
/** @var int 0|1 */
|
||||||
public $active = 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
|
* @return int -1 Fehler, 0 nicht gefunden, >0 OK
|
||||||
*/
|
*/
|
||||||
public function fetchByStufe($stufe)
|
public function fetchByStufe($stufe)
|
||||||
|
|
@ -138,6 +176,164 @@ class MahnungStufe extends CommonObject
|
||||||
return $result;
|
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
|
* @param User $user
|
||||||
* @return int <0 Fehler, sonst rowid
|
* @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_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 .= ", 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 .= ", 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 .= ", active = ".((int) (!empty($this->active) ? 1 : 0));
|
||||||
$sql .= " WHERE rowid = ".((int) $this->id);
|
$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);
|
dol_syslog(get_class($this).'::update', LOG_DEBUG);
|
||||||
$resql = $this->db->query($sql);
|
$resql = $this->db->query($sql);
|
||||||
|
|
@ -174,6 +374,17 @@ class MahnungStufe extends CommonObject
|
||||||
return $this->id;
|
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.
|
* 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_subject = $obj->email_subject;
|
||||||
$this->email_body = $obj->email_body;
|
$this->email_body = $obj->email_body;
|
||||||
$this->pdf_intro = $obj->pdf_intro;
|
$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->active = (int) $obj->active;
|
||||||
$this->datec = $this->db->jdate($obj->datec);
|
$this->datec = $this->db->jdate($obj->datec);
|
||||||
$this->tms = $this->db->jdate($obj->tms);
|
$this->tms = $this->db->jdate($obj->tms);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@
|
||||||
* die nächste vorgeschlagene Mahnstufe ermitteln.
|
* die nächste vorgeschlagene Mahnstufe ermitteln.
|
||||||
*
|
*
|
||||||
* Geteilte Logik zwischen Cron-Job (Ntfy-Push) und Vorschlagslisten-UI.
|
* 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';
|
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
|
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 */
|
/** @var DoliDB */
|
||||||
public $db;
|
public $db;
|
||||||
|
|
||||||
/** @var int */
|
/** @var int */
|
||||||
public $entity;
|
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();
|
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
|
* @param DoliDB $db
|
||||||
*/
|
*/
|
||||||
|
|
@ -44,31 +84,212 @@ class MahnungVorschlag
|
||||||
*
|
*
|
||||||
* Rückgabe-Schlüssel je Eintrag:
|
* Rückgabe-Schlüssel je Eintrag:
|
||||||
* facture_id, facture_ref, facture_date_lim_reglement (Unix), facture_total_ttc,
|
* 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'),
|
* kundentyp ('B2C'|'B2B'),
|
||||||
* tage_verzug,
|
* tage_verzug,
|
||||||
* betrag_offen,
|
* betrag_offen,
|
||||||
* letzte_mahnung_id (int|null), letzte_mahnung_stufe (int|null), letzte_mahnung_datum (Unix|null),
|
* letzte_mahnung_id (int|null), letzte_mahnung_stufe (int|null), letzte_mahnung_datum (Unix|null),
|
||||||
* vorgeschlagene_stufe (int 1..3),
|
* vorgeschlagene_stufe (int|null — frei konfigurierbare Stufennummer, 0 möglich),
|
||||||
* vorgeschlagene_stufe_label (string)
|
* 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',
|
* @param array $filter Optional: 'soc_id', 'min_tage_verzug', 'max_tage_verzug', 'stufe',
|
||||||
* 'min_betrag' (float), 'kundentyp' ('B2B'|'B2C')
|
* 'min_betrag' (float), 'kundentyp' ('B2B'|'B2C')
|
||||||
* @return array
|
* @return array|false false bei Fehler ($this->error gesetzt)
|
||||||
*/
|
*/
|
||||||
public function getVorschlaege(array $filter = array())
|
public function getVorschlaege(array $filter = array())
|
||||||
{
|
{
|
||||||
$this->loadStufen();
|
$this->resetError();
|
||||||
if (empty($this->stufen)) {
|
|
||||||
return array();
|
if (!$this->loadStufen()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$today = dol_now();
|
$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 = "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 .= " 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 .= " FROM ".MAIN_DB_PREFIX."facture as f";
|
||||||
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
|
$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 .= " WHERE f.entity = ".((int) $this->entity);
|
||||||
$sql .= " AND f.fk_statut = 1";
|
$sql .= " AND f.fk_statut = 1";
|
||||||
$sql .= " AND f.paye = 0";
|
$sql .= " AND f.paye = 0";
|
||||||
|
|
@ -84,121 +305,47 @@ class MahnungVorschlag
|
||||||
|
|
||||||
$resql = $this->db->query($sql);
|
$resql = $this->db->query($sql);
|
||||||
if (!$resql) {
|
if (!$resql) {
|
||||||
dol_syslog('MahnungVorschlag::getVorschlaege SQL-Fehler: '.$this->db->lasterror(), LOG_ERR);
|
$dbError = $this->db->lasterror();
|
||||||
return array();
|
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)) {
|
while ($obj = $this->db->fetch_object($resql)) {
|
||||||
$row = $this->buildVorschlag($obj, $today);
|
$rows[] = $obj;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
$this->db->free($resql);
|
$this->db->free($resql);
|
||||||
return $result;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert alle überfälligen Rechnungen, für die aktuell KEIN Vorschlag passt,
|
* Filter, die für Vorschlagsliste UND Übersprungen-Liste gleichermaßen gelten.
|
||||||
* 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)
|
|
||||||
*
|
*
|
||||||
|
* @param array $row
|
||||||
* @param array $filter
|
* @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();
|
|
||||||
}
|
|
||||||
|
|
||||||
$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']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$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();
|
|
||||||
}
|
|
||||||
|
|
||||||
$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']) {
|
if (isset($filter['min_tage_verzug']) && $row['tage_verzug'] < (int) $filter['min_tage_verzug']) {
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) {
|
if (isset($filter['min_betrag']) && (float) $row['betrag_offen'] < (float) $filter['min_betrag']) {
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) {
|
if (!empty($filter['kundentyp']) && $row['kundentyp'] !== $filter['kundentyp']) {
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
$result[] = $row;
|
return true;
|
||||||
}
|
|
||||||
$this->db->free($resql);
|
|
||||||
return $result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Berechnet für eine einzelne Rechnung, ob/wozu eine Mahnung vorgeschlagen wird.
|
* 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 int $today Unix-Zeit
|
||||||
* @param bool $includeSkipped true = liefert auch übersprungene mit skip_reason
|
* @param bool $includeSkipped true = liefert auch übersprungene mit skip_reason
|
||||||
* @return array|null
|
* @return array|null
|
||||||
|
|
@ -214,41 +361,18 @@ class MahnungVorschlag
|
||||||
$tageVerzug = 0;
|
$tageVerzug = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$kundentyp = !empty($factureObj->tva_intra) ? Mahnung::KUNDENTYP_B2B : Mahnung::KUNDENTYP_B2C;
|
$kundentyp = $this->ermittleKundentyp($factureObj);
|
||||||
|
|
||||||
// Letzte aktive Mahnung zur Rechnung holen
|
// Letzte aktive Mahnung zur Rechnung holen
|
||||||
$lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id);
|
$lastMahnung = (new Mahnung($this->db))->fetchLastByFacture((int) $factureObj->facture_id);
|
||||||
|
|
||||||
// Nächste Stufe ermitteln
|
$this->ladeSprache();
|
||||||
$proposedStufe = null;
|
|
||||||
$skipReason = null;
|
|
||||||
global $langs;
|
global $langs;
|
||||||
$langs->load('mahnung@mahnung');
|
|
||||||
|
|
||||||
if ($lastMahnung === null) {
|
// Zielstufe rein datengetrieben ermitteln
|
||||||
$frist1 = isset($this->stufen[1]) ? (int) $this->stufen[1]->frist_tage : 0;
|
$entscheidung = $this->ermittleZielstufe($lastMahnung, $tageVerzug, $today);
|
||||||
if (!isset($this->stufen[1])) {
|
$proposedStufe = $entscheidung['stufe'];
|
||||||
$skipReason = $langs->trans('MahnungVorschlagStufeNichtKonfiguriert');
|
$skipReason = $entscheidung['grund'];
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen)
|
// Offenen Betrag berechnen (total_ttc - Summe aller Zahlungen)
|
||||||
$betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc);
|
$betragOffen = $this->getBetragOffen((int) $factureObj->facture_id, (float) $factureObj->total_ttc);
|
||||||
|
|
@ -264,6 +388,8 @@ class MahnungVorschlag
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$zielStufeObj = ($proposedStufe !== null && isset($this->stufen[$proposedStufe])) ? $this->stufen[$proposedStufe] : null;
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'facture_id' => (int) $factureObj->facture_id,
|
'facture_id' => (int) $factureObj->facture_id,
|
||||||
'facture_ref' => $factureObj->facture_ref,
|
'facture_ref' => $factureObj->facture_ref,
|
||||||
|
|
@ -272,8 +398,11 @@ class MahnungVorschlag
|
||||||
'soc_id' => (int) $factureObj->fk_soc,
|
'soc_id' => (int) $factureObj->fk_soc,
|
||||||
'soc_nom' => $factureObj->soc_nom,
|
'soc_nom' => $factureObj->soc_nom,
|
||||||
'soc_tva_intra' => $factureObj->tva_intra,
|
'soc_tva_intra' => $factureObj->tva_intra,
|
||||||
'soc_phone' => $factureObj->soc_phone ?? '',
|
'soc_siret' => isset($factureObj->soc_siret) ? $factureObj->soc_siret : '',
|
||||||
'soc_email' => $factureObj->soc_email ?? '',
|
'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,
|
'kundentyp' => $kundentyp,
|
||||||
'tage_verzug' => $tageVerzug,
|
'tage_verzug' => $tageVerzug,
|
||||||
'betrag_offen' => $betragOffen,
|
'betrag_offen' => $betragOffen,
|
||||||
|
|
@ -281,11 +410,158 @@ class MahnungVorschlag
|
||||||
'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null,
|
'letzte_mahnung_stufe' => $lastMahnung ? (int) $lastMahnung->stufe : null,
|
||||||
'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null,
|
'letzte_mahnung_datum' => $lastMahnung ? $lastMahnung->date_mahnung : null,
|
||||||
'vorgeschlagene_stufe' => $proposedStufe,
|
'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,
|
'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).
|
* 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()
|
private function loadStufen()
|
||||||
{
|
{
|
||||||
if (!empty($this->stufen)) {
|
if (!$this->stufenGeladen) {
|
||||||
return;
|
$this->stufenGeladen = true;
|
||||||
}
|
|
||||||
$so = new MahnungStufe($this->db);
|
$so = new MahnungStufe($this->db);
|
||||||
foreach ($so->fetchAllActive() as $s) {
|
$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;
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 void
|
||||||
* @return MahnungStufe|null
|
|
||||||
*/
|
*/
|
||||||
public function getStufe($stufe)
|
private function ladeSprache()
|
||||||
{
|
{
|
||||||
$this->loadStufen();
|
global $langs;
|
||||||
return $this->stufen[(int) $stufe] ?? null;
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.'/compta/facture/class/facture.class.php';
|
||||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.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/class/mahnung.class.php';
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/lib/mahnung_ui.lib.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widget: Aelteste offene Kundenrechnungen mit Mahnstufe-Badge.
|
* Widget: Aelteste offene Kundenrechnungen mit Mahnstufe-Badge.
|
||||||
|
|
@ -30,6 +31,9 @@ class box_mahnung_offen extends ModeleBoxes
|
||||||
/** @var array<int,array|null> Zahlprognose je socid — 1 Query pro Kunde/Request (Cache) */
|
/** @var array<int,array|null> Zahlprognose je socid — 1 Query pro Kunde/Request (Cache) */
|
||||||
private static $prognoseCache = array();
|
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 DoliDB $db
|
||||||
* @param string $param
|
* @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.total_ht, f.total_tva, f.total_ttc,";
|
||||||
$sql .= " f.paye, f.fk_statut as status,";
|
$sql .= " f.paye, f.fk_statut as status,";
|
||||||
$sql .= " SUM(pf.amount) as am,";
|
$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 .= " (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 .= " 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 .= " (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 .= " 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 .= " (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 .= " 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 .= " FROM ".MAIN_DB_PREFIX."facture as f";
|
||||||
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = f.fk_soc";
|
$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";
|
$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);
|
$boxmax = getDolGlobalInt('MAHNUNG_BOX_MAXLINES', 0);
|
||||||
$renderLimit = ($boxmax > 0) ? min($num, $boxmax) : $num;
|
$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) {
|
while ($line < $renderLimit) {
|
||||||
$objp = $this->db->fetch_object($result);
|
$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')));
|
$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 = '<span class="opacitymedium">—</span>';
|
$mahnCell = '<span class="opacitymedium">—</span>';
|
||||||
if (!empty($objp->mahnstufe)) {
|
if ($objp->mahnstufe !== null && $objp->mahnstufe !== '') {
|
||||||
$stufe = (int) $objp->mahnstufe;
|
$stufe = (int) $objp->mahnstufe;
|
||||||
$colors = array(1 => '#4a90d9', 2 => '#e68a00', 3 => '#cc3333');
|
// Ob es eine kostenlose Zahlungserinnerung ist, entscheidet ausschließlich
|
||||||
$color = $colors[$stufe] ?? '#666';
|
// das Flag ist_erinnerung der Stufe — NICHT die Stufennummer.
|
||||||
$label = $langs->trans('MahnungBoxStufe', $stufe);
|
$istErinnerung = in_array($stufe, $erinnerungsStufen, true);
|
||||||
$mahnDatum = $objp->mahndatum ? dol_print_date($this->db->jdate($objp->mahndatum), 'day') : '';
|
$mahnDatum = $objp->mahndatum ? dol_print_date($this->db->jdate($objp->mahndatum), 'day') : '';
|
||||||
|
|
||||||
|
// 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;
|
$tooltip = $mahnDatum ? $langs->trans('MahnungBoxStufeVom', $stufe, $mahnDatum) : $label;
|
||||||
$badge = '<span class="badge" style="background-color:'.$color.';color:#fff;font-size:0.75em;" title="'.dol_escape_htmltag($tooltip).'">'.$label.'</span>';
|
}
|
||||||
if (!empty($objp->mahnid)) {
|
|
||||||
|
$badge = '<span class="badge" style="background-color:'.$color.';color:#fff;font-size:0.75em;" title="'.dol_escape_htmltag($tooltip).'">'.dol_escape_htmltag($label).'</span>';
|
||||||
|
if ((int) $objp->mahnid > 0) {
|
||||||
$mahnCell = '<a href="'.DOL_URL_ROOT.'/custom/mahnung/card.php?id='.((int) $objp->mahnid).'">'.$badge.'</a>';
|
$mahnCell = '<a href="'.DOL_URL_ROOT.'/custom/mahnung/card.php?id='.((int) $objp->mahnid).'">'.$badge.'</a>';
|
||||||
} else {
|
} else {
|
||||||
$mahnCell = $badge;
|
$mahnCell = $badge;
|
||||||
|
|
@ -313,6 +343,50 @@ class box_mahnung_offen extends ModeleBoxes
|
||||||
$this->db->free($result);
|
$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.
|
* Zahlungsverhalten eines Kunden aus seinen bezahlten Rechnungen.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
$texthelp .= '<br><b>Mahnung-spezifisch:</b><br>';
|
$texthelp .= '<br><b>Mahnung-spezifisch:</b><br>';
|
||||||
$texthelp .= '{mahnung_ref}, {mahnung_stufe}, {mahnung_stufe_label}, {mahnung_date},<br>';
|
$texthelp .= '{mahnung_ref}, {mahnung_stufe}, {mahnung_stufe_label}, {mahnung_date},<br>';
|
||||||
$texthelp .= '{mahnung_betrag_offen}, {mahnung_mahngebuehr}, {mahnung_verzugszinsen},<br>';
|
$texthelp .= '{mahnung_betrag_offen}, {mahnung_mahngebuehr}, {mahnung_verzugszinsen},<br>';
|
||||||
|
$texthelp .= '{mahnung_kosten_vorstufen}, {mahnung_pauschale_b2b},<br>';
|
||||||
$texthelp .= '{mahnung_summe}, {mahnung_basiszins}, {mahnung_zinssatz}, {mahnung_kundentyp},<br>';
|
$texthelp .= '{mahnung_summe}, {mahnung_basiszins}, {mahnung_zinssatz}, {mahnung_kundentyp},<br>';
|
||||||
$texthelp .= '{mahnung_pdf_intro}, {mahnung_date_lim_alt}, {mahnung_date_lim_neu}<br>';
|
$texthelp .= '{mahnung_pdf_intro}, {mahnung_date_lim_alt}, {mahnung_date_lim_neu}<br>';
|
||||||
$texthelp .= '<br><b>Rechnungsdaten:</b><br>';
|
$texthelp .= '<br><b>Rechnungsdaten:</b><br>';
|
||||||
|
|
@ -125,7 +126,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
$texthelp .= '<br><b>Bankdaten:</b><br>';
|
$texthelp .= '<br><b>Bankdaten:</b><br>';
|
||||||
$texthelp .= '{mahnung_bank_label}, {mahnung_bank_iban}, {mahnung_bank_bic}<br>';
|
$texthelp .= '{mahnung_bank_label}, {mahnung_bank_iban}, {mahnung_bank_bic}<br>';
|
||||||
$texthelp .= '<br><b>Stufen-spezifische Templates:</b><br>';
|
$texthelp .= '<br><b>Stufen-spezifische Templates:</b><br>';
|
||||||
$texthelp .= 'mahnung_stufe1.odt, mahnung_stufe2.odt, mahnung_stufe3.odt<br>';
|
$texthelp .= 'mahnung_stufe{Nummer}.odt — z.B. mahnung_stufe1.odt, mahnung_stufe2.odt<br>';
|
||||||
|
|
||||||
$texte .= $form->textwithpicto($texttitle, $texthelp, 1, 'help', '', 1, 3, $this->name);
|
$texte .= $form->textwithpicto($texttitle, $texthelp, 1, 'help', '', 1, 3, $this->name);
|
||||||
$texte .= '<div><div style="display: inline-block; min-width: 100px; vertical-align: middle;">';
|
$texte .= '<div><div style="display: inline-block; min-width: 100px; vertical-align: middle;">';
|
||||||
|
|
@ -151,13 +152,13 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
$texte .= '</div>';
|
$texte .= '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hinweis zur Benennung
|
// Hinweis zur Benennung — Stufennummern sind frei konfigurierbar
|
||||||
$texte .= '<br><div class="opacitymedium" style="margin: 8px 0; padding: 6px 10px; border-left: 3px solid #4a90d9; background: rgba(74,144,217,0.05);">';
|
$texte .= '<br><div class="opacitymedium" style="margin: 8px 0; padding: 6px 10px; border-left: 3px solid #4a90d9; background: rgba(74,144,217,0.05);">';
|
||||||
$texte .= '<b>Dateinamen-Konvention:</b><br>';
|
$texte .= '<b>Dateinamen-Konvention:</b><br>';
|
||||||
$texte .= '<code>mahnung_stufe1.odt</code> = Zahlungserinnerung (Stufe 1)<br>';
|
$texte .= '<code>mahnung_stufe1.odt</code> = Template für Stufe 1<br>';
|
||||||
$texte .= '<code>mahnung_stufe2.odt</code> = 1. Mahnung (Stufe 2)<br>';
|
$texte .= '<code>mahnung_stufe2.odt</code> = Template für Stufe 2 usw.<br>';
|
||||||
$texte .= '<code>mahnung_stufe3.odt</code> = Letzte Mahnung (Stufe 3)<br>';
|
$texte .= '<code>mahnung.odt</code> = Fallback für alle Stufen<br>';
|
||||||
$texte .= '<code>mahnung.odt</code> = Fallback für alle Stufen';
|
$texte .= 'Für Zahlungserinnerungen wird kein Dokument erzeugt — dort ist die Original-Rechnung der Anhang.';
|
||||||
$texte .= '</div>';
|
$texte .= '</div>';
|
||||||
|
|
||||||
// Upload-Feld
|
// Upload-Feld
|
||||||
|
|
@ -231,7 +232,19 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
$mahnung->thirdparty = $societe;
|
$mahnung->thirdparty = $societe;
|
||||||
|
|
||||||
$stufeObj = new MahnungStufe($this->db);
|
$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
|
// Stufen-spezifisches Template suchen
|
||||||
$srctemplatepath = $this->findStufeTemplate($srctemplatepath, (int) $mahnung->stufe);
|
$srctemplatepath = $this->findStufeTemplate($srctemplatepath, (int) $mahnung->stufe);
|
||||||
|
|
@ -382,7 +395,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
* Fallback: das übergebene generische Template.
|
* Fallback: das übergebene generische Template.
|
||||||
*
|
*
|
||||||
* @param string $srctemplatepath Generisches Template (von commonGenerateDocument)
|
* @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
|
* @return string Pfad zum besten Template
|
||||||
*/
|
*/
|
||||||
private function findStufeTemplate($srctemplatepath, $stufe)
|
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_B2B', '9.0')
|
||||||
: (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.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 ?? '');
|
$intro = (string) ($stufe->pdf_intro ?? '');
|
||||||
if (empty($intro)) {
|
if (empty($intro)) {
|
||||||
$intro = $this->defaultIntro((int) $mahnung->stufe);
|
$intro = $this->defaultIntro((int) $mahnung->stufe);
|
||||||
|
|
@ -439,9 +457,10 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
'mahnung_mahngebuehr' => price((float) $mahnung->mahngebuehr),
|
'mahnung_mahngebuehr' => price((float) $mahnung->mahngebuehr),
|
||||||
'mahnung_pauschale_b2b' => price((float) $mahnung->pauschale_b2b),
|
'mahnung_pauschale_b2b' => price((float) $mahnung->pauschale_b2b),
|
||||||
'mahnung_verzugszinsen' => price((float) $mahnung->verzugszinsen),
|
'mahnung_verzugszinsen' => price((float) $mahnung->verzugszinsen),
|
||||||
|
'mahnung_kosten_vorstufen' => price((float) $mahnung->kosten_vorstufen),
|
||||||
'mahnung_summe' => price((float) $mahnung->summe_mahnung),
|
'mahnung_summe' => price((float) $mahnung->summe_mahnung),
|
||||||
'mahnung_basiszins' => number_format($basiszins, 2, ',', '.'),
|
'mahnung_basiszins' => number_format($basiszins, 2, ',', '.'),
|
||||||
'mahnung_zinssatz' => number_format($basiszins + $aufschlag, 2, ',', '.'),
|
'mahnung_zinssatz' => number_format($zinssatz, 2, ',', '.'),
|
||||||
'mahnung_kundentyp' => $mahnung->customertype,
|
'mahnung_kundentyp' => $mahnung->customertype,
|
||||||
'mahnung_versandart' => $mahnung->versandart ?? '',
|
'mahnung_versandart' => $mahnung->versandart ?? '',
|
||||||
'mahnung_pdf_intro' => $intro,
|
'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
|
* @param int $stufe
|
||||||
* @return string
|
* @return string
|
||||||
|
|
@ -489,6 +509,7 @@ class doc_generic_mahnung_odt extends ModelePDFMahnung
|
||||||
global $langs;
|
global $langs;
|
||||||
$langs->load('mahnung@mahnung');
|
$langs->load('mahnung@mahnung');
|
||||||
switch ((int) $stufe) {
|
switch ((int) $stufe) {
|
||||||
|
case 0:
|
||||||
case 1:
|
case 1:
|
||||||
return $langs->trans('MahnungPdfDefaultIntro1');
|
return $langs->trans('MahnungPdfDefaultIntro1');
|
||||||
case 2:
|
case 2:
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,15 @@ class pdf_standard_mahnung extends ModelePDFMahnung
|
||||||
return -1;
|
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
|
// Ziel-Verzeichnis
|
||||||
$dirOutput = $this->getOutputDir($mahnung, $facture);
|
$dirOutput = $this->getOutputDir($mahnung, $facture);
|
||||||
if (dol_mkdir($dirOutput) < 0) {
|
if (dol_mkdir($dirOutput) < 0) {
|
||||||
|
|
@ -111,6 +120,17 @@ class pdf_standard_mahnung extends ModelePDFMahnung
|
||||||
|
|
||||||
$pdf->Output($absPath, 'F');
|
$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
|
// Pfad in DB persistieren
|
||||||
$mahnung->pdf_path = $absPath;
|
$mahnung->pdf_path = $absPath;
|
||||||
$mahnung->update($user);
|
$mahnung->update($user);
|
||||||
|
|
@ -212,6 +232,13 @@ class pdf_standard_mahnung extends ModelePDFMahnung
|
||||||
$pdf->SetFont('helvetica', '', 10);
|
$pdf->SetFont('helvetica', '', 10);
|
||||||
$pdf->Cell(130, 6, $outputlangs->trans('MahnungBetragOffen'), 0, 0, 'L');
|
$pdf->Cell(130, 6, $outputlangs->trans('MahnungBetragOffen'), 0, 0, 'L');
|
||||||
$pdf->Cell(30, 6, price((float) $mahnung->betrag_offen).' EUR', 0, 1, 'R');
|
$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) {
|
if ((float) $mahnung->mahngebuehr > 0) {
|
||||||
$pdf->SetX(25);
|
$pdf->SetX(25);
|
||||||
$pdf->Cell(130, 6, $outputlangs->trans('MahnungGebuehr'), 0, 0, 'L');
|
$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) {
|
if ((float) $mahnung->verzugszinsen > 0) {
|
||||||
$pdf->SetX(25);
|
$pdf->SetX(25);
|
||||||
$basis = $mahnung->basiszins_snapshot !== null ? (float) $mahnung->basiszins_snapshot : 0.0;
|
$satz = $this->getAusgewiesenerZinssatz($mahnung, $stufe);
|
||||||
$auf = $mahnung->customertype === Mahnung::KUNDENTYP_B2B
|
|
||||||
? (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2B', '9.0')
|
|
||||||
: (float) getDolGlobalString('MAHNUNG_AUFSCHLAG_B2C', '5.0');
|
|
||||||
$satz = $basis + $auf;
|
|
||||||
$pdf->Cell(130, 6, $outputlangs->trans('MahnungVerzugszinsen').' ('.number_format($satz, 2, ',', '.').' %)', 0, 0, 'L');
|
$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');
|
$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');
|
$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.
|
* 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
|
* @param int $stufe
|
||||||
* @return string
|
* @return string
|
||||||
|
|
@ -316,6 +367,7 @@ class pdf_standard_mahnung extends ModelePDFMahnung
|
||||||
global $langs;
|
global $langs;
|
||||||
$langs->load('mahnung@mahnung');
|
$langs->load('mahnung@mahnung');
|
||||||
switch ((int) $stufe) {
|
switch ((int) $stufe) {
|
||||||
|
case 0:
|
||||||
case 1:
|
case 1:
|
||||||
return $langs->trans('MahnungPdfDefaultIntro1');
|
return $langs->trans('MahnungPdfDefaultIntro1');
|
||||||
case 2:
|
case 2:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* \defgroup mahnung Modul Mahnwesen
|
* \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
|
* \file htdocs/custom/mahnung/core/modules/modMahnung.class.php
|
||||||
* \ingroup mahnung
|
* \ingroup mahnung
|
||||||
*/
|
*/
|
||||||
|
|
@ -26,6 +27,19 @@ include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
|
||||||
*/
|
*/
|
||||||
class modMahnung extends DolibarrModules
|
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
|
* @param DoliDB $db Datenbank-Handler
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,7 +68,9 @@ class modMahnung extends DolibarrModules
|
||||||
$this->editor_name = 'Alles Watt läuft';
|
$this->editor_name = 'Alles Watt läuft';
|
||||||
$this->editor_url = '';
|
$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);
|
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
||||||
|
|
||||||
|
|
@ -325,13 +341,21 @@ class modMahnung extends DolibarrModules
|
||||||
{
|
{
|
||||||
global $conf;
|
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
|
// Tabellen anlegen aus sql/-Verzeichnis
|
||||||
$result = $this->_load_tables('/mahnung/sql/');
|
$result = $this->_load_tables('/mahnung/sql/');
|
||||||
if ($result < 0) {
|
if ($result < 0) {
|
||||||
return -1;
|
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();
|
$this->migrateVersandFelder();
|
||||||
|
|
||||||
// Migration: tms-Spalten auf "ON UPDATE CURRENT_TIMESTAMP" umstellen
|
// 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
|
* Lazy-Migration: bringt das DB-Schema auf self::DB_VERSION, ohne dass das
|
||||||
* in einer älteren Schema-Version noch fehlen. Idempotent — fehlende
|
* Modul deaktiviert/aktiviert oder die Setup-Seite aufgerufen werden muss.
|
||||||
* Spalten werden geprüft via SHOW COLUMNS und nur dann hinzugefügt.
|
*
|
||||||
|
* 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
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function migrateVersandFelder()
|
public function migrateVersandFelder()
|
||||||
{
|
{
|
||||||
global $db;
|
global $conf;
|
||||||
|
|
||||||
$alter = array();
|
$db = $this->db;
|
||||||
$cols = array(
|
|
||||||
|
// Versand-/Tracking-Felder + kumulierte Vorstufen-Kosten
|
||||||
|
$this->addMissingColumns($db, 'mahnung_mahnung', array(
|
||||||
'date_versand' => "ADD COLUMN date_versand DATETIME NULL",
|
'date_versand' => "ADD COLUMN date_versand DATETIME NULL",
|
||||||
'versandweg' => "ADD COLUMN versandweg VARCHAR(30) NULL",
|
'versandweg' => "ADD COLUMN versandweg VARCHAR(30) NULL",
|
||||||
'tracking_nr' => "ADD COLUMN tracking_nr VARCHAR(50) NULL",
|
'tracking_nr' => "ADD COLUMN tracking_nr VARCHAR(50) NULL",
|
||||||
'tracking_provider' => "ADD COLUMN tracking_provider VARCHAR(20) 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) {
|
foreach ($cols as $col => $clause) {
|
||||||
$res = $db->query("SHOW COLUMNS FROM ".MAIN_DB_PREFIX."mahnung_mahnung LIKE '".$db->escape($col)."'");
|
$exists = $this->columnExists($db, $table, $col);
|
||||||
if ($res && $db->num_rows($res) == 0) {
|
if ($exists === null) {
|
||||||
|
// Tabelle fehlt (frische Installation legt sie über die sql/-Dateien an)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($exists === false) {
|
||||||
$alter[] = $clause;
|
$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) {
|
if ($res) {
|
||||||
$db->free($res);
|
$db->free($res);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!empty($alter)) {
|
$col = $db->fetch_object($res);
|
||||||
$db->query("ALTER TABLE ".MAIN_DB_PREFIX."mahnung_mahnung ".implode(', ', $alter));
|
$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()
|
public function migrateTimestampSpalten()
|
||||||
{
|
{
|
||||||
global $db;
|
$db = $this->db;
|
||||||
|
|
||||||
$tables = array('mahnung_mahnung', 'mahnung_stufe', 'mahnung_trackingpattern');
|
$tables = array('mahnung_mahnung', 'mahnung_stufe', 'mahnung_trackingpattern');
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
|
||||||
* @param User $user aktueller User
|
* @param User $user aktueller User
|
||||||
* @param Translate $langs Sprache
|
* @param Translate $langs Sprache
|
||||||
* @param Conf $conf Konfig
|
* @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)
|
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.
|
* 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 int $factureId
|
||||||
* @param User $user
|
* @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)
|
private function onRechnungBezahlt($factureId, User $user)
|
||||||
{
|
{
|
||||||
|
|
@ -80,11 +87,14 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
|
||||||
|
|
||||||
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
||||||
$sql .= " WHERE fk_facture = ".((int) $factureId);
|
$sql .= " WHERE fk_facture = ".((int) $factureId);
|
||||||
|
$sql .= " AND entity IN (".getEntity('mahnung').")";
|
||||||
$sql .= " AND status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")";
|
$sql .= " AND status NOT IN (".Mahnung::STATUS_ERLEDIGT.", ".Mahnung::STATUS_STORNIERT.")";
|
||||||
|
|
||||||
$resql = $this->db->query($sql);
|
$resql = $this->db->query($sql);
|
||||||
if (!$resql) {
|
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;
|
$count = 0;
|
||||||
|
|
@ -92,6 +102,9 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
|
||||||
$m = new Mahnung($this->db);
|
$m = new Mahnung($this->db);
|
||||||
if ($m->fetch((int) $obj->rowid) > 0 && $m->setErledigt($user) > 0) {
|
if ($m->fetch((int) $obj->rowid) > 0 && $m->setErledigt($user) > 0) {
|
||||||
$count++;
|
$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);
|
$this->db->free($resql);
|
||||||
|
|
@ -144,10 +157,12 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
|
||||||
private function hatOffeneMahnungen()
|
private function hatOffeneMahnungen()
|
||||||
{
|
{
|
||||||
$sql = "SELECT COUNT(*) AS nb FROM ".MAIN_DB_PREFIX."mahnung_mahnung";
|
$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);
|
$resql = $this->db->query($sql);
|
||||||
if (!$resql) {
|
if (!$resql) {
|
||||||
|
dol_syslog('InterfaceMahnungTriggers::hatOffeneMahnungen SQL-Fehler: '.$this->db->lasterror(), LOG_ERR);
|
||||||
return true; // Im Zweifel Badge stehen lassen
|
return true; // Im Zweifel Badge stehen lassen
|
||||||
}
|
}
|
||||||
$obj = $this->db->fetch_object($resql);
|
$obj = $this->db->fetch_object($resql);
|
||||||
|
|
@ -168,7 +183,12 @@ class InterfaceMahnungTriggers extends DolibarrTriggers
|
||||||
$sql .= " GROUP BY f.rowid, f.total_ttc";
|
$sql .= " GROUP BY f.rowid, f.total_ttc";
|
||||||
|
|
||||||
$resql = $this->db->query($sql);
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
$obj = $this->db->fetch_object($resql);
|
$obj = $this->db->fetch_object($resql);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ PermMahnungSetup = Mahnwesen konfigurieren
|
||||||
#
|
#
|
||||||
# Menüs
|
# Menüs
|
||||||
#
|
#
|
||||||
|
MahnungSetupOeffnen = Mahnwesen-Einstellungen öffnen
|
||||||
MahnungMenu = Mahnwesen
|
MahnungMenu = Mahnwesen
|
||||||
MahnungVorschlagsliste = Vorschlagsliste
|
MahnungVorschlagsliste = Vorschlagsliste
|
||||||
MahnungArchiv = Mahnvorgänge
|
MahnungArchiv = Mahnvorgänge
|
||||||
|
|
@ -40,10 +41,67 @@ MahnungStufeZinssatzB2B = Zinssatz B2B (Override)
|
||||||
MahnungZinssatzHelpB2C = Leer = Standard (%s + %s %% = %s %%), 0 = keine Zinsen
|
MahnungZinssatzHelpB2C = Leer = Standard (%s + %s %% = %s %%), 0 = keine Zinsen
|
||||||
MahnungZinssatzHelpB2B = Leer = Standard (%s + %s %% = %s %%), 0 = keine Zinsen
|
MahnungZinssatzHelpB2B = Leer = Standard (%s + %s %% = %s %%), 0 = keine Zinsen
|
||||||
MahnungStufeVersandartDefault = Versandart-Default
|
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
|
MahnungStufeEmailSubject = E-Mail-Betreff
|
||||||
MahnungStufeEmailBody = E-Mail-Text
|
MahnungStufeEmailBody = E-Mail-Text
|
||||||
MahnungStufePdfIntro = PDF-Einleitungstext
|
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
|
# Status
|
||||||
#
|
#
|
||||||
|
|
@ -89,6 +147,11 @@ MahnungVersandGespeichert = Versanddaten gespeichert
|
||||||
MahnungVersandGeleert = Versanddaten zurückgesetzt
|
MahnungVersandGeleert = Versanddaten zurückgesetzt
|
||||||
MahnungSendebelege = Sendebelege
|
MahnungSendebelege = Sendebelege
|
||||||
MahnungSendebelegeHint = Hier Beleg von Post/DHL/Fax/Mail hochladen (PDF, Foto). Bleibt am Mahnvorgang für spätere Nachweise.
|
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)
|
# Tracking-Patterns (Phase 3)
|
||||||
|
|
@ -168,6 +231,8 @@ MahnungPauschaleB2B = Pauschale (40 € §288)
|
||||||
MahnungVerzugszinsen = Verzugszinsen
|
MahnungVerzugszinsen = Verzugszinsen
|
||||||
MahnungSumme = Gesamtsumme
|
MahnungSumme = Gesamtsumme
|
||||||
MahnungBasiszinsSnapshot = Basiszins (Snapshot)
|
MahnungBasiszinsSnapshot = Basiszins (Snapshot)
|
||||||
|
MahnungZinssatzOverrideStufe = Override der Mahnstufe
|
||||||
|
MahnungZinssatzAusBasiszins = Basiszins %s %% + Aufschlag
|
||||||
MahnungLetzteMahnung = Letzte Mahnung
|
MahnungLetzteMahnung = Letzte Mahnung
|
||||||
MahnungVorgeschlageneStufe = Vorgeschlagene Stufe
|
MahnungVorgeschlageneStufe = Vorgeschlagene Stufe
|
||||||
MahnungAktion = Aktion
|
MahnungAktion = Aktion
|
||||||
|
|
@ -178,6 +243,65 @@ MahnungKeineUeberfaelligen = Keine überfälligen Rechnungen vorhanden.
|
||||||
MahnungUebersprungen = Aktuell übersprungene Rechnungen
|
MahnungUebersprungen = Aktuell übersprungene Rechnungen
|
||||||
MahnungUebersprungenHint = Diese Rechnungen sind überfällig, werden aber aktuell nicht vorgeschlagen (Wartefrist läuft noch oder alle Mahnstufen ausgeschöpft).
|
MahnungUebersprungenHint = Diese Rechnungen sind überfällig, werden aber aktuell nicht vorgeschlagen (Wartefrist läuft noch oder alle Mahnstufen ausgeschöpft).
|
||||||
MahnungSkipGrund = Grund
|
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
|
# Setup-Seite
|
||||||
|
|
@ -185,10 +309,10 @@ MahnungSkipGrund = Grund
|
||||||
MahnungSetup = Mahnwesen Einstellungen
|
MahnungSetup = Mahnwesen Einstellungen
|
||||||
MahnungSetupPage = Mahnwesen Konfiguration
|
MahnungSetupPage = Mahnwesen Konfiguration
|
||||||
MahnungSetupDescription = Mahnstufen, Basiszins, Versandwege und Ntfy-Topic konfigurieren.
|
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.).
|
MahnungBasiszinsHelp = Aktueller Basiszins der Bundesbank, halbjährlich pflegen (1.1. / 1.7.).
|
||||||
MahnungAufschlagB2C = Aufschlag B2C (%)
|
MahnungAufschlagB2C = Aufschlag B2C (%%)
|
||||||
MahnungAufschlagB2B = Aufschlag B2B (%)
|
MahnungAufschlagB2B = Aufschlag B2B (%%)
|
||||||
MahnungPauschaleB2BLabel = Pauschale B2B (EUR)
|
MahnungPauschaleB2BLabel = Pauschale B2B (EUR)
|
||||||
MahnungNtfyTopic = Ntfy-Topic
|
MahnungNtfyTopic = Ntfy-Topic
|
||||||
MahnungNtfyTopicHelp = Topic für Push-Benachrichtigungen (Default: vk-builds).
|
MahnungNtfyTopicHelp = Topic für Push-Benachrichtigungen (Default: vk-builds).
|
||||||
|
|
@ -200,6 +324,11 @@ MahnungSettingsSaved = Einstellungen gespeichert.
|
||||||
MahnungCronBuildVorschlag = Mahnwesen — Vorschlagsliste aufbauen
|
MahnungCronBuildVorschlag = Mahnwesen — Vorschlagsliste aufbauen
|
||||||
MahnungCronVersandReminder = Mahnwesen — Versand-Reminder (unversendete Mahnungen)
|
MahnungCronVersandReminder = Mahnwesen — Versand-Reminder (unversendete Mahnungen)
|
||||||
MahnungCronBuildVorschlagDesc = Sucht täglich überfällige Rechnungen und sendet einen Ntfy-Push mit der Anzahl neuer Vorschläge.
|
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
|
# Widget
|
||||||
|
|
@ -261,6 +390,7 @@ MahnungCronWeitere = + %s weitere
|
||||||
MahnungCronArchivOeffnen = Archiv öffnen
|
MahnungCronArchivOeffnen = Archiv öffnen
|
||||||
MahnungCronEintraege = %s — %s Einträge
|
MahnungCronEintraege = %s — %s Einträge
|
||||||
MahnungCsrfFehler = Token-Verifikation fehlgeschlagen (CSRF).
|
MahnungCsrfFehler = Token-Verifikation fehlgeschlagen (CSRF).
|
||||||
|
MahnungNurPostErlaubt = Diese Aktion ist nur über das Formular (POST) erlaubt.
|
||||||
MahnungNichtBerechtigt = Nicht berechtigt.
|
MahnungNichtBerechtigt = Nicht berechtigt.
|
||||||
MahnungKeineRechnungenAusgewaehlt = Keine Rechnungen ausgewählt.
|
MahnungKeineRechnungenAusgewaehlt = Keine Rechnungen ausgewählt.
|
||||||
MahnungStufeNichtKonfiguriert = Rechnung #%s: Stufe %s nicht konfiguriert
|
MahnungStufeNichtKonfiguriert = Rechnung #%s: Stufe %s nicht konfiguriert
|
||||||
|
|
@ -268,13 +398,9 @@ MahnungMahnungErstellt = %s Mahnung(en) erstellt
|
||||||
MahnungUebersprungen2 = , %s übersprungen (Wartefrist)
|
MahnungUebersprungen2 = , %s übersprungen (Wartefrist)
|
||||||
MahnungFehlerLabel = — Fehler: %s
|
MahnungFehlerLabel = — Fehler: %s
|
||||||
MahnungCsrfTokenUngueltig = CSRF-Token ungültig.
|
MahnungCsrfTokenUngueltig = CSRF-Token ungültig.
|
||||||
MahnungNichtBerechtigtSend = Nicht berechtigt (mahnung.send).
|
MahnungNichtBerechtigtWrite = Nicht berechtigt (mahnung.write).
|
||||||
MahnungIdFehlt = mahnung_id fehlt.
|
|
||||||
MahnungNichtGefunden = Mahnung %s nicht gefunden.
|
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.
|
MahnungKundeKeineEmail = Kunde hat keine E-Mail-Adresse hinterlegt.
|
||||||
MahnungEmailGesendet = E-Mail an %s gesendet.
|
|
||||||
MahnungEmailFehlgeschlagen = E-Mail-Versand fehlgeschlagen: %s
|
MahnungEmailFehlgeschlagen = E-Mail-Versand fehlgeschlagen: %s
|
||||||
MahnungSammelbriefNichtBerechtigt = Nicht berechtigt.
|
MahnungSammelbriefNichtBerechtigt = Nicht berechtigt.
|
||||||
MahnungSammelbriefCsrfFehler = Token-Verifikation fehlgeschlagen.
|
MahnungSammelbriefCsrfFehler = Token-Verifikation fehlgeschlagen.
|
||||||
|
|
@ -359,11 +485,19 @@ MahnungSetupTemplateVars = Verfügbare Template-Variablen
|
||||||
MahnungTriggerBeschreibung = Mahnung-Trigger: erledigt offene Mahnvorgänge bei Zahlungseingang.
|
MahnungTriggerBeschreibung = Mahnung-Trigger: erledigt offene Mahnvorgänge bei Zahlungseingang.
|
||||||
MahnungBoxStufe = Stufe %s
|
MahnungBoxStufe = Stufe %s
|
||||||
MahnungBoxStufeVom = Stufe %s vom %s
|
MahnungBoxStufeVom = Stufe %s vom %s
|
||||||
|
MahnungBoxErinnerung = Erinnerung
|
||||||
|
MahnungBoxErinnerungVom = Zahlungserinnerung vom %s
|
||||||
MahnungVorschlagStufeNichtKonfiguriert = Stufe 1 nicht konfiguriert
|
MahnungVorschlagStufeNichtKonfiguriert = Stufe 1 nicht konfiguriert
|
||||||
MahnungVorschlagFristNichtErreicht = Frist Stufe 1 (%s Tage) noch nicht erreicht (Verzug %s Tage)
|
MahnungVorschlagFristNichtErreicht = Frist Stufe 1 (%s Tage) noch nicht erreicht (Verzug %s Tage)
|
||||||
MahnungVorschlagAlleStufenAusgeschoepft = Alle Mahnstufen ausgeschöpft (zuletzt Stufe %s)
|
MahnungVorschlagAlleStufenAusgeschoepft = Alle Mahnstufen ausgeschöpft (zuletzt Stufe %s)
|
||||||
MahnungVorschlagWartefristLaeuft = Wartefrist nach Stufe %s läuft noch (%s/%s Tage)
|
MahnungVorschlagWartefristLaeuft = Wartefrist nach Stufe %s läuft noch (%s/%s Tage)
|
||||||
MahnungVorschlagBetragNull = Offener Betrag <= 0 (vermutl. komplett bezahlt, paye-Flag noch nicht gesetzt)
|
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
|
MahnungPatternNichtGefunden = Pattern nicht gefunden
|
||||||
MahnungSpeichernFehlgeschlagen = Speichern fehlgeschlagen
|
MahnungSpeichernFehlgeschlagen = Speichern fehlgeschlagen
|
||||||
MahnungCronCommentBuild = Sucht überfällige Rechnungen, ermittelt vorgeschlagene Mahnstufen, sendet Ntfy-Push
|
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
|
MahnungPdfVerzeichnisFehler = Kann Verzeichnis nicht anlegen: %s
|
||||||
MahnungPdfFooterEmail = E-Mail: %s
|
MahnungPdfFooterEmail = E-Mail: %s
|
||||||
MahnungPdfFooterTel = Tel: %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
|
# 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
|
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
|
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
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ PermMahnungSetup = Configure dunning module
|
||||||
#
|
#
|
||||||
# Menus
|
# Menus
|
||||||
#
|
#
|
||||||
|
MahnungSetupOeffnen = Open dunning settings
|
||||||
MahnungMenu = Dunning
|
MahnungMenu = Dunning
|
||||||
MahnungVorschlagsliste = Proposal list
|
MahnungVorschlagsliste = Proposal list
|
||||||
MahnungArchiv = Dunning records
|
MahnungArchiv = Dunning records
|
||||||
|
|
@ -40,10 +41,67 @@ MahnungStufeZinssatzB2B = Interest rate B2B (override)
|
||||||
MahnungZinssatzHelpB2C = Empty = default (%s + %s %% = %s %%), 0 = no interest
|
MahnungZinssatzHelpB2C = Empty = default (%s + %s %% = %s %%), 0 = no interest
|
||||||
MahnungZinssatzHelpB2B = Empty = default (%s + %s %% = %s %%), 0 = no interest
|
MahnungZinssatzHelpB2B = Empty = default (%s + %s %% = %s %%), 0 = no interest
|
||||||
MahnungStufeVersandartDefault = Default dispatch method
|
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
|
MahnungStufeEmailSubject = E-mail subject
|
||||||
MahnungStufeEmailBody = E-mail body
|
MahnungStufeEmailBody = E-mail body
|
||||||
MahnungStufePdfIntro = PDF introduction text
|
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
|
# Status
|
||||||
#
|
#
|
||||||
|
|
@ -89,6 +147,11 @@ MahnungVersandGespeichert = Shipment data saved
|
||||||
MahnungVersandGeleert = Shipment data reset
|
MahnungVersandGeleert = Shipment data reset
|
||||||
MahnungSendebelege = Shipment receipts
|
MahnungSendebelege = Shipment receipts
|
||||||
MahnungSendebelegeHint = Upload receipt from postal carrier/DHL/fax/mail (PDF or photo). Stays attached to the dunning case for later verification.
|
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)
|
# Tracking patterns (Phase 3)
|
||||||
|
|
@ -168,6 +231,8 @@ MahnungPauschaleB2B = Flat fee (40 € §288)
|
||||||
MahnungVerzugszinsen = Late-payment interest
|
MahnungVerzugszinsen = Late-payment interest
|
||||||
MahnungSumme = Total
|
MahnungSumme = Total
|
||||||
MahnungBasiszinsSnapshot = Base rate (snapshot)
|
MahnungBasiszinsSnapshot = Base rate (snapshot)
|
||||||
|
MahnungZinssatzOverrideStufe = Overridden by dunning stage
|
||||||
|
MahnungZinssatzAusBasiszins = Base rate %s %% + surcharge
|
||||||
MahnungLetzteMahnung = Last dunning
|
MahnungLetzteMahnung = Last dunning
|
||||||
MahnungVorgeschlageneStufe = Proposed stage
|
MahnungVorgeschlageneStufe = Proposed stage
|
||||||
MahnungAktion = Action
|
MahnungAktion = Action
|
||||||
|
|
@ -178,6 +243,65 @@ MahnungKeineUeberfaelligen = No overdue invoices found.
|
||||||
MahnungUebersprungen = Currently skipped invoices
|
MahnungUebersprungen = Currently skipped invoices
|
||||||
MahnungUebersprungenHint = These invoices are overdue but currently not proposed (waiting period running or all dunning stages exhausted).
|
MahnungUebersprungenHint = These invoices are overdue but currently not proposed (waiting period running or all dunning stages exhausted).
|
||||||
MahnungSkipGrund = Reason
|
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
|
# Setup page
|
||||||
|
|
@ -185,10 +309,10 @@ MahnungSkipGrund = Reason
|
||||||
MahnungSetup = Dunning settings
|
MahnungSetup = Dunning settings
|
||||||
MahnungSetupPage = Dunning configuration
|
MahnungSetupPage = Dunning configuration
|
||||||
MahnungSetupDescription = Configure dunning stages, base rate, dispatch methods, and Ntfy topic.
|
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).
|
MahnungBasiszinsHelp = Current Bundesbank base rate; update twice a year (Jan 1 / Jul 1).
|
||||||
MahnungAufschlagB2C = Surcharge B2C (%)
|
MahnungAufschlagB2C = Surcharge B2C (%%)
|
||||||
MahnungAufschlagB2B = Surcharge B2B (%)
|
MahnungAufschlagB2B = Surcharge B2B (%%)
|
||||||
MahnungPauschaleB2BLabel = Flat fee B2B (EUR)
|
MahnungPauschaleB2BLabel = Flat fee B2B (EUR)
|
||||||
MahnungNtfyTopic = Ntfy topic
|
MahnungNtfyTopic = Ntfy topic
|
||||||
MahnungNtfyTopicHelp = Topic for push notifications (default: vk-builds).
|
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.
|
MahnungCronBuildVorschlagDesc = Daily scan for overdue invoices, sends a Ntfy push with the count of new proposals.
|
||||||
MahnungCronVersandReminder = Dunning — shipment reminder (unsent dunnings)
|
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).
|
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'?
|
MahnungDokumentLoeschenConfirm = Really delete document '%s'?
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
@ -231,6 +367,7 @@ MahnungCronWeitere = + %s more
|
||||||
MahnungCronArchivOeffnen = Open archive
|
MahnungCronArchivOeffnen = Open archive
|
||||||
MahnungCronEintraege = %s — %s entries
|
MahnungCronEintraege = %s — %s entries
|
||||||
MahnungCsrfFehler = Token verification failed (CSRF).
|
MahnungCsrfFehler = Token verification failed (CSRF).
|
||||||
|
MahnungNurPostErlaubt = This action is only allowed via form submission (POST).
|
||||||
MahnungNichtBerechtigt = Not authorised.
|
MahnungNichtBerechtigt = Not authorised.
|
||||||
MahnungKeineRechnungenAusgewaehlt = No invoices selected.
|
MahnungKeineRechnungenAusgewaehlt = No invoices selected.
|
||||||
MahnungStufeNichtKonfiguriert = Invoice #%s: stage %s not configured
|
MahnungStufeNichtKonfiguriert = Invoice #%s: stage %s not configured
|
||||||
|
|
@ -238,13 +375,9 @@ MahnungMahnungErstellt = %s dunning(s) created
|
||||||
MahnungUebersprungen2 = , %s skipped (waiting period)
|
MahnungUebersprungen2 = , %s skipped (waiting period)
|
||||||
MahnungFehlerLabel = — Errors: %s
|
MahnungFehlerLabel = — Errors: %s
|
||||||
MahnungCsrfTokenUngueltig = CSRF token invalid.
|
MahnungCsrfTokenUngueltig = CSRF token invalid.
|
||||||
MahnungNichtBerechtigtSend = Not authorised (mahnung.send).
|
MahnungNichtBerechtigtWrite = Not authorised (mahnung.write).
|
||||||
MahnungIdFehlt = mahnung_id missing.
|
|
||||||
MahnungNichtGefunden = Dunning %s not found.
|
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.
|
MahnungKundeKeineEmail = Customer has no e-mail address configured.
|
||||||
MahnungEmailGesendet = E-mail sent to %s.
|
|
||||||
MahnungEmailFehlgeschlagen = E-mail dispatch failed: %s
|
MahnungEmailFehlgeschlagen = E-mail dispatch failed: %s
|
||||||
MahnungSammelbriefNichtBerechtigt = Not authorised.
|
MahnungSammelbriefNichtBerechtigt = Not authorised.
|
||||||
MahnungSammelbriefCsrfFehler = Token verification failed.
|
MahnungSammelbriefCsrfFehler = Token verification failed.
|
||||||
|
|
@ -329,6 +462,8 @@ MahnungSetupTemplateVars = Available template variables
|
||||||
MahnungTriggerBeschreibung = Dunning trigger: marks open dunnings as closed on payment receipt.
|
MahnungTriggerBeschreibung = Dunning trigger: marks open dunnings as closed on payment receipt.
|
||||||
MahnungBoxStufe = Stage %s
|
MahnungBoxStufe = Stage %s
|
||||||
MahnungBoxStufeVom = Stage %s from %s
|
MahnungBoxStufeVom = Stage %s from %s
|
||||||
|
MahnungBoxErinnerung = Reminder
|
||||||
|
MahnungBoxErinnerungVom = Payment reminder from %s
|
||||||
MahnungBoxOffeneRechnungen = Overdue customer invoices with dunning stage
|
MahnungBoxOffeneRechnungen = Overdue customer invoices with dunning stage
|
||||||
MahnungBoxKeineOffenenRechnungen = No open customer invoices
|
MahnungBoxKeineOffenenRechnungen = No open customer invoices
|
||||||
MahnungBoxMaxLines = Widget: displayed open 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)
|
MahnungVorschlagAlleStufenAusgeschoepft = All dunning stages exhausted (last stage %s)
|
||||||
MahnungVorschlagWartefristLaeuft = Waiting period after stage %s still running (%s/%s days)
|
MahnungVorschlagWartefristLaeuft = Waiting period after stage %s still running (%s/%s days)
|
||||||
MahnungVorschlagBetragNull = Open amount <= 0 (probably fully paid, paye flag not yet set)
|
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
|
MahnungPatternNichtGefunden = Pattern not found
|
||||||
MahnungSpeichernFehlgeschlagen = Save failed
|
MahnungSpeichernFehlgeschlagen = Save failed
|
||||||
MahnungCronCommentBuild = Scans for overdue invoices, determines proposed dunning stages, sends Ntfy push
|
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
|
MahnungPdfVerzeichnisFehler = Cannot create directory: %s
|
||||||
MahnungPdfFooterEmail = E-mail: %s
|
MahnungPdfFooterEmail = E-mail: %s
|
||||||
MahnungPdfFooterTel = Phone: %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
|
# 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
|
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
|
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
|
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.
|
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.
|
||||||
|
|
|
||||||
223
lib/mahnung_anlage.lib.php
Normal file
223
lib/mahnung_anlage.lib.php
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
<?php
|
||||||
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License, version 3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* \file htdocs/custom/mahnung/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;
|
||||||
|
}
|
||||||
111
lib/mahnung_ui.lib.php
Normal file
111
lib/mahnung_ui.lib.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||||
|
*
|
||||||
|
* 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 '<a class="butActionNew" href="'.DOL_URL_ROOT.'/custom/mahnung/admin/setup.php"'
|
||||||
|
.' title="'.dol_escape_htmltag($langs->trans('MahnungSetupOeffnen')).'">'
|
||||||
|
.img_picto($langs->trans('MahnungSetupOeffnen'), 'setup', 'class="pictofixedwidth"')
|
||||||
|
.'</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = '<span class="badge" style="background-color:'.mahnungStufeFarbe($stufe, $istErinnerung).';color:#fff;"';
|
||||||
|
$out .= ' title="'.dol_escape_htmltag($tooltip).'">'.dol_escape_htmltag($text).'</span>';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
273
list.php
273
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/mahnung.class.php';
|
||||||
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.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/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.'/core/class/html.form.class.php';
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/core/modules/modMahnung.class.php';
|
||||||
|
|
||||||
global $langs, $user, $db;
|
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'));
|
$langs->loadLangs(array('mahnung@mahnung', 'companies', 'bills'));
|
||||||
|
|
||||||
if (!$user->hasRight('mahnung', 'read')) {
|
if (!$user->hasRight('mahnung', 'read')) {
|
||||||
|
|
@ -58,9 +69,19 @@ if ($mode !== 'archiv') {
|
||||||
}
|
}
|
||||||
|
|
||||||
$filter = array();
|
$filter = array();
|
||||||
$filter_stufe = GETPOST('filter_stufe', 'int');
|
|
||||||
if ($filter_stufe !== '' && $filter_stufe !== null) {
|
// Stufen-Filter: 0 ist eine gültige Stufennummer (kostenlose Zahlungserinnerung),
|
||||||
$filter['stufe'] = (int) $filter_stufe;
|
// "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');
|
$filter_minverzug = GETPOST('filter_minverzug', 'int');
|
||||||
if ($filter_minverzug !== '' && $filter_minverzug !== null) {
|
if ($filter_minverzug !== '' && $filter_minverzug !== null) {
|
||||||
|
|
@ -87,7 +108,7 @@ llxHeader('', $langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorsc
|
||||||
|
|
||||||
print load_fiche_titre(
|
print load_fiche_titre(
|
||||||
$langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'),
|
$langs->trans($mode === 'archiv' ? 'MahnungArchiv' : 'MahnungVorschlagsliste'),
|
||||||
'',
|
mahnungSetupLink(),
|
||||||
'fa-envelope-open-text'
|
'fa-envelope-open-text'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -105,11 +126,27 @@ print '<th>'.$langs->trans('MahnungKunde').'</th>';
|
||||||
print '<th></th></tr>';
|
print '<th></th></tr>';
|
||||||
print '<tr>';
|
print '<tr>';
|
||||||
|
|
||||||
// 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 '<td><select name="filter_stufe">';
|
print '<td><select name="filter_stufe">';
|
||||||
print '<option value="">— '.$langs->trans('All').' —</option>';
|
print '<option value="">— '.$langs->trans('All').' —</option>';
|
||||||
foreach (array(1, 2, 3) as $st) {
|
foreach ($stufenOptionen as $st => $stLabel) {
|
||||||
print '<option value="'.$st.'"'.((string) $filter_stufe === (string) $st ? ' selected' : '').'>'.$st.'</option>';
|
print '<option value="'.((int) $st).'"'.((string) $filter_stufe === (string) $st ? ' selected' : '').'>';
|
||||||
|
print ((int) $st).($stLabel !== '' ? ' — '.dol_escape_htmltag($stLabel) : '');
|
||||||
|
print '</option>';
|
||||||
}
|
}
|
||||||
print '</select></td>';
|
print '</select></td>';
|
||||||
|
|
||||||
|
|
@ -133,22 +170,14 @@ print '</select></td>';
|
||||||
// Kunden-Auswahl: Dolibarr-Standard select_company (Ajax wenn COMPANY_USE_SEARCH_TO_SELECT,
|
// Kunden-Auswahl: Dolibarr-Standard select_company (Ajax wenn COMPANY_USE_SEARCH_TO_SELECT,
|
||||||
// sonst klassisches Dropdown). htmlname='search_socid' bleibt = Backward-Kompatibilität
|
// sonst klassisches Dropdown). htmlname='search_socid' bleibt = Backward-Kompatibilität
|
||||||
// zu Direkt-Links (?search_socid=74) von der Kundenkarte. Wenn Kundentyp-Filter
|
// zu Direkt-Links (?search_socid=74) von der Kundenkarte. Wenn Kundentyp-Filter
|
||||||
// gesetzt ist, schränken wir die Dropdown-Liste passend ein.
|
// gesetzt ist, schränken wir die Dropdown-Liste passend ein — deckungsgleich mit
|
||||||
//
|
// MahnungVorschlag::ermittleKundentyp(), siehe mahnungBaueKundentypSocFilter().
|
||||||
// WICHTIG: select_thirdparty_list erwartet $filter im Universal-Search-Criteria-Format
|
$socFilter = mahnungBaueKundentypSocFilter($db, $filter_kundentyp);
|
||||||
// (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:=:'')";
|
|
||||||
}
|
|
||||||
print '<td>';
|
print '<td>';
|
||||||
print $form->select_company(
|
print $form->select_company(
|
||||||
$filter_socid, // selected
|
$filter_socid, // selected
|
||||||
'search_socid', // htmlname (Hidden-Input-Name)
|
'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)
|
'SelectThirdParty', // showempty (Translation-Key)
|
||||||
0, 0, array(), 0, 'minwidth250'
|
0, 0, array(), 0, 'minwidth250'
|
||||||
);
|
);
|
||||||
|
|
@ -179,8 +208,30 @@ function renderVorschlagsliste($db, $filter)
|
||||||
global $langs, $user;
|
global $langs, $user;
|
||||||
|
|
||||||
$service = new MahnungVorschlag($db);
|
$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);
|
$rows = $service->getVorschlaege($filter);
|
||||||
|
if ($rows === false) {
|
||||||
|
print '<div class="error">'.dol_escape_htmltag(mahnungVorschlagFehlertext($service)).'</div>';
|
||||||
|
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);
|
$skipped = $service->getUebersprungeneRechnungen($filter);
|
||||||
|
if ($skipped === false) {
|
||||||
|
print '<div class="warning">'.dol_escape_htmltag(
|
||||||
|
$langs->transnoentities('MahnungUebersprungenFehler', mahnungVorschlagFehlertext($service))
|
||||||
|
).'</div>';
|
||||||
|
$skipped = array();
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($rows) && empty($skipped)) {
|
if (empty($rows) && empty($skipped)) {
|
||||||
print '<div class="info">'.$langs->trans('MahnungKeineUeberfaelligen').'</div>';
|
print '<div class="info">'.$langs->trans('MahnungKeineUeberfaelligen').'</div>';
|
||||||
|
|
@ -226,8 +277,8 @@ function renderVorschlagsliste($db, $filter)
|
||||||
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
||||||
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
||||||
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
||||||
print '<td>'.($r['letzte_mahnung_stufe'] ? $langs->trans('MahnungStufeAmDatum', ((int) $r['letzte_mahnung_stufe']), dol_print_date($r['letzte_mahnung_datum'], 'day')) : '—').'</td>';
|
print '<td>'.renderLetzteMahnungZelle($r).'</td>';
|
||||||
print '<td><strong>'.((int) $r['vorgeschlagene_stufe']).'</strong> — '.dol_escape_htmltag($r['vorgeschlagene_stufe_label']).'</td>';
|
print '<td>'.renderVorschlagStufeZelle($r).'</td>';
|
||||||
print '</tr>';
|
print '</tr>';
|
||||||
$summeOffen += (float) $r['betrag_offen'];
|
$summeOffen += (float) $r['betrag_offen'];
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +336,7 @@ function renderUebersprungeneTabelle($skipped)
|
||||||
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
print '<td>'.dol_print_date($r['facture_date_lim_reglement'], 'day').'</td>';
|
||||||
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
print '<td class="right">'.((int) $r['tage_verzug']).'</td>';
|
||||||
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
print '<td class="right">'.price($r['betrag_offen']).'</td>';
|
||||||
print '<td>'.($r['letzte_mahnung_stufe'] ? $langs->trans('MahnungStufeAmDatum', ((int) $r['letzte_mahnung_stufe']), dol_print_date($r['letzte_mahnung_datum'], 'day')) : '—').'</td>';
|
print '<td>'.renderLetzteMahnungZelle($r).'</td>';
|
||||||
print '<td><span class="opacitymedium">'.dol_escape_htmltag((string) $r['skip_reason']).'</span></td>';
|
print '<td><span class="opacitymedium">'.dol_escape_htmltag((string) $r['skip_reason']).'</span></td>';
|
||||||
print '</tr>';
|
print '</tr>';
|
||||||
}
|
}
|
||||||
|
|
@ -319,6 +370,175 @@ function renderKontaktIcons($phone, $email)
|
||||||
return $out;
|
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 '<span class="opacitymedium">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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.
|
* Rendert das Archiv aller bestehenden Mahnvorgänge.
|
||||||
*
|
*
|
||||||
|
|
@ -337,6 +557,15 @@ function renderArchiv($db, $filter)
|
||||||
}
|
}
|
||||||
$mahnungen = $mahnungObj->fetchAll('date_mahnung', 'DESC', 200, 0, $archivFilter);
|
$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 '<div class="error">'.dol_escape_htmltag($langs->transnoentities('MahnungArchivFehler', $detail)).'</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($mahnungen)) {
|
if (empty($mahnungen)) {
|
||||||
print '<div class="info">'.$langs->trans('MahnungKeineVorgaenge').'</div>';
|
print '<div class="info">'.$langs->trans('MahnungKeineVorgaenge').'</div>';
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,13 @@ CREATE TABLE llx_mahnung_mahnung (
|
||||||
mahngebuehr DOUBLE(10,2) DEFAULT 0,
|
mahngebuehr DOUBLE(10,2) DEFAULT 0,
|
||||||
pauschale_b2b DOUBLE(10,2) DEFAULT 0,
|
pauschale_b2b DOUBLE(10,2) DEFAULT 0,
|
||||||
verzugszinsen 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,
|
summe_mahnung DOUBLE(24,8) DEFAULT 0,
|
||||||
versandart VARCHAR(20) DEFAULT 'pdf',
|
versandart VARCHAR(20) DEFAULT 'pdf',
|
||||||
customertype VARCHAR(3),
|
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),
|
pdf_path VARCHAR(255),
|
||||||
note_private TEXT,
|
note_private TEXT,
|
||||||
status TINYINT DEFAULT 0 NOT NULL,
|
status TINYINT DEFAULT 0 NOT NULL,
|
||||||
|
|
|
||||||
4
sql/llx_mahnung_mailprotokoll.key.sql
Normal file
4
sql/llx_mahnung_mailprotokoll.key.sql
Normal file
|
|
@ -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);
|
||||||
33
sql/llx_mahnung_mailprotokoll.sql
Normal file
33
sql/llx_mahnung_mailprotokoll.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||||||
|
-- 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;
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
-- the Free Software Foundation, either version 3 of the License, or
|
-- the Free Software Foundation, either version 3 of the License, or
|
||||||
-- (at your option) any later version.
|
-- (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 (
|
CREATE TABLE llx_mahnung_stufe (
|
||||||
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
|
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
@ -17,19 +18,33 @@ CREATE TABLE llx_mahnung_stufe (
|
||||||
mahngebuehr_b2c DOUBLE(10,2) DEFAULT 0,
|
mahngebuehr_b2c DOUBLE(10,2) DEFAULT 0,
|
||||||
mahngebuehr_b2b DOUBLE(10,2) DEFAULT 0,
|
mahngebuehr_b2b DOUBLE(10,2) DEFAULT 0,
|
||||||
pauschale_b2b_einmalig TINYINT DEFAULT 0,
|
pauschale_b2b_einmalig TINYINT DEFAULT 0,
|
||||||
zinssatz_b2c_uebersteuern DECIMAL(5,4),
|
-- DECIMAL(6,3): der B2B-Satz (Basiszins + 9 %) liegt ueber 9,9999 und passte
|
||||||
zinssatz_b2b_uebersteuern DECIMAL(5,4),
|
-- 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',
|
versandart_default VARCHAR(20) DEFAULT 'pdf',
|
||||||
email_subject VARCHAR(255),
|
email_subject VARCHAR(255),
|
||||||
email_body TEXT,
|
email_body TEXT,
|
||||||
pdf_intro 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,
|
active TINYINT DEFAULT 1 NOT NULL,
|
||||||
datec DATETIME,
|
datec DATETIME,
|
||||||
tms TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
tms TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- Default-Stufen (idempotent: INSERT IGNORE wegen UNIQUE entity+stufe)
|
-- 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
|
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, 1, '1. Mahnung', 7, 14, 0.00, 0.00, 1, 'pdf', NOW()),
|
||||||
(1, 2, '1. Mahnung', 14, 10, 5.00, 5.00, 0, '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());
|
(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());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue