mahnung/CLAUDE.md
Eduard Wisch 0244a5b07e feat(mahnung): Mailversand ueber FormMail, Versandprotokoll, HTML-Mails
Phase 13/14 abgeschlossen. Der Mailversand war zuvor KAPUTT: ajax/sendmail.php
war bereits zur Funktionsbibliothek umgebaut, card.php postete aber weiterhin
per JS dagegen.

Mailformular (FormMail)
- card.php nutzt jetzt Dolibarrs Standard-Mailformular (action=presend/send):
  Empfaenger (Firma + alle Ansprechpartner), Betreff, Text und Anhang sichtbar
  und aenderbar. Gesendet wird ausschliesslich ueber mahnungSendeErinnerungsMail().
- Anhang = unveraenderte Original-Rechnungs-PDF, wird bei Bedarf nacherzeugt.
  Eigener Parameter mailinit statt mode=init, weil get_form() bei mode=init die
  Anhangsliste selbst leert.
- HTML-Mails: DolEditor im Setup + withfckeditor=-1 im Formular (folgt
  FCKEDITOR_ENABLE_MAIL wie Dolibarrs eigene Mailvorlagen).
- Klartext bleibt Klartext: GETPOST('restricthtml') jagt jeden Nicht-HTML-Text
  durch dol_nl2br() — mahnungBodyEntkleiden() nimmt nur dieses Artefakt zurueck
  und laesst echte Formatierung unangetastet.
- Platzhalter jetzt auch in Dolibarr-Schreibweise (__REF__, __DATE_YMD__,
  __AMOUNT_FORMATED__, __DATE_DUE_YMD__, __FRIST_TAGE__ ...), Liste sichtbar im Setup.
- Absender-Adresse und -Name konfigurierbar (MAHNUNG_EMAIL_SENDER[_NAME]).
- Erneuter Versand moeglich (force aus dem Status abgeleitet, nicht aus dem
  Request — Doppelversand-Schutz bleibt wirksam).

Versandprotokoll (neue Tabelle llx_mahnung_mailprotokoll)
- Jede versendete Erinnerung wird mit Empfaenger, Kopie, Betreff, Text und
  Anhangsnamen festgehalten, einsehbar unter Versandstatus. Historie statt
  Spalten am Vorgang, weil erneut gesendet werden kann.
- Lazy-Migration legt die Tabelle an (DB_VERSION 0.4.0), kein Reaktivieren noetig.

Haertung nach Code-Review (21 bestaetigte Funde)
- Anhang liess sich nicht abwaehlen (wurde sofort wieder eingehaengt)
- Upload/Entfernen ohne Rechtepruefung; Temp-Verzeichnis pro Vorgang getrennt
- Teilzahlung zwischen Oeffnen und Senden fuehrt zurueck ins Formular
- Empfaenger: Semikolon-Trenner, keine stillen Verwerfungen, Dubletten, CR/LF
- CSRF: presend + Core-Dateiaktionen (confirm_deletefile, renamefile, sendit,
  linkit) token-pflichtig
- Externe Benutzer sehen nur eigene Vorgaenge; Abschreiben verlangt facture.creer

UI
- Mahnstufe nur noch EINE Darstellung (Badge), Farbskala zentral in
  lib/mahnung_ui.lib.php statt doppelt gepflegt
- Zahnrad oben rechts in die Einstellungen (nur mit Recht mahnung.setup)
- Original-Rechnung unter Verknuepfte Dokumente mit Vorschau, Groesse in KB
- PDF-Einleitungstext nur noch, wo ueberhaupt ein PDF entsteht

Sprachdateien de_DE/en_US deckungsgleich, 9 tote Keys entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:31:25 +02:00

12 KiB
Raw Blame History

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.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)

Schema-Migration

  • modMahnung::migrateVersandFelder() läuft automatisch beim Setup-Page-Aufruf
  • Idempotent via SHOW COLUMNS LIKE → fehlende Spalten via ALTER 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 automatisch doc_/pdf_ Prefix hinzu
  • DB-Einträge in llx_document_model.nom OHNE Prefix speichern
  • actions_setmoduleoptions.inc.php MUSS vor llxHeader() stehen (Upload)
  • ODT-Templates: mahnung_stufe1.odt, mahnung_stufe2.odt, mahnung_stufe3.odt, mahnung.odt (Fallback)

Widget

  • box_mahnung_offen basiert 1:1 auf box_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 $num bekannt ist), damit die frühen Return-Pfade (fehlende Rechte / SQL-Fehler) ohne Zähler bleiben.
  • Zeilenanzahl konfigurierbar via Konstante MAHNUNG_BOX_MAXLINES (Admin-Select in setup.php: Alle/5/10/20/30/50, Default 0 = alle). Widget lädt IMMER alle offenen Rechnungen (für den korrekten Zähler), rendert aber nur MAHNUNG_BOX_MAXLINES Zeilen + eine ...-Überlaufzeile. Nicht auf das von Dolibarr übergebene $max verlassen — das kommt aus MAIN_SIZE_SHORTLIST_LIMIT (Default 5) und gilt global für ALLE Home-Boxen. (KB #598)
  • Empty-State Pflicht: bei $num == 0 Platzhalter-Zeile in info_box_contents einfügen — sonst rendert ModeleBoxes::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 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).

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

  • 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.

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=-1 wird von select_company als "nichts ausgewählt" geliefert → im Filter-Check > 0 statt !empty() nutzen.

Pipeline-Stolperfallen

  • ${{ github.event.head_commit.message }} NIE direkt in run:-Skript interpolieren — bei Sonderzeichen (Klammern, Backticks) bricht Bash. Immer via env: durchreichen. (KB #603)
  • [deploy]-Tag im Commit nötig, sonst kein Auto-Deploy.

Verzugszinsen-Override

  • zinssatz_b2c_uebersteuern / zinssatz_b2b_uebersteuern in llx_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_provider an llx_mahnung_mahnung
  • Tracking-URLs aus DB (llx_mahnung_trackingpattern) via MahnungTrackingPattern::urlFor(), Fallback: Mahnung::trackingUrl() (hardcoded)
  • Beleg-Upload: formfile->showdocuments('mahnung', $ref, $filedir, ...)$conf->mahnung->dir_output wird von Dolibarr automatisch gesetzt (KB #605), kein Custom-Setup nötig
  • Beleg-Scan: pdftotext + ocrmypdf (OCR-Fallback für Bild-PDFs) im 90-Dolibarr-Prod-Custom-Container; Pattern-Match via MahnungTrackingPattern::detectFromText()
  • pdftotext gibt \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 + versandweg automatisch (kein extra Speichern)
  • Uneinbringlich-Klassifikation: Facture::setCanceled($user, CommonInvoice::CLOSECODE_BADDEBT, $note) → setzt fk_statut=3 + close_code='badcustomer' (KB #606)
  • Steuer-Modul kompatibel: EÜR ignoriert (liest nur llx_paiement), UStVA filtert fk_statut IN (1,2) automatisch (KB #607)

Dolibarr-Versionshinweise

  • f.fk_statut statt f.statut (seit Dolibarr 22.x)
  • verifCsrf() existiert nicht — CSRF via newToken() + GETPOST('token')
  • dol_mkdir() gibt 0 zurück wenn Verzeichnis bereits existiert (nicht false)
  • dol_dir_list() gibt fullname zurück (nicht fullpath)
  • $form->formconfirm() unterstützt textarea-Feld via $formquestion-Array (KB #609)