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>
91 lines
12 KiB
Markdown
91 lines
12 KiB
Markdown
# 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)
|