mahnung/CLAUDE.md
Eduard Wisch d6b6dd6d47 Widget-Kopf: kein rohes HTML mehr im Tooltip
Dolibarr 22 setzt den kompletten Widget-Titel samt Anzahl-Badge als
title-Attribut an den Kopf und als Bezeichnung fuer die Widget-Auswahl.
Beim Ueberfahren erschien deshalb "...Mahnstufe<span class="badge">2</span>".
mahnungBoxKopfOhneHtml() setzt an beiden Stellen den reinen Titel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:09:09 +02:00

92 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.
- **Tooltip am Kopf**: Dolibarr 22 kopiert den kompletten Titel samt Badge-HTML ins `title` des Kopfs und in `boxlabelentry` — `showBox()` setzt beides über `mahnungBoxKopfOhneHtml()` auf den reinen Titel (ohne Anzahl, die las sich dort wie eine Mahnstufe). Gleiches Muster in BuchhaltungsWidget (`buchaltungswidgetCleanBoxHead()`).
- **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)