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

91 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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