# 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`, 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 `
` 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)