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>
12 KiB
12 KiB
CLAUDE.md — Mahnung-Modul
Projekt
Dolibarr Custom-Modul: 3-stufiges Mahnwesen nach BGB §288 + Versand-Tracking + Forderungsausfall-Workflow.
Technisches
- numero: 500038 (NICHT ändern — 500037 ist Eplan)
- Deploy: nur via Pipeline (
[deploy]in Commit-Message), NIEMALS manuell kopieren - Prod-Pfad: /mnt/appdata/firma/dolibarr-202509/modules/mahnung/
- Lokal: Symlink /var/www/dolibarr/custom/mahnung → repo/, erreichbar unter http://localhost:8080
- Test-DB: dolibarr_test auf 192.168.155.11 (User
dolibarr_test)- Seit 22.07.2026 zeigt auch
/var/www/dolibarr/conf/conf.phpdorthin; vorher lief localhost:8080 gegen eine lokale DBdolibarr@localhostmit 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.
- Seit 22.07.2026 zeigt auch
- Forgejo-Repo: data/mahnung (NICHT data-it/ — historisch, soll bleiben)
Schema-Migration
modMahnung::migrateVersandFelder()läuft automatisch beim Setup-Page-Aufruf- Idempotent via
SHOW COLUMNS LIKE→ fehlende Spalten viaALTER TABLE ADD COLUMN - Default-Tracking-Patterns werden via
MahnungTrackingPattern::seedDefaults()geseedet (Check:COUNT(*) > 0→ skip) - Nach Deploy: User muss Setup-Page einmal aufrufen, sonst fehlen die neuen Spalten
Dokumentenmodell-System
commonGenerateDocument()fügt automatischdoc_/pdf_Prefix hinzu- DB-Einträge in
llx_document_model.nomOHNE Prefix speichern actions_setmoduleoptions.inc.phpMUSS vorllxHeader()stehen (Upload)- ODT-Templates: mahnung_stufe1.odt, mahnung_stufe2.odt, mahnung_stufe3.odt, mahnung.odt (Fallback)
Widget
box_mahnung_offenbasiert 1:1 aufbox_factures_imp.php(Standard-Widget)- Zeigt ALLE offenen Rechnungen, nicht nur überfällige
- Mahnstufe-Badge nur wenn Mahnung existiert, sonst Strich
- Zähler im Kopf: Der Badge im Widget-Kopf zeigt die tatsächliche Gesamtzahl offener Rechnungen (verlinkt auf die Rechnungsliste). Titel-Lang-Key hat kein
(%s)mehr — die Zahl steckt im Badge. Der Zähler wird erst NACH der Query gesetzt (wenn$numbekannt ist), damit die frühen Return-Pfade (fehlende Rechte / SQL-Fehler) ohne Zähler bleiben. - Zeilenanzahl konfigurierbar via Konstante
MAHNUNG_BOX_MAXLINES(Admin-Select insetup.php: Alle/5/10/20/30/50, Default0= alle). Widget lädt IMMER alle offenen Rechnungen (für den korrekten Zähler), rendert aber nurMAHNUNG_BOX_MAXLINESZeilen + eine...-Überlaufzeile. Nicht auf das von Dolibarr übergebene$maxverlassen — das kommt ausMAIN_SIZE_SHORTLIST_LIMIT(Default 5) und gilt global für ALLE Home-Boxen. (KB #598) - Empty-State Pflicht: bei
$num == 0Platzhalter-Zeile ininfo_box_contentseinfügen — sonst rendertModeleBoxes::showBox()gar nichts und das Widget verschwindet komplett (auch nach neuen Rechnungen sieht der User es nicht zurückkommen). Siehe KB #682. - Summenzeile Netto+Brutto: Betragszelle der
liste_total-Zeile zeigt zweizeilig Netto (SUM(f.total_ht)) und Brutto (SUM(f.total_ttc)). Brutto kommt direkt ausf.total_ttcder Rechnung, NICHT aus Netto × Steuersatz hochgerechnet — sonst wären Reverse-Charge §13b, Steuerbefreiung und Kleinunternehmer §19 UStG falsch. Lang-KeysMahnungBoxNetto/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, Filtertype 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), FallbackFä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 überdiff = Ø Tage nach Fälligkeit(Parität zur Kundenkarte). Mindest-nviaMAHNUNG_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 (HooktabContentViewThirdparty) — Mahnung baut dort KEINEN zweiten Block.$langs->transnoentities(...)verwenden (nichttrans()+sprintf→ leere%s; nichttrans()+dol_escape_htmltag→ doppeltes&-Encoding).
Mailversand der Zahlungserinnerung (FormMail)
- Genau EIN Sendeweg:
mahnungSendeErinnerungsMail()inajax/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 gegenaction=sendauf derselben Karte.sendsteht in$actionsMitToken(der Core-CSRF-Check ist auf dieser Installation abgeschaltet). FormMail::get_form()leert beiGETPOST('mode')=='init'selbst die Anhangsliste. Deshalb benutzt card.php bewusst den eigenen Parametermailinitstatt des Dolibarr-üblichenmode=init— sonst würde die frisch eingehängte Rechnungs-PDF sofort wieder entfernt.- Die Anhangsliste liegt in
$_SESSION['listofpaths'.'-'.$trackid](dazulistofnames,listofmimes).trackidist hiermah<id>, damit zwei offene Karten sich nicht in die Quere kommen. Auslesen ausschließlich überget_attached_files(). - Alles in
$formmail->param[...]wird vonget_form()als hidden input ausgegeben und landet im POST (action,id,returnurl,models).models = 'none'schaltet diec_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 einesreceiver-Schlüssels immer gegenmahnungEmpfaengerListe($societe)prüfen, sonst kann ein manipuliertesreceiver[]die Mail an einen fremden Kontakt schicken.- In den Lang-Dateien geschriebenes
\nwandelt Dolibarr beim Laden in einen echten Zeilenumbruch (translate.class.php) — im Mailtext also unbedenklich. Für Klartext-Mailstransnoentities()nutzen,trans()würde Umlaute zu HTML-Entities kodieren. - HTML-Mails: Der Stufen-Mailtext wird im Setup mit
DolEditorgepflegt (Toolbardolibarr_mailings, SchalterFCKEDITOR_ENABLE_MAIL— dieselbe Konstante wie bei Dolibarrs Mailvorlagen). Das Formular auf der Karte setztwithfckeditor = -1und folgt damit derselben Einstellung. Ob die Mail als HTML rausgeht, entscheidet am Endedol_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:
$istErneuterVersandwird aus dem Status abgeleitet (>= VERSENDET), NICHT aus einem Request-Parameter, und alsforceanmahnungSendeErinnerungsMail()gereicht. Nur so bleibt der Doppelversand-Schutz wirksam — beim erzwungenen Versand nageltmahnungReserviereVersand()zusätzlich das bisherigedate_versandfest, sonst kämen zwei Parallelklicks beide durch. Erledigte und stornierte Vorgänge bleiben gesperrt (mahnungVersandErlaubt()). - Bekannte, harmlose Log-Warnungen: bei
models = 'none'bleibt$arraydefaultmessageim Core der Integer-1;get_form()greift trotzdem mit->topic/->content/->content_linesdarauf zu. Das erzeugt unter PHP 8 pro Formularaufruf dreiAttempt to read property … on int-Warnungen (html.formmail.class.php:1472/994/1041). Funktional folgenlos — der jeweiligeelseif-Zweig setzt korrektwithtopic/withbodyein. 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()auslib/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 inlist.phpundbox_mahnung_offen.phpdoppelt gepflegt. Das Widget behält sein kurzes Label ("Stufe N"), weil die Spalte schmal ist — aber dieselbe Farbquelle.
Hooks-Stolperfallen
completeTabsHeadwird bei jedem Aufruf voncomplete_head_from_modules()getriggert — pro Karte mehrfach (core + external + remove). Filter aufmode=add+filterorigmodule=external, sonst doppelter Tab. (KB #601)- Hook-Kontexte:
invoicecard,thirdpartycard,ordercard— letztere für Bonitäts-Warnings.
Filter-Syntax-Stolperfallen
$form->select_company($selected, $htmlname, $filter, ...): der$filter-Parameter erwartet USC-Syntax(feld:operator:wert), NICHT plain SQL. Beispiel B2C:(s.tva_intra:is:NULL) OR (s.tva_intra:=:''). Sonst SQL-Syntax-Error + 500. (KB #602)search_socid=-1wird vonselect_companyals "nichts ausgewählt" geliefert → im Filter-Check> 0statt!empty()nutzen.
Pipeline-Stolperfallen
${{ github.event.head_commit.message }}NIE direkt inrun:-Skript interpolieren — bei Sonderzeichen (Klammern, Backticks) bricht Bash. Immer viaenv:durchreichen. (KB #603)[deploy]-Tag im Commit nötig, sonst kein Auto-Deploy.
Verzugszinsen-Override
zinssatz_b2c_uebersteuern/zinssatz_b2b_uebersteuerninllx_mahnung_stufe: NULL = Standard (Basiszins + Aufschlag), 0 = keine Zinsen, Wert = fester Prozentsatz- Nicht-versandte Mahnungen (Status ≤ ERSTELLT) werden beim card.php-Aufruf automatisch neu berechnet
- Setup-Seite zeigt Placeholder mit Standard-Zinssatz + Hilfetext
Versand & Bonität (Phase 6)
- Versand-Felder:
date_versand,versandweg,tracking_nr,tracking_provideranllx_mahnung_mahnung - Tracking-URLs aus DB (
llx_mahnung_trackingpattern) viaMahnungTrackingPattern::urlFor(), Fallback:Mahnung::trackingUrl()(hardcoded) - Beleg-Upload:
formfile->showdocuments('mahnung', $ref, $filedir, ...)—$conf->mahnung->dir_outputwird von Dolibarr automatisch gesetzt (KB #605), kein Custom-Setup nötig - Beleg-Scan:
pdftotext+ocrmypdf(OCR-Fallback für Bild-PDFs) im90-Dolibarr-Prod-Custom-Container; Pattern-Match viaMahnungTrackingPattern::detectFromText() pdftotextgibt\x0C(Form-Feed) bei Bild-PDFs zurück —trim()mit expliziter Zeichenliste" \t\n\r\0\x0B\x0C"nötig- "Übernehmen" setzt
tracking_nr+tracking_provider+date_versand+versandwegautomatisch (kein extra Speichern) - Uneinbringlich-Klassifikation:
Facture::setCanceled($user, CommonInvoice::CLOSECODE_BADDEBT, $note)→ setztfk_statut=3+close_code='badcustomer'(KB #606) - Steuer-Modul kompatibel: EÜR ignoriert (liest nur
llx_paiement), UStVA filtertfk_statut IN (1,2)automatisch (KB #607)
Dolibarr-Versionshinweise
f.fk_statutstattf.statut(seit Dolibarr 22.x)verifCsrf()existiert nicht — CSRF vianewToken()+ GETPOST('token')dol_mkdir()gibt 0 zurück wenn Verzeichnis bereits existiert (nicht false)dol_dir_list()gibtfullnamezurück (nichtfullpath)$form->formconfirm()unterstützt textarea-Feld via$formquestion-Array (KB #609)